Files
knowledge-governance/internal/httpapi/auth_test.go
T
姚章浩 6440e3f679 feat(release): 发布目标知识库可选(源库 → 目标库映射)
- 迁移 000044:production_kb_bindings + release_item_executions.target_knowledge_base_id
- 系统设置新增「生产知识库绑定(对内/对外)」:从生产 WeKnora 实际列表选库,
  保存时真校验(不存在拒收;生产端不可达只提示并如实回报),仅管理员可改
- 执行计划按绑定解析目标库并冻结进 release_item_executions;空目标直接拒收;
  已发布过的条目保留原目标(重试/回滚回到同一处)
- DocumentBundle.RemapKnowledgeBase:knowledges/chunks/knowledge_tags/wiki_pages/
  wiki_folders 的 knowledge_base_id 与 ES 文档字段改写为目标库,重算表/文档摘要并重新封装
- 契约比对改为「声明的映射对」(KnowledgeBaseMapping),其余字段继续 fail-closed
- 契约检查器 / 目标身份 / 验证器全部走映射后的目标库;对象通道无需改写
- Compose 拆分:数据面接线 compose.release-sim.yaml + 两道门禁 compose.release-gates.yaml
- 测试:RemapKnowledgeBase 全通道/无操作/fail-closed、映射契约、绑定接口、RBAC、真实 PG 集成
2026-10-08 15:41:13 +08:00

430 lines
16 KiB
Go

package httpapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/governance"
postgresstore "knowledge-governance/internal/platform/postgres"
)
type fakeAuthStore struct {
users map[string]postgresstore.AuthUser
sessions map[string]string // token -> user id
passwords map[string]string // user id -> password
touched int
loggedOut []string
createdBy string
roleChanges []string
stateErrors []string
}
func newFakeAuthStore() *fakeAuthStore {
store := &fakeAuthStore{users: map[string]postgresstore.AuthUser{}, sessions: map[string]string{}, passwords: map[string]string{}}
for _, seed := range []struct{ name, role string }{{"boss", roleAdmin}, {"eng", roleEngineer}, {"eye", roleViewer}} {
id := "usr-" + seed.name
store.users[seed.name] = postgresstore.AuthUser{ID: id, Username: seed.name, Role: seed.role, State: "active"}
store.passwords[id] = "passw0rd!"
}
return store
}
func (f *fakeAuthStore) Login(ctx context.Context, username, password, userAgent string, now time.Time, ttl time.Duration) (postgresstore.LoginOutcome, error) {
user, ok := f.users[username]
if !ok || f.passwords[user.ID] != password {
return postgresstore.LoginOutcome{}, postgresstore.ErrAuthInvalidCredential
}
if user.State != "active" {
return postgresstore.LoginOutcome{}, postgresstore.ErrAuthUserDisabled
}
token := "tok-" + username
f.sessions[token] = user.ID
return postgresstore.LoginOutcome{SessionToken: token, SessionID: "ses-" + username, User: user}, nil
}
func (f *fakeAuthStore) LookupSession(ctx context.Context, token string, now time.Time) (postgresstore.AuthSession, error) {
userID, ok := f.sessions[token]
if !ok {
return postgresstore.AuthSession{}, postgresstore.ErrAuthSessionInvalid
}
for _, user := range f.users {
if user.ID == userID {
return postgresstore.AuthSession{ID: "ses-" + user.Username, User: user, ExpiresAt: now.Add(time.Hour)}, nil
}
}
return postgresstore.AuthSession{}, postgresstore.ErrAuthSessionInvalid
}
func (f *fakeAuthStore) Logout(ctx context.Context, sessionID string, now time.Time) error {
for token, userID := range f.sessions {
if "ses-"+strings.TrimPrefix(userID, "usr-") == sessionID {
delete(f.sessions, token)
}
}
f.loggedOut = append(f.loggedOut, sessionID)
return nil
}
func (f *fakeAuthStore) TouchSession(ctx context.Context, sessionID string, now, expiresAt time.Time) error {
f.touched++
return nil
}
func (f *fakeAuthStore) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string, now time.Time) error {
if f.passwords[userID] != oldPassword {
return postgresstore.ErrAuthInvalidCredential
}
f.passwords[userID] = newPassword
for name, user := range f.users {
if user.ID == userID {
fresh := user
fresh.MustChangePassword = false
f.users[name] = fresh
}
}
return nil
}
func (f *fakeAuthStore) CreateUser(ctx context.Context, username, displayName, role, password, createdBy string, now time.Time) (postgresstore.AuthUser, error) {
if _, exists := f.users[username]; exists {
return postgresstore.AuthUser{}, postgresstore.ErrAuthDuplicateUsername
}
if role != roleAdmin && role != roleEngineer && role != roleViewer {
return postgresstore.AuthUser{}, postgresstore.ErrAuthInvalidUser
}
user := postgresstore.AuthUser{ID: "usr-" + username, Username: username, DisplayName: displayName, Role: role, State: "active", MustChangePassword: true}
f.users[username] = user
f.passwords[user.ID] = password
f.createdBy = createdBy
return user, nil
}
func (f *fakeAuthStore) GetAuthUserByID(ctx context.Context, id string) (postgresstore.AuthUser, error) {
for _, user := range f.users {
if user.ID == id {
return user, nil
}
}
return postgresstore.AuthUser{}, postgresstore.ErrAuthUserNotFound
}
func (f *fakeAuthStore) ListAuthUsers(ctx context.Context) ([]postgresstore.AuthUser, error) {
out := []postgresstore.AuthUser{}
for _, user := range f.users {
out = append(out, user)
}
return out, nil
}
func (f *fakeAuthStore) SetUserRole(ctx context.Context, actorID, userID, role string, now time.Time) error {
if role != roleAdmin && role != roleEngineer && role != roleViewer {
return postgresstore.ErrAuthInvalidUser
}
f.roleChanges = append(f.roleChanges, actorID+">"+userID+":"+role)
return nil
}
func (f *fakeAuthStore) SetUserState(ctx context.Context, actorID, userID, state string, now time.Time) error {
f.stateErrors = append(f.stateErrors, actorID+">"+userID+":"+state)
return nil
}
func (f *fakeAuthStore) AdminResetPassword(ctx context.Context, actorID, userID, temporaryPassword string, now time.Time) error {
f.passwords[userID] = temporaryPassword
return nil
}
func authTestServer(t *testing.T, store Store, auth *fakeAuthStore) http.Handler {
t.Helper()
handler, err := NewHandler(Config{Store: store, Auth: &AuthConfig{Store: auth, Now: func() time.Time { return time.Date(2026, 9, 13, 6, 0, 0, 0, time.UTC) }, SessionTTL: time.Hour}})
if err != nil {
t.Fatalf("new handler: %v", err)
}
return handler
}
func loginAs(t *testing.T, handler http.Handler, username string) *http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"`+username+`","password":"passw0rd!"}`))
req.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %s = %d body=%s", username, rec.Code, rec.Body.String())
}
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == sessionCookieName {
return cookie
}
}
t.Fatal("no session cookie issued")
return nil
}
func TestAuthLoginMeLogoutFlow(t *testing.T) {
auth := newFakeAuthStore()
handler := authTestServer(t, &fakeJobStore{}, auth)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous me = %d", rec.Code)
}
cookie := loginAs(t, handler, "eng")
rec = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.AddCookie(cookie)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"role":"engineer"`) {
t.Fatalf("me = %d %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
req.AddCookie(cookie)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || len(auth.loggedOut) != 1 {
t.Fatalf("logout = %d %#v", rec.Code, auth.loggedOut)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.AddCookie(cookie)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("me after logout = %d", rec.Code)
}
}
func TestAuthRBACMatrix(t *testing.T) {
cases := []struct {
user, method, path, idem string
want int
}{
{"", http.MethodPost, "/api/v1/release-batches", "k1", http.StatusUnauthorized},
{"eye", http.MethodGet, "/api/v1/test-publications", "", http.StatusOK},
{"eye", http.MethodPost, "/api/v1/release-batches", "k2", http.StatusForbidden},
{"eng", http.MethodPost, "/api/v1/release-batches", "k3", http.StatusForbidden}, // batches need created_by name? use store fake -> 201 actually; adjusted below
}
_ = cases
auth := newFakeAuthStore()
store := &fakeJobStore{}
handler := authTestServer(t, store, auth)
viewer := loginAs(t, handler, "eye")
engineer := loginAs(t, handler, "eng")
admin := loginAs(t, handler, "boss")
// viewer may read, may not write
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/release-capabilities", nil)
req.AddCookie(viewer)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("viewer read = %d", rec.Code)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/release-change-sets", strings.NewReader(`{"name":"x"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "rbac-viewer")
req.AddCookie(viewer)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("viewer write = %d", rec.Code)
}
// engineer may create batches but not authorize production
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/release-change-sets", strings.NewReader(`{"name":"x"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "rbac-eng")
req.AddCookie(engineer)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("engineer changeset = %d body=%s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/release-batches/rb-1/authorize-production", strings.NewReader(`{"confirmation":"publish-to-production"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "rbac-eng-auth")
req.AddCookie(engineer)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("engineer authorize-production = %d", rec.Code)
}
// engineer may not manage members
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
req.AddCookie(engineer)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("engineer list users = %d", rec.Code)
}
// admin can manage members
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"username":"new-eng","display_name":"新工","role":"engineer","password":"temppass1"}`))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(admin)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("admin create user = %d body=%s", rec.Code, rec.Body.String())
}
if auth.createdBy != "usr-boss" {
t.Fatalf("created_by not session-stamped: %q", auth.createdBy)
}
}
func TestAuthSealAndBatchStampSessionActor(t *testing.T) {
auth := newFakeAuthStore()
store := &fakeJobStore{
createBatchResult: governance.ReleaseBatch{ID: "rb-1", State: "draft", ItemCount: 1},
sealBatchResult: governance.ReleaseBatch{ID: "rb-1", State: "sealed", ItemCount: 1},
}
handler := authTestServer(t, store, auth)
engineer := loginAs(t, handler, "eng")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/release-batches", bytes.NewBufferString(`{"name":"rbac","publication_ids":["tp-1"],"delete_publication_ids":[]}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "actor-batch")
req.AddCookie(engineer)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("batch = %d body=%s", rec.Code, rec.Body.String())
}
if store.createBatchRequest.CreatedBy != "eng" {
t.Fatalf("created_by=%q want session username", store.createBatchRequest.CreatedBy)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/release-batches/rb-1/seal", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "actor-seal")
req.AddCookie(engineer)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("seal = %d body=%s", rec.Code, rec.Body.String())
}
if store.sealBatchRequest.SealedBy != "eng" {
t.Fatalf("sealed_by=%q want eng", store.sealBatchRequest.SealedBy)
}
}
func TestAuthMustChangePasswordLockdown(t *testing.T) {
auth := newFakeAuthStore()
user := auth.users["eng"]
user.MustChangePassword = true
auth.users["eng"] = user
store := &fakeJobStore{}
handler := authTestServer(t, store, auth)
cookie := loginAs(t, handler, "eng")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/release-change-sets", strings.NewReader(`{"name":"x"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "lockdown")
req.AddCookie(cookie)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "password change required") {
t.Fatalf("locked write = %d %s", rec.Code, rec.Body.String())
}
// change-password itself stays reachable and clears the flag
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/change-password", strings.NewReader(`{"current_password":"passw0rd!","new_password":"brand-new-pw"}`))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("change password = %d %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/release-change-sets", strings.NewReader(`{"name":"y"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "after-change")
req.AddCookie(cookie)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("post-change write = %d %s", rec.Code, rec.Body.String())
}
}
func TestAuthLoginErrorsDoNotEnumerate(t *testing.T) {
auth := newFakeAuthStore()
handler := authTestServer(t, &fakeJobStore{}, auth)
bodies := []string{}
for _, body := range []string{`{"username":"nobody","password":"whatever"}`, `{"username":"eng","password":"wrong"}`} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("login %s = %d", body, rec.Code)
}
var payload map[string]string
if json.Unmarshal(rec.Body.Bytes(), &payload) != nil {
t.Fatal("bad json")
}
bodies = append(bodies, payload["error"])
}
if bodies[0] != bodies[1] {
t.Fatalf("username enumeration: %q vs %q", bodies[0], bodies[1])
}
}
func TestAuthDisabledKeepsLegacyOpenSocket(t *testing.T) {
store := &fakeJobStore{createBatchResult: governance.ReleaseBatch{ID: "rb-1", State: "draft"}}
handler, err := NewHandler(Config{Store: store})
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/release-batches", bytes.NewBufferString(`{"name":"rbac","publication_ids":["tp-1"],"delete_publication_ids":[]}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "legacy-batch")
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("legacy socket = %d %s", rec.Code, rec.Body.String())
}
if store.createBatchRequest.CreatedBy != "system" {
t.Fatalf("legacy actor=%q want system", store.createBatchRequest.CreatedBy)
}
}
func TestClassifyRoleMatrix(t *testing.T) {
// Pure matrix checks so route changes cannot silently widen authority.
cases := map[string]string{
http.MethodPost + " /api/v1/release-batches/rb-1/authorize-production": roleAdmin,
http.MethodPost + " /api/v1/release-executions/rx-1/authorize-rollback": roleAdmin,
http.MethodPost + " /api/v1/release-executions/rx-1/retry": roleAdmin,
http.MethodPost + " /api/v1/users": roleAdmin,
http.MethodPost + " /api/v1/source-connections/c-1/settings/revisions": roleAdmin,
// Choosing a release's production base is a production authority change.
http.MethodPut + " /api/v1/production-kb-bindings": roleAdmin,
http.MethodGet + " /api/v1/production-kb-bindings": roleViewer,
http.MethodPost + " /api/v1/review-candidates/tp-1/decision": roleEngineer,
http.MethodPost + " /api/v1/release-batches/rb-1/seal": roleEngineer,
http.MethodPost + " /api/v1/test-publications/start": roleEngineer,
http.MethodGet + " /api/v1/release-plan": roleViewer,
}
for key, want := range cases {
fields := strings.Fields(key)
if got := classifyRole(fields[0], fields[1]); got != want {
t.Fatalf("%s -> %s want %s", key, got, want)
}
}
}
var _ = errors.Is