Files
pangolin/server/internal/admin/handlers_test.go
T
wangjia 6e99e32285 feat(admin): 管理端最小后台(独立监听 + 白名单 + 2FA)tsk_SCMtcGF4F434
实现「一个二进制三个监听」中的管理端:

- 独立监听 ADMIN_LISTEN(默认 127.0.0.1:9443,FromEnv 拒绝 0.0.0.0/:: 等公网绑定)
- 中间件链:mw_ipallow(CIDR 白名单,仅信任 RemoteAddr,默认内网段)→
  mw_session(HttpOnly+Secure+SameSite=Strict cookie,Redis 30min 滑动 TTL)
- 登录双因素:argon2id 密码 + RFC6238 TOTP;失败限流与临时锁定;
  TOTP 密钥 AES-GCM 加密入库;migration 000008 增补 admins 表;
  首个管理员由 cmd/adminctl create 创建(终端输出 otpauth URI/Secret)
- 功能三块(html/template + embed 静态资源,原生 JS 二次确认):
  1. 码批次:表单生成→明文仅在本次 CSV 下载出现(不落盘/不入日志);
     批次列表 + 整批作废(复用 #3 codes,新增 Store.ListBatches/VoidBatch)
  2. 节点操作:列表 + 近期 node_events;replace 经 #14 ProvisionService、
     draining/up 经 #5 Lifecycle(二者未就绪→注入 stub,UI 置灰);二次确认 + CSRF
  3. audit_log 查看:actor/action/target/时间范围过滤分页;同页查 node_events
- 所有写操作写 audit_log(actor=用户名,meta 不含明文);管理端仅记安全事件
  (登录失败/锁定/白名单拦截),不记常规访问日志
- internal/totp 复用包(与 doc/05 用户中心 2FA 同算法,纯标准库)

测试:totp RFC6238 向量、argon2/AES 往返、白名单放行/拦截、会话滑动过期、
登录成功/密码错/TOTP 错/未知用户/锁定、批次 CSV 含明文且 audit 不泄露、
作废/节点操作的二次确认与 CSRF 缺失被拒、mock 断言 service 调用参数、审计过滤。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:49:32 +08:00

353 lines
11 KiB
Go

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")
}
}