mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 08:57:55 +00:00
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:
110
internal/httpapi/handlers_audit_test.go
Normal file
110
internal/httpapi/handlers_audit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user