451 lines
14 KiB
Go
451 lines
14 KiB
Go
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)
|
|
insertEndpoint(t, db, "carol", "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":["carol","ghost-ep"]}`, csrf())
|
|
env = decodeEnv(t, res)
|
|
if res.StatusCode != http.StatusOK || !env.OK {
|
|
t.Fatalf("add members: %d %+v", res.StatusCode, env)
|
|
}
|
|
var addBody struct {
|
|
Failed []any `json:"failed"`
|
|
Added int `json:"added"`
|
|
}
|
|
if err := json.Unmarshal(env.Data, &addBody); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if addBody.Added != 1 {
|
|
t.Fatalf("add response added=%d want 1", addBody.Added)
|
|
}
|
|
if len(addBody.Failed) != 1 {
|
|
t.Fatalf("add response failed=%v want 1", addBody.Failed)
|
|
}
|
|
|
|
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" {
|
|
t.Fatalf("add members result=%v want partial", 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)
|
|
}
|
|
if ad["added"] != float64(1) {
|
|
t.Fatalf("add members added=%v want 1", ad["added"])
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func TestH02AddMembersAllSkippedAuditNotOK(t *testing.T) {
|
|
_, auditBuf, db, srv, client := setupH02(t)
|
|
login(t, client, srv.URL)
|
|
insertEndpoint(t, db, "alice", "admin", true, false)
|
|
insertEndpoint(t, db, "bob", "admin", true, false)
|
|
|
|
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)
|
|
}
|
|
|
|
auditBuf.Reset()
|
|
var before int
|
|
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = ?`, created.ID).Scan(&before); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res = doReq(t, client, http.MethodPost, srv.URL+"/api/admin/groups/"+created.ID+"/members",
|
|
`{"member_ids":["bob","bob","alice"]}`, csrf())
|
|
env = decodeEnv(t, res)
|
|
if res.StatusCode != http.StatusOK || !env.OK {
|
|
t.Fatalf("add members: %d %+v", res.StatusCode, env)
|
|
}
|
|
var body struct {
|
|
Failed []any `json:"failed"`
|
|
Added int `json:"added"`
|
|
}
|
|
if err := json.Unmarshal(env.Data, &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(body.Failed) != 0 {
|
|
t.Fatalf("failed=%v want empty (already members are not errors)", body.Failed)
|
|
}
|
|
if body.Added != 0 {
|
|
t.Fatalf("added=%d want 0", body.Added)
|
|
}
|
|
|
|
var after int
|
|
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = ?`, created.ID).Scan(&after); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after != before {
|
|
t.Fatalf("member rows before=%d after=%d want unchanged", before, after)
|
|
}
|
|
|
|
recs := parseSlogJSON(t, auditBuf)
|
|
add := lastAuditByAction(t, recs, "group_add_members")
|
|
if add["result"] == "ok" {
|
|
t.Fatalf("audit result must not be ok when all skipped: %v", add)
|
|
}
|
|
ad, _ := add["detail"].(map[string]any)
|
|
if ad == nil {
|
|
t.Fatalf("missing detail: %v", add)
|
|
}
|
|
if ad["added"] != float64(0) {
|
|
t.Fatalf("detail.added=%v want 0", ad["added"])
|
|
}
|
|
}
|