feat: 用户端改为账号登录 (#50)

This commit is contained in:
ila
2026-08-24 17:29:47 +08:00
parent 773ed74752
commit 0af44bd523
34 changed files with 428 additions and 89 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
<template>
<BasicLayout><template #wrapper><main class="chorus-page">
<header class="page-heading"><div><h1>用户管理</h1><p>查看终端用户账号和状态;管理员账号在系统管理中单独维护。</p></div><el-button :loading="loading" @click="load"><i class="ri-refresh-line" /> 刷新</el-button></header>
<div class="toolbar"><el-input v-model.trim="keyword" clearable placeholder="用户编号 / 昵称 / 邮箱" style="width: 300px" /><el-select v-model="status" clearable placeholder="全部状态" style="width: 140px"><el-option label="正常" value="active" /><el-option label="已停用" value="disabled" /></el-select><el-button @click="reset">重置</el-button><span class="spacer" /><span class="muted">共 {{ filtered.length }} 条</span></div>
<div class="toolbar"><el-input v-model.trim="keyword" clearable placeholder="用户编号 / 账号 / 昵称 / 邮箱" style="width: 320px" /><el-select v-model="status" clearable placeholder="全部状态" style="width: 140px"><el-option label="正常" value="active" /><el-option label="已停用" value="disabled" /></el-select><el-button @click="reset">重置</el-button><span class="spacer" /><span class="muted">共 {{ filtered.length }} 条</span></div>
<section class="table-panel"><el-table v-loading="loading" :data="filtered" border empty-text="暂无终端用户">
<el-table-column prop="id" label="用户编号" width="110"><template #default="{ row }"><span class="mono">#{{ row.id }}</span></template></el-table-column><el-table-column prop="display_name" label="昵称" min-width="150" /><el-table-column prop="email" label="邮箱" min-width="230" />
<el-table-column prop="id" label="用户编号" width="110"><template #default="{ row }"><span class="mono">#{{ row.id }}</span></template></el-table-column><el-table-column prop="username" label="账号" min-width="150"><template #default="{ row }"><span class="mono">{{ row.username }}</span></template></el-table-column><el-table-column prop="display_name" label="昵称" min-width="150" /><el-table-column prop="email" label="邮箱" min-width="220" />
<el-table-column label="状态" width="120"><template #default="{ row }"><el-switch :model-value="row.status === 'active'" :loading="saving===row.id" inline-prompt active-text="正常" inactive-text="停用" @change="changeStatus(row, $event)" /></template></el-table-column>
<el-table-column prop="created_at" label="注册时间" width="180"><template #default="{ row }">{{ formatTime(row.created_at) }}</template></el-table-column><el-table-column prop="updated_at" label="更新时间" width="180"><template #default="{ row }">{{ formatTime(row.updated_at) }}</template></el-table-column>
<el-table-column label="操作" fixed="right" width="110"><template #default="{ row }"><el-button link type="primary" @click="openGenerations(row)">生成记录</el-button></template></el-table-column>
@@ -16,7 +16,7 @@ import { contains, dataOf, formatTime } from '../shared'
export default {
name: 'ChorusUsers',
data() { return { loading: false, saving: null, rows: [], keyword: '', status: '' } },
computed: { filtered() { return this.rows.filter(row => (!this.keyword || [row.id, row.display_name, row.email].some(value => contains(value, this.keyword))) && (!this.status || row.status === this.status)) } },
computed: { filtered() { return this.rows.filter(row => (!this.keyword || [row.id, row.username, row.display_name, row.email].some(value => contains(value, this.keyword))) && (!this.status || row.status === this.status)) } },
created() { this.load() },
methods: {
formatTime,
@@ -0,0 +1,15 @@
import fs from 'fs'
import path from 'path'
describe('portal user account column', () => {
const source = fs.readFileSync(
path.resolve(__dirname, '../../../src/views/chorus/users/index.vue'),
'utf8'
)
test('shows and searches the username while retaining email as profile data', () => {
expect(source).toContain('prop="username" label="账号"')
expect(source).toContain('[row.id, row.username, row.display_name, row.email]')
expect(source).toContain('prop="email" label="邮箱"')
})
})
+11 -2
View File
@@ -217,6 +217,7 @@ func (auditRow) TableName() string { return "admin_audit_events" }
type portalUserRow struct {
ID uint64 `gorm:"column:id;primaryKey"`
Username string `gorm:"column:username"`
Email string `gorm:"column:email"`
DisplayName string `gorm:"column:display_name"`
Status string `gorm:"column:status"`
@@ -692,7 +693,7 @@ func (s *Service) Users(ctx context.Context) ([]UserView, error) {
}
items := make([]UserView, 0, len(rows))
for _, row := range rows {
items = append(items, UserView{ID: row.ID, Email: row.Email, DisplayName: row.DisplayName, Status: row.Status, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt})
items = append(items, portalUserView(row))
}
return items, nil
}
@@ -719,7 +720,15 @@ func (s *Service) UpdateUserStatus(ctx context.Context, actor uint64, requestID
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
return UserView{}, translateNotFound(err)
}
return UserView{ID: row.ID, Email: row.Email, DisplayName: row.DisplayName, Status: row.Status, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil
return portalUserView(row), nil
}
func portalUserView(row portalUserRow) UserView {
username := row.Username
if username == "" {
username = fmt.Sprintf("user_%d", row.ID)
}
return UserView{ID: row.ID, Username: username, Email: row.Email, DisplayName: row.DisplayName, Status: row.Status, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
}
func (s *Service) Generations(ctx context.Context) ([]GenerationView, error) {
@@ -121,7 +121,8 @@ func TestAdminServiceMySQL(t *testing.T) {
t.Fatalf("new provider model missing from health view: %#v", health)
}
userEmail := "user-" + suffix + "@chorus.invalid"
if err := db.Exec("INSERT INTO users (email, password_hash, display_name, status) VALUES (?, 'synthetic', 'Admin Test User', 'active')", userEmail).Error; err != nil {
userUsername := "user-" + suffix
if err := db.Exec("INSERT INTO users (username, email, password_hash, display_name, status) VALUES (?, ?, 'synthetic', 'Admin Test User', 'active')", userUsername, userEmail).Error; err != nil {
t.Fatalf("create user status fixture: %v", err)
}
defer db.Exec("DELETE FROM users WHERE email = ?", userEmail)
@@ -130,7 +131,7 @@ func TestAdminServiceMySQL(t *testing.T) {
t.Fatalf("user fixture id=%d error=%v", userID, err)
}
updatedUser, err := service.UpdateUserStatus(ctx, 7, "user-status-"+suffix, userID, UserStatusInput{Status: "disabled"})
if err != nil || updatedUser.Status != "disabled" {
if err != nil || updatedUser.Status != "disabled" || updatedUser.Username != userUsername {
t.Fatalf("updated user=%#v error=%v", updatedUser, err)
}
templateView, err := service.CreatePromptTemplate(ctx, 7, "template-"+suffix, PromptTemplateInput{
+12
View File
@@ -0,0 +1,12 @@
package chorus
import "testing"
func TestPortalUserViewUsername(t *testing.T) {
if got := portalUserView(portalUserRow{ID: 7, Username: "named-user"}).Username; got != "named-user" {
t.Fatalf("migrated username = %q", got)
}
if got := portalUserView(portalUserRow{ID: 7}).Username; got != "user_7" {
t.Fatalf("pre-migration username = %q", got)
}
}
+1
View File
@@ -165,6 +165,7 @@ type UserStatusInput struct {
type UserView struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Status string `json:"status"`
+1
View File
@@ -38,6 +38,7 @@ func run() error {
return fmt.Errorf("connect to seed database")
}
return seed.Run(ctx, db, seed.Options{
UserUsername: os.Getenv("CHORUS_SEED_USER_USERNAME"),
UserEmail: os.Getenv("CHORUS_SEED_USER_EMAIL"),
UserPassword: os.Getenv("CHORUS_SEED_USER_PASSWORD"),
ProviderBaseURL: os.Getenv("CHORUS_SEED_PROVIDER_BASE_URL"),
+8 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.-
wiki_revision: a660cd26350c8f7d242788e28c6ecc01bcf31f4d
synchronized_at: 2026-08-24T07:28:55Z
wiki_revision: 656753d7ce1285da7857e5276fb7748c2b429d64
synchronized_at: 2026-08-24T09:08:44Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -416,3 +416,9 @@ Provider 限流检查发生在真实上游调用前。放行后才执行 `BeginP
- 同步提交继续只做认证、严格校验、路由快照和事务落库,返回 202;没有 Provider 调用或 attempt。上游仍只由 worker 调用。
- 仓库契约源为 `portal/openapi/openapi.json`,由 `portal/openapi` 嵌入二进制;合约测试固定 OpenAPI 3.1、八条路径、方法和 Bearer security。
<!-- issue-43:end -->
## #50 Portal 仅账号登录(2026-08-24)
- `migrations/000008_portal_username_login` 为 `users` 增加非空唯一 `username`,历史用户确定性回填为 `user_<id>`;down 只删除账号约束、索引和字段,不修改邮箱、密码哈希或业务数据。
- Portal `POST /api/session/login` 只接收 `account` 与 `password`,认证服务规范化账号后只查询 `users.username`;`email` 请求字段和把邮箱值作为账号提交都不能登录。
- `users.email` 继续作为受控用户资料保留;管理员 `sys_user` 与终端用户 `users` 继续分表,密码、Cookie、JWT 和 API Key 均不复用。
- `cmd/chorus-seed` 要求 `CHORUS_SEED_USER_USERNAME`、邮箱、密码和 mock Provider 地址;账号冲突失败关闭,命令不输出凭据。
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Business-Rules-and-Glossary.-
wiki_revision: e63946a4f4b0ca4acbad0a0ba0e75b9ed70db95a
synchronized_at: 2026-08-24T07:29:01Z
wiki_revision: 575443f808c909383071207adb0be843b88f8b0d
synchronized_at: 2026-08-24T09:08:49Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -32,7 +32,7 @@ synchronized_at: 2026-08-24T07:29:01Z
### 数据与约束
1. `users.email`(或最终选定的登录名)规范化后唯一;管理员仍使用独立 `sys_user`。
1. `users.username` 规范化后唯一且只允许小写字母、数字、点、下划线和连字符;Portal 只按账号登录,`users.email` 仅作为资料保留,不参与认证;管理员仍使用独立 `sys_user`。
2. ProviderModel 至少以 Provider、模型标识和 api_type 形成不重复绑定;所有外键、删除行为和软删除策略在首个迁移工单明确,不依赖 GORM 自动推断。
3. `generations` 至少有 `(status,lease_until,created_at)` 取任务索引、`(user_id,created_at)` 历史索引和 `(user_id,idempotency_key)` 唯一约束。
4. 输入、输出、attempt 与 generation 的关系使用显式外键/索引;生产表、`sys_*` 基线、菜单和 API 种子都由可逆 SQL 管理。
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Local-Development-and-Verification.-
wiki_revision: e8d57350c40ad6a6ac996b5a3e5903ca782a1d1c
synchronized_at: 2026-08-24T07:29:07Z
wiki_revision: 4d052c431913a259bbf1383b4e40c729486f3d99
synchronized_at: 2026-08-24T09:08:54Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -42,7 +42,7 @@ synchronized_at: 2026-08-24T07:29:07Z
| `CHORUS_ADMIN_PASSWORD` | 管理员 bootstrap 密码;只在当前受保护进程环境中提供 |
| `CHORUS_ADMIN_RESET_PASSWORD` | 仅设为 `1` 时允许 bootstrap 重置已有管理员密码 |
| `CHORUS_MIGRATE_URL` | golang-migrate 专用 MySQL URL |
| `CHORUS_SEED_USER_EMAIL` / `CHORUS_SEED_USER_PASSWORD` | 构造种子用户;仅通过环境注入 |
| `CHORUS_SEED_USER_USERNAME` / `CHORUS_SEED_USER_EMAIL` / `CHORUS_SEED_USER_PASSWORD` | 构造受控种子用户;Portal 只使用账号登录,值仅通过环境注入 |
| `CHORUS_SEED_PROVIDER_BASE_URL` | mock Provider 基础 URL;不得指向生产 |
| `CHORUS_ADMIN_ALLOW_CONNECTIVITY_PROBES` | 默认不设置或 `0`,管理端拒绝连通性探测;只有人工授权真实探测时才设为 `1` |
| `CHORUS_ADMIN_CONNECTIVITY_COOLDOWN_SECONDS` | 探测已授权时的正整数冷却;未授权时不需要 |
@@ -118,6 +118,7 @@ $env:MYSQL_PWD = $null
```powershell
migrate -path migrations -database $env:CHORUS_MIGRATE_URL up
$env:CHORUS_SEED_USER_USERNAME = "mvp0-test"
$env:CHORUS_SEED_USER_EMAIL = "mvp0-test@chorus.invalid"
$env:CHORUS_SEED_USER_PASSWORD = "<本次构造密码>"
$env:CHORUS_SEED_PROVIDER_BASE_URL = "<mock 上游 URL>"
@@ -188,7 +189,7 @@ go run ./cmd/chorus-mock-provider
生产安全 Client 始终拒绝回环/私网 Provider URL。自动化端到端测试使用构造公共域名解析和受控 `DialContext` 把请求送到进程内 mock,不应为了让 portal 直连本机 mock 而放宽 SSRF 规则。
种子密码从 #11 起保存为 `bcrypt:v1:<hash>`。已有无版本构造哈希不会被登录兼容;使用同一构造邮箱和本次选择的密码重新运行 `go run ./cmd/chorus-seed`,会更新密码哈希而不新增用户。开发/测试未提供 session key 时进程随机生成临时 key,重启后会话失效;生产必须显式提供至少 32 字节且与 Provider 主密钥分离的 key。
种子密码从 #11 起保存为 `bcrypt:v1:<hash>`。已有无版本构造哈希不会被登录兼容;使用同一构造账号、邮箱和本次选择的密码重新运行 `go run ./cmd/chorus-seed`,会更新密码哈希而不新增用户。邮箱只作为资料保留,不能登录。开发/测试未提供 session key 时进程随机生成临时 key,重启后会话失效;生产必须显式提供至少 32 字节且与 Provider 主密钥分离的 key。
### 5. 启动 portal(MVP-0)
@@ -272,7 +273,7 @@ portal 静态资源和浏览器 E2E 使用固定锁文件。E2E 账号、密码
corepack pnpm --dir portal/web install --frozen-lockfile
corepack pnpm --dir portal/web build
$env:CHORUS_E2E_BASE_URL = "http://127.0.0.1:8080"
# 在当前进程安全设置 CHORUS_E2E_EMAIL 与 CHORUS_E2E_PASSWORD
# 在当前进程安全设置 CHORUS_E2E_USERNAME、CHORUS_E2E_EMAIL 与 CHORUS_E2E_PASSWORD
corepack pnpm --dir portal/web test:e2e
```
@@ -471,3 +472,6 @@ go test ./...
真实 Handler 集成测试必须使用显式命名、可丢弃的 MySQL 8 隔离库。#43 已在 `chorus_mvp2_openapi_test` 覆盖三条认证链隔离、无效/到期/撤销/停用 Key、JSON/multipart、跨渠道幂等与冲突、游标、跨用户详情和文件访问;测试结束已删除该库,未调用真实 Provider。
<!-- issue-43:end -->
### 用户端账号登录(#50)
执行 `000008_portal_username_login` 后,历史用户账号为 `user_<id>`。Portal 登录 JSON 契约固定为 `{"account":"<账号>","password":"<密码>"}`;旧 `email` 字段返回 400,把邮箱作为 `account` 返回统一的 401。需要易记账号时,在受保护环境中设置 `CHORUS_SEED_USER_USERNAME` 后重新执行 seed。生产和常驻启动脚本仍不会自动执行 migration 或 seed。
+5 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Product-Requirements-Overview.-
wiki_revision: b9376419b0451024cbff90d3e2f9888d033a1ef8
synchronized_at: 2026-08-24T08:00:09Z
wiki_revision: da0a7321fbffe53956c4bdb28a5b3b07a261c6f7
synchronized_at: 2026-08-24T09:09:22Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -290,3 +290,6 @@ MVP-1 的设计、生产实现、变更工单和跨模块集成验收均已完
用户已确认 MVP-2 面向现有受控用户交付安全的 API Key、OpenAPI 生成接口、用户/API Key/Provider 限流、审计和必要的用户端/管理端页面;不开放注册,不实施自动数据删除,不增加计费、点数或每日配额。汇总工单为 [#35](https://git.ilapage.cn/OPC/chorus/issues/35)。用户于 2026-08-24 验收通过技术设计 [#36](https://git.ilapage.cn/OPC/chorus/issues/36) 和界面原型 [#37](https://git.ilapage.cn/OPC/chorus/issues/37);已确认快照为 `prototypes/37/v1/index.html`。
生产实现拆单门禁已经解除,但功能尚未实现或交付。下一步按 #36 的认证、API、数据、限流、审计和迁移设计,以及 #37 v1 的页面结构、权限、流程和异常状态,建立生产实现单元工单并按依赖顺序实施。
## 用户端仅账号登录(#50)
用户于 2026-08-24 确认终端用户 Portal 直接停用邮箱登录,只保留独立账号和密码。`users.username` 是规范化后唯一的登录标识,历史用户迁移为 `user_<id>`;邮箱继续作为资料显示但不参与认证。不开放注册、找回密码、账号自助修改或管理员/终端用户凭据复用。
@@ -44,7 +44,8 @@ func TestGORMRepositoryMySQL(t *testing.T) {
err = db.Transaction(func(tx *gorm.DB) error {
suffix := time.Now().UTC().UnixNano()
user := model.User{
Email: fmt.Sprintf("api-key-%d@chorus.invalid", suffix), PasswordHash: "synthetic",
Username: fmt.Sprintf("api-key-%d", suffix),
Email: fmt.Sprintf("api-key-%d@chorus.invalid", suffix), PasswordHash: "synthetic",
DisplayName: "API Key Test", Status: "active",
}
if err := tx.Create(&user).Error; err != nil {
@@ -64,7 +65,8 @@ func TestGORMRepositoryMySQL(t *testing.T) {
return err
}
otherUser := model.User{
Email: fmt.Sprintf("api-key-other-%d@chorus.invalid", suffix), PasswordHash: "synthetic",
Username: fmt.Sprintf("api-key-other-%d", suffix),
Email: fmt.Sprintf("api-key-other-%d@chorus.invalid", suffix), PasswordHash: "synthetic",
DisplayName: "Other API Key Test", Status: "active",
}
if err := tx.Create(&otherUser).Error; err != nil {
+1
View File
@@ -7,6 +7,7 @@ import (
type User struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Username string `gorm:"column:username;size:64;not null;uniqueIndex:uq_users_username"`
Email string `gorm:"column:email;size:320;not null;uniqueIndex:uq_users_email"`
PasswordHash string `gorm:"column:password_hash;size:255;not null"`
DisplayName string `gorm:"column:display_name;size:120;not null"`
+1 -1
View File
@@ -13,7 +13,7 @@ func TestGORMModelsMatchMigrationColumns(t *testing.T) {
model any
want []string
}{
{User{}, []string{"id", "email", "password_hash", "display_name", "status", "created_at", "updated_at"}},
{User{}, []string{"id", "username", "email", "password_hash", "display_name", "status", "created_at", "updated_at"}},
{Provider{}, []string{"id", "slug", "name", "base_url", "auth_type", "api_key_enc", "enabled", "created_at", "updated_at"}},
{ProviderModel{}, []string{"id", "provider_id", "name", "model_id", "api_type", "kind", "extra_body", "timeout_ms", "weight", "enabled", "created_at", "updated_at"}},
{PromptTemplate{}, []string{"id", "template_key", "kind", "api_type", "capability", "name", "version", "template_text", "default_role_rule", "enabled", "created_at", "updated_at"}},
@@ -37,6 +37,7 @@ func integrationRepository(t *testing.T) (*MySQLRepository, *gorm.DB) {
func createIntegrationUser(t *testing.T, db *gorm.DB, suffix string) model.User {
t.Helper()
user := model.User{
Username: fmt.Sprintf("queue-%s-%d", suffix, time.Now().UnixNano()),
Email: fmt.Sprintf("queue-%s-%d@chorus.invalid", suffix, time.Now().UnixNano()),
PasswordHash: "synthetic-hash", DisplayName: "Queue Test", Status: "active",
}
+23 -10
View File
@@ -10,12 +10,14 @@ import (
var defaultsJSON []byte
type Prompt struct {
Key string `json:"key"`
Kind string `json:"kind"`
APIType string `json:"api_type"`
Name string `json:"name"`
Version uint `json:"version"`
Template string `json:"template"`
Key string `json:"key"`
Kind string `json:"kind"`
APIType string `json:"api_type"`
Capability string `json:"capability"`
Name string `json:"name"`
Version uint `json:"version"`
Template string `json:"template"`
DefaultRoleRule string `json:"default_role_rule"`
}
type Provider struct {
@@ -24,10 +26,11 @@ type Provider struct {
}
type Model struct {
Name string `json:"name"`
ModelID string `json:"model_id"`
APIType string `json:"api_type"`
Kind string `json:"kind"`
Name string `json:"name"`
ModelID string `json:"model_id"`
APIType string `json:"api_type"`
Kind string `json:"kind"`
Capability string `json:"capability"`
}
type Defaults struct {
@@ -44,5 +47,15 @@ func LoadDefaults() (Defaults, error) {
if len(defaults.Prompts) != 2 || len(defaults.Models) != 2 || defaults.Provider.Slug == "" {
return Defaults{}, fmt.Errorf("embedded seed data is incomplete")
}
for _, prompt := range defaults.Prompts {
if prompt.Key == "" || prompt.Capability == "" {
return Defaults{}, fmt.Errorf("embedded seed prompt data is incomplete")
}
}
for _, model := range defaults.Models {
if model.ModelID == "" || model.Capability == "" {
return Defaults{}, fmt.Errorf("embedded seed model data is incomplete")
}
}
return defaults, nil
}
+10 -5
View File
@@ -4,17 +4,21 @@
"key": "mvp0-chat-default",
"kind": "text",
"api_type": "chat",
"capability": "text",
"name": "MVP-0 Chat Default",
"version": 1,
"template": "{{.UserPrompt}}"
"template": "{{.UserPrompt}}",
"default_role_rule": ""
},
{
"key": "mvp0-images-edits-default",
"kind": "image",
"api_type": "images_edits",
"capability": "image_edit",
"name": "MVP-0 Images Edits Default",
"version": 1,
"template": "用户要求:\n{{.UserPrompt}}\n\n图片说明:\n- 第 1 张图片是需要处理的主图。\n- 其余图片仅作为参考。\n- 仅按用户明确要求进行修改,不添加用户未要求的风格、文字、商品属性或场景。"
"template": "用户要求:\n{{.UserPrompt}}\n\n图片说明:\n- 第 1 张图片是需要处理的主图。\n- 其余图片仅作为参考。\n- 仅按用户明确要求进行修改,不添加用户未要求的风格、文字、商品属性或场景。",
"default_role_rule": ""
}
],
"provider": {
@@ -26,14 +30,15 @@
"name": "Mock Chat",
"model_id": "mock-chat",
"api_type": "chat",
"kind": "text"
"kind": "text",
"capability": "text"
},
{
"name": "Mock Images Edits",
"model_id": "mock-images-edits",
"api_type": "images_edits",
"kind": "image"
"kind": "image",
"capability": "image_edit"
}
]
}
+8
View File
@@ -20,5 +20,13 @@ func TestDefaultsMatchConfirmedPrompts(t *testing.T) {
if prompt.Template != want[prompt.Key] {
t.Errorf("prompt %q does not match confirmed text", prompt.Key)
}
if prompt.Capability == "" {
t.Errorf("prompt %q has no capability", prompt.Key)
}
}
for _, model := range defaults.Models {
if model.Capability == "" {
t.Errorf("model %q has no capability", model.ModelID)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package seed
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
)
func TestSeedUserUsernameIdempotencyMySQL(t *testing.T) {
if os.Getenv("CHORUS_RUN_SEED_TESTS") != "1" {
t.Skip("set CHORUS_RUN_SEED_TESTS=1 for the disposable MySQL seed database")
}
dsn := strings.TrimSpace(os.Getenv("CHORUS_DSN"))
if dsn == "" {
t.Fatal("CHORUS_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var databaseName string
if err := db.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&databaseName); err != nil {
t.Fatal(err)
}
expected := strings.TrimSpace(os.Getenv("CHORUS_MIGRATION_TEST_DATABASE"))
if expected == "" {
expected = "chorus_test"
}
if databaseName != expected {
t.Fatalf("refusing seed integration test against %q; expected %s", databaseName, expected)
}
defaults, err := LoadDefaults()
if err != nil {
t.Fatal(err)
}
var providerExisted int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM providers WHERE slug = ?`, defaults.Provider.Slug).Scan(&providerExisted); err != nil {
t.Fatal(err)
}
promptExisted := make(map[string]int, len(defaults.Prompts))
for _, prompt := range defaults.Prompts {
var count int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM prompt_templates WHERE template_key = ?`, prompt.Key).Scan(&count); err != nil {
t.Fatal(err)
}
promptExisted[prompt.Key] = count
}
suffix := time.Now().UnixNano()
username := fmt.Sprintf("seed-%d", suffix)
email := fmt.Sprintf("seed-%d@chorus.invalid", suffix)
options := Options{UserUsername: username, UserEmail: email, UserPassword: fmt.Sprintf("synthetic-%d", suffix), ProviderBaseURL: "https://provider.invalid/v1"}
t.Cleanup(func() {
_, _ = db.Exec(`DELETE FROM users WHERE username = ?`, username)
if providerExisted == 0 {
_, _ = db.Exec(`DELETE FROM provider_model_capabilities WHERE provider_model_id IN (SELECT id FROM provider_models WHERE provider_id IN (SELECT id FROM providers WHERE slug = ?))`, defaults.Provider.Slug)
_, _ = db.Exec(`DELETE FROM provider_models WHERE provider_id IN (SELECT id FROM providers WHERE slug = ?)`, defaults.Provider.Slug)
_, _ = db.Exec(`DELETE FROM providers WHERE slug = ?`, defaults.Provider.Slug)
}
for _, prompt := range defaults.Prompts {
if promptExisted[prompt.Key] == 0 {
_, _ = db.Exec(`DELETE FROM prompt_templates WHERE template_key = ?`, prompt.Key)
}
}
})
if err := Run(ctx, db, options); err != nil {
t.Fatal(err)
}
if err := Run(ctx, db, options); err != nil {
t.Fatal(err)
}
var count int
var passwordHash string
if err := db.QueryRowContext(ctx, `SELECT COUNT(*), MAX(password_hash) FROM users WHERE username = ? AND email = ?`, username, email).Scan(&count, &passwordHash); err != nil {
t.Fatal(err)
}
if count != 1 || !strings.HasPrefix(passwordHash, "bcrypt:v1:") {
t.Fatalf("seeded user count=%d hash versioned=%t", count, strings.HasPrefix(passwordHash, "bcrypt:v1:"))
}
}
+36 -13
View File
@@ -5,22 +5,28 @@ import (
"database/sql"
"fmt"
"net/url"
"regexp"
"strings"
passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password"
)
type Options struct {
UserUsername string
UserEmail string
UserPassword string
ProviderBaseURL string
}
func Run(ctx context.Context, db *sql.DB, options Options) error {
options.UserUsername = strings.ToLower(strings.TrimSpace(options.UserUsername))
options.UserEmail = strings.ToLower(strings.TrimSpace(options.UserEmail))
options.ProviderBaseURL = strings.TrimSpace(options.ProviderBaseURL)
if !validUsername(options.UserUsername) {
return fmt.Errorf("seed user username must be 3-64 lowercase letters, digits, dots, underscores, or hyphens and start with a letter or digit")
}
if options.UserEmail == "" || options.UserPassword == "" || options.ProviderBaseURL == "" {
return fmt.Errorf("seed options require user email, user password, and provider base URL")
return fmt.Errorf("seed options require user username, user email, user password, and provider base URL")
}
parsedURL, err := url.Parse(options.ProviderBaseURL)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
@@ -43,11 +49,12 @@ func Run(ctx context.Context, db *sql.DB, options Options) error {
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO users (email, password_hash, display_name, status)
VALUES (?, ?, ?, 'active')
INSERT INTO users (username, email, password_hash, display_name, status)
VALUES (?, ?, ?, ?, 'active')
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash), display_name = VALUES(display_name), status = 'active'`,
options.UserEmail, passwordHash, "MVP-0 Test User"); err != nil {
username = VALUES(username), email = VALUES(email), password_hash = VALUES(password_hash),
display_name = VALUES(display_name), status = 'active'`,
options.UserUsername, options.UserEmail, passwordHash, "MVP-0 Test User"); err != nil {
return fmt.Errorf("seed user: %w", err)
}
@@ -67,27 +74,39 @@ func Run(ctx context.Context, db *sql.DB, options Options) error {
}
for _, model := range defaults.Models {
if _, err := tx.ExecContext(ctx, `
result, err := tx.ExecContext(ctx, `
INSERT INTO provider_models
(provider_id, name, model_id, api_type, kind, extra_body, timeout_ms, weight, enabled)
VALUES (?, ?, ?, ?, ?, JSON_OBJECT(), 30000, 100, TRUE)
ON DUPLICATE KEY UPDATE
name = VALUES(name), kind = VALUES(kind), extra_body = JSON_OBJECT(),
id = LAST_INSERT_ID(id), name = VALUES(name), kind = VALUES(kind), extra_body = JSON_OBJECT(),
timeout_ms = VALUES(timeout_ms), weight = VALUES(weight), enabled = TRUE`,
providerID, model.Name, model.ModelID, model.APIType, model.Kind); err != nil {
providerID, model.Name, model.ModelID, model.APIType, model.Kind)
if err != nil {
return fmt.Errorf("seed provider model %s: %w", model.ModelID, err)
}
providerModelID, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("read seeded provider model %s: %w", model.ModelID, err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO provider_model_capabilities (provider_model_id, capability)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE capability = VALUES(capability)`, providerModelID, model.Capability); err != nil {
return fmt.Errorf("seed provider model capability %s: %w", model.ModelID, err)
}
}
for _, prompt := range defaults.Prompts {
if _, err := tx.ExecContext(ctx, `
INSERT INTO prompt_templates
(template_key, kind, api_type, name, version, template_text, enabled)
VALUES (?, ?, ?, ?, ?, ?, TRUE)
(template_key, kind, api_type, capability, name, version, template_text, default_role_rule, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, TRUE)
ON DUPLICATE KEY UPDATE
kind = VALUES(kind), api_type = VALUES(api_type), name = VALUES(name),
version = VALUES(version), template_text = VALUES(template_text), enabled = TRUE`,
prompt.Key, prompt.Kind, prompt.APIType, prompt.Name, prompt.Version, prompt.Template); err != nil {
kind = VALUES(kind), api_type = VALUES(api_type), capability = VALUES(capability), name = VALUES(name),
version = VALUES(version), template_text = VALUES(template_text),
default_role_rule = VALUES(default_role_rule), enabled = TRUE`,
prompt.Key, prompt.Kind, prompt.APIType, prompt.Capability, prompt.Name, prompt.Version, prompt.Template, prompt.DefaultRoleRule); err != nil {
return fmt.Errorf("seed prompt %s: %w", prompt.Key, err)
}
}
@@ -97,3 +116,7 @@ func Run(ctx context.Context, db *sql.DB, options Options) error {
}
return nil
}
var usernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{2,63}$`)
func validUsername(value string) bool { return usernamePattern.MatchString(value) }
+19
View File
@@ -0,0 +1,19 @@
package seed
import "testing"
func TestValidUsername(t *testing.T) {
tests := map[string]bool{
"user_1": true,
"demo.user-2": true,
"ab": false,
"_user": false,
"User": false,
"user@example.com": false,
}
for value, want := range tests {
if got := validUsername(value); got != want {
t.Errorf("validUsername(%q) = %t, want %t", value, got, want)
}
}
}
@@ -0,0 +1,4 @@
ALTER TABLE users
DROP CHECK chk_users_username,
DROP INDEX uq_users_username,
DROP COLUMN username;
@@ -0,0 +1,14 @@
ALTER TABLE users
ADD COLUMN username VARCHAR(64) NULL AFTER id;
UPDATE users
SET username = CONCAT('user_', id)
WHERE username IS NULL OR username = '';
ALTER TABLE users
MODIFY COLUMN username VARCHAR(64) NOT NULL,
ADD UNIQUE KEY uq_users_username (username),
ADD CONSTRAINT chk_users_username CHECK (
BINARY username = BINARY LOWER(username)
AND username REGEXP '^[a-z0-9][a-z0-9._-]{2,63}$'
);
+32
View File
@@ -29,6 +29,7 @@ func TestMigrationPairsAndProductionTables(t *testing.T) {
"000005_provider_plaintext_credentials.up.sql",
"000006_mvp2_openapi_governance.up.sql",
"000007_admin_navigation_localization.up.sql",
"000008_portal_username_login.up.sql",
}
slices.Sort(upFiles)
if !slices.Equal(upFiles, wantFiles) {
@@ -60,6 +61,37 @@ func TestMigrationPairsAndProductionTables(t *testing.T) {
}
}
func TestPortalUsernameLoginMigrationContracts(t *testing.T) {
up, err := os.ReadFile("000008_portal_username_login.up.sql")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{
"ADD COLUMN username VARCHAR(64) NULL",
"CONCAT('user_', id)",
"MODIFY COLUMN username VARCHAR(64) NOT NULL",
"ADD UNIQUE KEY uq_users_username (username)",
"ADD CONSTRAINT chk_users_username CHECK",
"BINARY username = BINARY LOWER(username)",
} {
if !strings.Contains(string(up), required) {
t.Errorf("username login migration is missing %s", required)
}
}
down, err := os.ReadFile("000008_portal_username_login.down.sql")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{"DROP CHECK chk_users_username", "DROP INDEX uq_users_username", "DROP COLUMN username"} {
if !strings.Contains(string(down), required) {
t.Errorf("username login rollback is missing %s", required)
}
}
if strings.Contains(strings.ToLower(string(up)), "automigrate") {
t.Error("username login migration must not use AutoMigrate")
}
}
func TestAdminNavigationLocalizationMigrationContracts(t *testing.T) {
read := func(name string) string {
t.Helper()
+17 -1
View File
@@ -154,7 +154,18 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) {
assertMenuTitleSort(t, ctx, db, "/chorus/generations", "生成记录", 60)
assertMenuTitleSort(t, ctx, db, "/chorus/users", "用户管理", 70)
assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API 密钥", 80)
assertColumnExists(t, ctx, db, "users", "username", true)
assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1)
if _, err := db.ExecContext(ctx, `INSERT INTO users (username, email, password_hash, display_name, status) VALUES ('UpperCase', 'uppercase@chorus.invalid', 'synthetic', 'Invalid Username', 'active')`); err == nil {
t.Fatal("username check accepted uppercase input")
}
if _, err := db.ExecContext(ctx, `INSERT INTO users (username, email, password_hash, display_name, status) VALUES ('email@example.com', 'email-value@chorus.invalid', 'synthetic', 'Invalid Username', 'active')`); err == nil {
t.Fatal("username check accepted an email-shaped input")
}
runMigrate("down", "1")
assertColumnExists(t, ctx, db, "users", "username", false)
assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10)
runMigrate("down", "1")
assertTableExists(t, ctx, db, "api_keys", true)
assertTableExists(t, ctx, db, "api_audit_events", true)
@@ -165,8 +176,11 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) {
runMigrate("up", "1")
assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10)
assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API 密钥", 80)
runMigrate("up", "1")
assertColumnExists(t, ctx, db, "users", "username", true)
assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1)
runMigrate("down", "2")
runMigrate("down", "3")
assertTableExists(t, ctx, db, "api_keys", false)
assertTableExists(t, ctx, db, "api_audit_events", false)
assertColumnExists(t, ctx, db, "generations", "available_at", false)
@@ -177,6 +191,8 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) {
assertColumnExists(t, ctx, db, "generations", "available_at", true)
assertMenuTitleSort(t, ctx, db, "/chorus/providers", "上游服务商", 10)
assertMenuTitleSort(t, ctx, db, "/chorus/api-keys", "API 密钥", 80)
assertColumnExists(t, ctx, db, "users", "username", true)
assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1)
}
func resetDisposableSchema(t *testing.T, ctx context.Context, db *sql.DB) {
+3 -3
View File
@@ -28,14 +28,14 @@ func NewService(db *gorm.DB, attempts int, window time.Duration) (*Service, erro
return &Service{db: db, limiter: NewLimiter(attempts, window)}, nil
}
func (s *Service) Login(ctx context.Context, remoteKey, email, password string) (model.User, error) {
func (s *Service) Login(ctx context.Context, remoteKey, account, password string) (model.User, error) {
if !s.limiter.Allow(remoteKey, time.Now()) {
_ = passwordpkg.Verify(dummyHash, password)
return model.User{}, ErrInvalidCredentials
}
normalized := strings.ToLower(strings.TrimSpace(email))
normalized := strings.ToLower(strings.TrimSpace(account))
var user model.User
err := s.db.WithContext(ctx).Where("email = ?", normalized).First(&user).Error
err := s.db.WithContext(ctx).Where("username = ?", normalized).First(&user).Error
encoded := dummyHash
if err == nil {
encoded = user.PasswordHash
+18 -7
View File
@@ -71,9 +71,9 @@ func (c *apiClient) start(t *testing.T) {
}
c.csrf = body.CSRF
}
func (c *apiClient) login(t *testing.T, email, password string) *httptest.ResponseRecorder {
func (c *apiClient) login(t *testing.T, account, password string) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
body, _ := json.Marshal(map[string]string{"account": account, "password": password})
response := c.do(http.MethodPost, "/api/session/login", body, "application/json")
if response.Code == 200 {
var result struct {
@@ -113,7 +113,7 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) {
if err != nil {
t.Fatal(err)
}
users := []model.User{{Email: "portal-a-" + suffix + "@example.invalid", PasswordHash: encoded, DisplayName: "User A", Status: "active"}, {Email: "portal-b-" + suffix + "@example.invalid", PasswordHash: encoded, DisplayName: "User B", Status: "active"}}
users := []model.User{{Username: "portal-a-" + suffix, Email: "portal-a-" + suffix + "@example.invalid", PasswordHash: encoded, DisplayName: "User A", Status: "active"}, {Username: "portal-b-" + suffix, Email: "portal-b-" + suffix + "@example.invalid", PasswordHash: encoded, DisplayName: "User B", Status: "active"}}
if err := db.Create(&users).Error; err != nil {
t.Fatal(err)
}
@@ -164,21 +164,32 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) {
if loginPage.Code != http.StatusOK || !strings.Contains(loginPage.Body.String(), `data-return-to="/"`) || strings.Contains(loginPage.Body.String(), "cdn.") || strings.Contains(loginPage.Body.String(), "test-password") {
t.Fatalf("login page=%d %s", loginPage.Code, loginPage.Body.String())
}
legacy := &apiClient{router: router}
legacy.start(t)
legacyBody, _ := json.Marshal(map[string]string{"email": users[0].Email, "password": password})
if response := legacy.do(http.MethodPost, "/api/session/login", legacyBody, "application/json"); response.Code != http.StatusBadRequest {
t.Fatalf("legacy email contract=%d %s", response.Code, response.Body.String())
}
unknown := &apiClient{router: router}
unknown.start(t)
unknownResponse := unknown.login(t, "missing-"+suffix+"@example.invalid", "wrong-password-value")
unknownResponse := unknown.login(t, "missing-"+suffix, "wrong-password-value")
wrong := &apiClient{router: router}
wrong.start(t)
wrongResponse := wrong.login(t, users[0].Email, "wrong-password-value")
wrongResponse := wrong.login(t, users[0].Username, "wrong-password-value")
if unknownResponse.Code != 401 || wrongResponse.Code != 401 || unknownResponse.Body.String() != wrongResponse.Body.String() {
t.Fatalf("credential responses differ: %d %s / %d %s", unknownResponse.Code, unknownResponse.Body.String(), wrongResponse.Code, wrongResponse.Body.String())
}
clientA := &apiClient{router: router}
clientA.start(t)
if response := clientA.login(t, users[0].Email, password); response.Code != http.StatusUnauthorized {
t.Fatalf("email login=%d %s", response.Code, response.Body.String())
}
clientA = &apiClient{router: router}
clientA.start(t)
oldCookie := *clientA.cookie
if response := clientA.login(t, users[0].Email, password); response.Code != 200 {
if response := clientA.login(t, users[0].Username, password); response.Code != 200 {
t.Fatalf("login=%d %s", response.Code, response.Body.String())
}
emptyWorkspace := clientA.do(http.MethodGet, "/", nil, "")
@@ -494,7 +505,7 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) {
clientB := &apiClient{router: router}
clientB.start(t)
if response := clientB.login(t, users[1].Email, password); response.Code != 200 {
if response := clientB.login(t, users[1].Username, password); response.Code != 200 {
t.Fatalf("login B=%d", response.Code)
}
if response := clientB.do(http.MethodGet, fmt.Sprintf("/api/generations/%d", imageID), nil, ""); response.Code != 404 {
+4 -4
View File
@@ -137,20 +137,20 @@ func (h *Handler) sessionState(c *gin.Context) {
func (h *Handler) login(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var input struct {
Email string `json:"email" binding:"required"`
Account string `json:"account" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
writeError(c, 400, "invalid_request", "email and password are required")
writeError(c, 400, "invalid_request", "account and password are required")
return
}
user, err := h.auth.Login(c.Request.Context(), remoteKey(c.Request), input.Email, input.Password)
user, err := h.auth.Login(c.Request.Context(), remoteKey(c.Request), input.Account, input.Password)
if err != nil {
if !errors.Is(err, auth.ErrInvalidCredentials) {
writeError(c, http.StatusInternalServerError, "internal_error", "request could not be completed")
return
}
writeError(c, http.StatusUnauthorized, "invalid_credentials", "email or password is incorrect")
writeError(c, http.StatusUnauthorized, "invalid_credentials", "account or password is incorrect")
return
}
state, err := h.sessions.Authenticate(c.Writer, c.Request, user.ID)
+5 -4
View File
@@ -52,7 +52,7 @@ func run() error {
switch os.Args[1] {
case "setup":
return setup(db, os.Args[3], os.Getenv("CHORUS_E2E_PASSWORD"))
return setup(db, os.Getenv("CHORUS_E2E_USERNAME"), os.Args[3], os.Getenv("CHORUS_E2E_PASSWORD"))
case "cleanup":
return cleanup(db, os.Args[3])
case "running":
@@ -68,9 +68,10 @@ func run() error {
}
}
func setup(db *gorm.DB, email, password string) error {
func setup(db *gorm.DB, username, email, password string) error {
username = strings.ToLower(strings.TrimSpace(username))
email = strings.ToLower(strings.TrimSpace(email))
if email == "" || password == "" {
if username == "" || email == "" || password == "" {
return fmt.Errorf("e2e credentials are required")
}
passwordHash, err := passwordpkg.Encode(password)
@@ -78,7 +79,7 @@ func setup(db *gorm.DB, email, password string) error {
return fmt.Errorf("encode e2e password")
}
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`INSERT INTO users (email,password_hash,display_name,status) VALUES (?,?,?,'active') ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),display_name=VALUES(display_name),status='active'`, email, passwordHash, "Portal E2E User").Error; err != nil {
if err := tx.Exec(`INSERT INTO users (username,email,password_hash,display_name,status) VALUES (?,?,?,?,'active') ON DUPLICATE KEY UPDATE username=VALUES(username),email=VALUES(email),password_hash=VALUES(password_hash),display_name=VALUES(display_name),status='active'`, username, email, passwordHash, "Portal E2E User").Error; err != nil {
return err
}
if err := tx.Exec(`INSERT INTO providers (slug,name,base_url,auth_type,enabled) VALUES ('portal-e2e','Portal E2E','https://example.invalid','none',TRUE) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id),name=VALUES(name),base_url=VALUES(base_url),auth_type='none',enabled=TRUE`).Error; err != nil {
+10 -9
View File
@@ -3,11 +3,12 @@ const { execFileSync } = require("node:child_process");
const path = require("node:path");
const email = process.env.CHORUS_E2E_EMAIL;
const username = process.env.CHORUS_E2E_USERNAME;
const password = process.env.CHORUS_E2E_PASSWORD;
const baseURL = process.env.CHORUS_E2E_BASE_URL || "http://127.0.0.1:18082";
test.describe("portal responsive workflow", () => {
test.skip(!email || !password, "CHORUS_E2E_EMAIL and CHORUS_E2E_PASSWORD are required");
test.skip(!username || !email || !password, "CHORUS_E2E_USERNAME, CHORUS_E2E_EMAIL and CHORUS_E2E_PASSWORD are required");
test("login, submit, poll, and preserve context after expiry", async ({ page, context }, testInfo) => {
const browserErrors = [];
@@ -32,14 +33,14 @@ test.describe("portal responsive workflow", () => {
await expect(page.getByRole("heading", { name: "登录 Chorus", level: 2 })).toBeVisible();
await expectNoHorizontalOverflow(page);
await expectMinimumTargetSize(page, "#login-button");
await expect(page.locator("#email")).toBeFocused();
await expect(page.locator("#account")).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.locator("#password")).toBeFocused();
await page.locator("#email").fill("invalid-email");
await page.locator("#account").fill("");
await page.locator("#login-button").click();
await expect(page.locator("#email-error")).toBeVisible();
await expect(page.locator("#email")).toBeFocused();
await page.locator("#email").fill(email);
await expect(page.locator("#account-error")).toBeVisible();
await expect(page.locator("#account")).toBeFocused();
await page.locator("#account").fill(username);
await page.locator("#password").fill(password);
await page.locator("#login-button").click();
await expect(page).toHaveURL(/\/$/);
@@ -116,7 +117,7 @@ test.describe("portal responsive workflow", () => {
});
test.describe("API key responsive workflow", () => {
test.skip(!email || !password, "CHORUS_E2E_EMAIL and CHORUS_E2E_PASSWORD are required");
test.skip(!username || !email || !password, "CHORUS_E2E_USERNAME, CHORUS_E2E_EMAIL and CHORUS_E2E_PASSWORD are required");
test("create once, rename, refresh without secret, and revoke", async ({ page }, testInfo) => {
const browserErrors = [];
@@ -177,7 +178,7 @@ test.describe("API key responsive workflow", () => {
test("browser submits image generation and ordered image editing", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "desktop-small", "terminal state flow runs once");
test.skip(!email || !password || !process.env.CHORUS_E2E_DSN || !process.env.CHORUS_E2E_STORAGE_ROOT, "E2E fixture environment is required");
test.skip(!username || !email || !password || !process.env.CHORUS_E2E_DSN || !process.env.CHORUS_E2E_STORAGE_ROOT, "E2E fixture environment is required");
test.setTimeout(60_000);
await login(page);
@@ -251,7 +252,7 @@ test("browser submits image generation and ordered image editing", async ({ page
async function login(page) {
await page.goto("/login");
await page.locator("#email").fill(email);
await page.locator("#account").fill(username);
await page.locator("#password").fill(password);
await page.locator("#login-button").click();
await expect(page).toHaveURL(/\/$/);
+5 -5
View File
@@ -445,14 +445,14 @@
});
form?.addEventListener("submit", async event => {
event.preventDefault();
const email = document.querySelector("#email"); const password = document.querySelector("#password");
const error = document.querySelector("#login-error"); const emailError = document.querySelector("#email-error");
hide(error); hide(emailError);
if (!email.validity.valid) { show(emailError); email.focus(); return; }
const account = document.querySelector("#account"); const password = document.querySelector("#password");
const error = document.querySelector("#login-error"); const accountError = document.querySelector("#account-error");
hide(error); hide(accountError);
if (!account.validity.valid) { show(accountError); account.focus(); return; }
if (!password.value) { show(error); password.focus(); return; }
const button = document.querySelector("#login-button"); button.disabled = true; button.querySelector("span").textContent = "登录中";
try {
const response = await fetch("/api/session/login", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ email: email.value, password: password.value }) });
const response = await fetch("/api/session/login", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ account: account.value, password: password.value }) });
if (!response.ok) { show(error); password.focus(); return; }
const requested = document.body.dataset.returnTo || "/";
window.location.assign(requested.startsWith("/") && !requested.startsWith("//") ? requested : "/");
+4 -4
View File
@@ -33,9 +33,9 @@
<h2 id="login-title">登录 Chorus</h2>
<p>使用项目负责人提供的测试账户。</p>
<div class="form-field">
<label class="field-label" for="email">邮箱</label>
<input class="input" id="email" name="email" type="email" autocomplete="username" required autofocus>
<p class="field-error hidden" id="email-error">请输入有效邮箱。</p>
<label class="field-label" for="account">账号</label>
<input class="input" id="account" name="account" type="text" autocomplete="username" minlength="3" maxlength="64" required autofocus>
<p class="field-error hidden" id="account-error">请输入账号。</p>
</div>
<div class="form-field">
<label class="field-label" for="password">密码</label>
@@ -43,7 +43,7 @@
<input class="input" id="password" name="password" type="password" autocomplete="current-password" required>
<button class="icon-button" type="button" id="toggle-password" aria-label="显示密码" title="显示密码"><i class="icon" data-lucide="eye"></i></button>
</div>
<p class="field-error hidden" id="login-error" role="alert">邮箱或密码不正确。</p>
<p class="field-error hidden" id="login-error" role="alert">账号或密码不正确。</p>
</div>
<div class="form-field">
<button class="button button-primary button-block" id="login-button" type="submit"><span>登录</span></button>
+47
View File
@@ -0,0 +1,47 @@
package web
import (
"bytes"
"io/fs"
"strings"
"testing"
)
func TestLoginUsesAccountOnly(t *testing.T) {
renderer, err := NewRenderer()
if err != nil {
t.Fatal(err)
}
var page bytes.Buffer
if err := renderer.Render(&page, "login", Page{CSRFToken: "synthetic", ReturnTo: "/"}); err != nil {
t.Fatal(err)
}
html := page.String()
for _, required := range []string{`for="account">账号`, `id="account"`, `name="account"`, `type="text"`, `autocomplete="username"`, `账号或密码不正确`} {
if !strings.Contains(html, required) {
t.Errorf("login page is missing %s", required)
}
}
for _, forbidden := range []string{`id="email"`, `name="email"`, `type="email"`, `邮箱或密码不正确`} {
if strings.Contains(html, forbidden) {
t.Errorf("login page still contains %s", forbidden)
}
}
static, err := Static()
if err != nil {
t.Fatal(err)
}
script, err := fs.ReadFile(static, "app.js")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{`querySelector("#account")`, `account: account.value`} {
if !strings.Contains(string(script), required) {
t.Errorf("login script is missing %s", required)
}
}
if strings.Contains(string(script), `email: email.value`) {
t.Error("login script still submits email")
}
}
+2 -2
View File
@@ -92,7 +92,7 @@ func TestMySQLWorkerUsesSnapshotAndMockUpstream(t *testing.T) {
}
suffix := time.Now().UnixNano()
user := model.User{Email: fmt.Sprintf("worker-%d@chorus.invalid", suffix), PasswordHash: "synthetic", DisplayName: "Worker", Status: "active"}
user := model.User{Username: fmt.Sprintf("worker-%d", suffix), Email: fmt.Sprintf("worker-%d@chorus.invalid", suffix), PasswordHash: "synthetic", DisplayName: "Worker", Status: "active"}
providerRow := model.Provider{Slug: fmt.Sprintf("worker-%d", suffix), Name: "Worker Mock", BaseURL: "http://provider.test/v1", AuthType: "none", Enabled: true}
modelRow := model.ProviderModel{ProviderID: 0, Name: "Worker Chat", ModelID: "mock-chat", APIType: model.APIChat, Kind: model.KindText, ExtraBody: json.RawMessage("{}"), TimeoutMS: 1000, Weight: 1, Enabled: true}
template := model.PromptTemplate{TemplateKey: fmt.Sprintf("worker-%d", suffix), Kind: model.KindText, APIType: model.APIChat, Capability: model.CapabilityText, Name: "Worker", Version: 1, TemplateText: "{{.UserPrompt}}", DefaultRoleRule: "", Enabled: true}
@@ -247,7 +247,7 @@ func runMySQLImageProtocolWorker(t *testing.T, apiType model.APIType, capability
}
suffix := time.Now().UnixNano()
user := model.User{Email: fmt.Sprintf("worker-image-%d@chorus.invalid", suffix), PasswordHash: "synthetic", DisplayName: "Worker Image", Status: "active"}
user := model.User{Username: fmt.Sprintf("worker-image-%d", suffix), Email: fmt.Sprintf("worker-image-%d@chorus.invalid", suffix), PasswordHash: "synthetic", DisplayName: "Worker Image", Status: "active"}
providerRow := model.Provider{Slug: fmt.Sprintf("worker-image-%d", suffix), Name: "Worker Image Mock", BaseURL: "http://provider.test/v1", AuthType: "none", Enabled: true}
if err := tx.Create(&user).Error; err != nil {
t.Fatal(err)