背景:auth.go 注释写着 RoleViewer 是只读监控档,但实现从未接线 —— 管理侧 24 个 GET 全是 owner/admin,viewer 全 403。本批把它接上(手机端「全功能管理」的前置之一)。
- routes.go:新增第四道门 viewerAuth = Authorize(owner, admin, viewer);registerAdminRoutes 传入。**只**把 12 条只读监控 GET 从 writeAuth 换过来:personas / sessions / plugins / plugins-installed / channels / iot / automations / rag / observability{metrics,trace,logs} / notifications;其余 12 条读与 32 条写**一行不动**
- 保持 owner/admin(viewer 403):memories / llm / users / backthink / audit / sessions-export / channels-messages ×2;owner-only(35 条)含凭据获取(login-qr/login-status)、channels view/friends、全部写操作
- 三处「编码旧策略」的既有测试按**新权限矩阵**改写(不是放宽):gateway_test 的 /admin/plugins viewer 403→200 且补一条 memories 403;observability_logs 的 ViewerForbidden→ViewerReadsTail;notifications 的 viewer 403→200
- 新增 rbac_viewer_test.go:5 个顶层 / 185 个子测试(12 条×3 角色 200、9+35 条 viewer 403、admin 在读档非 401/403 且在 owner-only 403、56 条匿名 401、写路由 viewer 403);含 3 次变异验证(含**反向变异**:把 memories 误放开为 viewerAuth 会 FAIL)
- auth.go 的 RoleViewer 注释改为**实际**可见面;Docs/api-client 新增 §2.0 管理侧权限矩阵 + 变更记录 v0.40;顺带修 §0 一处不实陈述(原写「管理端另支持 X-API-Key」→ 标注尚未接线)
- 验收:**隔离 worktree(HEAD + 本片)** go build/vet/test 全绿 + linux/amd64 与 darwin/arm64 交叉编译 + gofmt 干净(主工作树此时被另一代理的在途改动占用,见下条提交)
- 已知遗留(未改前端,按范围只记录):管理面板未按角色隐藏分区 → viewer 打开 memories/users/llm/审计等页会吃 403(应做能力降级)
222 lines
7.4 KiB
Go
222 lines
7.4 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
)
|
|
|
|
// logEnvelope decodes the {data:[...]} envelope of the log-tail endpoint.
|
|
type logEnvelope struct {
|
|
Data []AdminLogEntry `json:"data"`
|
|
}
|
|
|
|
// logsServer builds a gateway with an injected log ring buffer already fed by a
|
|
// real logger, so the endpoint is exercised against the production capture path.
|
|
func logsServer(t *testing.T) (*Server, string, string) {
|
|
t.Helper()
|
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
|
ring := logger.NewRingBuffer(16)
|
|
log := logger.New(logger.Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring})
|
|
log.Info("boot ok")
|
|
logger.WithSource(log, "gateway").Warn("slow request")
|
|
logger.WithSource(log, "runtime/loop").Error("loop crashed")
|
|
srv.SetLogBuffer(ring)
|
|
return srv, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)
|
|
}
|
|
|
|
func TestAdminObservabilityLogs_NewestFirstWithFields(t *testing.T) {
|
|
t.Parallel()
|
|
_, base, tok := logsServer(t)
|
|
|
|
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var env logEnvelope
|
|
if err := json.Unmarshal(body, &env); err != nil {
|
|
t.Fatalf("decode: %v (body=%s)", err, body)
|
|
}
|
|
want := []AdminLogEntry{
|
|
{Level: "error", Source: "runtime/loop", Msg: "loop crashed"},
|
|
{Level: "warn", Source: "gateway", Msg: "slow request"},
|
|
{Level: "info", Source: "", Msg: "boot ok"},
|
|
}
|
|
if len(env.Data) != len(want) {
|
|
t.Fatalf("got %d entries, want %d (%+v)", len(env.Data), len(want), env.Data)
|
|
}
|
|
for i, w := range want {
|
|
got := env.Data[i]
|
|
if got.Level != w.Level || got.Source != w.Source || got.Msg != w.Msg {
|
|
t.Fatalf("data[%d] = %+v, want level=%s source=%q msg=%q", i, got, w.Level, w.Source, w.Msg)
|
|
}
|
|
if _, err := time.Parse(time.RFC3339, got.Time); err != nil {
|
|
t.Fatalf("data[%d].Time = %q, want RFC3339: %v", i, got.Time, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAdminObservabilityLogs_LevelFilter(t *testing.T) {
|
|
t.Parallel()
|
|
_, base, tok := logsServer(t)
|
|
|
|
cases := []struct {
|
|
level string
|
|
want []string
|
|
}{
|
|
{"debug", []string{"error", "warn", "info"}},
|
|
{"info", []string{"error", "warn", "info"}},
|
|
{"warn", []string{"error", "warn"}},
|
|
{"error", []string{"error"}},
|
|
{"ERROR", []string{"error"}}, // case-insensitive
|
|
{"", []string{"error", "warn", "info"}},
|
|
}
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.level, func(t *testing.T) {
|
|
t.Parallel()
|
|
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?level="+tc.level, nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var env logEnvelope
|
|
if err := json.Unmarshal(body, &env); err != nil {
|
|
t.Fatalf("decode: %v (body=%s)", err, body)
|
|
}
|
|
if len(env.Data) != len(tc.want) {
|
|
t.Fatalf("level=%q got %d entries, want %d (%+v)", tc.level, len(env.Data), len(tc.want), env.Data)
|
|
}
|
|
for i, lvl := range tc.want {
|
|
if env.Data[i].Level != lvl {
|
|
t.Fatalf("level=%q data[%d].Level = %q, want %q", tc.level, i, env.Data[i].Level, lvl)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminObservabilityLogs_UnknownLevelIs400(t *testing.T) {
|
|
t.Parallel()
|
|
_, base, tok := logsServer(t)
|
|
|
|
for _, bad := range []string{"trace", "warning", "verbose"} {
|
|
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?level="+bad, nil, tok)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("level=%q status = %d, want 400 (body=%s)", bad, resp.StatusCode, body)
|
|
}
|
|
wantErrorShape(t, body)
|
|
if !strings.Contains(string(body), "invalid_level") {
|
|
t.Fatalf("level=%q body = %s, want invalid_level code", bad, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAdminObservabilityLogs_LimitClamped(t *testing.T) {
|
|
t.Parallel()
|
|
_, base, tok := logsServer(t)
|
|
|
|
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?limit=2", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var env logEnvelope
|
|
if err := json.Unmarshal(body, &env); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(env.Data) != 2 || env.Data[0].Msg != "loop crashed" || env.Data[1].Msg != "slow request" {
|
|
t.Fatalf("limit=2 data = %+v, want the two newest", env.Data)
|
|
}
|
|
|
|
// An invalid/absent limit falls back to the default (all 3 here < 200).
|
|
resp, body = doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?limit=abc", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("bad limit status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var env2 logEnvelope
|
|
if err := json.Unmarshal(body, &env2); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(env2.Data) != 3 {
|
|
t.Fatalf("bad limit returned %d entries, want the default (3)", len(env2.Data))
|
|
}
|
|
}
|
|
|
|
func TestParseLogLimit_ClampBounds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mk := func(raw string) int {
|
|
r, err := http.NewRequest(http.MethodGet, "/?limit="+raw, nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
return parseLogLimit(r)
|
|
}
|
|
if got := mk("99999"); got != maxLogLimit {
|
|
t.Fatalf("parseLogLimit(99999) = %d, want max %d", got, maxLogLimit)
|
|
}
|
|
if got := mk("0"); got != defaultLogLimit {
|
|
t.Fatalf("parseLogLimit(0) = %d, want default %d", got, defaultLogLimit)
|
|
}
|
|
if got := mk(""); got != defaultLogLimit {
|
|
t.Fatalf("parseLogLimit(empty) = %d, want default %d", got, defaultLogLimit)
|
|
}
|
|
if got := mk("7"); got != 7 {
|
|
t.Fatalf("parseLogLimit(7) = %d, want 7", got)
|
|
}
|
|
}
|
|
|
|
func TestAdminObservabilityLogs_NoBufferIs501(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
|
|
if resp.StatusCode != http.StatusNotImplemented {
|
|
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
wantErrorShape(t, body)
|
|
if !strings.Contains(string(body), "not_implemented") {
|
|
t.Fatalf("body = %s, want not_implemented code", body)
|
|
}
|
|
}
|
|
|
|
func TestAdminObservabilityLogs_EmptyBufferIs200Empty(t *testing.T) {
|
|
t.Parallel()
|
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
|
srv.SetLogBuffer(logger.NewRingBuffer(4))
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
if !strings.Contains(string(body), `"data":[]`) {
|
|
t.Fatalf("body = %s, want an empty data list", body)
|
|
}
|
|
}
|
|
|
|
// TestAdminObservabilityLogs_ViewerReadsTail locks the viewer gate: the log tail
|
|
// is part of the read-only monitoring surface the viewer role exists for, so a
|
|
// viewer reads it exactly like an owner (the payload is log records, not
|
|
// credentials).
|
|
func TestAdminObservabilityLogs_ViewerReadsTail(t *testing.T) {
|
|
t.Parallel()
|
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
|
srv.SetLogBuffer(logger.NewRingBuffer(4))
|
|
tok := signAny(t, jwt, "acct", auth.RoleViewer)
|
|
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("viewer status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
if !strings.Contains(string(body), `"data":[]`) {
|
|
t.Fatalf("body = %s, want an empty data list", body)
|
|
}
|
|
}
|