fix: 独立审计日志并补齐令牌身份与失败记录

This commit is contained in:
Nixevol
2026-09-30 16:22:49 +08:00
parent eb4e2db918
commit b7c8b6ffd6
12 changed files with 641 additions and 124 deletions
+366
View File
@@ -0,0 +1,366 @@
package admin_test
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.asio.asia/nixevol/NixMsg/internal/admin"
"git.asio.asia/nixevol/NixMsg/internal/app/group"
"git.asio.asia/nixevol/NixMsg/internal/auth"
"git.asio.asia/nixevol/NixMsg/internal/config"
"git.asio.asia/nixevol/NixMsg/internal/store"
)
func setupH02(t *testing.T) (warnBuf, auditBuf *bytes.Buffer, db *store.DB, srv *httptest.Server, client *http.Client) {
t.Helper()
prev := slog.Default()
t.Cleanup(func() { slog.SetDefault(prev) })
warnBuf = &bytes.Buffer{}
auditBuf = &bytes.Buffer{}
slog.SetDefault(slog.New(slog.NewJSONHandler(warnBuf, &slog.HandlerOptions{Level: slog.LevelWarn})))
auditLog := slog.New(slog.NewJSONHandler(auditBuf, &slog.HandlerOptions{Level: slog.LevelInfo}))
dir := t.TempDir()
var err error
db, err = store.Open(filepath.Join(dir, "data"), "FULL")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
hash := auth.NewStubHashPool()
if seedErr := admin.SeedAdminPassword(context.Background(), db, hash, testPassword); seedErr != nil {
t.Fatal(seedErr)
}
gApp := group.New(group.Config{DB: db, MaxGroupMembers: 100})
h := admin.New(admin.Deps{
DB: db,
Hash: hash,
Tokens: admin.NewRandomAPITokens(),
Locks: admin.NewMemoryLoginLocks(),
Logger: slog.Default(),
AuditLogger: auditLog,
Groups: gApp,
Config: config.Default(),
})
srv = httptest.NewServer(h)
t.Cleanup(srv.Close)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
client = &http.Client{Jar: jar}
return warnBuf, auditBuf, db, srv, client
}
func parseSlogJSON(t *testing.T, buf *bytes.Buffer) []map[string]any {
t.Helper()
var out []map[string]any
for _, line := range bytes.Split(buf.Bytes(), []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var m map[string]any
if err := json.Unmarshal(line, &m); err != nil {
t.Fatalf("slog json: %v line=%s", err, line)
}
out = append(out, m)
}
return out
}
func lastAuditByAction(t *testing.T, recs []map[string]any, action string) map[string]any {
t.Helper()
var last map[string]any
for _, r := range recs {
if r["msg"] == "admin_audit" && r["action"] == action {
last = r
}
}
if last == nil {
t.Fatalf("missing admin_audit action=%s recs=%v", action, recs)
}
return last
}
func assertAuditCore(t *testing.T, rec map[string]any) {
t.Helper()
for _, k := range []string{"actor", "action", "result", "ip"} {
v, _ := rec[k].(string)
if v == "" {
t.Fatalf("missing %s in %v", k, rec)
}
}
}
func assertNoSecrets(t *testing.T, raw string, secrets ...string) {
t.Helper()
for _, s := range secrets {
if s != "" && strings.Contains(raw, s) {
t.Fatalf("secret %q leaked in logs:\n%s", s, raw)
}
}
}
func TestH02AuditIndependentOfWarnLevel(t *testing.T) {
warnBuf, auditBuf, _, srv, client := setupH02(t)
login(t, client, srv.URL)
recs := parseSlogJSON(t, auditBuf)
loginRec := lastAuditByAction(t, recs, "login")
assertAuditCore(t, loginRec)
if loginRec["actor"] != "admin" {
t.Fatalf("actor=%v", loginRec["actor"])
}
if _, ok := loginRec["token_id"]; ok {
t.Fatalf("cookie login should omit token_id: %v", loginRec)
}
warnRaw := warnBuf.String()
if strings.Contains(warnRaw, `"msg":"admin_audit"`) || strings.Contains(warnRaw, `"msg":"admin_auth_fail"`) {
t.Fatalf("warn logger should drop audit: %s", warnRaw)
}
if !strings.Contains(auditBuf.String(), `"msg":"admin_audit"`) {
t.Fatalf("audit logger missing admin_audit: %s", auditBuf.String())
}
}
func TestH02AuthFailAndNoSecrets(t *testing.T) {
_, auditBuf, _, srv, client := setupH02(t)
wrong := "wrong-password-h02!!"
res := postJSON(t, client, srv.URL+"/api/admin/login",
`{"username":"admin","password":"`+wrong+`"}`, nil)
env := decodeEnv(t, res)
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("want 401 got %d %+v", res.StatusCode, env)
}
res = doReq(t, client, http.MethodGet, srv.URL+"/api/admin/me", "",
map[string]string{"Authorization": "Bearer nxm_deadbeefdeadbeefdeadbeefdeadbeef"})
env = decodeEnv(t, res)
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad token want 401 got %d %+v", res.StatusCode, env)
}
recs := parseSlogJSON(t, auditBuf)
var badPass, badTok bool
for _, r := range recs {
if r["msg"] != "admin_auth_fail" {
continue
}
if r["reason"] == "bad_password" {
badPass = true
}
if r["reason"] == "invalid_token" {
badTok = true
}
if r["ip"] == "" {
t.Fatalf("admin_auth_fail missing ip: %v", r)
}
}
if !badPass || !badTok {
t.Fatalf("want bad_password and invalid_token, recs=%v", recs)
}
assertNoSecrets(t, auditBuf.String(), wrong, testPassword, "nxm_deadbeefdeadbeefdeadbeefdeadbeef")
}
func TestH02TokenFieldsDetailsAndNameLength(t *testing.T) {
_, auditBuf, db, srv, client := setupH02(t)
login(t, client, srv.URL)
insertEndpoint(t, db, "ep-h02", "admin", true, false)
res := postJSON(t, client, srv.URL+"/api/admin/tokens",
`{"name":"`+strings.Repeat("名", 65)+`"}`, csrfHeaders())
env := decodeEnv(t, res)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("65-char name want 400 got %d %+v", res.StatusCode, env)
}
res = postJSON(t, client, srv.URL+"/api/admin/tokens",
`{"name":"`+strings.Repeat("a", 64)+`"}`, csrfHeaders())
env = decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("64-char name: %d %+v", res.StatusCode, env)
}
var created struct {
ID string `json:"id"`
Token string `json:"token"`
}
if err := json.Unmarshal(env.Data, &created); err != nil {
t.Fatal(err)
}
tokClient := &http.Client{}
hdr := map[string]string{"Authorization": "Bearer " + created.Token}
res = doReq(t, tokClient, http.MethodPatch, srv.URL+"/api/admin/endpoints/ep-h02",
`{"enabled":false}`, hdr)
env = decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("token patch: %d %+v", res.StatusCode, env)
}
res = doReq(t, client, http.MethodPatch, srv.URL+"/api/admin/tokens/"+created.ID,
`{"enabled":false}`,
map[string]string{"X-Nixmsg-Request": "1", "Content-Type": "application/json"})
env = decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("disable token: %d %+v", res.StatusCode, env)
}
regCode := "h02-reg-code-secret"
res = doReq(t, client, http.MethodPut, srv.URL+"/api/admin/registration",
`{"enabled":true,"code":"`+regCode+`"}`,
map[string]string{"X-Nixmsg-Request": "1", "Content-Type": "application/json"})
env = decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("registration: %d %+v", res.StatusCode, env)
}
recs := parseSlogJSON(t, auditBuf)
patch := lastAuditByAction(t, recs, "endpoint_patch")
assertAuditCore(t, patch)
if patch["token_id"] != created.ID {
t.Fatalf("token_id=%v want %s", patch["token_id"], created.ID)
}
if !strings.HasPrefix(patch["actor"].(string), "token:") {
t.Fatalf("actor=%v", patch["actor"])
}
detail, _ := patch["detail"].(map[string]any)
if detail == nil || detail["enabled"] != false {
t.Fatalf("endpoint_patch detail=%v", patch["detail"])
}
tokUpd := lastAuditByAction(t, recs, "token_update")
td, _ := tokUpd["detail"].(map[string]any)
if td == nil || td["enabled"] != false {
t.Fatalf("token_update detail=%v", tokUpd["detail"])
}
reg := lastAuditByAction(t, recs, "registration_update")
rd, _ := reg["detail"].(map[string]any)
if rd == nil || rd["enabled"] != true || rd["code_changed"] != true {
t.Fatalf("registration detail=%v", reg["detail"])
}
assertNoSecrets(t, auditBuf.String(), created.Token, regCode, testPassword)
}
func TestH02BatchPartialImportAndGroups(t *testing.T) {
_, auditBuf, db, srv, client := setupH02(t)
login(t, client, srv.URL)
insertEndpoint(t, db, "keep-1", "admin", true, false)
insertEndpoint(t, db, "alice", "admin", true, false)
insertEndpoint(t, db, "bob", "admin", true, false)
res := doReq(t, client, http.MethodPost, srv.URL+"/api/admin/endpoints/batch",
`{"ids":["keep-1","missing-ep"],"action":"disable"}`,
map[string]string{"X-Nixmsg-Request": "1", "Content-Type": "application/json"})
env := decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("batch: %d %+v", res.StatusCode, env)
}
csvBody := "" +
"id,name,login_password,talk_password,default_delay_seconds,remark\n" +
"imp-1,甲,csv-pass-secret-1,,0,\n" +
"imp-2,乙,csv-pass-secret-2,,0,\n"
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/admin/endpoints/import", strings.NewReader(csvBody))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "text/csv")
req.Header.Set("X-Nixmsg-Request", "1")
res, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
env = decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("import: %d %+v", res.StatusCode, env)
}
res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups",
`{"name":"一组","owner_id":"alice","member_ids":["bob"]}`, csrf())
env = decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("group create: %d %+v", res.StatusCode, env)
}
var created struct {
ID string `json:"id"`
}
if err := json.Unmarshal(env.Data, &created); err != nil {
t.Fatal(err)
}
res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/members",
`{"member_ids":["keep-1","ghost-ep"]}`, csrf())
env = decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("add members: %d %+v", res.StatusCode, env)
}
res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/transfer",
`{"endpoint_id":"bob"}`, csrf())
env = decodeEnv(t, res)
if res.StatusCode != http.StatusOK || !env.OK {
t.Fatalf("transfer: %d %+v", res.StatusCode, env)
}
recs := parseSlogJSON(t, auditBuf)
batch := lastAuditByAction(t, recs, "endpoint_batch_disable")
assertAuditCore(t, batch)
if batch["result"] != "partial" {
t.Fatalf("batch result=%v rec=%v", batch["result"], batch)
}
bd, _ := batch["detail"].(map[string]any)
if bd == nil {
t.Fatalf("batch missing detail: %v", batch)
}
imp := lastAuditByAction(t, recs, "endpoint_import")
id, _ := imp["detail"].(map[string]any)
if id == nil {
t.Fatalf("import missing detail: %v", imp)
}
if id["total"] != float64(2) {
t.Fatalf("import total=%v", id["total"])
}
ids, _ := id["ids"].([]any)
if len(ids) != 2 {
t.Fatalf("import ids=%v", id["ids"])
}
add := lastAuditByAction(t, recs, "group_add_members")
ad, _ := add["detail"].(map[string]any)
if ad == nil {
t.Fatalf("add members missing detail: %v", add)
}
if add["result"] != "partial" && add["result"] != "failed" && add["result"] != "ok" {
t.Fatalf("add members result=%v", add["result"])
}
if _, ok := ad["members"]; !ok {
t.Fatalf("add members missing members: %v", ad)
}
if _, ok := ad["failed"]; !ok {
t.Fatalf("add members missing failed: %v", ad)
}
tr := lastAuditByAction(t, recs, "group_transfer")
td, _ := tr["detail"].(map[string]any)
if td == nil || td["new_owner"] != "bob" {
t.Fatalf("transfer detail=%v", tr["detail"])
}
assertNoSecrets(t, auditBuf.String(), "csv-pass-secret-1", "csv-pass-secret-2", testPassword)
}