From 47463a9623692eed0c5a42255e019480a1773a21 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 03:54:59 +0000 Subject: [PATCH] Align admin account e2e with must-change password gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: U4 middleware restricts must_change sessions to me/password/logout; the legacy e2e expected admin create before first password change. Evidence: go test ./internal/store/ ./internal/server/ ./internal/app/ Impact: TestAdminAccountFlow matches set-password + reloginRequired. Refs: docs/svc-deploy/u4-one-time-login-key.md §5–6 Co-authored-by: Cursor --- internal/server/auth_e2e_test.go | 52 ++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index 4ad0424..7c476cd 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -321,10 +321,29 @@ func TestAdminAccountFlow(t *testing.T) { return rec } - // 会话可访问 API;/api/me 携带身份与管理员位。 + // /api/me 允许 must_change;业务 API 拒绝(U4 设密门)。 if rec := session("GET", "/api/me", ""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"isAdmin":true`) { t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String()) } + if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusForbidden { + t.Fatalf("must_change admin must be gated before create: %d %s", rec.Code, rec.Body.String()) + } + // 首登改密:不要求 current;成功后销毁会话,须重新密码登录。 + if rec := session("POST", "/api/auth/password", `{"next":"admin-final-9"}`); rec.Code != http.StatusOK || + !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { + t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String()) + } + if rec := session("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("admin session must die after set-password: %d", rec.Code) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("admin relogin after set-password: %d %s", rec.Code, rec.Body.String()) + } + tok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] // 管理员建号(普通用户,初始密码)。 if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusOK { @@ -335,7 +354,7 @@ func TestAdminAccountFlow(t *testing.T) { t.Fatalf("duplicate owner must 409: %d", rec.Code) } - // alice 首登:mustChangePassword=true;改密后清除。 + // alice 首登:mustChangePassword=true;业务 API 被设密门拒绝。 req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() @@ -358,16 +377,19 @@ func TestAdminAccountFlow(t *testing.T) { if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { t.Fatalf("alice first login must force change: %s", rec.Body.String()) } - // alice 非管理员:建号被拒 403。 if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden { - t.Fatalf("non-admin create must 403: %d", rec.Code) + t.Fatalf("must_change non-admin create must 403: %d", rec.Code) } - // alice 改密(错误当前密码 → 401;成功 → must_change 清除)。 - if rec := alice("POST", "/api/auth/password", `{"current":"bad","next":"alice-final-9"}`); rec.Code != http.StatusUnauthorized { - t.Fatalf("wrong current password: %d", rec.Code) + // must_change 改密忽略 current;短密码拒绝;成功后 reloginRequired。 + if rec := alice("POST", "/api/auth/password", `{"next":"short"}`); rec.Code != http.StatusBadRequest { + t.Fatalf("short password must 400: %d", rec.Code) } - if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { - t.Fatalf("alice change password: %d %s", rec.Code, rec.Body.String()) + if rec := alice("POST", "/api/auth/password", `{"current":"ignored","next":"alice-final-9"}`); rec.Code != http.StatusOK || + !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { + t.Fatalf("alice force set-password: %d %s", rec.Code, rec.Body.String()) + } + if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("alice session must die after set-password: %d", rec.Code) } // 旧密码不能再登录;新密码可以且无需改密。 req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`)) @@ -377,6 +399,18 @@ func TestAdminAccountFlow(t *testing.T) { if rec.Code != http.StatusUnauthorized { t.Fatalf("old password must fail after change: %d", rec.Code) } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("alice relogin: %d %s", rec.Code, rec.Body.String()) + } + aliceTok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] + // 已设密普通用户:建号仍 403(非管理员)。 + if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden { + t.Fatalf("non-admin create must 403: %d", rec.Code) + } // 管理员重置 alice 密码 → 其旧会话吊销 + 首登再改密。 if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusOK {