From 98625ff9fe5945757ed22201962a969ff8a5bf73 Mon Sep 17 00:00:00 2001 From: ila Date: Mon, 24 Aug 2026 12:22:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20MVP-2=20API=20Key?= =?UTF-8?q?=20=E6=A0=B8=E5=BF=83=E4=B8=8E=E8=BF=81=E7=A7=BB=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/02-architecture-and-code-map.md | 11 +- docs/03-business-rules-and-glossary.md | 11 +- docs/04-local-development-and-verification.md | 26 +++- docs/10-deployment-and-operations.md | 11 +- .../core/apikey/mysql_integration_test.go | 99 +++++++++++++ internal/core/apikey/repository.go | 133 ++++++++++++++++++ internal/core/apikey/repository_test.go | 36 +++++ internal/core/model/api_key.go | 45 ++++++ internal/core/model/api_key_test.go | 41 ++++++ internal/core/model/models.go | 1 + internal/core/model/schema_test.go | 4 +- internal/core/model/types_test.go | 2 + internal/platform/apikey/credential.go | 80 +++++++++++ internal/platform/apikey/credential_test.go | 81 +++++++++++ .../000006_mvp2_openapi_governance.down.sql | 44 ++++++ .../000006_mvp2_openapi_governance.up.sql | 92 ++++++++++++ migrations/migrations_test.go | 39 +++++ migrations/mysql_integration_test.go | 52 ++++++- 18 files changed, 794 insertions(+), 14 deletions(-) create mode 100644 internal/core/apikey/mysql_integration_test.go create mode 100644 internal/core/apikey/repository.go create mode 100644 internal/core/apikey/repository_test.go create mode 100644 internal/core/model/api_key.go create mode 100644 internal/core/model/api_key_test.go create mode 100644 internal/platform/apikey/credential.go create mode 100644 internal/platform/apikey/credential_test.go create mode 100644 migrations/000006_mvp2_openapi_governance.down.sql create mode 100644 migrations/000006_mvp2_openapi_governance.up.sql diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 16c47c6..9252960 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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: 1e8fea6c058d6d23f4a40183dd51708c8a4dae84 -synchronized_at: 2026-08-24T03:48:52Z +wiki_revision: f0a042b18061ece7423e3e7bb4219f84df78d71e +synchronized_at: 2026-08-24T04:20:08Z # 架构与代码地图 @@ -388,3 +388,10 @@ Provider 限流检查发生在真实上游调用前。放行后才执行 `BeginP `000006_mvp2_openapi_governance` 创建两张表、`available_at`/队列索引和管理端菜单/API/Casbin seed;down 按稳定键删除 seed、索引、列和新表。必须在固定隔离 MySQL 8 上完成空库及含 MVP-1 数据的 up/down/up,并验证现有 generation 仍可领取。回退前停止 portal/worker;回退会删除 API Key 和新增审计,属于需人工确认的数据丢失操作,不能在生产自动执行。 必测矩阵包括:随机格式/哈希/常量比较、完整 Key 只出现一次、撤销/到期/用户停用、三条认证链不混用、CSRF、跨用户 404、幂等重放/冲突、JSON 与 multipart 边界、历史 cursor、文件归属、429 与 Retry-After、user/key/provider 三维桶、重启边界、Provider 本地限流不调用上游且不改变 retryable/circuit/attempt、Defer CAS 和旧 lease、审计脱敏、管理员权限、OpenAPI schema 合约、MySQL up/down/up,以及 mock 上游完整链路。不得使用真实 Provider 额度做回归。 + +### #41 已实现的数据与凭据核心(2026-08-24,待验收) + +- `migrations/000006_mvp2_openapi_governance` 已实现 `api_keys`、`api_audit_events`、`generations.available_at`、新队列索引以及管理端 API Key 菜单/API/Casbin 种子。`public_id` 固定 24 字符、可见前缀固定 32 字符、哈希固定 32 字节,并包含用户状态、最近使用时间和唯一定位索引。 +- `internal/platform/apikey` 使用 `crypto/rand` 生成 256 bit secret,令牌格式为 `chorus__`;只计算并保存 secret 的 SHA-256,认证比较使用常量时间。完整令牌与哈希均被排除在默认 JSON 序列化之外。 +- `internal/core/apikey` 提供按 `user_id` 隔离的创建、读取、列表、改名和撤销仓储。改名与撤销可安全重放;跨用户读取不会返回其他用户记录。 +- `000006` 的 down 在 `api_keys` 或 `api_audit_events` 存在数据时主动失败,防止静默丢失安全数据。不得在生产用 `force` 绕过;只有完成备份、停服、风险确认和数据处置后才能回退。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index f5e954b..925c9dc 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: e3789d9b44c51d9f1363df03f1bbc51db1c296a2 -synchronized_at: 2026-08-24T03:48:57Z +wiki_revision: 00ab703e701a3b9cb4107a3fb9a48fef6acea27d +synchronized_at: 2026-08-24T04:20:13Z # 业务规则与术语 @@ -139,3 +139,10 @@ synchronized_at: 2026-08-24T03:48:57Z 5. Provider 本地限流不得记作上游 429、不得增加 Provider attempt、不得触发熔断或消耗故障转移;所有候选受限时只延后 generation。 6. API 安全审计禁止保存 Prompt、文件、完整 Key、Authorization、Cookie、响应正文或原始 IP;未知 Key 失败不允许制造无限数据库审计行。 7. API Key 只撤销不物理删除;本 MVP 不自动删除 Key、审计、生成任务或生成物。 + +## MVP-2 API Key 持久化规则(#41,2026-08-24 待验收) + +- API Key 完整值只允许在创建成功响应中出现一次;数据库不保存原始 Key,只保存 24 字符公开标识、32 字符可见前缀和 32 字节 SHA-256。 +- Key 的有效性同时取决于未撤销、未到期以及所属终端用户可用;管理员凭据与终端用户 API Key 继续分离。 +- API Key 名称去除首尾空白后必须为 1 至 80 字符。改名和撤销按 Key 所属用户执行并支持幂等重放,不物理删除记录。 +- `api_audit_events` 是追加写安全审计模型,摘要只能保存脱敏结构化信息;完整 Key、Authorization、Cookie、Prompt、文件内容和响应正文不得进入审计。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 6e2950b..d93abf1 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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: 7ac7e6b87149b70514488e836a6d5a5336ecdb6f -synchronized_at: 2026-08-24T03:49:02Z +wiki_revision: 9661a72756f4c6d53eabd454df03625d382f1ac0 +synchronized_at: 2026-08-24T04:20:17Z # 本地开发与验证 @@ -402,3 +402,25 @@ D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf status | `chorus-admin-ui` | Vue 开发服务 | `config/local-services.yml` 的 `admin_ui`;API 基址由 Admin 地址派生 | 单个实例可使用 `ctl ... stop <名称>`、`start <名称>` 或 `restart <名称>` 管理。日志位于 `D:\supervisor\logs\chorus-*.log`。更新代码、端口或配置路径后必须重新运行安装器并 `reload`;安装器不复制敏感配置,也不执行迁移、seed、账号创建或依赖安装。 + +## #41 MVP-2 迁移与 API Key 核心验证 + +普通回归不执行破坏性迁移测试: + +```powershell +go test ./... +go vet ./... +go -C admin test ./... +``` + +真实迁移和仓储集成测试必须显式指定一个可丢弃且名称完全匹配的隔离库,并顺序运行,避免迁移 down 与仓储测试争用同一数据库: + +```powershell +$env:CHORUS_MIGRATION_TEST_DATABASE = "chorus_mvp2_test" +$env:CHORUS_RUN_MIGRATION_TESTS = "1" +$env:CHORUS_RUN_MYSQL_TESTS = "1" +go test ./migrations -count=1 +go test ./internal/core/apikey -count=1 +``` + +`CHORUS_DSN` 与 `CHORUS_MIGRATE_URL` 必须指向同一个明确创建的可丢弃库。测试会重置目标库结构,严禁指向 `chorus`、当前开发库、共享库或生产库。#41 已在本机 MySQL 8.4.8 独立库完成迁移 up/down/up、非空数据 down 拒绝、仓储创建/读取/改名/撤销和事务回滚验证。 diff --git a/docs/10-deployment-and-operations.md b/docs/10-deployment-and-operations.md index 380cf53..eda020e 100644 --- a/docs/10-deployment-and-operations.md +++ b/docs/10-deployment-and-operations.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Deployment-and-Operations.- -wiki_revision: c19a066002ec15cdacdf454dea4da4b67c3a4529 -synchronized_at: 2026-08-24T02:18:23Z +wiki_revision: 2eb4eb745005bbc3c7467b1202e4f0b779525224 +synchronized_at: 2026-08-24T04:20:48Z # 部署与运维 @@ -207,3 +207,10 @@ D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf status ``` 回退时停止 `chorus-user`、`chorus-admin-api`、`chorus-admin-ui`,移除 `D:\supervisor\programs\chorus.conf` 后 reload,再按需使用 `scripts/start-all.bat`。不得同时使用两套入口占用相同端口。 + +## MVP-2 迁移 000006 运维门禁(#41) + +- 发布包含 #41 或后续 MVP-2 服务代码前,必须先备份并执行 `000006_mvp2_openapi_governance.up.sql`;应用启动仍不会自动迁移或 AutoMigrate。 +- `000006` 为 `generations` 增加非空 `available_at` 并重建队列索引,同时新增 API Key、安全审计表和管理端授权种子。升级后要检查 migration version、两张表、队列索引和稳定键 seed。 +- down 会删除 API Key 与安全审计结构,因此在任一新表非空时必定失败。生产不得使用 migrate `force` 绕过保护;需要回退时先停止 portal/worker、备份并核验、明确处置数据、取得人工确认,再在已演练步骤下执行。 +- 仅回退二进制不能撤销本次数据库结构。旧二进制与 `available_at` 的兼容性必须在具体发布工单中验证,不能把隔离库测试替代真实发布演练。 diff --git a/internal/core/apikey/mysql_integration_test.go b/internal/core/apikey/mysql_integration_test.go new file mode 100644 index 0000000..389c6a9 --- /dev/null +++ b/internal/core/apikey/mysql_integration_test.go @@ -0,0 +1,99 @@ +package apikey + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "git.ilapage.cn/OPC/chorus/internal/core/model" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestGORMRepositoryMySQL(t *testing.T) { + if os.Getenv("CHORUS_RUN_MYSQL_TESTS") != "1" { + t.Skip("set CHORUS_RUN_MYSQL_TESTS=1 for the disposable MySQL database") + } + dsn := strings.TrimSpace(os.Getenv("CHORUS_DSN")) + if dsn == "" { + t.Fatal("CHORUS_DSN is required") + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + var databaseName string + if err := db.Raw("SELECT DATABASE()").Scan(&databaseName).Error; err != nil { + t.Fatal(err) + } + expectedDatabase := strings.TrimSpace(os.Getenv("CHORUS_MIGRATION_TEST_DATABASE")) + if expectedDatabase == "" { + expectedDatabase = "chorus_test" + } + if databaseName != expectedDatabase { + t.Fatalf("refusing API key integration test against %q; expected %s", databaseName, expectedDatabase) + } + + rollback := errors.New("rollback api key integration fixture") + 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", + DisplayName: "API Key Test", Status: "active", + } + if err := tx.Create(&user).Error; err != nil { + return err + } + repository, err := NewGORMRepository(tx) + if err != nil { + return err + } + publicID := fmt.Sprintf("%024d", suffix%1_000_000_000_000_000_000) + hash := sha256.Sum256([]byte("synthetic-secret-material")) + key := model.APIKey{ + UserID: user.ID, Name: "Automation", PublicID: publicID, + KeyPrefix: "chorus_" + publicID + "_", SecretHash: hash[:], + } + if err := repository.Create(context.Background(), &key); err != nil { + return err + } + loaded, err := repository.ByPublicID(context.Background(), publicID) + if err != nil || loaded.ID != key.ID || string(loaded.SecretHash) != string(hash[:]) { + return fmt.Errorf("load created api key: id=%d err=%w", loaded.ID, err) + } + keys, err := repository.ListForUser(context.Background(), user.ID) + if err != nil || len(keys) != 1 { + return fmt.Errorf("list api keys: count=%d err=%w", len(keys), err) + } + if renamed, err := repository.Rename(context.Background(), key.ID, user.ID, "Renamed"); err != nil || !renamed { + return fmt.Errorf("rename api key: renamed=%t err=%w", renamed, err) + } + if renamed, err := repository.Rename(context.Background(), key.ID, user.ID, "Renamed"); err != nil || !renamed { + return fmt.Errorf("repeat rename api key: renamed=%t err=%w", renamed, err) + } + revokedAt := time.Now().UTC().Truncate(time.Microsecond) + if revoked, err := repository.Revoke(context.Background(), key.ID, user.ID, revokedAt); err != nil || !revoked { + return fmt.Errorf("revoke api key: revoked=%t err=%w", revoked, err) + } + if revoked, err := repository.Revoke(context.Background(), key.ID, user.ID, revokedAt.Add(time.Minute)); err != nil || !revoked { + return fmt.Errorf("repeat revoke api key: revoked=%t err=%w", revoked, err) + } + owned, err := repository.ByIDForUser(context.Background(), key.ID, user.ID) + if err != nil { + return fmt.Errorf("verify revoked api key: %w", err) + } + if owned.RevokedAt == nil || !owned.RevokedAt.Equal(revokedAt) { + return errors.New("revoked api key timestamp changed") + } + return rollback + }) + if !errors.Is(err, rollback) { + t.Fatalf("API key transaction error = %v", err) + } +} diff --git a/internal/core/apikey/repository.go b/internal/core/apikey/repository.go new file mode 100644 index 0000000..d5aa950 --- /dev/null +++ b/internal/core/apikey/repository.go @@ -0,0 +1,133 @@ +package apikey + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "git.ilapage.cn/OPC/chorus/internal/core/model" + "gorm.io/gorm" +) + +var ( + ErrInvalidAPIKey = errors.New("api key is invalid") + ErrAPIKeyNotFound = errors.New("api key not found") +) + +type Repository interface { + Create(ctx context.Context, key *model.APIKey) error + ByPublicID(ctx context.Context, publicID string) (model.APIKey, error) + ByIDForUser(ctx context.Context, id, userID uint64) (model.APIKey, error) + ListForUser(ctx context.Context, userID uint64) ([]model.APIKey, error) + Rename(ctx context.Context, id, userID uint64, name string) (bool, error) + Revoke(ctx context.Context, id, userID uint64, revokedAt time.Time) (bool, error) +} + +type GORMRepository struct{ db *gorm.DB } + +func NewGORMRepository(db *gorm.DB) (*GORMRepository, error) { + if db == nil { + return nil, errors.New("api key database is required") + } + return &GORMRepository{db: db}, nil +} + +func (r *GORMRepository) Create(ctx context.Context, key *model.APIKey) error { + if key == nil { + return ErrInvalidAPIKey + } + key.Name = strings.TrimSpace(key.Name) + key.PublicID = strings.TrimSpace(key.PublicID) + key.KeyPrefix = strings.TrimSpace(key.KeyPrefix) + if key.UserID == 0 || key.Name == "" || len(key.Name) > 80 || len(key.PublicID) != 24 || len(key.KeyPrefix) != 32 || len(key.SecretHash) != 32 { + return ErrInvalidAPIKey + } + if err := r.db.WithContext(ctx).Create(key).Error; err != nil { + return fmt.Errorf("create api key: %w", err) + } + return nil +} + +func (r *GORMRepository) ByPublicID(ctx context.Context, publicID string) (model.APIKey, error) { + publicID = strings.TrimSpace(publicID) + if len(publicID) != 24 { + return model.APIKey{}, ErrInvalidAPIKey + } + var key model.APIKey + if err := r.db.WithContext(ctx).Where("public_id = ?", publicID).First(&key).Error; err != nil { + return model.APIKey{}, mapNotFound(err, "find api key by public id") + } + return key, nil +} + +func (r *GORMRepository) ByIDForUser(ctx context.Context, id, userID uint64) (model.APIKey, error) { + if id == 0 || userID == 0 { + return model.APIKey{}, ErrInvalidAPIKey + } + var key model.APIKey + if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).First(&key).Error; err != nil { + return model.APIKey{}, mapNotFound(err, "find user api key") + } + return key, nil +} + +func (r *GORMRepository) ListForUser(ctx context.Context, userID uint64) ([]model.APIKey, error) { + if userID == 0 { + return nil, ErrInvalidAPIKey + } + var keys []model.APIKey + if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("created_at DESC, id DESC").Find(&keys).Error; err != nil { + return nil, fmt.Errorf("list user api keys: %w", err) + } + return keys, nil +} + +func (r *GORMRepository) Rename(ctx context.Context, id, userID uint64, name string) (bool, error) { + name = strings.TrimSpace(name) + if id == 0 || userID == 0 || name == "" || len(name) > 80 { + return false, ErrInvalidAPIKey + } + result := r.db.WithContext(ctx).Model(&model.APIKey{}). + Where("id = ? AND user_id = ?", id, userID). + Update("name", name) + if result.Error != nil { + return false, fmt.Errorf("rename api key: %w", result.Error) + } + if result.RowsAffected == 1 { + return true, nil + } + var count int64 + if err := r.db.WithContext(ctx).Model(&model.APIKey{}).Where("id = ? AND user_id = ?", id, userID).Count(&count).Error; err != nil { + return false, fmt.Errorf("verify renamed api key: %w", err) + } + return count == 1, nil +} + +func (r *GORMRepository) Revoke(ctx context.Context, id, userID uint64, revokedAt time.Time) (bool, error) { + if id == 0 || userID == 0 || revokedAt.IsZero() { + return false, ErrInvalidAPIKey + } + result := r.db.WithContext(ctx).Model(&model.APIKey{}). + Where("id = ? AND user_id = ? AND revoked_at IS NULL", id, userID). + Update("revoked_at", revokedAt.UTC()) + if result.Error != nil { + return false, fmt.Errorf("revoke api key: %w", result.Error) + } + if result.RowsAffected == 1 { + return true, nil + } + var count int64 + if err := r.db.WithContext(ctx).Model(&model.APIKey{}).Where("id = ? AND user_id = ?", id, userID).Count(&count).Error; err != nil { + return false, fmt.Errorf("verify revoked api key: %w", err) + } + return count == 1, nil +} + +func mapNotFound(err error, operation string) error { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrAPIKeyNotFound + } + return fmt.Errorf("%s: %w", operation, err) +} diff --git a/internal/core/apikey/repository_test.go b/internal/core/apikey/repository_test.go new file mode 100644 index 0000000..a9c50d5 --- /dev/null +++ b/internal/core/apikey/repository_test.go @@ -0,0 +1,36 @@ +package apikey + +import ( + "errors" + "testing" + + "git.ilapage.cn/OPC/chorus/internal/core/model" + "gorm.io/gorm" +) + +func TestNewGORMRepositoryRejectsNilDatabase(t *testing.T) { + if _, err := NewGORMRepository(nil); err == nil { + t.Fatal("NewGORMRepository(nil) succeeded") + } +} + +func TestMapNotFound(t *testing.T) { + if !errors.Is(mapNotFound(gorm.ErrRecordNotFound, "read"), ErrAPIKeyNotFound) { + t.Fatal("record-not-found was not mapped") + } + base := errors.New("database error") + if !errors.Is(mapNotFound(base, "read"), base) { + t.Fatal("database error was not preserved") + } +} + +func TestCreateRejectsNonStandardKeyPrefixBeforeDatabaseAccess(t *testing.T) { + repository := &GORMRepository{db: &gorm.DB{}} + key := &model.APIKey{ + UserID: 1, Name: "Automation", PublicID: "123456789012345678901234", + KeyPrefix: "chorus_short_", SecretHash: make([]byte, 32), + } + if err := repository.Create(t.Context(), key); !errors.Is(err, ErrInvalidAPIKey) { + t.Fatalf("Create() error = %v, want ErrInvalidAPIKey", err) + } +} diff --git a/internal/core/model/api_key.go b/internal/core/model/api_key.go new file mode 100644 index 0000000..ef77460 --- /dev/null +++ b/internal/core/model/api_key.go @@ -0,0 +1,45 @@ +package model + +import ( + "encoding/json" + "time" +) + +type APIKey struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + UserID uint64 `gorm:"column:user_id;not null"` + Name string `gorm:"column:name;size:80;not null"` + PublicID string `gorm:"column:public_id;size:24;not null;uniqueIndex:uq_api_keys_public_id"` + KeyPrefix string `gorm:"column:key_prefix;size:32;not null"` + SecretHash []byte `gorm:"column:secret_hash;type:binary(32);not null" json:"-"` + ExpiresAt *time.Time `gorm:"column:expires_at"` + LastUsedAt *time.Time `gorm:"column:last_used_at"` + RevokedAt *time.Time `gorm:"column:revoked_at"` + CreatedAt time.Time `gorm:"column:created_at;not null"` + UpdatedAt time.Time `gorm:"column:updated_at;not null"` +} + +func (APIKey) TableName() string { return "api_keys" } + +func (key APIKey) UsableAt(now time.Time) bool { + if key.RevokedAt != nil { + return false + } + return key.ExpiresAt == nil || key.ExpiresAt.After(now) +} + +type APIAuditEvent struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + UserID *uint64 `gorm:"column:user_id"` + APIKeyID *uint64 `gorm:"column:api_key_id"` + GenerationID *uint64 `gorm:"column:generation_id"` + Action string `gorm:"column:action;size:128;not null"` + Result string `gorm:"column:result;size:16;not null"` + RequestID string `gorm:"column:request_id;size:128;not null"` + StatusCode *uint16 `gorm:"column:status_code"` + ErrorCode *string `gorm:"column:error_code;size:64"` + Summary json.RawMessage `gorm:"column:summary;type:json;not null"` + CreatedAt time.Time `gorm:"column:created_at;not null"` +} + +func (APIAuditEvent) TableName() string { return "api_audit_events" } diff --git a/internal/core/model/api_key_test.go b/internal/core/model/api_key_test.go new file mode 100644 index 0000000..e27e845 --- /dev/null +++ b/internal/core/model/api_key_test.go @@ -0,0 +1,41 @@ +package model + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestAPIKeyUsableAt(t *testing.T) { + now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC) + future := now.Add(time.Hour) + past := now.Add(-time.Hour) + tests := []struct { + name string + key APIKey + want bool + }{ + {"no expiry", APIKey{}, true}, + {"future expiry", APIKey{ExpiresAt: &future}, true}, + {"expired", APIKey{ExpiresAt: &past}, false}, + {"revoked", APIKey{RevokedAt: &past}, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.key.UsableAt(now); got != test.want { + t.Fatalf("UsableAt() = %t, want %t", got, test.want) + } + }) + } +} + +func TestAPIKeyJSONExcludesSecretHash(t *testing.T) { + encoded, err := json.Marshal(APIKey{PublicID: "public", SecretHash: []byte("protected-hash")}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "protected-hash") || strings.Contains(string(encoded), "SecretHash") { + t.Fatalf("API key JSON exposes secret hash: %s", encoded) + } +} diff --git a/internal/core/model/models.go b/internal/core/model/models.go index 329ec89..6674d46 100644 --- a/internal/core/model/models.go +++ b/internal/core/model/models.go @@ -87,6 +87,7 @@ type Generation struct { LeaseOwner *string `gorm:"column:lease_owner;size:128"` LeaseToken *string `gorm:"column:lease_token;size:36"` LeaseUntil *time.Time `gorm:"column:lease_until"` + AvailableAt time.Time `gorm:"column:available_at;not null;default:CURRENT_TIMESTAMP(6)"` StartedAt *time.Time `gorm:"column:started_at"` CompletedAt *time.Time `gorm:"column:completed_at"` CreatedAt time.Time `gorm:"column:created_at;not null"` diff --git a/internal/core/model/schema_test.go b/internal/core/model/schema_test.go index 03bd95c..e800a5b 100644 --- a/internal/core/model/schema_test.go +++ b/internal/core/model/schema_test.go @@ -17,9 +17,11 @@ func TestGORMModelsMatchMigrationColumns(t *testing.T) { {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"}}, - {Generation{}, []string{"id", "user_id", "provider_model_id", "route_pool_id", "route_pool_version", "prompt_template_id", "route_snapshot", "role_rule", "provider_attempt_count", "kind", "status", "idempotency_key", "user_prompt", "rendered_prompt", "attempts", "attempt_count", "error_code", "error_message", "lease_owner", "lease_token", "lease_until", "started_at", "completed_at", "created_at", "updated_at"}}, + {Generation{}, []string{"id", "user_id", "provider_model_id", "route_pool_id", "route_pool_version", "prompt_template_id", "route_snapshot", "role_rule", "provider_attempt_count", "kind", "status", "idempotency_key", "user_prompt", "rendered_prompt", "attempts", "attempt_count", "error_code", "error_message", "lease_owner", "lease_token", "lease_until", "available_at", "started_at", "completed_at", "created_at", "updated_at"}}, {GenerationInput{}, []string{"id", "generation_id", "position", "role", "note", "original_name", "mime_type", "storage_key", "size_bytes", "width", "height", "created_at"}}, {GenerationOutput{}, []string{"id", "generation_id", "kind", "text_content", "storage_key", "thumbnail_storage_key", "mime_type", "size_bytes", "width", "height", "created_at"}}, + {APIKey{}, []string{"id", "user_id", "name", "public_id", "key_prefix", "secret_hash", "expires_at", "last_used_at", "revoked_at", "created_at", "updated_at"}}, + {APIAuditEvent{}, []string{"id", "user_id", "api_key_id", "generation_id", "action", "result", "request_id", "status_code", "error_code", "summary", "created_at"}}, } for _, test := range tests { diff --git a/internal/core/model/types_test.go b/internal/core/model/types_test.go index fb9cb24..8dc039c 100644 --- a/internal/core/model/types_test.go +++ b/internal/core/model/types_test.go @@ -31,6 +31,8 @@ func TestTableNames(t *testing.T) { (Generation{}).TableName(): "generations", (GenerationInput{}).TableName(): "generation_inputs", (GenerationOutput{}).TableName(): "generation_outputs", + (APIKey{}).TableName(): "api_keys", + (APIAuditEvent{}).TableName(): "api_audit_events", } for got, want := range tests { if got != want { diff --git a/internal/platform/apikey/credential.go b/internal/platform/apikey/credential.go new file mode 100644 index 0000000..516175b --- /dev/null +++ b/internal/platform/apikey/credential.go @@ -0,0 +1,80 @@ +package apikey + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" +) + +const ( + tokenPrefix = "chorus_" + publicIDLength = 24 + secretLength = 43 +) + +var ErrInvalidToken = errors.New("api key token is invalid") + +type Credential struct { + Token string `json:"-"` + PublicID string + KeyPrefix string + SecretHash []byte `json:"-"` +} + +type Parsed struct { + PublicID string + SecretHash []byte `json:"-"` +} + +func Generate() (Credential, error) { + publicBytes := make([]byte, 18) + secretBytes := make([]byte, 32) + if _, err := rand.Read(publicBytes); err != nil { + return Credential{}, fmt.Errorf("generate api key public id: %w", err) + } + if _, err := rand.Read(secretBytes); err != nil { + return Credential{}, fmt.Errorf("generate api key secret: %w", err) + } + publicID := base64.RawURLEncoding.EncodeToString(publicBytes) + secret := base64.RawURLEncoding.EncodeToString(secretBytes) + prefix := tokenPrefix + publicID + "_" + hash := sha256.Sum256([]byte(secret)) + return Credential{ + Token: prefix + secret, PublicID: publicID, KeyPrefix: prefix, + SecretHash: append([]byte(nil), hash[:]...), + }, nil +} + +func Parse(token string) (Parsed, error) { + if len(token) != len(tokenPrefix)+publicIDLength+1+secretLength || !strings.HasPrefix(token, tokenPrefix) { + return Parsed{}, ErrInvalidToken + } + publicStart := len(tokenPrefix) + separator := publicStart + publicIDLength + if token[separator] != '_' { + return Parsed{}, ErrInvalidToken + } + publicID := token[publicStart:separator] + secret := token[separator+1:] + publicBytes, publicErr := base64.RawURLEncoding.DecodeString(publicID) + secretBytes, secretErr := base64.RawURLEncoding.DecodeString(secret) + if publicErr != nil || secretErr != nil || len(publicBytes) != 18 || len(secretBytes) != 32 { + return Parsed{}, ErrInvalidToken + } + hash := sha256.Sum256([]byte(secret)) + return Parsed{PublicID: publicID, SecretHash: append([]byte(nil), hash[:]...)}, nil +} + +func Verify(token, expectedPublicID string, expectedHash []byte) bool { + parsed, err := Parse(token) + if err != nil || len(expectedPublicID) != publicIDLength || len(expectedHash) != sha256.Size { + return false + } + publicMatches := subtle.ConstantTimeCompare([]byte(parsed.PublicID), []byte(expectedPublicID)) + hashMatches := subtle.ConstantTimeCompare(parsed.SecretHash, expectedHash) + return publicMatches&hashMatches == 1 +} diff --git a/internal/platform/apikey/credential_test.go b/internal/platform/apikey/credential_test.go new file mode 100644 index 0000000..ae84651 --- /dev/null +++ b/internal/platform/apikey/credential_test.go @@ -0,0 +1,81 @@ +package apikey + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestGenerateParseAndVerify(t *testing.T) { + credential, err := Generate() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(credential.Token, credential.KeyPrefix) { + t.Fatal("token does not start with its non-secret prefix") + } + if len(credential.PublicID) != publicIDLength || len(credential.KeyPrefix) != 32 || len(credential.SecretHash) != 32 { + t.Fatalf("unexpected credential lengths: public=%d prefix=%d hash=%d", len(credential.PublicID), len(credential.KeyPrefix), len(credential.SecretHash)) + } + parsed, err := Parse(credential.Token) + if err != nil { + t.Fatal(err) + } + if parsed.PublicID != credential.PublicID || !bytes.Equal(parsed.SecretHash, credential.SecretHash) { + t.Fatal("parsed credential does not match generated metadata") + } + if !Verify(credential.Token, credential.PublicID, credential.SecretHash) { + t.Fatal("generated token did not verify") + } + if strings.Contains(string(credential.SecretHash), credential.Token) { + t.Fatal("stored hash contains the complete token") + } +} + +func TestCredentialJSONExcludesTokenAndHash(t *testing.T) { + credential, err := Generate() + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(credential) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), credential.Token) || strings.Contains(string(encoded), "SecretHash") { + t.Fatalf("credential JSON exposes protected material: %s", encoded) + } +} + +func TestVerifyRejectsTamperingAndMalformedTokens(t *testing.T) { + credential, err := Generate() + if err != nil { + t.Fatal(err) + } + other, err := Generate() + if err != nil { + t.Fatal(err) + } + if Verify(other.Token, credential.PublicID, credential.SecretHash) { + t.Fatal("different token verified") + } + for _, token := range []string{"", "chorus_short", credential.Token + "x", strings.Replace(credential.Token, "chorus_", "other_", 1)} { + if _, err := Parse(token); err != ErrInvalidToken { + t.Fatalf("Parse(%q) error = %v, want ErrInvalidToken", token, err) + } + } +} + +func TestGenerateProducesDistinctCredentials(t *testing.T) { + first, err := Generate() + if err != nil { + t.Fatal(err) + } + second, err := Generate() + if err != nil { + t.Fatal(err) + } + if first.Token == second.Token || first.PublicID == second.PublicID || bytes.Equal(first.SecretHash, second.SecretHash) { + t.Fatal("independent credentials were not distinct") + } +} diff --git a/migrations/000006_mvp2_openapi_governance.down.sql b/migrations/000006_mvp2_openapi_governance.down.sql new file mode 100644 index 0000000..b2125b4 --- /dev/null +++ b/migrations/000006_mvp2_openapi_governance.down.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS chorus_mvp2_governance_down_guard ( + id TINYINT NOT NULL, + CONSTRAINT chk_chorus_mvp2_governance_down_guard CHECK (id = 0) +) ENGINE=InnoDB; + +DELETE FROM chorus_mvp2_governance_down_guard; +INSERT INTO chorus_mvp2_governance_down_guard (id) +SELECT 1 +WHERE EXISTS (SELECT 1 FROM api_keys) + OR EXISTS (SELECT 1 FROM api_audit_events); +DROP TABLE chorus_mvp2_governance_down_guard; + +DELETE casbin +FROM sys_casbin_rule casbin +JOIN sys_api api + ON casbin.ptype = 'p' AND casbin.v0 = 'chorus_operator' + AND casbin.v1 = api.path AND casbin.v2 = api.action +WHERE api.handle IN ('chorus.api-keys.list', 'chorus.api-keys.get', 'chorus.api-keys.revoke'); + +DELETE rule +FROM sys_menu_api_rule rule +JOIN sys_menu menu ON rule.menu_id = menu.menu_id +JOIN sys_api api ON rule.sys_api_id = api.id +WHERE menu.path = '/chorus/api-keys' + AND api.handle IN ('chorus.api-keys.list', 'chorus.api-keys.get', 'chorus.api-keys.revoke'); + +DELETE role_menu +FROM sys_role_menu role_menu +JOIN sys_role role_record ON role_menu.role_id = role_record.role_id +JOIN sys_menu menu ON role_menu.menu_id = menu.menu_id +WHERE role_record.role_key = 'chorus_operator' + AND menu.path = '/chorus/api-keys'; + +DELETE FROM sys_api +WHERE handle IN ('chorus.api-keys.list', 'chorus.api-keys.get', 'chorus.api-keys.revoke'); +DELETE FROM sys_menu WHERE path = '/chorus/api-keys'; + +ALTER TABLE generations + DROP INDEX idx_generations_queue, + DROP COLUMN available_at, + ADD KEY idx_generations_queue (status, lease_until, created_at); + +DROP TABLE api_audit_events; +DROP TABLE api_keys; diff --git a/migrations/000006_mvp2_openapi_governance.up.sql b/migrations/000006_mvp2_openapi_governance.up.sql new file mode 100644 index 0000000..64ef834 --- /dev/null +++ b/migrations/000006_mvp2_openapi_governance.up.sql @@ -0,0 +1,92 @@ +CREATE TABLE api_keys ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(80) NOT NULL, + public_id VARCHAR(24) NOT NULL, + key_prefix VARCHAR(32) NOT NULL, + secret_hash BINARY(32) NOT NULL, + expires_at DATETIME(6) NULL, + last_used_at DATETIME(6) NULL, + revoked_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_api_keys_public_id (public_id), + KEY idx_api_keys_user_status (user_id, revoked_at, created_at), + KEY idx_api_keys_last_used (last_used_at), + CONSTRAINT fk_api_keys_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT, + CONSTRAINT chk_api_keys_name CHECK (CHAR_LENGTH(TRIM(name)) > 0), + CONSTRAINT chk_api_keys_public_id CHECK (CHAR_LENGTH(public_id) = 24), + CONSTRAINT chk_api_keys_prefix CHECK (CHAR_LENGTH(key_prefix) = 32) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE api_audit_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NULL, + api_key_id BIGINT UNSIGNED NULL, + generation_id BIGINT UNSIGNED NULL, + action VARCHAR(128) NOT NULL, + result VARCHAR(16) NOT NULL, + request_id VARCHAR(128) NOT NULL, + status_code SMALLINT UNSIGNED NULL, + error_code VARCHAR(64) NULL, + summary JSON NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + KEY idx_api_audit_events_user (user_id, created_at), + KEY idx_api_audit_events_key (api_key_id, created_at), + KEY idx_api_audit_events_generation (generation_id, created_at), + KEY idx_api_audit_events_request (request_id, created_at), + CONSTRAINT fk_api_audit_events_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT, + CONSTRAINT fk_api_audit_events_key FOREIGN KEY (api_key_id) REFERENCES api_keys (id) ON DELETE RESTRICT, + CONSTRAINT fk_api_audit_events_generation FOREIGN KEY (generation_id) REFERENCES generations (id) ON DELETE RESTRICT, + CONSTRAINT chk_api_audit_events_result CHECK (result IN ('succeeded', 'failed', 'denied')), + CONSTRAINT chk_api_audit_events_summary CHECK (JSON_TYPE(summary) = 'OBJECT') +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE generations + DROP INDEX idx_generations_queue, + ADD COLUMN available_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) AFTER lease_until, + ADD KEY idx_generations_queue (status, available_at, lease_until, created_at); + +INSERT INTO sys_menu + (menu_name, title, icon, path, paths, menu_type, action, permission, parent_id, no_cache, breadcrumb, component, sort, visible, is_frame) +SELECT 'chorus-api-keys', 'API Keys', 'ri:key-2-line', '/chorus/api-keys', '', 'C', '', 'chorus:api-keys:view', + parent.menu_id, FALSE, 'Chorus / API Keys', 'chorus/api-keys/index', 70, '0', '0' +FROM sys_menu parent +WHERE parent.path = '/chorus' +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), title = VALUES(title), icon = VALUES(icon), menu_type = VALUES(menu_type), + action = VALUES(action), permission = VALUES(permission), parent_id = VALUES(parent_id), no_cache = VALUES(no_cache), + breadcrumb = VALUES(breadcrumb), component = VALUES(component), sort = VALUES(sort), visible = VALUES(visible), + is_frame = VALUES(is_frame), deleted_at = NULL; + +UPDATE sys_menu child +JOIN sys_menu parent ON parent.path = '/chorus' +SET child.paths = CONCAT(parent.paths, '/', child.menu_id) +WHERE child.path = '/chorus/api-keys'; + +INSERT INTO sys_api (handle, title, path, type, action) +VALUES + ('chorus.api-keys.list', 'List API keys', '/api/v1/chorus/api-keys', 'BUS', 'GET'), + ('chorus.api-keys.get', 'Get API key metadata', '/api/v1/chorus/api-keys/:id', 'BUS', 'GET'), + ('chorus.api-keys.revoke', 'Revoke API key', '/api/v1/chorus/api-keys/:id/revoke', 'BUS', 'POST') +ON DUPLICATE KEY UPDATE + title = VALUES(title), type = VALUES(type), deleted_at = NULL; + +INSERT IGNORE INTO sys_role_menu (role_id, menu_id) +SELECT role_record.role_id, menu.menu_id +FROM sys_role role_record +JOIN sys_menu menu ON menu.path = '/chorus/api-keys' +WHERE role_record.role_key = 'chorus_operator'; + +INSERT IGNORE INTO sys_menu_api_rule (menu_id, sys_api_id) +SELECT menu.menu_id, api.id +FROM sys_menu menu +JOIN sys_api api ON api.handle IN ('chorus.api-keys.list', 'chorus.api-keys.get', 'chorus.api-keys.revoke') +WHERE menu.path = '/chorus/api-keys'; + +INSERT IGNORE INTO sys_casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', 'chorus_operator', path, action, '', '', '' +FROM sys_api +WHERE handle IN ('chorus.api-keys.list', 'chorus.api-keys.get', 'chorus.api-keys.revoke'); diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index a899e62..7a47033 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -27,6 +27,7 @@ func TestMigrationPairsAndProductionTables(t *testing.T) { "000003_mvp1_routing.up.sql", "000004_mvp1_admin_seed.up.sql", "000005_provider_plaintext_credentials.up.sql", + "000006_mvp2_openapi_governance.up.sql", } slices.Sort(upFiles) if !slices.Equal(upFiles, wantFiles) { @@ -58,6 +59,44 @@ func TestMigrationPairsAndProductionTables(t *testing.T) { } } +func TestMVP2MigrationContracts(t *testing.T) { + read := func(name string) string { + t.Helper() + content, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + return string(content) + } + + up := read("000006_mvp2_openapi_governance.up.sql") + for _, required := range []string{ + "CREATE TABLE api_keys", "CREATE TABLE api_audit_events", "secret_hash BINARY(32)", + "uq_api_keys_public_id", "idx_api_keys_user_status", "idx_api_keys_last_used", "CHAR_LENGTH(public_id) = 24", + "CHAR_LENGTH(key_prefix) = 32", "available_at DATETIME(6)", + "idx_generations_queue (status, available_at", "/chorus/api-keys", + "chorus.api-keys.list", "chorus.api-keys.get", "chorus.api-keys.revoke", + } { + if !strings.Contains(up, required) { + t.Errorf("MVP-2 migration is missing %s", required) + } + } + if strings.Contains(strings.ToLower(up), "automigrate") { + t.Error("MVP-2 migration must not use AutoMigrate") + } + + down := read("000006_mvp2_openapi_governance.down.sql") + for _, required := range []string{ + "chorus_mvp2_governance_down_guard", "EXISTS (SELECT 1 FROM api_keys)", + "DROP TABLE api_audit_events", "DROP TABLE api_keys", "DROP COLUMN available_at", + "ADD KEY idx_generations_queue (status, lease_until, created_at)", + } { + if !strings.Contains(down, required) { + t.Errorf("MVP-2 rollback is missing %s", required) + } + } +} + func TestMVP1MigrationContracts(t *testing.T) { read := func(name string) string { t.Helper() diff --git a/migrations/mysql_integration_test.go b/migrations/mysql_integration_test.go index b1a2eac..9b7a804 100644 --- a/migrations/mysql_integration_test.go +++ b/migrations/mysql_integration_test.go @@ -15,7 +15,7 @@ import ( // This test resets only the explicitly designated disposable migration database. // It is opt-in so normal package tests cannot erase a developer's local data. -func TestMVP1MigrationsUpDownUpMySQL(t *testing.T) { +func TestMigrationsUpDownUpMySQL(t *testing.T) { if os.Getenv("CHORUS_RUN_MIGRATION_TESTS") != "1" { t.Skip("set CHORUS_RUN_MIGRATION_TESTS=1 for the disposable MySQL migration database") } @@ -42,8 +42,12 @@ func TestMVP1MigrationsUpDownUpMySQL(t *testing.T) { if err := db.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&databaseName); err != nil { t.Fatalf("read disposable migration database name: %v", err) } - if databaseName != "chorus_test" { - t.Fatalf("refusing destructive migration test against database %q; expected chorus_test", databaseName) + expectedDatabase := strings.TrimSpace(os.Getenv("CHORUS_MIGRATION_TEST_DATABASE")) + if expectedDatabase == "" { + expectedDatabase = "chorus_test" + } + if databaseName != expectedDatabase { + t.Fatalf("refusing destructive migration test against database %q; expected %s", databaseName, expectedDatabase) } runMigrate := func(args ...string) { @@ -55,6 +59,14 @@ func TestMVP1MigrationsUpDownUpMySQL(t *testing.T) { t.Fatalf("migrate %s: %v\n%s", strings.Join(args, " "), err, output) } } + runMigrateExpectFailure := func(args ...string) { + t.Helper() + commandArgs := append([]string{"-path", ".", "-database", migrationURL}, args...) + command := exec.CommandContext(ctx, "migrate", commandArgs...) + if output, err := command.CombinedOutput(); err == nil { + t.Fatalf("migrate %s unexpectedly succeeded\n%s", strings.Join(args, " "), output) + } + } resetDisposableSchema(t, ctx, db) runMigrate("goto", "1") @@ -108,11 +120,41 @@ func TestMVP1MigrationsUpDownUpMySQL(t *testing.T) { assertColumnExists(t, ctx, db, "provider_credentials", "api_key", true) assertColumnExists(t, ctx, db, "provider_credentials", "api_key_enc", false) assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle = 'chorus.providers.credential.get'`, 1) + runMigrate("up") + if _, err := db.ExecContext(ctx, ` + INSERT INTO api_keys (user_id, name, public_id, key_prefix, secret_hash) + VALUES (1, 'Rollback guard', 'rollback0000000000000000', 'chorus_rollback0000000000000000_', UNHEX(REPEAT('ab', 32)))`); err != nil { + t.Fatal(err) + } + runMigrateExpectFailure("down", "1") + if _, err := db.ExecContext(ctx, `DELETE FROM api_keys WHERE public_id = 'rollback0000000000000000'`); err != nil { + t.Fatal(err) + } + runMigrate("force", "6") runMigrate("down", "1") - assertColumnExists(t, ctx, db, "provider_credentials", "api_key", false) - assertColumnExists(t, ctx, db, "provider_credentials", "api_key_enc", true) + assertTableExists(t, ctx, db, "api_keys", false) + assertTableExists(t, ctx, db, "api_audit_events", false) + assertColumnExists(t, ctx, db, "generations", "available_at", false) + assertColumnExists(t, ctx, db, "provider_credentials", "api_key", true) + assertColumnExists(t, ctx, db, "provider_credentials", "api_key_enc", false) runMigrate("up") assertColumnExists(t, ctx, db, "provider_credentials", "api_key", true) + assertTableExists(t, ctx, db, "api_keys", true) + assertTableExists(t, ctx, db, "api_audit_events", true) + assertColumnExists(t, ctx, db, "generations", "available_at", true) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle = 'chorus.api-keys.revoke'`, 1) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path = '/chorus/api-keys'`, 1) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM generations WHERE id = 1 AND available_at IS NOT NULL`, 1) + + runMigrate("down", "1") + assertTableExists(t, ctx, db, "api_keys", false) + assertTableExists(t, ctx, db, "api_audit_events", false) + assertColumnExists(t, ctx, db, "generations", "available_at", false) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle = 'chorus.api-keys.revoke'`, 0) + runMigrate("up") + assertTableExists(t, ctx, db, "api_keys", true) + assertTableExists(t, ctx, db, "api_audit_events", true) + assertColumnExists(t, ctx, db, "generations", "available_at", true) } func resetDisposableSchema(t *testing.T, ctx context.Context, db *sql.DB) {