9a8a3e7795
build / build (push) Successful in 57s
现象:点「公司级技术支持知识库」登记,返回 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。
157 lines
6.4 KiB
Go
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)
|
|
}
|
|
}
|