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