From 88b7c11a235a9252068ec32747afc4e929896dd9 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 01:12:07 +0000 Subject: [PATCH 1/2] fix(auth): reseed standard roles and reject duplicate usernames as 400 Registration always writes role-guest; a deleted standard role became a masked INTERNAL. Username unique violations are now a 400 instead of an infrastructure 500. --- ...00077_2026-09-28-reseed-standard-roles.sql | 36 ++++++++++++++++ internal/auth/pg_store.go | 18 ++++++++ internal/auth/pg_store_test.go | 43 +++++++++++++++++++ internal/auth/store.go | 6 ++- internal/auth/username_conflict_test.go | 39 +++++++++++++++++ internal/server/auth_handlers.go | 9 +++- internal/server/auth_handlers_test.go | 42 ++++++++++++++++++ 7 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 deploy/postgres/goose/00077_2026-09-28-reseed-standard-roles.sql create mode 100644 internal/auth/username_conflict_test.go diff --git a/deploy/postgres/goose/00077_2026-09-28-reseed-standard-roles.sql b/deploy/postgres/goose/00077_2026-09-28-reseed-standard-roles.sql new file mode 100644 index 00000000..bf4a14fe --- /dev/null +++ b/deploy/postgres/goose/00077_2026-09-28-reseed-standard-roles.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- 注册与管理员建号硬编码依赖 role-guest / role-engineer / role-director / role-tech-lead。 +-- 00015 仅把 role-admin 标为 is_system,其余四个标准角色可被角色管理删除, +-- 且 goose 不会重跑种子、启动引导也不补角色,缺失后注册写入 user_role_bindings 触发 FK 23503。 +-- 幂等补种并升为系统角色(禁删、禁改名;权限矩阵仍可编辑)。 + +INSERT INTO system_roles (id, code, name, description, is_system) VALUES + ('role-director', 'DIRECTOR', '研发总监', '负责研发中心整体项目决策、立项审查、权限监督与终审批准。', true), + ('role-tech-lead', 'TECH_LEAD', '研发技术主管', '负责具体研发管线的进度把控、文档责任人分配与技术审核。', true), + ('role-engineer', 'ENGINEER', '方案工程师', '负责医疗器械规格设计、受控正文编制、技术测算与推演。', true), + ('role-guest', 'GUEST', '外部审计/访客', '默认最小权限,仅在被授权范围内查阅。', true) +ON CONFLICT (id) DO UPDATE SET is_system = true; + +INSERT INTO system_role_permissions (role_id, permission_id, effect) +SELECT 'role-director', p.id, 'allow' FROM system_permissions p +WHERE p.perm_key IN ('project:create','project:assign_lead','workflow:config_visibility','doc:override_visibility','doc:export','workflow:view_all') +ON CONFLICT DO NOTHING; + +INSERT INTO system_role_permissions (role_id, permission_id, effect) +SELECT 'role-tech-lead', p.id, 'allow' FROM system_permissions p +WHERE p.perm_key IN ('project:create','project:assign_lead','doc:override_visibility','doc:export','workflow:view_all') +ON CONFLICT DO NOTHING; + +INSERT INTO system_role_permissions (role_id, permission_id, effect) +SELECT 'role-engineer', p.id, 'allow' FROM system_permissions p +WHERE p.perm_key IN ('doc:export','workflow:view_all') +ON CONFLICT DO NOTHING; + +INSERT INTO system_role_permissions (role_id, permission_id, effect) +SELECT 'role-guest', p.id, 'allow' FROM system_permissions p +WHERE p.perm_key IN ('doc:export') +ON CONFLICT DO NOTHING; + +-- +goose Down +UPDATE system_roles SET is_system = false +WHERE id IN ('role-director', 'role-tech-lead', 'role-engineer', 'role-guest'); diff --git a/internal/auth/pg_store.go b/internal/auth/pg_store.go index 3fb5ff9a..de85bdb1 100644 --- a/internal/auth/pg_store.go +++ b/internal/auth/pg_store.go @@ -10,6 +10,7 @@ import ( "chat-agent-system/internal/config" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) @@ -161,6 +162,9 @@ func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccou `, user.Username, user.Name, roleLegacy, user.Status, user.MustChangePassword, user.RoleID, user.Position, user.Department, user.EmployeeID, user.Email).Scan(&id) if err != nil { + if isUsernameConflict(err) { + return nil, ErrUsernameExists + } return nil, fmt.Errorf("insert user: %w", err) } @@ -188,6 +192,20 @@ func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccou return s.GetUserByID(ctx, id) } +func isUsernameConflict(err error) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Code != "23505" { + return false + } + switch pgErr.ConstraintName { + case "uq_users_username", "users_account_id_key", "users_username_key": + return true + default: + name := strings.ToLower(pgErr.ConstraintName) + return strings.Contains(name, "username") || strings.Contains(name, "account_id") + } +} + func (s *PgStore) UpdateUser(ctx context.Context, user *UserAccount, actorID int64) (*UserAccount, error) { _, err := s.pool.Exec(ctx, ` UPDATE users SET diff --git a/internal/auth/pg_store_test.go b/internal/auth/pg_store_test.go index 7f1113a3..413e6d33 100644 --- a/internal/auth/pg_store_test.go +++ b/internal/auth/pg_store_test.go @@ -2,6 +2,7 @@ package auth_test import ( "context" + "errors" "os" "testing" "time" @@ -80,6 +81,48 @@ func TestPgStore_SessionSurvivesNewStoreInstance(t *testing.T) { _ = store2.RevokeByTokenHash(ctx, tokenHash) } +func TestPgStore_CreateUserDuplicateUsername(t *testing.T) { + pool := openTestPool(t) + if pool == nil { + return + } + defer pool.Close() + + cfg := &config.Config{Env: "dev"} + ctx := context.Background() + store, err := auth.NewPgStore(pool, cfg) + if err != nil { + t.Fatalf("NewPgStore: %v", err) + } + username := "dup_" + time.Now().Format("150405.000000") + account := &auth.UserAccount{ + Username: username, + Name: "重复探测", + PasswordHash: "hash", + Status: auth.StatusActive, + RoleID: "role-guest", + Position: "访客/待指派", + } + created, err := store.CreateUser(ctx, account) + if err != nil { + t.Fatalf("first CreateUser: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, created.ID) + }) + _, err = store.CreateUser(ctx, &auth.UserAccount{ + Username: username, + Name: "重复探测", + PasswordHash: "hash", + Status: auth.StatusActive, + RoleID: "role-guest", + Position: "访客/待指派", + }) + if !errors.Is(err, auth.ErrUsernameExists) { + t.Fatalf("duplicate username = %v, want ErrUsernameExists", err) + } +} + func TestPgStore_DisabledUserSessionRejected(t *testing.T) { pool := openTestPool(t) if pool == nil { diff --git a/internal/auth/store.go b/internal/auth/store.go index ca7c4ccc..9627246b 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "fmt" "strings" "sync" @@ -10,6 +11,9 @@ import ( "chat-agent-system/internal/config" ) +// ErrUsernameExists 用户名或 account_id 唯一约束冲突。调用方应返回 400,而不是基础设施 500。 +var ErrUsernameExists = errors.New("用户名已存在") + // AuthStore 用户与认证配置存储接口 type AuthStore interface { GetUserByUsername(ctx context.Context, username string) (*UserAccount, error) @@ -162,7 +166,7 @@ func (s *MemoryStore) CreateUser(ctx context.Context, user *UserAccount) (*UserA for _, existing := range s.users { if strings.EqualFold(existing.Username, user.Username) { - return nil, fmt.Errorf("username already exists: %s", user.Username) + return nil, ErrUsernameExists } } diff --git a/internal/auth/username_conflict_test.go b/internal/auth/username_conflict_test.go new file mode 100644 index 00000000..76788f87 --- /dev/null +++ b/internal/auth/username_conflict_test.go @@ -0,0 +1,39 @@ +package auth + +import ( + "errors" + "testing" + + "github.com/jackc/pgx/v5/pgconn" +) + +func TestIsUsernameConflict(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"username index", &pgconn.PgError{Code: "23505", ConstraintName: "uq_users_username"}, true}, + {"account id", &pgconn.PgError{Code: "23505", ConstraintName: "users_account_id_key"}, true}, + {"username key", &pgconn.PgError{Code: "23505", ConstraintName: "users_username_key"}, true}, + {"fk is not username", &pgconn.PgError{Code: "23503", ConstraintName: "user_role_bindings_role_id_fkey"}, false}, + {"other unique", &pgconn.PgError{Code: "23505", ConstraintName: "user_credentials_pkey"}, false}, + {"plain error", errors.New("insert user: boom"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isUsernameConflict(tc.err); got != tc.want { + t.Fatalf("isUsernameConflict() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestErrUsernameExistsIsNotInfrastructureShaped(t *testing.T) { + if !errors.Is(ErrUsernameExists, ErrUsernameExists) { + t.Fatal("sentinel identity") + } + if ErrUsernameExists.Error() != "用户名已存在" { + t.Fatalf("message = %q", ErrUsernameExists.Error()) + } +} diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 224f361b..71317e23 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "log/slog" "net/http" "strconv" "strings" @@ -89,7 +90,12 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusForbidden, errMsg) return } - writeSafeError(w, r, http.StatusUnauthorized, errMsg) + status := http.StatusUnauthorized + if authIsInfrastructureError(err) { + status = http.StatusInternalServerError + slog.Error("auth_login_failed", "request_id", httpx.RequestID(r), "err", err) + } + writeSafeError(w, r, status, errMsg) return } @@ -122,6 +128,7 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { status := http.StatusBadRequest if authIsInfrastructureError(err) { status = http.StatusInternalServerError + slog.Error("auth_register_failed", "request_id", httpx.RequestID(r), "err", err) } writeSafeError(w, r, status, err.Error()) return diff --git a/internal/server/auth_handlers_test.go b/internal/server/auth_handlers_test.go index e9508d98..408671c6 100644 --- a/internal/server/auth_handlers_test.go +++ b/internal/server/auth_handlers_test.go @@ -216,6 +216,48 @@ func TestAuthHandlers_RegisterAndApproval(t *testing.T) { } } +func TestAuthHandlers_RegisterDuplicateUsername(t *testing.T) { + ts, _ := setupTestServer(t) + body, _ := json.Marshal(map[string]string{ + "username": "dup_user", + "password": "DupPass123", + "name": "重复用户", + }) + first, err := http.Post(ts.URL+"/api/v1/auth/register", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("first register: %v", err) + } + defer first.Body.Close() + if first.StatusCode != http.StatusOK { + t.Fatalf("first register status %d", first.StatusCode) + } + + secondBody, _ := json.Marshal(map[string]string{ + "username": "dup_user", + "password": "DupPass123", + "name": "重复用户", + }) + second, err := http.Post(ts.URL+"/api/v1/auth/register", "application/json", bytes.NewReader(secondBody)) + if err != nil { + t.Fatalf("second register: %v", err) + } + defer second.Body.Close() + if second.StatusCode != http.StatusBadRequest { + t.Fatalf("duplicate username must be 400, got %d", second.StatusCode) + } + var env map[string]any + if err := json.NewDecoder(second.Body).Decode(&env); err != nil { + t.Fatalf("decode: %v", err) + } + errText, _ := env["error"].(string) + if !bytes.Contains([]byte(errText), []byte("已存在")) { + t.Fatalf("expected error to mention 已存在, got %#v", env) + } + if env["code"] != "VALIDATION" { + t.Fatalf("expected VALIDATION, got %#v", env["code"]) + } +} + func TestAuthHandlers_AdminCreateUser_MustChangePassword(t *testing.T) { ts, _ := setupTestServer(t) From e1d6a2c192e71f4c7e5cf1e97da2cc7f66277c9f Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 01:12:14 +0000 Subject: [PATCH 2/2] docs(task): record auth register INTERNAL fix and test evidence --- .doc/audit/ISSUES.md | 3 +- .doc/ops/CURRENT.md | 1 + .../2026-09-28-standard-role-reseed.md | 20 +++++ .doc/ops/lessons/INDEX.md | 1 + .../2026-09-28-auth-register-internal.md | 64 +++++++++++++++ ...9-28-auth-register-internal-test-report.md | 77 +++++++++++++++++++ .../go-auth.txt | 20 +++++ .../go-server.txt | 8 ++ .../register-fresh.body | 1 + 9 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 .doc/ops/lessons/2026-09-28-standard-role-reseed.md create mode 100644 .doc/ops/short-term/2026-09-28-auth-register-internal.md create mode 100644 .doc/test-reports/2026-09-28-auth-register-internal-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-28-auth-register-internal/go-auth.txt create mode 100644 .doc/test-reports/assets/2026-09-28-auth-register-internal/go-server.txt create mode 100644 .doc/test-reports/assets/2026-09-28-auth-register-internal/register-fresh.body diff --git a/.doc/audit/ISSUES.md b/.doc/audit/ISSUES.md index 573fed8d..addb588e 100644 --- a/.doc/audit/ISSUES.md +++ b/.doc/audit/ISSUES.md @@ -2,10 +2,11 @@ 维护规则:[问题记录与开发经验闭环](../ops/2026-09-23-issue-learning-workflow.md)。每个问题一个稳定 ID;主记录是事实与状态来源,本表为可检索投影。新发现及时登记,修复后保留记录;发布 blocker 仍见 [gap-matrix](../remediation/gap-matrix.md)。 -更新:2026-09-28。管理台重置密码入口被 F4-A 误删(端点仍在),已恢复 UI(resolved)。2026-09-27。上传链路:登记并修复上传文档跨端不可访问(占位模板退化 + 元数据/正文不落服务端,fixing 待 dev 集成)。2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。 +更新:2026-09-28。test 注册 INTERNAL:标准角色 role-guest 缺失导致 FK 23503,重复用户名被误判 500(fixing,见 auth-register-internal)。管理台重置密码入口被 F4-A 误删(端点仍在),已恢复 UI(resolved)。2026-09-27。上传链路:登记并修复上传文档跨端不可访问(占位模板退化 + 元数据/正文不落服务端,fixing 待 dev 集成)。2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。 | issue_id | 模块 / 检索词 | 问题 | 严重度 | issue_state | owner | 证据日期 / 范围 | 唯一主记录 | 经验 | |---|---|---|---|---|---|---|---|---| +| ISSUE-20260928-register-internal | auth / register / role-guest / system_roles / 23503 / 23505 / INTERNAL | test 缺 role-guest 时注册 FK 失败被脱敏为 INTERNAL;重复用户名唯一冲突同样 500 | high | fixing | Cursor | 2026-09-28;dev:8080 新用户成功、重复用户名 INTERNAL;test:8081 任意新用户 INTERNAL(req_diag_repro_002) | [主记录](../ops/short-term/2026-09-28-auth-register-internal.md#issue-20260928-register-internal) | [LESSON-20260928-standard-role-reseed](../ops/lessons/2026-09-28-standard-role-reseed.md) | | ISSUE-20260928-admin-reset-ui-removed | web / UsersSection / 重置密码 / F4-A / adminResetPassword | F4-A 在 F4-B 端点已合入的基线上删除管理台重置密码按钮,管理员无法重置口令 | high | resolved | Cursor | 2026-09-28;dev API :8080 + worktree Vite :3110 真实 UI 探针 PASS(临时口令长度 16、强制改密仅一次、旧口令 401) | [主记录](../ops/short-term/2026-09-28-restore-admin-password-reset.md#issue-20260928-admin-reset-ui-removed) | [LESSON-20260928-ui-dead-control](../ops/lessons/2026-09-28-ui-dead-control.md) | | ISSUE-20260927-upload-doc-cross-device | freedoc / upload / freeform / fileMeta / 占位模板 / 跨端 | 上传文档源文件元数据与正文不落服务端:换浏览器/清存储/共享他人查看时退化为注册占位模板,文本类连二进制都未存(伴生 <512B 显示 "0 KB") | high | resolved | pi | 2026-09-27;dev `86efba2c` 合入并在 dev 环境验证:FS-UPLOAD-01/02 真实浏览器 PASS(跨上下文可见真实正文),DB 核验元数据+asset 落库;存量无元数据旧文档不可恢复(重新上传即可) | [主记录](../ops/short-term/2026-09-27-upload-doc-access.md) | [LESSON-20260927-server-facts-and-iso-db-recipe](../ops/lessons/2026-09-27-server-facts-and-iso-db-recipe.md) | | ISSUE-20260923-nullable-duty | docgov / nullable / 23503 / reviewer_id | 可选职责空值写 0 导致 FK 失败 | high | resolved | Codex(历史补录) | 2026-09-17;历史 PG / API 冒烟;当前未复测 | [主记录](2026-09-23-historical-issue-records.md#nullable-duty) | [LESSON-20260923-nullable-duty](../ops/lessons/2026-09-23-nullable-duty.md) | diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index aeb9b2f6..601a98df 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | auth-register-internal-2026-09-28 | fix/auth-register-internal-2026-09-28 | .worktrees/auth-register-internal-2026-09-28 | auth-register | in_progress | | Cursor | restore-admin-password-reset-2026-09-28 | feature/restore-admin-password-reset-2026-09-28 | .worktrees/restore-admin-password-reset-2026-09-28 | auth | done | | pi | test-single-binary-2026-09-27 | feature/test-single-binary-2026-09-27 | .worktrees/test-single-binary-2026-09-27 | env:dev+env:test+git:dev+git:test | done | | pi | upload-doc-access-2026-09-27 | fix/upload-doc-access-2026-09-27 | .worktrees/upload-doc-access-2026-09-27 | freedoc+web-upload | done | diff --git a/.doc/ops/lessons/2026-09-28-standard-role-reseed.md b/.doc/ops/lessons/2026-09-28-standard-role-reseed.md new file mode 100644 index 00000000..5cbae64b --- /dev/null +++ b/.doc/ops/lessons/2026-09-28-standard-role-reseed.md @@ -0,0 +1,20 @@ +--- +lesson_id: LESSON-20260928-standard-role-reseed +lesson_state: validated +owner: Cursor +reviewer: Cursor(自审) +last_reviewed: 2026-09-28 +reviewed_revision: 4ba60c2c9d7eaf3e3e824475c0f3a44e10641190 +source_issues: [ISSUE-20260928-register-internal] +keywords: [auth, register, role-guest, system_roles, is_system, 23503, 23505, INTERNAL] +--- + +# 代码硬编码的标准角色必须可自愈且不可删 + +- 触发条件:注册、管理员建号或种子用户写入固定 `role_id`,而该角色只在历史 goose 种子里出现一次。 +- 机制与来源:`Register` 固定写 `role-guest`。00015 仅 `role-admin` 为 `is_system`。角色一旦被删,goose 不重跑、`BootstrapIAM` 只补用户,`user_role_bindings` 外键失败被脱敏成 INTERNAL。证据日期 2026-09-28,见 [任务](../short-term/2026-09-28-auth-register-internal.md)。 +- 开发检查:硬编码角色 id 须在迁移里 `ON CONFLICT DO UPDATE` 补种,并标 `is_system`。用户名一类唯一冲突返回领域错误,不要包进 `insert user:` 这类会被当成 500 的前缀。 +- 验证方式:缺角色的库补种后注册返回 200;重复用户名返回 400 且 `error` 为「用户名已存在」。 +- 适用边界:适用于 IAM 标准角色。业务自定义角色仍允许删除(无人引用时)。 +- 防护落实:migration 00077 + `DeleteRole` 既有系统角色拒绝。未新增 lint。 +- 修订与失效:若注册不再绑定 `role-guest`,本条对那个写入点失效,保留历史。 diff --git a/.doc/ops/lessons/INDEX.md b/.doc/ops/lessons/INDEX.md index 58cf63cc..d9cf32fe 100644 --- a/.doc/ops/lessons/INDEX.md +++ b/.doc/ops/lessons/INDEX.md @@ -4,6 +4,7 @@ | lesson_id | 触发条件 / 关键词 | 注意事项 | lesson_state | 主记录 / 最近复核 | |---|---|---|---|---| +| LESSON-20260928-standard-role-reseed | 注册、role-guest、system_roles、is_system、23503、23505、INTERNAL | 硬编码标准角色须幂等补种并标 is_system;用户名冲突返回领域 400,不要包进 insert user | validated | [标准角色可自愈](2026-09-28-standard-role-reseed.md);2026-09-28 | | LESSON-20260928-ui-dead-control | 死按钮、端点未开通、UsersSection、重置密码、merge-base | 删除「未开通」控件前用当前基线核对路由已存在;注释不能代替 merge-base | validated | [删控件前核对端点](2026-09-28-ui-dead-control.md);2026-09-28 | | LESSON-20260927-server-facts-and-iso-db-recipe | 上传持久化、fileMeta、占位模板、config.Load 默认 DSN、TEST_DATABASE_URL、隔离库、RefuseSharedEnvDSN、goose 顺序 | 服务端事实源优先(本地仅缓存);worktree 测试必须显式钉隔离库并避开 5433/5434/5435 子串端口;隔离库配方=扩展→goose→init SQL,重组件用例先做基线四格对照 | validated | [服务端事实源与隔离库配方](2026-09-27-server-facts-and-iso-db-recipe.md) | | LESSON-20260923-nullable-duty | docgov、SQL、nullable、23503、可选职责、创建/heal | 检查所有写入口的缺席值语义;用真实 PG 验证 NULL/FK | validated | [可选职责持久化](2026-09-23-nullable-duty.md);2026-09-23 | diff --git a/.doc/ops/short-term/2026-09-28-auth-register-internal.md b/.doc/ops/short-term/2026-09-28-auth-register-internal.md new file mode 100644 index 00000000..01c384dd --- /dev/null +++ b/.doc/ops/short-term/2026-09-28-auth-register-internal.md @@ -0,0 +1,64 @@ +--- +title: "修复 test 环境注册 INTERNAL" +task_id: auth-register-internal-2026-09-28 +status: in_progress +created: 2026-09-28 +updated: 2026-09-28 +owner: Cursor +task_type: fix +policy_revision: "AGENTS.md + agent-worktree-workflow @ 2026-09-21" +branch: fix/auth-register-internal-2026-09-28 +worktree: .worktrees/auth-register-internal-2026-09-28 +base_dev_sha: "4ba60c2c9d7eaf3e3e824475c0f3a44e10641190" +write_scope: + - deploy + - internal + - .doc +mutex: auth-register +review_state: pending +integration_state: pending +deployment: pending +cleanup: pending +remote_sync: not_requested +--- + +# 修复 test 环境注册 INTERNAL + +## 用户目标与授权 + +用户在 test 环境注册新用户时收到 `{"code":"INTERNAL","message":"请求无法完成","error":"INTERNAL"}`。授权:完整修复(补种标准角色、重复用户名改为 400、500 补日志、回归与 test 实测)。 + +## 检索 + +- 检索词:注册、`role-guest`、`auth/register`、INTERNAL、23503。 +- ISSUES.md 无同因条目。catalog overlap(mutex `auth-register`)hard/soft 均为空。 +- 采纳:不改注册角色语义;修复缺失角色的自愈与唯一冲突误判。 + +## 根因 + +见 [ISSUE-20260928-register-internal](#issue-20260928-register-internal)。 + +## 变更 + +| 项 | 说明 | +|---|---| +| 迁移 00077 | 幂等补种 role-director/tech-lead/engineer/guest,并置 `is_system=true` | +| CreateUser | 用户名/account_id 唯一冲突返回 `ErrUsernameExists`(400) | +| handler | 注册与登录的基础设施失败打 `slog.Error`(带 request_id);登录基础设施失败改为 500 | + +## 验证 + +见 [测试报告](../../test-reports/2026-09-28-auth-register-internal-test-report.md)。 + +## ISSUE-20260928-register-internal + +- 预期:test 允许自主注册时,新用户名注册成功;重复用户名返回可理解的 400。 +- 实际:test `POST /api/v1/auth/register` 必现 INTERNAL(复现 `req_diag_repro_002`);dev 新用户名成功,重复用户名也是 INTERNAL(`req_f0a8c723ed8e6c4a`)。 +- 已证实根因:test 库 `system_roles` 缺 `role-guest`,`user_role_bindings.role_id` 外键 23503。00015 只保护 `role-admin`,其余标准角色可删且启动引导不补种。重复用户名 23505 被 `insert user:` 判成基础设施 500。 +- 修复:00077 + `ErrUsernameExists` + 500 日志。 +- 状态:fixing,待 test 库迁移与合入后标 resolved。 + +## 禁止 + +- 未授权 `git push` / 晋升 test/main +- 用 `--build` 替换 rc 制品 `bin/agent-server-test` diff --git a/.doc/test-reports/2026-09-28-auth-register-internal-test-report.md b/.doc/test-reports/2026-09-28-auth-register-internal-test-report.md new file mode 100644 index 00000000..ad5feeef --- /dev/null +++ b/.doc/test-reports/2026-09-28-auth-register-internal-test-report.md @@ -0,0 +1,77 @@ +# 注册 INTERNAL(标准角色缺失)测试报告 + +## 一、 测试背景 (Background) + +test 环境(`:8081`)注册新用户返回 `INTERNAL` / 「请求无法完成」。dev(`:8080`)新用户名可成功,但重复用户名同样是 `INTERNAL`。根因是 test 库缺 `role-guest`(FK 23503),以及用户名唯一冲突被当成基础设施错误。 + +## 二、 测试目标 (Test Objectives) + +- test 补种标准角色后,新用户名注册返回 200 +- 重复用户名在新代码下返回 400,`error` 为「用户名已存在」 +- 既有注册成功路径(审核中)不回归 +- 不替换 rc 制品 `bin/agent-server-test` + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux;worktree `fix/auth-register-internal-2026-09-28`,基线 `4ba60c2c9d7eaf3e3e824475c0f3a44e10641190` +- 单测:`go test` 内存库与冲突分类(不连 5433/5434) +- 实库:test localpg `:5434` / `agent_system_test`;当时 goose `version_id=76`。Up SQL 手工执行(幂等),goose 版本仍为 76,待迁移文件合入 dev 后由 test 进程重启记 77 +- 运行中的 `:8081` 仍是 rc 单二进制,不含本次 Go 改动 +- **NOT REQUIRED**:产品 PI 全链路。依据:IAM 注册与迁移,不改工作流模板或提示词 + +证据目录:[assets/2026-09-28-auth-register-internal/](./assets/2026-09-28-auth-register-internal/) + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|---|---| +| test 缺 role-guest 时注册 | 修复前 INTERNAL;补种后 200 | +| 新代码重复用户名 | 400,`error` 含「已存在」,`code=VALIDATION` | +| 当前 rc 二进制重复用户名 | 仍 INTERNAL(制品未换) | +| dev 新用户名 | 200 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 1. 修复前复现 + +- **输入**:`POST /api/v1/auth/register` 用户名 `diag_user_0928` +- **实际输出**:dev `{"status":"active",...,"user_id":75}`;test `{"code":"INTERNAL","message":"请求无法完成","request_id":"req_diag_repro_002","error":"INTERNAL"}`。dev 重复同一用户名:`request_id=req_f0a8c723ed8e6c4a`,同样 INTERNAL +- **评价**:PASS(复现) +- **耗时**:约 150ms + +### 2. 补种后 test 注册 + +- **输入**:00077 Up(`INSERT 0 4` 角色);`POST :8081/api/v1/auth/register` 用户名 `regfix_0928a`,`X-Request-ID: req_regfix_fresh_0928` +- **实际输出**:HTTP 200 `{"status":"active","message":"注册成功,已激活,请返回登录","user_id":43}`。`role-guest/director/engineer/tech-lead` 的 `is_system=t`。探测用户与 dev 上的 `diag_user_0928`、`regfix_dev_0928a` 已删除 +- **评价**:PASS +- **耗时**:注册请求 < 1s + +### 3. 当前 test 二进制的重复用户名 + +- **输入**:再次注册 `regfix_0928a`,`X-Request-ID: req_regfix_dup_0928` +- **实际输出**:`{"code":"INTERNAL","message":"请求无法完成","request_id":"req_regfix_dup_0928","error":"INTERNAL"}` +- **评价**:符合预期(rc 制品尚无 `ErrUsernameExists`)。400 行为由单测覆盖,随下次含本提交的 test 制品生效 +- **耗时**:< 1s + +### 4. dev 新用户名对照 + +- **输入**:`POST :8080` 用户名 `regfix_dev_0928a` +- **实际输出**:`{"status":"active","message":"注册成功,已激活,请返回登录","user_id":79}`(随后删除) +- **评价**:PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +ok chat-agent-system/internal/auth 0.087s + TestIsUsernameConflict PASS(23505 用户名约束为真,23503 与其它唯一约束为假) + TestErrUsernameExistsIsNotInfrastructureShaped PASS +ok chat-agent-system/internal/server 0.349s + TestAuthHandlers_RegisterAndApproval PASS + TestAuthHandlers_RegisterDuplicateUsername PASS(400 / VALIDATION / error 含「已存在」) +``` + +`TestPgStore_CreateUserDuplicateUsername` 在未设置 `TEST_DATABASE_URL` 时 Skip,不连共享库。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +授权范围内的 test 注册失败已由补种解除。代码侧 400 与日志在本 worktree 单测通过。合入 local dev 后重启 test(`launch-single.sh test`,不要 `--build`),让 goose 把 00077 记为 version 77。不要为这个 400 行为替换当前 rc 二进制。 diff --git a/.doc/test-reports/assets/2026-09-28-auth-register-internal/go-auth.txt b/.doc/test-reports/assets/2026-09-28-auth-register-internal/go-auth.txt new file mode 100644 index 00000000..bed5cbbf --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-auth-register-internal/go-auth.txt @@ -0,0 +1,20 @@ +=== RUN TestIsUsernameConflict +=== RUN TestIsUsernameConflict/username_index +=== RUN TestIsUsernameConflict/account_id +=== RUN TestIsUsernameConflict/username_key +=== RUN TestIsUsernameConflict/fk_is_not_username +=== RUN TestIsUsernameConflict/other_unique +=== RUN TestIsUsernameConflict/plain_error +--- PASS: TestIsUsernameConflict (0.00s) + --- PASS: TestIsUsernameConflict/username_index (0.00s) + --- PASS: TestIsUsernameConflict/account_id (0.00s) + --- PASS: TestIsUsernameConflict/username_key (0.00s) + --- PASS: TestIsUsernameConflict/fk_is_not_username (0.00s) + --- PASS: TestIsUsernameConflict/other_unique (0.00s) + --- PASS: TestIsUsernameConflict/plain_error (0.00s) +=== RUN TestErrUsernameExistsIsNotInfrastructureShaped +--- PASS: TestErrUsernameExistsIsNotInfrastructureShaped (0.00s) +=== RUN TestMemoryStore_DevEnvSeedsDemoUsers +--- PASS: TestMemoryStore_DevEnvSeedsDemoUsers (0.08s) +PASS +ok chat-agent-system/internal/auth 0.087s diff --git a/.doc/test-reports/assets/2026-09-28-auth-register-internal/go-server.txt b/.doc/test-reports/assets/2026-09-28-auth-register-internal/go-server.txt new file mode 100644 index 00000000..0f5431dc --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-auth-register-internal/go-server.txt @@ -0,0 +1,8 @@ +=== RUN TestAuthHandlers_RegisterAndApproval +time=2026-09-28T01:11:09.621Z level=INFO msg="[kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment" +--- PASS: TestAuthHandlers_RegisterAndApproval (0.17s) +=== RUN TestAuthHandlers_RegisterDuplicateUsername +time=2026-09-28T01:11:09.792Z level=INFO msg="[kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment" +--- PASS: TestAuthHandlers_RegisterDuplicateUsername (0.17s) +PASS +ok chat-agent-system/internal/server 0.349s diff --git a/.doc/test-reports/assets/2026-09-28-auth-register-internal/register-fresh.body b/.doc/test-reports/assets/2026-09-28-auth-register-internal/register-fresh.body new file mode 100644 index 00000000..0f6f5a6f --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-auth-register-internal/register-fresh.body @@ -0,0 +1 @@ +{"status":"active","message":"注册成功,已激活,请返回登录","user_id":43}