package httpapi_test import ( "bytes" "errors" "log/slog" "net/http" "os" "path/filepath" "strings" "testing" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" "git.yeij.top/AskaEth/Scoreboard/server/internal/logging" ) // fileLogEnv 把服务端日志按「放行 / 拦截」两类真的写进临时目录,返回两个目录路径。 // 到这里为止的用例都只看内存缓冲,这一份是「真的读文件」的验收:分流、级别、落盘都要落地。 func fileLogEnv(t *testing.T) (*testEnv, string, string) { t.Helper() dir := t.TempDir() handler, closer := logging.New(logging.Options{ Dir: dir, Level: slog.LevelInfo, MaxSizeMB: 20, KeepFiles: 10, Console: &bytes.Buffer{}, }) t.Cleanup(func() { _ = closer.Close() }) env := newTestEnvWith(t, func(o *httpapi.Options) { o.Logger = slog.New(handler) }) return env, filepath.Join(dir, logging.DirOperations), filepath.Join(dir, logging.DirInterceptions) } // readCategory 读某类日志今天的文件;文件还没建出来时返回空串。 func readCategory(t *testing.T, dir, prefix string) string { t.Helper() path := filepath.Join(dir, prefix+"-"+time.Now().Format("20060102")+".log") raw, err := os.ReadFile(path) if os.IsNotExist(err) { return "" } if err != nil { t.Fatalf("读日志 %s: %v", path, err) } return string(raw) } // waitCategory 等到某类日志出现目标片段(审计 INFO 在响应写完之后才落)。 func waitCategory(t *testing.T, dir, prefix, want string) string { t.Helper() for i := 0; i < 200; i++ { got := readCategory(t, dir, prefix) if strings.Contains(got, want) { return got } time.Sleep(5 * time.Millisecond) } t.Fatalf("等不到 %s/%s 里出现 %q,当前内容:\n%s", dir, prefix, want, readCategory(t, dir, prefix)) return "" } // 中间件层的拦截(403/401 这类发生在 handler 之前的拦截)必须落进 interceptions/, // 带中文原因与指向中间件的拦截点;同时验证两类文件互不串门、5xx 只进拦截类。 func TestMiddlewareInterceptsLandInInterceptionFile(t *testing.T) { env, opsDir, interDir := fileLogEnv(t) env.seed(t) // recorder 访问 admin-only 的只读接口 → RequireRole 403。 env.createUser("recorder", "recorder-pass", "recorder") recorder, _ := env.client() env.login(recorder, "recorder", "recorder-pass") if status, _ := env.do(recorder, http.MethodGet, "/api/audit-logs", nil, false); status != http.StatusForbidden { t.Fatalf("recorder 读审计应 403,得到 %d", status) } // 已登录但不带 CSRF 头的写请求 → CSRF 中间件 403。 if status, _ := env.do(recorder, http.MethodPost, "/api/teams", map[string]any{"code": "X", "name": "越权单位"}, false); status != http.StatusForbidden { t.Fatalf("缺 CSRF 的写请求应 403,得到 %d", status) } // 必须改密的账号写操作 → MustChangePassword 403。 lockedID := env.createUser("locked", "locked-pass", "recorder") if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, lockedID); err != nil { t.Fatalf("设置 must_change_password: %v", err) } locked, _ := env.client() env.login(locked, "locked", "locked-pass") if status, _ := env.do(locked, http.MethodPost, "/api/teams", map[string]any{"code": "Y", "name": "越权单位"}, true); status != http.StatusForbidden { t.Fatalf("改密前写操作应 403,得到 %d", status) } // 未登录的写请求 → RequireWrite 401。 guest, _ := env.client() if status, _ := env.do(guest, http.MethodPost, "/api/teams", map[string]any{"code": "Z", "name": "未登录"}, false); status != http.StatusUnauthorized { t.Fatalf("guest 写请求应 401,得到 %d", status) } inter := waitCategory(t, interDir, logging.DirInterceptions, "RequireRole") for _, want := range []string{ "请求被拦截", "无权访问该资源", "请先登录", "CSRF 校验失败,请退出后重新登录", "请先修改初始密码", "internal/middleware/middleware.go:", } { if !strings.Contains(inter, want) { t.Fatalf("interceptions 应含 %q,得到:\n%s", want, inter) } } // 四个中间件各留下一条 WARN,拦截点都在 middleware.go、都不是 response.go。 for _, fn := range []string{"RequireRole", "RequireWrite", "CSRF", "MustChangePassword"} { if !strings.Contains(inter, fn) { t.Fatalf("interceptions 应含拦截点函数 %s,得到:\n%s", fn, inter) } } if strings.Contains(inter, "response.go") { t.Fatalf("中间件拦截的拦截点不该落在 response.go,得到:\n%s", inter) } // 放行类里不能出现这些拦截行。 ops := readCategory(t, opsDir, logging.DirOperations) for _, unwanted := range []string{"请求被拦截", "无权访问该资源", "CSRF 校验失败"} { if strings.Contains(ops, unwanted) { t.Fatalf("operations 不该含拦截日志 %q,得到:\n%s", unwanted, ops) } } // 放行类里应当有登录这条 INFO(证明两类文件各自都有内容)。 if !strings.Contains(waitCategory(t, opsDir, logging.DirOperations, "操作已放行"), `"action":"auth.login"`) { t.Fatalf("operations 应含放行日志,得到:\n%s", readCategory(t, opsDir, logging.DirOperations)) } } // 5xx 的归属:只进 interceptions/,不进 operations/。 func TestServerErrorGoesToInterceptionFileOnly(t *testing.T) { dir := t.TempDir() // 未知 action 现在是 4xx;这里用真正的下发失败(写 socket 失败)造 5xx。 sender := &strictDisplaySender{failWith: errors.New("写 socket 失败: broken pipe")} handler, closer := logging.New(logging.Options{Dir: dir, Level: slog.LevelInfo, Console: &bytes.Buffer{}}) defer func() { _ = closer.Close() }() env := newTestEnvWith(t, func(o *httpapi.Options) { o.Logger = slog.New(handler) o.Display = sender }) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") if status, _ := env.do(client, http.MethodPost, "/api/display/command", map[string]any{"action": "goto", "payload": map[string]any{"page": "medals"}}, true); status != http.StatusInternalServerError { t.Fatalf("下发失败应 500,得到 %d", status) } inter := waitCategory(t, filepath.Join(dir, logging.DirInterceptions), logging.DirInterceptions, "请求失败") if !strings.Contains(inter, "displayCommandHandler") { t.Fatalf("ERROR 行应带拦截点,得到:\n%s", inter) } ops := readCategory(t, filepath.Join(dir, logging.DirOperations), logging.DirOperations) if strings.Contains(ops, "请求失败") { t.Fatalf("5xx 不该进 operations,得到:\n%s", ops) } }