mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
新增端点 (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
111 lines
3.1 KiB
Go
111 lines
3.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|