6440e3f679
- 迁移 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 集成
430 lines
16 KiB
Go
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
|