package auth import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "errors" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) // 本文件锁定 K1(API key 落盘):修「每次重启 key 全失效」。核心回归是 // TestAPIKeyStore_SurvivesRestart —— 用同一个库丢掉实例再重建,key 仍然可认证。 // 既有内存用例(apikey_test.go / rbac_test.go)断言一字不改,用来证明「未接库的 // 路径行为不变」。 // errKeyStoreDown 是内存桩注入的落盘失败。 var errKeyStoreDown = errors.New("api key store down") // openMigratedKeyDB 打开(文件或 :memory:)库并跑完全部迁移。跨重启用例要用文件, // 因为「重启仍在」必须跨连接读同一份数据。 func openMigratedKeyDB(t *testing.T, path string) *sql.DB { t.Helper() ctx := context.Background() conn, err := db.Open(ctx, path, nil) if err != nil { t.Fatalf("db.Open(%s): %v", path, err) } if err := db.MigrateUp(ctx, conn, nil); err != nil { _ = conn.Close() t.Fatalf("db.MigrateUp: %v", err) } t.Cleanup(func() { _ = conn.Close() }) return conn } // newStoredKeyManager 构建「已接上 SQLite 落盘载体」的 key 管理器(等价于组合根 // wireAPIKeyStore 的那两步:构造 store → Attach)。 func newStoredKeyManager(t *testing.T, conn *sql.DB) *APIKey { t.Helper() store, err := NewSQLiteKeyStore(conn, nil) if err != nil { t.Fatalf("NewSQLiteKeyStore: %v", err) } keys := NewAPIKey(config.AuthConfig{}) if err := keys.AttachKeyStore(context.Background(), store); err != nil { t.Fatalf("AttachKeyStore: %v", err) } return keys } // apiKeysTableExists 报告 api_keys 表是否存在。 func apiKeysTableExists(t *testing.T, ctx context.Context, conn *sql.DB) bool { t.Helper() var name string err := conn.QueryRowContext(ctx, `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'api_keys'`).Scan(&name) if errors.Is(err, sql.ErrNoRows) { return false } if err != nil { t.Fatalf("query sqlite_master: %v", err) } return true } // migrationStepsPastKey 计算「回滚到 id 之前」需要几步(按 schema_migrations 现算, // 因此之后新增迁移不会把本测试带偏)。 func migrationStepsPastKey(t *testing.T, ctx context.Context, conn *sql.DB, id string) int { t.Helper() rows, err := conn.QueryContext(ctx, `SELECT id FROM schema_migrations`) if err != nil { t.Fatalf("列出已应用迁移:%v", err) } defer func() { _ = rows.Close() }() steps := 0 for rows.Next() { var got string if err := rows.Scan(&got); err != nil { t.Fatalf("读取迁移 id:%v", err) } if got >= id { steps++ } } if err := rows.Err(); err != nil { t.Fatalf("遍历迁移:%v", err) } if steps == 0 { t.Fatalf("已应用迁移里没有 %s,本测试的前提不成立", id) } return steps } // TestAPIKeyStore_SurvivesRestart 是 K1 的核心回归:签发 → 丢弃实例(关连接, // 等价于进程退出)→ 用同一个库重新构造 → Verify 仍为 true,且 Lookup 给 owner、 // List 里能看见同一条脱敏 id(面板跨重启仍能辨认与吊销它)。 func TestAPIKeyStore_SurvivesRestart(t *testing.T) { path := filepath.Join(t.TempDir(), "cyrene.db") first := openMigratedKeyDB(t, path) keys := newStoredKeyManager(t, first) key, err := keys.Generate() if err != nil { t.Fatalf("Generate: %v", err) } id := MaskKey(key) if err := first.Close(); err != nil { // 模拟进程退出 t.Fatalf("close first connection: %v", err) } second := openMigratedKeyDB(t, path) restarted := newStoredKeyManager(t, second) if !restarted.Verify(key) { t.Fatal("重启后 Verify(key) = false:key 没有跨重启存活(未落盘或未载入)") } if role, ok := restarted.Lookup(key); !ok || role != RoleOwner { t.Fatalf("重启后 Lookup = (%q,%v), want (owner,true)", role, ok) } found := false for _, s := range restarted.List() { if s.ID == id { found = true } } if !found { t.Fatalf("重启后 List 里没有 id %q,面板将无法吊销这把 key", id) } } // TestAPIKeyStore_RevokedKeyStaysRevoked 锁定「吊销持久」:Revoke 后重建实例, // 同一把 key 仍然无效(吊销不能只活在内存里)。 func TestAPIKeyStore_RevokedKeyStaysRevoked(t *testing.T) { path := filepath.Join(t.TempDir(), "cyrene.db") first := openMigratedKeyDB(t, path) keys := newStoredKeyManager(t, first) key, err := keys.Generate() if err != nil { t.Fatalf("Generate: %v", err) } keys.Revoke(key) if keys.Verify(key) { t.Fatal("Revoke 后 Verify = true, want false") } if err := first.Close(); err != nil { t.Fatalf("close first connection: %v", err) } restarted := newStoredKeyManager(t, openMigratedKeyDB(t, path)) if restarted.Verify(key) { t.Fatal("重启后被吊销的 key 复活了(吊销未落盘)") } } // TestAPIKeyStore_RevokeAllStaysRevoked 锁定「全部吊销」也跨重启生效,且库里 // 保留行(标记 revoked_at,不删行)。 func TestAPIKeyStore_RevokeAllStaysRevoked(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "cyrene.db") first := openMigratedKeyDB(t, path) keys := newStoredKeyManager(t, first) key, err := keys.Generate() if err != nil { t.Fatalf("Generate: %v", err) } if got := keys.RevokeAll(); got != 1 { t.Fatalf("RevokeAll() = %d, want 1", got) } var rows, live int if err := first.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys`).Scan(&rows); err != nil { t.Fatalf("count api_keys: %v", err) } if err := first.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys WHERE revoked_at IS NULL`).Scan(&live); err != nil { t.Fatalf("count live api_keys: %v", err) } if rows != 1 || live != 0 { t.Fatalf("RevokeAll 后 rows=%d live=%d, want rows=1 live=0(标记而非删行)", rows, live) } if err := first.Close(); err != nil { t.Fatalf("close first connection: %v", err) } restarted := newStoredKeyManager(t, openMigratedKeyDB(t, path)) if restarted.Verify(key) { t.Fatal("重启后 RevokeAll 吊销的 key 复活了") } } // TestAPIKeyStore_StoresHashOnly 硬断言「库里只有哈希」:逐列取值,明文既不等于 // 任何列、也不作为子串出现在任何列里;key_hash 必须等于独立算出的 SHA-256 hex。 func TestAPIKeyStore_StoresHashOnly(t *testing.T) { ctx := context.Background() conn := openMigratedKeyDB(t, ":memory:") keys := newStoredKeyManager(t, conn) key, err := keys.Generate() if err != nil { t.Fatalf("Generate: %v", err) } var id, name, keyHash, createdAt, lastUsed, revokedAt sql.NullString if err := conn.QueryRowContext(ctx, `SELECT id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys`). Scan(&id, &name, &keyHash, &createdAt, &lastUsed, &revokedAt); err != nil { t.Fatalf("select api_keys row: %v", err) } columns := map[string]sql.NullString{ "id": id, "name": name, "key_hash": keyHash, "created_at": createdAt, "last_used_at": lastUsed, "revoked_at": revokedAt, } for column, value := range columns { if value.Valid && (value.String == key || strings.Contains(value.String, key)) { t.Fatalf("列 %s 里出现了明文 key", column) } } sum := sha256.Sum256([]byte(key)) if want := hex.EncodeToString(sum[:]); keyHash.String != want { t.Fatalf("key_hash = %q, want sha256 %q", keyHash.String, want) } } // TestAPIKeyStore_MigrationUpDownUp 锁定迁移 0016 可逆且幂等(临时库,不碰 data/)。 func TestAPIKeyStore_MigrationUpDownUp(t *testing.T) { ctx := context.Background() conn := openMigratedKeyDB(t, filepath.Join(t.TempDir(), "cyrene.db")) if !apiKeysTableExists(t, ctx, conn) { t.Fatal("api_keys 应在 MigrateUp 后存在") } steps := migrationStepsPastKey(t, ctx, conn, "0016_api_keys") if err := db.MigrateDown(ctx, conn, steps, nil); err != nil { t.Fatalf("MigrateDown(%d): %v", steps, err) } if apiKeysTableExists(t, ctx, conn) { t.Fatal("api_keys 应在 MigrateDown 后消失") } if err := db.MigrateUp(ctx, conn, nil); err != nil { t.Fatalf("MigrateUp(2nd): %v", err) } if !apiKeysTableExists(t, ctx, conn) { t.Fatal("api_keys 应在第二次 MigrateUp 后重新出现") } var rows int if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys`).Scan(&rows); err != nil { t.Fatalf("count api_keys: %v", err) } if rows != 0 { t.Fatalf("回滚再升级后应有 0 行,得到 %d 行(回滚未清空?)", rows) } } // TestAPIKey_GeneratePersistFailureKeepsMemory 锁定「先落库、后改内存」:落库失败 // 时 Generate 返回错误,且这把 key **不进入**内存表(否则本进程内它仍能过鉴权、 // 重启后又消失,等于一次假签发)。 func TestAPIKey_GeneratePersistFailureKeepsMemory(t *testing.T) { t.Parallel() store := newMemoryKeyStore() keys := NewAPIKey(config.AuthConfig{}) if err := keys.AttachKeyStore(context.Background(), store); err != nil { t.Fatalf("AttachKeyStore: %v", err) } store.failWrite = true key, err := keys.Generate() if err == nil { t.Fatal("落库失败时 Generate = nil error, want error") } if key != "" { t.Fatalf("落库失败时仍返回了明文 %q,调用方可能把它当签发成功", key) } if keys.Verify(key) { t.Fatal("落库失败的 key 进入了内存表") } if got := len(keys.List()); got != 0 { t.Fatalf("落库失败后内存表有 %d 条记录, want 0", got) } } // TestAPIKey_RevokePersistFailureKeepsKey 锁定吊销的「先落库、后改内存」:库标记 // 失败时内存不动(本进程内仍有效),绝不出现「本进程说已吊销、重启又复活」的反向 // 不一致。 func TestAPIKey_RevokePersistFailureKeepsKey(t *testing.T) { t.Parallel() store := newMemoryKeyStore() keys := NewAPIKey(config.AuthConfig{}) if err := keys.AttachKeyStore(context.Background(), store); err != nil { t.Fatalf("AttachKeyStore: %v", err) } key, err := keys.Generate() if err != nil { t.Fatalf("Generate: %v", err) } store.failWrite = true keys.Revoke(key) if !keys.Verify(key) { t.Fatal("落库失败时 Revoke 仍移出了内存,重启后这把 key 会复活") } if keys.RevokeByMask(MaskKey(key)) { t.Fatal("落库失败时 RevokeByMask = true, want false") } if got := keys.RevokeAll(); got != 0 { t.Fatalf("落库失败时 RevokeAll = %d, want 0", got) } } // TestAPIKey_AttachLoadFailureStillPersists 锁定 AttachKeyStore 在 Load 失败时仍 // 接上 store(与 AttachPolicyStore 的「Load 失败就不接线」刻意不同):否则签发会 // 静默退回内存态,正是 K1 要消灭的假成功。 func TestAPIKey_AttachLoadFailureStillPersists(t *testing.T) { t.Parallel() store := newMemoryKeyStore() store.failLoad = true keys := NewAPIKey(config.AuthConfig{}) if err := keys.AttachKeyStore(context.Background(), store); err == nil { t.Fatal("Load 失败时 AttachKeyStore = nil error, want error") } key, err := keys.Generate() if err != nil { t.Fatalf("Generate: %v", err) } if !keys.Verify(key) { t.Fatal("Attach 后签发的 key 无法认证") } if len(store.live()) != 1 { t.Fatal("Load 失败后签发的 key 没有落库(静默退回内存态)") } } // TestAPIKeyStore_MemoryHitNeverReQueries 锁定「内存命中即通过、未命中不回查库」: // 直连库把行标为已吊销后,本进程仍然照内存表放行(下个进程才失效);未命中的 key // 也不会去查库(LoadUnrevoked 只被 Attach 调用一次)。 func TestAPIKeyStore_MemoryHitNeverReQueries(t *testing.T) { t.Parallel() store := newMemoryKeyStore() keys := NewAPIKey(config.AuthConfig{}) if err := keys.AttachKeyStore(context.Background(), store); err != nil { t.Fatalf("AttachKeyStore: %v", err) } key, err := keys.Generate() if err != nil { t.Fatalf("Generate: %v", err) } store.hashes[hashAPIKey(key)] = true // 绕过管理器,直接标记库中已吊销 if !keys.Verify(key) { t.Fatal("内存命中时 Verify = false, want true(鉴权路径不应回查库)") } if keys.Verify("unknown-key") { t.Fatal("未知 key 通过了校验") } if store.loadCalls != 1 { t.Fatalf("LoadUnrevoked 被调用 %d 次, want 1(仅 Attach 时载入)", store.loadCalls) } // nil store 是合法的空操作:纯内存(既有行为)。 plain := NewAPIKey(config.AuthConfig{}) if err := plain.AttachKeyStore(context.Background(), nil); err != nil { t.Fatalf("AttachKeyStore(nil) = %v, want nil", err) } plainKey, err := plain.Generate() if err != nil || !plain.Verify(plainKey) { t.Fatalf("纯内存路径失效:key=%q err=%v", plainKey, err) } } // memoryKeyStore 是 KeyStore 的内存桩:语义与 SQLite 实现一致(hashes 记录 // hash → 是否已吊销),failLoad/failWrite 与 loadCalls 用于断言与故障注入。 type memoryKeyStore struct { hashes map[string]bool failLoad bool failWrite bool loadCalls int } func newMemoryKeyStore() *memoryKeyStore { return &memoryKeyStore{hashes: make(map[string]bool)} } // live 返回当前未吊销的哈希,供测试断言落库结果。 func (m *memoryKeyStore) live() []string { out := make([]string, 0, len(m.hashes)) for hash, revoked := range m.hashes { if !revoked { out = append(out, hash) } } return out } func (m *memoryKeyStore) LoadUnrevoked(_ context.Context) ([]string, error) { m.loadCalls++ if m.failLoad { return nil, errKeyStoreDown } return m.live(), nil } func (m *memoryKeyStore) Insert(_ context.Context, keyHash string) error { if m.failWrite { return errKeyStoreDown } m.hashes[keyHash] = false return nil } func (m *memoryKeyStore) Revoke(_ context.Context, keyHash string) error { if m.failWrite { return errKeyStoreDown } m.hashes[keyHash] = true return nil } func (m *memoryKeyStore) RevokeAll(_ context.Context) (int, error) { if m.failWrite { return 0, errKeyStoreDown } n := 0 for hash, revoked := range m.hashes { if !revoked { m.hashes[hash] = true n++ } } return n, nil }