e00cab2f70
用户对 系统设置 → 数据源配置 的四条要求: ① 这里不要做文件格式筛选(格式归目录级标注决定); ② 列出我账号下能看到的知识库;③ 列出钉盘能看到哪些;④ 勾选那些作为来源。 ① 来源卡片不再渲染 FilenameFilterEditor(目录级筛选仍在「来源与目录」里,按目录就近覆盖)。 ②③ 新端点 GET /api/v1/dingtalk-spaces/available(管理员,只读):用 dws 列举 - 知识库:wiki +space-list --type orgWikiSpace/myWikiSpace(实测 17 + 1 个) - 钉盘:wiki space list --type mySpace/orgSpace(实测 我的文件 + 6 个组织空间) 实测约束写进命令形态:--limit 上限是 50;翻页必须 --page-all(--max-items 兜底), 否则超过一页的知识库会被静默截断(被截断时页面显示"可能不全"而不是假装列全)。 列不出来 ≠ 空列表:dws 未登录/权限不足 → 503 + 原因;某一类失败只丢那一类。 ④ 面板勾选后走既有登记路径(/api/v1/dingtalk-spaces),首次目录扫描自动排队; 已登记的按空间名标「已是来源」并禁用勾选;扫码会话失效时直接弹二维码,扫完自动重试。 基础设施:api 与 worker 共享同一份 dws 状态(bind mount 同一份,refresh token 是轮换制, 不能各留一份)。子进程 HOME 必须指向 DWS_HOME —— 这条是实测踩出来的:漏了会报"未登录", 已有回归测试钉住。dws 子进程沿用白名单环境,库口令等变量不进子进程。 测试:新包 internal/dingtalkdiscovery(fixture 用真实 dws 输出形状):paging 约束、 截断提示、一类失败不连带、全部失败→ErrUnavailable、子进程环境;httpapi 侧 401/501/503/500 与不泄漏内部错误;OpenAPI 契约(400/503 组件)通过。前端 189 条 + tsc 干净。 面板实测:知识库(18)/钉盘(7)都列出来,已有三条钉钉来源自动标「已是来源」; 勾选「视觉文档」→ 加入为来源 → 新来源 sc-8a4be5b8293d1c9a048c3c7e9b194155(dingtalk,enabled) + source.catalog.refresh 已完成。
121 lines
4.3 KiB
Go
121 lines
4.3 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"knowledge-governance/internal/dingtalkdiscovery"
|
||
)
|
||
|
||
type fakeDingtalkSources struct {
|
||
result dingtalkdiscovery.Result
|
||
err error
|
||
}
|
||
|
||
func (f fakeDingtalkSources) ListAvailableDingtalkSources(context.Context) (dingtalkdiscovery.Result, error) {
|
||
return f.result, f.err
|
||
}
|
||
|
||
func dingtalkSourcesRequest(role string) *http.Request {
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/dingtalk-spaces/available", nil)
|
||
if role == "" {
|
||
return request
|
||
}
|
||
return request.WithContext(context.WithValue(request.Context(), sessionKey{}, sessionIdentity{Username: "tester", Role: role}))
|
||
}
|
||
|
||
func TestListDingtalkSourcesRequiresAdminAndWiring(t *testing.T) {
|
||
rec := httptest.NewRecorder()
|
||
listDingtalkSources(rec, dingtalkSourcesRequest(""), Config{})
|
||
if rec.Code != http.StatusUnauthorized {
|
||
t.Fatalf("unauthenticated = %d, want 401", rec.Code)
|
||
}
|
||
|
||
// 未接线:501("没接线"不能被当成空列表)。
|
||
rec = httptest.NewRecorder()
|
||
listDingtalkSources(rec, dingtalkSourcesRequest(roleAdmin), Config{})
|
||
if rec.Code != http.StatusNotImplemented {
|
||
t.Fatalf("unwired = %d, want 501 body=%s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestListDingtalkSourcesReportsEnvironmentProblemsAsUnavailable(t *testing.T) {
|
||
// 用包装错误模拟真实映射:errors.Is 必须认出来,页面才会显示"暂时列不出来"。
|
||
config := Config{DingtalkSources: fakeDingtalkSources{err: wrapUnavailable()}}
|
||
rec := httptest.NewRecorder()
|
||
listDingtalkSources(rec, dingtalkSourcesRequest(roleAdmin), config)
|
||
if rec.Code != http.StatusServiceUnavailable {
|
||
t.Fatalf("unavailable = %d, want 503 body=%s", rec.Code, rec.Body.String())
|
||
}
|
||
if !strings.Contains(rec.Body.String(), "dingtalk_discovery_unavailable") {
|
||
t.Fatalf("body = %s", rec.Body.String())
|
||
}
|
||
}
|
||
|
||
func wrapUnavailable() error {
|
||
return &wrappedUnavailable{err: dingtalkdiscovery.ErrUnavailable}
|
||
}
|
||
|
||
type wrappedUnavailable struct{ err error }
|
||
|
||
func (w *wrappedUnavailable) Error() string { return "list knowledge bases: " + w.err.Error() }
|
||
func (w *wrappedUnavailable) Unwrap() error { return w.err }
|
||
|
||
func TestListDingtalkSourcesReturnsKnowledgeBasesAndDriveSpaces(t *testing.T) {
|
||
config := Config{DingtalkSources: fakeDingtalkSources{result: dingtalkdiscovery.Result{
|
||
KnowledgeBases: []dingtalkdiscovery.KnowledgeBase{{Name: "ATOM知识库", SpaceID: "9JOGOQneRgkneX4Q", URL: "https://alidocs.dingtalk.com/i/spaces/9JOGOQneRgkneX4Q/overview"}},
|
||
DriveSpaces: []dingtalkdiscovery.DriveSpace{{Name: "全员文件夹", SpaceID: "102571304", SpaceType: "orgSpace"}},
|
||
DriveNotice: "list orgSpace: permission denied",
|
||
FetchedAt: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC),
|
||
}}}
|
||
rec := httptest.NewRecorder()
|
||
listDingtalkSources(rec, dingtalkSourcesRequest(roleAdmin), config)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||
}
|
||
var payload struct {
|
||
Data struct {
|
||
KnowledgeBases []struct {
|
||
Name string `json:"name"`
|
||
SpaceID string `json:"space_id"`
|
||
URL string `json:"url"`
|
||
} `json:"knowledge_bases"`
|
||
DriveSpaces []struct {
|
||
Name string `json:"name"`
|
||
SpaceType string `json:"space_type"`
|
||
} `json:"drive_spaces"`
|
||
DriveNotice string `json:"drive_notice"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(payload.Data.KnowledgeBases) != 1 || payload.Data.KnowledgeBases[0].Name != "ATOM知识库" {
|
||
t.Fatalf("knowledge bases = %+v", payload.Data.KnowledgeBases)
|
||
}
|
||
if len(payload.Data.DriveSpaces) != 1 || payload.Data.DriveSpaces[0].SpaceType != "orgSpace" {
|
||
t.Fatalf("drive spaces = %+v", payload.Data.DriveSpaces)
|
||
}
|
||
if payload.Data.DriveNotice == "" {
|
||
t.Fatal("drive notice should be relayed so the page can explain why 钉盘 is missing")
|
||
}
|
||
}
|
||
|
||
func TestListDingtalkSourcesHidesInternalErrors(t *testing.T) {
|
||
config := Config{DingtalkSources: fakeDingtalkSources{err: errors.New("internal detail: connection string leaked")}}
|
||
rec := httptest.NewRecorder()
|
||
listDingtalkSources(rec, dingtalkSourcesRequest(roleAdmin), config)
|
||
if rec.Code != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d", rec.Code)
|
||
}
|
||
if strings.Contains(rec.Body.String(), "connection string") {
|
||
t.Fatalf("internal error leaked: %s", rec.Body.String())
|
||
}
|
||
}
|