Files
姚章浩 9a8a3e7795
build / build (push) Successful in 57s
fix(dingtalk): 登记钉钉知识库 409 space_registration_conflict —— 归档行占名 + 错误被吞
现象:点「公司级技术支持知识库」登记,返回 HTTP 409:space_registration_conflict。

根因(真数据取证):
- source_connections.name 有唯一约束。这 3 个钉钉来源是**早期路径**登记的
  (base_url 为空,id 与 dws 现在推导的确定性 id 不同):
  sc-18d10ca0… 公司级技术支持知识库 / sc-eaef0ec1… ATOM知识库 / sc-dc7cea1c… 技术诊断部知识沉淀。
- 上一步「去掉来源」只是归档下架,**名字仍占着唯一槽位** ⇒ 重新登记时 INSERT 撞
  UNIQUE(name)。
- 处理器把**所有**存储层错误都翻译成同一个 409 space_registration_conflict(真实原因被吞)。

修复:
- store:同名的另一行若**已归档** ⇒ 改名腾位(`…(已下架 · <短ID>)`,归档行不显示,
  数据与审计一行不动);若**仍在使用** ⇒ ErrDingtalkSpaceNameTaken(绝不悄悄改名)。
  同一个登记键解析到别的 root 时:在用中的行报 ErrDingtalkSpaceIdentityConflict;
  归档过的行允许就地改写("去掉后再用 dws 重新获取"的语义)。
- httpapi:分类映射 —— space_name_conflict / space_identity_conflict(409 + 可行动 hint),
  其余内部故障改回 500 space_registration_failed,不再伪装成冲突。
- web:读一次响应体同时取 error 与 hint,**hint 优先**;补两个新码的中文说明。

验证:真 postgres 集成测试复刻线上形态(旧行占名 + 归档 ⇒ 登记成功、旧行改名保持归档、
内容不丢;同名在用 ⇒ 拒绝;同键换 root 在用 ⇒ 拒绝、归档 ⇒ 就地改写);handler 分类测试
(409/409/500,且不回显内部细节);前端 hint 优先用例;make verify 15/15。
2026-10-14 12:59:52 +08:00

157 lines
6.4 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
postgresstore "knowledge-governance/internal/platform/postgres"
)
func TestDingtalkSessionProxyRelaysSidecarStates(t *testing.T) {
sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/sessions":
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"id": "abc123", "state": "qr", "created_at": 1, "expires_at": 2})
case r.Method == http.MethodGet && r.URL.Path == "/sessions/abc123":
_ = json.NewEncoder(w).Encode(map[string]any{"id": "abc123", "state": "success"})
case r.Method == http.MethodGet && r.URL.Path == "/sessions/missing":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"session_not_found"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer sidecar.Close()
t.Setenv("DINGTALK_LOGIN_SIDECAR_URL", sidecar.URL)
start := httptest.NewRequest(http.MethodPost, "/api/v1/dingtalk-session", strings.NewReader(`{}`))
start = start.WithContext(context.WithValue(start.Context(), sessionKey{}, sessionIdentity{Username: "tester", Role: roleAdmin}))
rec := httptest.NewRecorder()
startDingtalkSession(rec, start)
if rec.Code != http.StatusCreated {
t.Fatalf("start proxy status = %d body=%s", rec.Code, rec.Body.String())
}
var created map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
if created["state"] != "qr" {
t.Fatalf("relayed state = %v", created["state"])
}
poll := httptest.NewRequest(http.MethodGet, "/api/v1/dingtalk-session/abc123", nil)
poll = poll.WithContext(context.WithValue(poll.Context(), sessionKey{}, sessionIdentity{Username: "tester", Role: roleAdmin}))
poll.SetPathValue("id", "abc123")
rec = httptest.NewRecorder()
getDingtalkSession(rec, poll)
if !strings.Contains(rec.Body.String(), `"success"`) {
t.Fatalf("poll body = %s", rec.Body.String())
}
missing := httptest.NewRequest(http.MethodGet, "/api/v1/dingtalk-session/missing", nil)
missing = missing.WithContext(context.WithValue(missing.Context(), sessionKey{}, sessionIdentity{Username: "tester", Role: roleAdmin}))
missing.SetPathValue("id", "missing")
rec = httptest.NewRecorder()
getDingtalkSession(rec, missing)
if rec.Code != http.StatusNotFound {
t.Fatalf("missing status = %d", rec.Code)
}
}
func TestDingtalkSessionRejectsUnauthenticatedAndHostileIDs(t *testing.T) {
rec := httptest.NewRecorder()
startDingtalkSession(rec, httptest.NewRequest(http.MethodPost, "/api/v1/dingtalk-session", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated start = %d, want 401", rec.Code)
}
if got := safeSessionID("../etc/passwd"); got != "" {
t.Fatalf("hostile id accepted: %q", got)
}
for _, test := range []struct{ raw, want string }{
{"https://alidocs.dingtalk.com/i/spaces/9JOGOQneRgkneX4Q/overview?rnd=1", "9JOGOQneRgkneX4Q"},
{"9JOGOQneRgkneX4Q", "9JOGOQneRgkneX4Q"},
{"https://example.com/not-a-space", ""},
} {
if got := extractDingtalkSpaceID(test.raw); got != test.want {
t.Fatalf("extractDingtalkSpaceID(%q) = %q, want %q", test.raw, got, test.want)
}
}
}
func TestDingtalkSessionUnavailableWithoutSidecar(t *testing.T) {
t.Setenv("DINGTALK_LOGIN_SIDECAR_URL", "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/dingtalk-session/abc", nil)
req = req.WithContext(context.WithValue(req.Context(), sessionKey{}, sessionIdentity{Username: "tester", Role: roleAdmin}))
req.SetPathValue("id", "abc")
rec := httptest.NewRecorder()
getDingtalkSession(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("sidecar down status = %d, want 503", rec.Code)
}
}
// 登记失败必须能被分类:同名来源仍在用 / 同键换空间 是两种不同的冲突,
// 数据库内部故障不能伪装成"冲突"(否则线上只能看到一个无细节的 409)。
type fakeDingtalkRegistrar struct {
*fakeJobStore
err error
}
func (f *fakeDingtalkRegistrar) RegisterDingtalkSpace(ctx context.Context, in postgresstore.RegisterDingtalkSpaceInput) (string, error) {
if f.err != nil {
return "", f.err
}
return in.ConnectionID(), nil
}
func TestDingtalkSpaceRegistrationErrorClassification(t *testing.T) {
sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/spaces/add" {
_ = json.NewEncoder(w).Encode(map[string]any{"key": "support-kb", "name": "公司级技术支持知识库", "root": "root-support"})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer sidecar.Close()
t.Setenv("DINGTALK_LOGIN_SIDECAR_URL", sidecar.URL)
call := func(storeErr error) (int, string) {
store := &fakeDingtalkRegistrar{fakeJobStore: &fakeJobStore{}, err: storeErr}
handler, err := NewHandler(Config{Store: store})
if err != nil {
t.Fatalf("NewHandler() error = %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/dingtalk-spaces", strings.NewReader(`{"url":"https://alidocs.dingtalk.com/i/spaces/9JOK9EEOMLMWX4Q"}`))
request.Header.Set("X-Governance-Action", "dingtalk-space")
request.Header.Set("Idempotency-Key", "op-dingtalk-register")
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, sessionIdentity{Username: "yaozhanghao", Role: roleAdmin}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
return recorder.Code, recorder.Body.String()
}
status, body := call(postgresstore.ErrDingtalkSpaceNameTaken)
if status != http.StatusConflict || !strings.Contains(body, "space_name_conflict") || !strings.Contains(body, "去掉来源") {
t.Fatalf("同名冲突 = %d %s", status, body)
}
status, body = call(postgresstore.ErrDingtalkSpaceIdentityConflict)
if status != http.StatusConflict || !strings.Contains(body, "space_identity_conflict") {
t.Fatalf("同键冲突 = %d %s", status, body)
}
status, body = call(errors.New("connection refused: password=hunter2"))
if status != http.StatusInternalServerError || !strings.Contains(body, "space_registration_failed") || strings.Contains(body, "hunter2") {
t.Fatalf("内部故障 = %d %s(且不得回显细节)", status, body)
}
// 正常路径仍然成功(别把好的也拦掉)。
status, body = call(nil)
if status != http.StatusCreated || !strings.Contains(body, "connection_id") {
t.Fatalf("正常登记 = %d %s", status, body)
}
}