feat: 用户端改为账号登录 (#50)
This commit is contained in:
@@ -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="邮箱"')
|
||||
})
|
||||
})
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 地址;账号冲突失败关闭,命令不输出凭据。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) }
|
||||
|
||||
@@ -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}$'
|
||||
);
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(/\/$/);
|
||||
|
||||
@@ -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 : "/");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user