package httpapi import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" postgresstore "knowledge-governance/internal/platform/postgres" ) // 复用既有 fakeJobStore 提供 Store 接口的其余方法;这里只关心归档两个写操作。 type fakeSourceArchiver struct { *fakeJobStore archivedID string archivedActor string restoredID string restoredActor string err error } func (f *fakeSourceArchiver) ArchiveSourceConnection(ctx context.Context, connectionID, actor string, now time.Time) error { f.archivedID, f.archivedActor = connectionID, actor return f.err } func (f *fakeSourceArchiver) RestoreSourceConnection(ctx context.Context, connectionID, actor string, now time.Time) error { f.restoredID, f.restoredActor = connectionID, actor return f.err } func archiveRequest(t *testing.T, path, intent, key string) *http.Request { t.Helper() request := httptest.NewRequest(http.MethodPost, path, nil) if intent != "" { request.Header.Set("X-Governance-Action", intent) } if key != "" { request.Header.Set("Idempotency-Key", key) } return request.WithContext(context.WithValue(request.Context(), sessionKey{}, sessionIdentity{Username: "yaozhanghao", Role: roleAdmin})) } func TestSourceConnectionArchiveHandler(t *testing.T) { store := &fakeSourceArchiver{fakeJobStore: &fakeJobStore{}} handler, err := NewHandler(Config{Store: store, Now: func() time.Time { return time.Date(2026, 10, 14, 4, 0, 0, 0, time.UTC) }}) if err != nil { t.Fatalf("NewHandler() error = %v", err) } // 写意图头缺失 ⇒ 拒绝(与其它来源设置写操作同规格)。 noIntent := httptest.NewRecorder() handler.ServeHTTP(noIntent, archiveRequest(t, "/api/v1/source-connections/conn-1/archive", "", "op-1")) if noIntent.Code != http.StatusForbidden { t.Fatalf("intent gate = %d, want 403", noIntent.Code) } // 幂等键缺失 ⇒ 400(不允许"没键的重试")。 noKey := httptest.NewRecorder() handler.ServeHTTP(noKey, archiveRequest(t, "/api/v1/source-connections/conn-1/archive", "source-archive", "")) if noKey.Code != http.StatusBadRequest { t.Fatalf("idempotency gate = %d, want 400", noKey.Code) } // 归档成功:把连接 ID 与操作人交给存储层。 ok := httptest.NewRecorder() handler.ServeHTTP(ok, archiveRequest(t, "/api/v1/source-connections/conn-1/archive", "source-archive", "op-archive-1")) if ok.Code != http.StatusOK || store.archivedID != "conn-1" || store.archivedActor != "yaozhanghao" { t.Fatalf("archive = %d id=%s actor=%s body=%s", ok.Code, store.archivedID, store.archivedActor, ok.Body.String()) } if !strings.Contains(ok.Body.String(), `"archived":true`) || strings.Contains(ok.Body.String(), "secret") { t.Fatalf("archive body = %s", ok.Body.String()) } // 恢复走另一条意图头。 unknownIntent := httptest.NewRecorder() handler.ServeHTTP(unknownIntent, archiveRequest(t, "/api/v1/source-connections/conn-1/restore", "source-archive", "op-restore-1")) if unknownIntent.Code != http.StatusForbidden { t.Fatalf("restore intent gate = %d, want 403", unknownIntent.Code) } restored := httptest.NewRecorder() handler.ServeHTTP(restored, archiveRequest(t, "/api/v1/source-connections/conn-1/restore", "source-restore", "op-restore-1")) if restored.Code != http.StatusOK || store.restoredID != "conn-1" || store.restoredActor != "yaozhanghao" { t.Fatalf("restore = %d id=%s actor=%s", restored.Code, store.restoredID, store.restoredActor) } // 未知连接 ⇒ 404(与"已经处于目标状态"区分开)。 store.err = postgresstore.ErrSourceConnectionNotFound missing := httptest.NewRecorder() handler.ServeHTTP(missing, archiveRequest(t, "/api/v1/source-connections/conn-missing/archive", "source-archive", "op-2")) if missing.Code != http.StatusNotFound { t.Fatalf("unknown connection = %d, want 404", missing.Code) } // 存储层故障 ⇒ 500,且不得回显内部细节。 store.err = errors.New("boom: password=hunter2") failure := httptest.NewRecorder() handler.ServeHTTP(failure, archiveRequest(t, "/api/v1/source-connections/conn-1/archive", "source-archive", "op-3")) if failure.Code != http.StatusInternalServerError || strings.Contains(failure.Body.String(), "hunter2") { t.Fatalf("store failure = %d body=%s", failure.Code, failure.Body.String()) } // 非法 ID ⇒ 400(超长 ID;带路径分隔符的会被 mux 规范化,不在这里测)。 badID := httptest.NewRecorder() handler.ServeHTTP(badID, archiveRequest(t, "/api/v1/source-connections/"+strings.Repeat("a", 300)+"/archive", "source-archive", "op-4")) if badID.Code != http.StatusBadRequest { t.Fatalf("invalid id = %d, want 400", badID.Code) } } // 「去掉来源」是管理员动作:工程号与只读号都不行。 func TestSourceConnectionArchiveRolePolicy(t *testing.T) { for _, tt := range []struct { method, path, want string }{ {http.MethodPost, "/api/v1/source-connections/conn-1/archive", roleAdmin}, {http.MethodPost, "/api/v1/source-connections/conn-1/restore", roleAdmin}, {http.MethodGet, "/api/v1/source-connections", roleViewer}, {http.MethodPost, "/api/v1/source-connections/conn-1/scan", roleEngineer}, } { if got := classifyRole(tt.method, tt.path); got != tt.want { t.Fatalf("classifyRole(%s %s) = %s, want %s", tt.method, tt.path, got, tt.want) } } }