2c7265efc4
build / build (push) Successful in 58s
主人要求:去掉入口,统一成 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。
127 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|