Files
knowledge-governance/internal/httpapi/source_connection_archive_test.go
姚章浩 2c7265efc4
build / build (push) Successful in 58s
feat(sources): 数据源配置收敛成 dws 导入 + PP 导入;「去掉来源」= 归档下架(不删数据)
主人要求:去掉入口,统一成 dws 导入与 PP 导入;其他来源连数据一起下架,之后用 dws 重新获取。

界面(web):
- 「数据源配置」只剩两块:**dws 导入(钉钉)**(按当前钉钉账号列出可访问的知识库与钉盘空间)
  与 **PP 导入**(PP 资料下载中心)。上一步加的「显示 AList/钉钉知识库来源」折叠开关删除。
- 来源卡片新增管理员可见的「去掉来源」(带确认弹窗,说明是归档下架、数据保留可恢复)。
- 侧栏文案改成"只留两个入口";发现面板标题改为「dws 导入(钉钉)」。

后端(归档语义,可逆):
- 迁移 000047:source_connections 增 archived_at / archived_by + 只覆盖未归档行的部分索引。
- store:ArchiveSourceConnection / RestoreSourceConnection(幂等;归档同时停用并清空
  next_refresh_at,避免被手工改回后立刻带着旧调度跑)。列表、目录、计划刷新(claimDue)、
  手动扫描(triggerBlock/inspect)四类查询一律排除归档行。
- 注册路径(dws 重新获取):RegisterDingtalkSpace 的 ON CONFLICT 清掉归档标记 ——
  重新登记同一个空间**原地复活同一行**,不产生重复,历史快照仍属于同一行。
- HTTP:POST /api/v1/source-connections/{id}/archive|restore(管理员专属,写意图头
  source-archive / source-restore + Idempotency-Key;404 区分"不存在"与"已是目标状态");
  openapi 同步两条契约。

验证:
- 集成测试(真 postgres):归档从列表消失但 source_items 一行不少、重复归档不漂移归档时间与人、
  未知 ID 报 ErrSourceConnectionNotFound、归档来源不能再被扫描、恢复后回到列表但保持停用;
  dws 重新登记同一空间复活同一行。
- httpapi:写意图/幂等键门禁、200/403/404/500、错误体不回显内部细节、classifyRole 管理员专属。
- 迁移护栏:000047 结构 + 四类 SQL 必须含 archived_at is null(漏一条就是入口没去掉)。
- make verify 15/15。
2026-10-14 12:36:32 +08:00

127 lines
5.3 KiB
Go

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)
}
}
}