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