- 两个只读端点:GET /admin/channels/events(列表,8 个过滤条件 + 分页钳制)与 .../events/counters(逐类计数,恒带 approx:true 如实标「近似」) - 诚实边界:>90 天窗口 ⇒ 400(不静默裁剪);未知 platform/档位 ⇒ 400;**未接线 ⇒ 500 channel_events_unavailable**(绝不回空 200 假装「没有事件」);微信平台的空如实说明「该渠道不提供非消息事件」而不借别平台顶替 - 面板:新 section「渠道事件」(计数卡 + 事件行 + 过滤条 + 30s 静默刷新),空/错误态分开且失败显示错误不显示 0 - 组合根接线(主会话补):wire_frontend.go 的 Deps 注入 EventStore: a.channelEventStore()(未接线时为真 nil ⇒ 保留诚实 500) - 契约不升版(查询面 v0.67 已登记):就地改「未实现 → 已实现 + 端点路径」,并补登记 5 处原先留白的落地值(camelCase / 通用 bad_request / RBAC owner|admin / data.approx / total 语义) - 顺带把 §5 三个 CHANNEL_EVENT_* env 键由「尚未实现」改「已接线」(E4) - 变异实测:去掉 90 天上限 ⇒ status 200 want 400;未接线回空 200 ⇒ status 200 want 500 - 债务登记:events.Filter 无 AccountID 列 ⇒ 服务端暂不能按账号筛(列表仍如实下发 accountId),要加需先改 internal/runtime/events
242 lines
11 KiB
Go
242 lines
11 KiB
Go
package gateway
|
||
|
||
import (
|
||
"net/http"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
)
|
||
|
||
// rbacRoute 是一条管理路由加上抵达它所需的方法。name 是
|
||
// 子测试标签,因此失败时不必读路径就能指明是哪条路由。
|
||
type rbacRoute struct {
|
||
name string
|
||
method string
|
||
path string
|
||
}
|
||
|
||
// viewerReadRoutes 是只读监控层:gateway/routes.go 中接到 viewerAuth
|
||
// 的确切管理路由集合(owner/admin/viewer)。它必须与
|
||
// registerAdminRoutes 保持同步 —— 把一条路由加进 viewerAuth 而不
|
||
// 加进这里会让它未被测试,而把一条移出 viewerAuth 会让对应
|
||
// 子测试失败(见本批次的变异验证记录)。
|
||
var viewerReadRoutes = []rbacRoute{
|
||
{"personas", http.MethodGet, "/api/v1/admin/personas"},
|
||
{"sessions", http.MethodGet, "/api/v1/admin/sessions"},
|
||
{"plugins", http.MethodGet, "/api/v1/admin/plugins"},
|
||
{"plugins/installed", http.MethodGet, "/api/v1/admin/plugins/installed"},
|
||
{"channels", http.MethodGet, "/api/v1/admin/channels"},
|
||
{"iot", http.MethodGet, "/api/v1/admin/iot"},
|
||
{"automations", http.MethodGet, "/api/v1/admin/automations"},
|
||
{"rag", http.MethodGet, "/api/v1/admin/rag"},
|
||
{"observability/metrics", http.MethodGet, "/api/v1/admin/observability/metrics"},
|
||
{"observability/trace", http.MethodGet, "/api/v1/admin/observability/trace"},
|
||
{"observability/logs", http.MethodGet, "/api/v1/admin/observability/logs"},
|
||
{"notifications", http.MethodGet, "/api/v1/admin/notifications"},
|
||
}
|
||
|
||
// ownerAdminReadRoutes 是仍保持 owner/admin 的管理读层:私有
|
||
// 内容、其他账号的数据与审计链。viewer 必须得到 403;
|
||
// admin 必须通过该门(handler 自身的状态在此不在范围内,因此
|
||
// 对 admin 只断言「不是 401/403」)。
|
||
var ownerAdminReadRoutes = []rbacRoute{
|
||
{"memories", http.MethodGet, "/api/v1/admin/memories"},
|
||
{"channels/messages", http.MethodGet, "/api/v1/admin/channels/messages"},
|
||
{"channels/{platform}/messages", http.MethodGet, "/api/v1/admin/channels/kook/messages"},
|
||
{"channels/events", http.MethodGet, "/api/v1/admin/channels/events"},
|
||
{"channels/events/counters", http.MethodGet, "/api/v1/admin/channels/events/counters"},
|
||
{"llm", http.MethodGet, "/api/v1/admin/llm"},
|
||
{"users", http.MethodGet, "/api/v1/admin/users"},
|
||
{"backthink", http.MethodGet, "/api/v1/admin/backthink"},
|
||
{"observability/audit", http.MethodGet, "/api/v1/admin/observability/audit"},
|
||
{"sessions/{id}/export", http.MethodGet, "/api/v1/admin/sessions/web/export"},
|
||
{"rag/query", http.MethodPost, "/api/v1/admin/rag/query"},
|
||
}
|
||
|
||
// ownerOnlyRoutes 是保留给 owner 的每一条路由,包括
|
||
// 获取凭据的渠道端点(login-qr/login-status)与
|
||
// 改变状态的写操作。admin 也必须被拒绝,因此该列表同时用作
|
||
// owner>admin 边界测试。
|
||
var ownerOnlyRoutes = []rbacRoute{
|
||
{"channels/{platform}/login-qr", http.MethodGet, "/api/v1/admin/channels/weixin-ilink/login-qr"},
|
||
{"channels/{platform}/login-status", http.MethodGet, "/api/v1/admin/channels/weixin-ilink/login-status"},
|
||
{"channels/{platform}/view", http.MethodGet, "/api/v1/admin/channels/kook/view"},
|
||
{"channels/{platform}/friends", http.MethodGet, "/api/v1/admin/channels/kook/friends"},
|
||
{"channels/{platform}/friend/handle", http.MethodPost, "/api/v1/admin/channels/kook/friend/handle"},
|
||
{"channels/{platform}/message/test", http.MethodPost, "/api/v1/admin/channels/kook/message/test"},
|
||
{"channels/{platform}/config PUT", http.MethodPut, "/api/v1/admin/channels/kook/config"},
|
||
{"channels/{platform}/config DELETE", http.MethodDelete, "/api/v1/admin/channels/kook/config"},
|
||
{"channels/open", http.MethodPost, "/api/v1/admin/channels/open"},
|
||
{"channels/close", http.MethodPost, "/api/v1/admin/channels/close"},
|
||
{"channels/test", http.MethodPost, "/api/v1/admin/channels/test"},
|
||
{"plugins/policy", http.MethodPost, "/api/v1/admin/plugins/policy"},
|
||
{"plugins/install", http.MethodPost, "/api/v1/admin/plugins/install"},
|
||
{"plugins/uninstall", http.MethodPost, "/api/v1/admin/plugins/uninstall"},
|
||
{"personas POST", http.MethodPost, "/api/v1/admin/personas"},
|
||
{"personas/{id} PUT", http.MethodPut, "/api/v1/admin/personas/cyrene"},
|
||
{"users POST", http.MethodPost, "/api/v1/admin/users"},
|
||
{"users/apikey", http.MethodPost, "/api/v1/admin/users/apikey"},
|
||
{"users/apikey/{id} DELETE", http.MethodDelete, "/api/v1/admin/users/apikey/abc123"},
|
||
{"iot/{id}/control", http.MethodPost, "/api/v1/admin/iot/dev1/control"},
|
||
{"iot/scenarios", http.MethodPost, "/api/v1/admin/iot/scenarios"},
|
||
{"iot/rules", http.MethodPost, "/api/v1/admin/iot/rules"},
|
||
{"automations POST", http.MethodPost, "/api/v1/admin/automations"},
|
||
{"automations/reminders POST", http.MethodPost, "/api/v1/admin/automations/reminders"},
|
||
{"automations/{id}/run", http.MethodPost, "/api/v1/admin/automations/a1/run"},
|
||
{"automations/{id} DELETE", http.MethodDelete, "/api/v1/admin/automations/a1"},
|
||
{"llm POST", http.MethodPost, "/api/v1/admin/llm"},
|
||
{"llm/test", http.MethodPost, "/api/v1/admin/llm/test"},
|
||
{"llm/reset-from-env", http.MethodPost, "/api/v1/admin/llm/reset-from-env"},
|
||
{"memories POST", http.MethodPost, "/api/v1/admin/memories"},
|
||
{"memories/search", http.MethodPost, "/api/v1/admin/memories/search"},
|
||
{"rag POST", http.MethodPost, "/api/v1/admin/rag"},
|
||
{"rag/delete", http.MethodPost, "/api/v1/admin/rag/delete"},
|
||
{"system/revoke-keys", http.MethodPost, "/api/v1/admin/system/revoke-keys"},
|
||
{"system/rotate-jwt", http.MethodPost, "/api/v1/admin/system/rotate-jwt"},
|
||
{"system/backup", http.MethodPost, "/api/v1/admin/system/backup"},
|
||
{"system/restore", http.MethodPost, "/api/v1/admin/system/restore"},
|
||
}
|
||
|
||
// newRBACEnv 用真实 JWT 服务、能力注册表
|
||
// 与注入的日志环形缓冲构建网关,使每条 viewer 可读路由都能返回
|
||
// 200(否则日志尾部会是诚实的 501)。
|
||
func newRBACEnv(t *testing.T) *testEnv {
|
||
t.Helper()
|
||
env := newEnv(t, nil, true)
|
||
env.srv.SetLogBuffer(logger.NewRingBuffer(8))
|
||
return env
|
||
}
|
||
|
||
// rbacBody 返回一个足够合法的请求体,使决定状态的是 RBAC 门 ——
|
||
// 而不是解码错误。解码失败仍返回 4xx,这绝不会把
|
||
// 403 变成假通过,但合法请求体让断言对每条路由
|
||
// 的预期保持诚实。
|
||
func rbacBody(rt rbacRoute) any {
|
||
switch rt.name {
|
||
case "rag/query":
|
||
return map[string]any{"query": "cyrene"}
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// TestAdminRBAC_ViewerReadsMonitoringSurface 锁定 viewer 层:十二条只读
|
||
// 监控路由对 viewer、admin 与 owner 都返回 200
|
||
// (owner>admin>viewer 都满足 viewerAuth)。
|
||
func TestAdminRBAC_ViewerReadsMonitoringSurface(t *testing.T) {
|
||
t.Parallel()
|
||
env := newRBACEnv(t)
|
||
for _, role := range []auth.Role{auth.RoleViewer, auth.RoleAdmin, auth.RoleOwner} {
|
||
role := role
|
||
t.Run(string(role), func(t *testing.T) {
|
||
tok := env.token(t, role)
|
||
for _, rt := range viewerReadRoutes {
|
||
rt := rt
|
||
t.Run(rt.name, func(t *testing.T) {
|
||
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("%s as %s: status = %d, want 200 (body=%s)", rt.path, role, resp.StatusCode, body)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAdminRBAC_AnonymousIsUnauthorized 证明未出示 bearer token 时每条
|
||
// 管理路由(两层)都以 401 失败关闭 —— 不是 403,也绝不是
|
||
// 静默成功。
|
||
func TestAdminRBAC_AnonymousIsUnauthorized(t *testing.T) {
|
||
t.Parallel()
|
||
env := newRBACEnv(t)
|
||
routes := make([]rbacRoute, 0, len(viewerReadRoutes)+len(ownerAdminReadRoutes)+len(ownerOnlyRoutes))
|
||
routes = append(routes, viewerReadRoutes...)
|
||
routes = append(routes, ownerAdminReadRoutes...)
|
||
routes = append(routes, ownerOnlyRoutes...)
|
||
for _, rt := range routes {
|
||
rt := rt
|
||
t.Run(rt.name, func(t *testing.T) {
|
||
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), "")
|
||
if resp.StatusCode != http.StatusUnauthorized {
|
||
t.Fatalf("%s anonymous: status = %d, want 401 (body=%s)", rt.path, resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes 锁定排除列表:viewer 在
|
||
// 每一条携带私有内容、其他账号数据或审计记录的
|
||
// 管理读上都是 403,在每一条仅 owner 路由(写操作加上
|
||
// 获取凭据的渠道端点)上亦然。
|
||
func TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes(t *testing.T) {
|
||
t.Parallel()
|
||
env := newRBACEnv(t)
|
||
viewer := env.token(t, auth.RoleViewer)
|
||
routes := make([]rbacRoute, 0, len(ownerAdminReadRoutes)+len(ownerOnlyRoutes))
|
||
routes = append(routes, ownerAdminReadRoutes...)
|
||
routes = append(routes, ownerOnlyRoutes...)
|
||
for _, rt := range routes {
|
||
rt := rt
|
||
t.Run(rt.name, func(t *testing.T) {
|
||
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), viewer)
|
||
if resp.StatusCode != http.StatusForbidden {
|
||
t.Fatalf("viewer %s: status = %d, want 403 (body=%s)", rt.path, resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAdminRBAC_AdminPassesReadGateButNotOwnerGate 区分上面两层:admin
|
||
// 在 owner/admin 读路由上通过该门(其 handler 仍可能返回
|
||
// 404/501 —— 只断言门的判定)但在每一条仅 owner 路由上
|
||
// 是 403,证明 admin 绝不越入 owner。
|
||
func TestAdminRBAC_AdminPassesReadGateButNotOwnerGate(t *testing.T) {
|
||
t.Parallel()
|
||
env := newRBACEnv(t)
|
||
admin := env.token(t, auth.RoleAdmin)
|
||
|
||
for _, rt := range ownerAdminReadRoutes {
|
||
rt := rt
|
||
t.Run("read/"+rt.name, func(t *testing.T) {
|
||
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), admin)
|
||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||
t.Fatalf("admin %s: status = %d, want the gate to admit admin (body=%s)", rt.path, resp.StatusCode, body)
|
||
}
|
||
})
|
||
}
|
||
for _, rt := range ownerOnlyRoutes {
|
||
rt := rt
|
||
t.Run("owner-only/"+rt.name, func(t *testing.T) {
|
||
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), admin)
|
||
if resp.StatusCode != http.StatusForbidden {
|
||
t.Fatalf("admin %s: status = %d, want 403 (body=%s)", rt.path, resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAdminRBAC_ViewerForbiddenOnWrites 钉住接线说明中点名的两条写
|
||
// 路由,使今后的门的改动无法悄悄把变更能力交给 viewer。
|
||
func TestAdminRBAC_ViewerForbiddenOnWrites(t *testing.T) {
|
||
t.Parallel()
|
||
env := newRBACEnv(t)
|
||
viewer := env.token(t, auth.RoleViewer)
|
||
for _, rt := range []rbacRoute{
|
||
{"POST /admin/personas", http.MethodPost, "/api/v1/admin/personas"},
|
||
{"DELETE /admin/users/apikey/{id}", http.MethodDelete, "/api/v1/admin/users/apikey/abc123"},
|
||
} {
|
||
rt := rt
|
||
t.Run(rt.name, func(t *testing.T) {
|
||
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, nil, viewer)
|
||
if resp.StatusCode != http.StatusForbidden {
|
||
t.Fatalf("viewer %s: status = %d, want 403 (body=%s)", rt.path, resp.StatusCode, body)
|
||
}
|
||
wantErrorShape(t, body)
|
||
})
|
||
}
|
||
}
|