package admin import ( "context" "crypto/rand" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/wangjia/pangolin/server/internal/totp" ) type testEnv struct { router http.Handler sessions *SessionStore store *fakeStore codes *fakeCodes life *recordingLifecycle prov *recordingProvision mr *miniredis.Miniredis cfg *Config key []byte } func newEnv(t *testing.T, provReady, lifeReady bool) *testEnv { t.Helper() mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { rdb.Close() }) key := make([]byte, 32) if _, err := rand.Read(key); err != nil { t.Fatal(err) } allow, _ := ParseCIDRs([]string{"127.0.0.0/8"}) cfg := &Config{ Listen: "127.0.0.1:9443", AllowCIDRs: allow, SecretKey: key, SessionTTL: 30 * time.Minute, LoginFailMax: 3, LoginLockDuration: time.Minute, CookieSecure: false, } store := newFakeStore() store.nodes = []NodeRow{{ID: 7, UUID: "u7", Region: "HK", NameZH: "香港节点", Tier: "pro", Status: "up", Weight: 100, Provider: "p"}} codes := &fakeCodes{} life := &recordingLifecycle{ready: lifeReady} prov := &recordingProvision{ready: provReady} sessions := NewSessionStore(rdb, cfg.SessionTTL) sec := NewSecurityLog(store, nil) auth := NewAuthenticator(store, sessions, rdb, cfg, sec) svc := Services{Codes: codes, Lifecycle: life, Provision: prov} h, err := NewHandlers(cfg, store, sessions, auth, svc, sec, nil) if err != nil { t.Fatal(err) } return &testEnv{ router: NewRouter(h, sessions, cfg, sec), sessions: sessions, store: store, codes: codes, life: life, prov: prov, mr: mr, cfg: cfg, key: key, } } // login creates a session and returns the cookie and its CSRF token. func (e *testEnv) login(t *testing.T) (*http.Cookie, string) { t.Helper() sid, sess, err := e.sessions.Create(context.Background(), 1, "alice") if err != nil { t.Fatal(err) } return &http.Cookie{Name: SessionCookieName, Value: sid}, sess.CSRFToken } func (e *testEnv) do(t *testing.T, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { t.Helper() var req *http.Request if form != nil { req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } else { req = httptest.NewRequest(method, target, nil) } req.RemoteAddr = "127.0.0.1:40000" if cookie != nil { req.AddCookie(cookie) } rec := httptest.NewRecorder() e.router.ServeHTTP(rec, req) return rec } // -------------------------------------------------------------------------- func TestRouter_IPBlocked(t *testing.T) { e := newEnv(t, true, true) req := httptest.NewRequest("GET", "/login", nil) req.RemoteAddr = "8.8.8.8:1234" rec := httptest.NewRecorder() e.router.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status %d; want 403", rec.Code) } } func TestRouter_UnauthRedirect(t *testing.T) { e := newEnv(t, true, true) rec := e.do(t, "GET", "/", nil, nil) if rec.Code != http.StatusFound { t.Fatalf("status %d; want 302", rec.Code) } if loc := rec.Header().Get("Location"); loc != "/login" { t.Errorf("redirect to %q; want /login", loc) } } func TestLoginFlow_EndToEnd(t *testing.T) { e := newEnv(t, true, true) secret := newTestAdmin(t, e.store, e.key, "alice", "s3cret-pass") code, _ := totp.Code(secret, time.Now().UTC()) form := url.Values{"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {code}} rec := e.do(t, "POST", "/login", form, nil) if rec.Code != http.StatusFound { t.Fatalf("login status %d; want 302", rec.Code) } var sc *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == SessionCookieName { sc = c } } if sc == nil { t.Fatal("no session cookie set") } if !sc.HttpOnly || sc.SameSite != http.SameSiteStrictMode { t.Errorf("cookie flags wrong: HttpOnly=%v SameSite=%v", sc.HttpOnly, sc.SameSite) } // Authenticated dashboard now reachable. rec2 := e.do(t, "GET", "/", nil, sc) if rec2.Code != http.StatusOK { t.Fatalf("dashboard status %d; want 200", rec2.Code) } } func TestLoginFlow_BadTOTPRejected(t *testing.T) { e := newEnv(t, true, true) _ = newTestAdmin(t, e.store, e.key, "alice", "s3cret-pass") form := url.Values{"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {"000000"}} rec := e.do(t, "POST", "/login", form, nil) if rec.Code != http.StatusUnauthorized { t.Fatalf("status %d; want 401", rec.Code) } if len(rec.Result().Cookies()) != 0 { t.Error("cookie set despite failed login") } } func TestCreateBatch_CSVAndAudit(t *testing.T) { e := newEnv(t, true, true) cookie, csrf := e.login(t) e.codes.nextCodes = []string{"PLAINCODE-A", "PLAINCODE-B"} form := url.Values{ "csrf_token": {csrf}, "plan": {"pro"}, "duration_days": {"30"}, "count": {"2"}, "channel": {"manual"}, "note": {"q2 promo"}, } rec := e.do(t, "POST", "/codes", form, cookie) if rec.Code != http.StatusOK { t.Fatalf("status %d; want 200", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") { t.Errorf("content-type %q; want text/csv", ct) } body := rec.Body.String() if !strings.Contains(body, "PLAINCODE-A") || !strings.Contains(body, "PLAINCODE-B") { t.Error("CSV missing plaintext codes") } // Service received the right params. if len(e.codes.created) != 1 { t.Fatalf("CreateBatch called %d times", len(e.codes.created)) } got := e.codes.created[0] if got.Plan != "pro" || got.DurationDays != 30 || got.Count != 2 || got.Channel != "manual" { t.Errorf("batch params wrong: %+v", got) } if got.CreatedBy != "admin:alice" { t.Errorf("CreatedBy = %q; want admin:alice", got.CreatedBy) } // Audit written, but with NO plaintext leaked into meta. au := e.store.auditFor("code_batch_create") if len(au) != 1 { t.Fatalf("expected 1 code_batch_create audit, got %d", len(au)) } if strings.Contains(au[0].Meta, "PLAINCODE") { t.Errorf("plaintext code leaked into audit meta: %s", au[0].Meta) } } func TestCreateBatch_BadCSRFRejected(t *testing.T) { e := newEnv(t, true, true) cookie, _ := e.login(t) form := url.Values{"csrf_token": {"wrong"}, "plan": {"pro"}, "duration_days": {"30"}, "count": {"2"}, "channel": {"manual"}} rec := e.do(t, "POST", "/codes", form, cookie) if rec.Code != http.StatusForbidden { t.Fatalf("status %d; want 403", rec.Code) } if len(e.codes.created) != 0 { t.Error("batch created despite CSRF failure") } } func TestVoidBatch_RequiresConfirm(t *testing.T) { e := newEnv(t, true, true) cookie, csrf := e.login(t) e.codes.voidReturn = 9 // Missing confirm → 400. form := url.Values{"csrf_token": {csrf}, "batch_id": {"5"}} rec := e.do(t, "POST", "/codes/void", form, cookie) if rec.Code != http.StatusBadRequest { t.Fatalf("missing-confirm status %d; want 400", rec.Code) } if len(e.codes.voided) != 0 { t.Error("void executed without confirmation") } // With confirm → 302 + audit. form.Set("confirm", "yes") rec = e.do(t, "POST", "/codes/void", form, cookie) if rec.Code != http.StatusFound { t.Fatalf("confirmed void status %d; want 302", rec.Code) } if len(e.codes.voided) != 1 || e.codes.voided[0] != 5 { t.Errorf("voided = %v; want [5]", e.codes.voided) } if len(e.store.auditFor("code_batch_void")) != 1 { t.Error("void not audited") } } func TestNodeOp_Replace(t *testing.T) { e := newEnv(t, true, true) cookie, csrf := e.login(t) form := url.Values{"csrf_token": {csrf}, "node_id": {"7"}, "op": {"replace"}, "confirm": {"yes"}} rec := e.do(t, "POST", "/nodes/op", form, cookie) if rec.Code != http.StatusFound { t.Fatalf("status %d; want 302", rec.Code) } if len(e.prov.calls) != 1 || e.prov.calls[0].NodeID != 7 || e.prov.calls[0].Actor != "alice" { t.Errorf("provision calls = %+v; want [{7 alice}]", e.prov.calls) } if len(e.store.auditFor("node_replace")) != 1 { t.Error("replace not audited") } } func TestNodeOp_Draining(t *testing.T) { e := newEnv(t, true, true) cookie, csrf := e.login(t) form := url.Values{"csrf_token": {csrf}, "node_id": {"7"}, "op": {"draining"}, "confirm": {"yes"}} rec := e.do(t, "POST", "/nodes/op", form, cookie) if rec.Code != http.StatusFound { t.Fatalf("status %d; want 302", rec.Code) } if len(e.life.calls) != 1 || e.life.calls[0].NodeID != 7 || e.life.calls[0].Target != "draining" || e.life.calls[0].Actor != "alice" { t.Errorf("lifecycle calls = %+v", e.life.calls) } if len(e.store.auditFor("node_draining")) != 1 { t.Error("draining not audited") } } func TestNodeOp_MissingConfirmRejected(t *testing.T) { e := newEnv(t, true, true) cookie, csrf := e.login(t) form := url.Values{"csrf_token": {csrf}, "node_id": {"7"}, "op": {"replace"}} rec := e.do(t, "POST", "/nodes/op", form, cookie) if rec.Code != http.StatusBadRequest { t.Fatalf("status %d; want 400", rec.Code) } if len(e.prov.calls) != 0 { t.Error("replace executed without confirmation") } } func TestNodeOp_BadCSRFRejected(t *testing.T) { e := newEnv(t, true, true) cookie, _ := e.login(t) form := url.Values{"csrf_token": {"nope"}, "node_id": {"7"}, "op": {"replace"}, "confirm": {"yes"}} rec := e.do(t, "POST", "/nodes/op", form, cookie) if rec.Code != http.StatusForbidden { t.Fatalf("status %d; want 403", rec.Code) } if len(e.prov.calls) != 0 { t.Error("replace executed despite bad CSRF") } } func TestSessionExpiry_RequiresReLogin(t *testing.T) { e := newEnv(t, true, true) cookie, _ := e.login(t) if rec := e.do(t, "GET", "/", nil, cookie); rec.Code != http.StatusOK { t.Fatalf("fresh session status %d; want 200", rec.Code) } // Idle past the 30-minute TTL. e.mr.FastForward(31 * time.Minute) rec := e.do(t, "GET", "/", nil, cookie) if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" { t.Fatalf("expired session: status %d loc %q; want 302 /login", rec.Code, rec.Header().Get("Location")) } } func TestNodesPage_Renders(t *testing.T) { e := newEnv(t, false, false) cookie, _ := e.login(t) rec := e.do(t, "GET", "/nodes", nil, cookie) if rec.Code != http.StatusOK { t.Fatalf("status %d; want 200", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "香港节点") { t.Error("node name not rendered") } // Stub services → controls greyed (disabled) + notice shown. if !strings.Contains(body, "disabled") { t.Error("expected disabled buttons when services not ready") } } func TestAuditPage_Filters(t *testing.T) { e := newEnv(t, true, true) cookie, _ := e.login(t) ctx := context.Background() _ = e.store.WriteAudit(ctx, "alice", "node_replace", "node:7", `{"op":"replace"}`) _ = e.store.WriteAudit(ctx, "bob", "code_batch_void", "batch:3", `{"voided":2}`) rec := e.do(t, "GET", "/audit?action=node_replace", nil, cookie) if rec.Code != http.StatusOK { t.Fatalf("status %d; want 200", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "node:7") { t.Error("filtered entry missing") } if strings.Contains(body, "batch:3") { t.Error("filter leaked non-matching entry") } }