feat: 审计 CSV 导出 — 用户/API Key/集群操作日志一键下载

新增端点 (read 级, 审计 viewer 角色即可导出):
- GET /audit/users.csv: 账号清单 + 最后登录时间
- GET /audit/apikeys.csv: 密钥清单 (前缀+scope+最后使用+过期)
- GET /audit/cluster-log.csv: 令牌环操作日志时间线
  (seq/time_utc/node/kind/detail/data_json 六列, detail 为人读摘要,
   data_json 保留无损原始载荷)

安全设计:
- RFC4180 转义 (引号/逗号/换行)
- 公式注入防御: =/+/@/tab/- 开头单元格加 ' 前缀
- ISO8601 UTC 时间戳, Excel 直接排序
- 明文密钥不可逆, 仅导出展示前缀

前端:
- UsersView: 账号表/API 密钥表各加「⤓ 导出 CSV」按钮
- ClusterView: 日志导出下拉新增 CSV 选项 (走服务端生成)
- api.ts: downloadAuditCsv() 统一下载管道

测试: csvEscape 全用例 / users+apikeys CSV 内容断言 / 未认证 401
This commit is contained in:
JianFeeeee
2026-08-24 22:52:32 +08:00
parent 4a41608d94
commit ef98d9dca1
10 changed files with 645 additions and 231 deletions

View File

@ -0,0 +1,110 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"webui4frpc/internal/store"
)
// newAuditHarness builds a Handler + mux with a temp store, mirroring
// TestSaveCanvasPublishesRevokeTask's setup minus the ring.
func newAuditHarness(t *testing.T) (*Handler, http.Handler) {
t.Helper()
dir := t.TempDir()
st, err := store.New(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if _, err := st.CreateUser("auditor", "pw-auditor", "viewer"); err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateApiKey(1, "ci-key", "read"); err != nil {
t.Fatal(err)
}
h := &Handler{Store: st, WorkDir: dir, User: "admin", Password: "pw"}
mux, err := NewServeMux(h)
if err != nil {
t.Fatal(err)
}
return h, mux
}
func TestCsvEscape(t *testing.T) {
cases := []struct{ in, want string }{
{"plain", "plain"},
{"", ""},
{"a,b", `"a,b"`},
{`say "hi"`, `"say ""hi"""`},
{"line\nbreak", "\"line\nbreak\""},
{"=cmd()", "'=cmd()"}, // formula injection defused
{"+1+1", "'+1+1"}, // formula injection defused
{"@SUM(A1)", "'@SUM(A1)"}, // formula injection defused
{"-2+3", "'-2+3"}, // formula injection defused
}
for _, c := range cases {
if got := csvEscape(c.in); got != c.want {
t.Errorf("csvEscape(%q)=%q want %q", c.in, got, c.want)
}
}
}
func TestIsoTimeEmpty(t *testing.T) {
if got := isoTime(0); got != "" {
t.Errorf("isoTime(0)=%q want empty", got)
}
}
func TestAuditUsersCsv(t *testing.T) {
_, mux := newAuditHarness(t)
req := httptest.NewRequest(http.MethodGet, "/api/manager/audit/users.csv", nil)
req.SetBasicAuth("admin", "pw")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"id,username,role,enabled,system,created_at,last_login_at", "auditor,viewer"} {
if !strings.Contains(body, want) {
t.Errorf("CSV missing %q:\n%s", want, body)
}
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") {
t.Errorf("content-type=%q", ct)
}
}
func TestAuditApiKeysCsv(t *testing.T) {
_, mux := newAuditHarness(t)
req := httptest.NewRequest(http.MethodGet, "/api/manager/audit/apikeys.csv", nil)
req.SetBasicAuth("admin", "pw")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "key_prefix,owner,label,scope") {
t.Errorf("CSV header missing:\n%s", body)
}
if !strings.Contains(body, "w4f_") || !strings.Contains(body, "ci-key") {
t.Errorf("key row missing:\n%s", body)
}
}
func TestAuditEndpointsNeedAuth(t *testing.T) {
_, mux := newAuditHarness(t)
for _, path := range []string{"/api/manager/audit/users.csv", "/api/manager/audit/apikeys.csv"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s without auth: status=%d want 401", path, rec.Code)
}
}
}