iop/apps/control-plane/internal/credentialstore/slot_test.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

643 lines
28 KiB
Go

package credentialstore
import (
"context"
"database/sql"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type fakeKeyRegistry struct {
keys map[string]map[uint64]bool
}
func newFakeKeyRegistry() *fakeKeyRegistry {
return &fakeKeyRegistry{
keys: make(map[string]map[uint64]bool),
}
}
func (f *fakeKeyRegistry) RegisterKey(keyID string, version uint64) {
if f.keys[keyID] == nil {
f.keys[keyID] = make(map[uint64]bool)
}
f.keys[keyID][version] = true
}
func (f *fakeKeyRegistry) RemoveKey(keyID string, version uint64) {
if versions := f.keys[keyID]; versions != nil {
delete(versions, version)
}
}
func (f *fakeKeyRegistry) HasEnvelopeKey(_ context.Context, keyID string, keyVersion uint64) (bool, error) {
if versions, ok := f.keys[keyID]; ok {
return versions[keyVersion], nil
}
return false, nil
}
func setupTestStoreWithRegistry(t *testing.T, registry EnvelopeKeyRegistry) (*Store, *IssuedPrincipal) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test_slots.db")
ctx := context.Background()
opts := []Option{}
if registry != nil {
opts = append(opts, WithEnvelopeKeyRegistry(registry))
}
store, err := Open(ctx, dbPath, opts...)
require.NoError(t, err)
t.Cleanup(func() { _ = store.Close() })
p, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "test-principal"})
require.NoError(t, err)
return store, p
}
func TestCredentialSlotsAllowSameVendorMultipleSlots(t *testing.T) {
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, p := setupTestStoreWithRegistry(t, reg)
ctx := context.Background()
env1 := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "k1",
KeyVersion: 1,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher1"),
}
slot1, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "slot-1",
Envelope: env1,
})
require.NoError(t, err)
require.Equal(t, "openai", slot1.Vendor)
require.Equal(t, "slot-1", slot1.Alias)
env2 := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "k1",
KeyVersion: 1,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher2"),
}
slot2, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "slot-2",
Envelope: env2,
})
require.NoError(t, err)
require.Equal(t, "openai", slot2.Vendor)
require.Equal(t, "slot-2", slot2.Alias)
slots, err := store.ListSlots(ctx, p.Principal.ID)
require.NoError(t, err)
require.Len(t, slots, 2)
}
func TestCredentialSlotAcceptsCallerSuppliedSlotID(t *testing.T) {
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, p := setupTestStoreWithRegistry(t, reg)
ctx := context.Background()
env := SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")}
callerID := uuid.New().String()
created, err := store.CreateSlot(ctx, CreateSlotInput{
SlotID: callerID, PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "caller-id", Envelope: env,
})
require.NoError(t, err)
require.Equal(t, callerID, created.ID)
// The persisted row and its initial revision both use the caller id.
loaded, err := store.GetSlot(ctx, p.Principal.ID, callerID)
require.NoError(t, err)
require.Equal(t, callerID, loaded.ID)
revs, err := store.ListSlotRevisions(ctx, p.Principal.ID, callerID)
require.NoError(t, err)
require.Len(t, revs, 1)
require.Equal(t, callerID, revs[0].SlotID)
// A non-UUID caller id is rejected before any row is written.
_, err = store.CreateSlot(ctx, CreateSlotInput{
SlotID: "not-a-uuid", PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "bad-id", Envelope: env,
})
require.Error(t, err)
require.Contains(t, err.Error(), "slot_id")
// The legacy zero value still yields a store-generated id.
legacy, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "legacy-id", Envelope: env,
})
require.NoError(t, err)
require.NotEmpty(t, legacy.ID)
require.NotEqual(t, callerID, legacy.ID)
}
func TestCredentialSlotRejectsUnknownEnvelopeKey(t *testing.T) {
reg := newFakeKeyRegistry()
// Do not register "unknown-key"
store, p := setupTestStoreWithRegistry(t, reg)
ctx := context.Background()
env := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "unknown-key",
KeyVersion: 1,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher"),
}
_, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "slot-unknown",
Envelope: env,
})
require.Error(t, err)
require.ErrorIs(t, err, ErrUnknownEnvelopeKey)
// Test unconfigured store (unavailable key registry)
unconfiguredStore, p2 := setupTestStoreWithRegistry(t, nil)
envRegistered := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "k1",
KeyVersion: 1,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher"),
}
_, err = unconfiguredStore.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p2.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "slot-unavail",
Envelope: envRegistered,
})
require.Error(t, err)
require.ErrorIs(t, err, ErrEnvelopeKeyUnavailable)
}
func TestCredentialSlotRotationRequiresRegisteredKey(t *testing.T) {
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, p := setupTestStoreWithRegistry(t, reg)
ctx := context.Background()
env1 := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "k1",
KeyVersion: 1,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher1"),
}
slot, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "rotate-slot",
Envelope: env1,
})
require.NoError(t, err)
// Attempt rotation with unregistered key "k2"
env2 := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "k2",
KeyVersion: 1,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher2"),
}
_, err = store.RotateSlotSecret(ctx, RotateSlotSecretInput{
PrincipalID: p.Principal.ID,
SlotID: slot.ID,
CurrentRevision: slot.Revision,
Envelope: env2,
})
require.Error(t, err)
require.ErrorIs(t, err, ErrUnknownEnvelopeKey)
// Register k2 and rotate successfully
reg.RegisterKey("k2", 1)
rotated, err := store.RotateSlotSecret(ctx, RotateSlotSecretInput{
PrincipalID: p.Principal.ID,
SlotID: slot.ID,
CurrentRevision: slot.Revision,
Envelope: env2,
})
require.NoError(t, err)
require.Equal(t, int64(1), rotated.Revision)
require.Equal(t, "k2", rotated.Envelope.KeyID)
}
func TestCredentialSlotAliasUniqueWithinPrincipal(t *testing.T) {
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, p := setupTestStoreWithRegistry(t, reg)
ctx := context.Background()
env := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "k1",
KeyVersion: 1,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher1"),
}
_, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "dup-alias",
Envelope: env,
})
require.NoError(t, err)
_, err = store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "anthropic",
CredentialKind: CredentialKindAPIKey,
Alias: "dup-alias",
Envelope: env,
})
require.Error(t, err)
require.ErrorIs(t, err, ErrSlotAliasAlreadyExists)
}
func TestCredentialSlotLifecyclePersists(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "lifecycle.db")
ctx := context.Background()
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
reg.RegisterKey("k1", 2)
store1, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
require.NoError(t, err)
p, err := store1.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "life-principal"})
require.NoError(t, err)
env1 := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "k1",
KeyVersion: 1,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher-v1"),
}
slot, err := store1.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "life-slot",
Envelope: env1,
})
require.NoError(t, err)
require.Equal(t, StatusDraft, slot.Status)
activatedRoute, err := store1.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: slot.ID,
Alias: "life-route",
ProfileID: "openai",
UpstreamModel: "gpt-4o",
})
require.NoError(t, err)
_ = activatedRoute
slot, err = store1.GetSlot(ctx, p.Principal.ID, slot.ID)
require.NoError(t, err)
require.Equal(t, StatusActive, slot.Status)
// Disable slot
disabled, err := store1.DisableSlot(ctx, p.Principal.ID, slot.ID, slot.Revision)
require.NoError(t, err)
require.Equal(t, StatusDisabled, disabled.Status)
require.Equal(t, int64(2), disabled.Revision)
// Enable slot
enabled, err := store1.EnableSlot(ctx, p.Principal.ID, slot.ID, disabled.Revision)
require.NoError(t, err)
require.Equal(t, StatusActive, enabled.Status)
require.Equal(t, int64(3), enabled.Revision)
// Rotate secret
env2 := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "k1",
KeyVersion: 2,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher-v2"),
}
rotated, err := store1.RotateSlotSecret(ctx, RotateSlotSecretInput{
PrincipalID: p.Principal.ID,
SlotID: slot.ID,
CurrentRevision: enabled.Revision,
Envelope: env2,
})
require.NoError(t, err)
require.Equal(t, int64(4), rotated.Revision)
require.Equal(t, uint64(2), rotated.Envelope.KeyVersion)
// Close store and reopen from dbPath
require.NoError(t, store1.Close())
store2, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
require.NoError(t, err)
defer store2.Close()
reloaded, err := store2.GetSlot(ctx, p.Principal.ID, slot.ID)
require.NoError(t, err)
require.Equal(t, StatusActive, reloaded.Status)
require.Equal(t, int64(4), reloaded.Revision)
require.Equal(t, []byte("cipher-v2"), reloaded.Envelope.Ciphertext)
revisions, err := store2.ListSlotRevisions(ctx, p.Principal.ID, slot.ID)
require.NoError(t, err)
require.Len(t, revisions, 2)
require.Equal(t, int64(0), revisions[0].Revision)
require.Equal(t, int64(4), revisions[1].Revision)
// Revoke slot
revoked, err := store2.RevokeSlot(ctx, p.Principal.ID, slot.ID, reloaded.Revision)
require.NoError(t, err)
require.Equal(t, StatusRevoked, revoked.Status)
require.NotNil(t, revoked.RevokedAt)
}
func TestCredentialSlotLifecycleReopensWithInactiveBindings(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
transition func(*Store, string, string, int64) (*CredentialSlotRecord, error)
}{
{
name: "disabled",
transition: func(store *Store, principalID, slotID string, revision int64) (*CredentialSlotRecord, error) {
return store.DisableSlot(ctx, principalID, slotID, revision)
},
},
{
name: "revoked",
transition: func(store *Store, principalID, slotID string, revision int64) (*CredentialSlotRecord, error) {
return store.RevokeSlot(ctx, principalID, slotID, revision)
},
},
} {
t.Run(tc.name, func(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), tc.name+".db")
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
require.NoError(t, err)
principal, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: tc.name + "-principal"})
require.NoError(t, err)
slot, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: principal.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: tc.name + "-slot",
Envelope: SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")},
})
require.NoError(t, err)
route, err := store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: principal.Principal.ID, SlotID: slot.ID, Alias: tc.name + "-route", ProfileID: "openai", UpstreamModel: "gpt-4o",
})
require.NoError(t, err)
slot, err = store.GetSlot(ctx, principal.Principal.ID, slot.ID)
require.NoError(t, err)
inactive, err := tc.transition(store, principal.Principal.ID, slot.ID, slot.Revision)
require.NoError(t, err)
require.NoError(t, store.Close())
reopened, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
require.NoError(t, err)
defer reopened.Close()
reloaded, err := reopened.GetSlot(ctx, principal.Principal.ID, slot.ID)
require.NoError(t, err)
require.Equal(t, inactive.Status, reloaded.Status)
retainedRoute, err := reopened.GetRoute(ctx, principal.Principal.ID, route.ID)
require.NoError(t, err)
require.Equal(t, StatusActive, retainedRoute.Status)
if tc.name == "disabled" {
enabled, err := reopened.EnableSlot(ctx, principal.Principal.ID, slot.ID, reloaded.Revision)
require.NoError(t, err)
require.Equal(t, StatusActive, enabled.Status)
} else {
_, err := reopened.EnableSlot(ctx, principal.Principal.ID, slot.ID, reloaded.Revision)
require.Error(t, err)
}
})
}
}
func TestCredentialSlotOptionalAliasDoesNotCollide(t *testing.T) {
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, p := setupTestStoreWithRegistry(t, reg)
ctx := context.Background()
env := SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")}
first, err := store.CreateSlot(ctx, CreateSlotInput{PrincipalID: p.Principal.ID, Vendor: " OPENAI ", CredentialKind: " BEARER ", Envelope: env})
require.NoError(t, err)
require.Empty(t, first.Alias)
require.Equal(t, "openai", first.Vendor)
require.Equal(t, CredentialKindBearer, first.CredentialKind)
second, err := store.CreateSlot(ctx, CreateSlotInput{PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: " ", Envelope: env})
require.NoError(t, err)
require.Empty(t, second.Alias)
aliased, err := store.CreateSlot(ctx, CreateSlotInput{PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: " primary ", Envelope: env})
require.NoError(t, err)
require.Equal(t, "primary", aliased.Alias)
byAlias, err := store.GetSlotByAlias(ctx, p.Principal.ID, " primary ")
require.NoError(t, err)
require.Equal(t, aliased.ID, byAlias.ID)
_, err = store.CreateSlot(ctx, CreateSlotInput{PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "primary", Envelope: env})
require.ErrorIs(t, err, ErrSlotAliasAlreadyExists)
}
func TestCredentialSlotRequiresKnownCredentialKind(t *testing.T) {
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, p := setupTestStoreWithRegistry(t, reg)
_, err := store.CreateSlot(context.Background(), CreateSlotInput{
PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: "session_cookie", Alias: "bad-kind",
Envelope: SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")},
})
require.Error(t, err)
require.Contains(t, err.Error(), "credential_kind")
}
func TestCredentialSlotActivationRequiresRegisteredKey(t *testing.T) {
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, p := setupTestStoreWithRegistry(t, reg)
ctx := context.Background()
env := SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")}
slot, err := store.CreateSlot(ctx, CreateSlotInput{PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "key-gated", Envelope: env})
require.NoError(t, err)
_, err = store.CreateRoute(ctx, CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, ProfileID: "openai", UpstreamModel: "gpt-4o"})
require.NoError(t, err)
slot, err = store.GetSlot(ctx, p.Principal.ID, slot.ID)
require.NoError(t, err)
disabled, err := store.DisableSlot(ctx, p.Principal.ID, slot.ID, slot.Revision)
require.NoError(t, err)
reg.RemoveKey("k1", 1)
_, err = store.EnableSlot(ctx, p.Principal.ID, slot.ID, disabled.Revision)
require.ErrorIs(t, err, ErrUnknownEnvelopeKey)
reg.RegisterKey("k1", 1)
enabled, err := store.EnableSlot(ctx, p.Principal.ID, slot.ID, disabled.Revision)
require.NoError(t, err)
require.Equal(t, StatusActive, enabled.Status)
}
func TestCredentialSlotMigrationPreservesIntermediateCatalog(t *testing.T) {
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "intermediate.db")
db, err := sql.Open(dialectSQLite, dbPath)
require.NoError(t, err)
_, err = db.Exec(`
CREATE TABLE principals (id TEXT PRIMARY KEY, alias TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE credential_slots (
id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, vendor TEXT NOT NULL, alias TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL,
algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, UNIQUE(principal_id, alias), UNIQUE(principal_id, id));
CREATE TABLE credential_slot_revisions (id TEXT PRIMARY KEY, slot_id TEXT NOT NULL, revision INTEGER NOT NULL, algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB, created_at TEXT NOT NULL, UNIQUE(slot_id, revision));
CREATE TABLE routes (id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, slot_id TEXT NOT NULL, alias TEXT, profile_id TEXT NOT NULL, upstream_model TEXT NOT NULL, resource_selector TEXT NOT NULL DEFAULT 'default', status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT);
INSERT INTO principals VALUES ('principal-1', 'legacy', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
INSERT INTO credential_slots VALUES ('slot-1', 'principal-1', 'openai', 'legacy-slot', 'active', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);
INSERT INTO credential_slots VALUES ('slot-2', 'principal-1', 'anthropic', 'legacy-anthropic', 'active', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);
INSERT INTO credential_slot_revisions VALUES ('revision-1', 'slot-1', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z');
INSERT INTO credential_slot_revisions VALUES ('revision-2', 'slot-2', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z');
INSERT INTO routes VALUES ('route-1', 'principal-1', 'slot-1', 'legacy-openai', 'openai', 'gpt-4o', 'default', 'active', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);
INSERT INTO routes VALUES ('route-2', 'principal-1', 'slot-2', 'legacy-anthropic-route', 'anthropic', 'claude-3-5-sonnet', 'default', 'active', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);`)
require.NoError(t, err)
require.NoError(t, db.Close())
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
require.NoError(t, err)
defer store.Close()
legacy, err := store.GetSlot(ctx, "principal-1", "slot-1")
require.NoError(t, err)
require.Equal(t, CredentialKindBearer, legacy.CredentialKind)
require.Equal(t, "legacy-slot", legacy.Alias)
legacyAnthropic, err := store.GetSlot(ctx, "principal-1", "slot-2")
require.NoError(t, err)
require.Equal(t, CredentialKindAPIKey, legacyAnthropic.CredentialKind)
created, err := store.CreateSlot(ctx, CreateSlotInput{PrincipalID: "principal-1", Vendor: "openai", CredentialKind: CredentialKindBearer, Envelope: SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("new")}})
require.NoError(t, err)
require.Empty(t, created.Alias)
require.NoError(t, store.Close())
reopened, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
require.NoError(t, err)
defer reopened.Close()
loaded, err := reopened.GetSlot(ctx, "principal-1", created.ID)
require.NoError(t, err)
require.Empty(t, loaded.Alias)
}
func TestCredentialSlotMigrationRejectsIncompatibleActiveBinding(t *testing.T) {
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "incompatible-intermediate.db")
db, err := sql.Open(dialectSQLite, dbPath)
require.NoError(t, err)
_, err = db.Exec(`
CREATE TABLE principals (id TEXT PRIMARY KEY, alias TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE credential_slots (
id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, vendor TEXT NOT NULL, alias TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL,
algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, UNIQUE(principal_id, alias), UNIQUE(principal_id, id));
CREATE TABLE credential_slot_revisions (id TEXT PRIMARY KEY, slot_id TEXT NOT NULL, revision INTEGER NOT NULL, algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB, created_at TEXT NOT NULL, UNIQUE(slot_id, revision));
CREATE TABLE routes (id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, slot_id TEXT NOT NULL, alias TEXT, profile_id TEXT NOT NULL, upstream_model TEXT NOT NULL, resource_selector TEXT NOT NULL DEFAULT 'default', status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT);
INSERT INTO principals VALUES ('principal-1', 'legacy', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
INSERT INTO credential_slots VALUES ('slot-1', 'principal-1', 'unknown-provider', 'legacy-slot', 'active', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);
INSERT INTO credential_slot_revisions VALUES ('revision-1', 'slot-1', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z');
INSERT INTO routes VALUES ('route-1', 'principal-1', 'slot-1', 'legacy-route', 'openai', 'model', 'default', 'active', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);`)
require.NoError(t, err)
require.NoError(t, db.Close())
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(newFakeKeyRegistry()))
require.Nil(t, store)
require.ErrorIs(t, err, ErrIncompatibleProfile)
require.NotContains(t, err.Error(), "opaque")
}
func TestCredentialSlotMigrationRejectsIncompatibleInactiveBinding(t *testing.T) {
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "incompatible-inactive-intermediate.db")
db, err := sql.Open(dialectSQLite, dbPath)
require.NoError(t, err)
_, err = db.Exec(`
CREATE TABLE principals (id TEXT PRIMARY KEY, alias TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE credential_slots (
id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, vendor TEXT NOT NULL, alias TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL,
algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, UNIQUE(principal_id, alias), UNIQUE(principal_id, id));
CREATE TABLE credential_slot_revisions (id TEXT PRIMARY KEY, slot_id TEXT NOT NULL, revision INTEGER NOT NULL, algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB, created_at TEXT NOT NULL, UNIQUE(slot_id, revision));
CREATE TABLE routes (id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, slot_id TEXT NOT NULL, alias TEXT, profile_id TEXT NOT NULL, upstream_model TEXT NOT NULL, resource_selector TEXT NOT NULL DEFAULT 'default', status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT);
INSERT INTO principals VALUES ('principal-1', 'legacy', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
INSERT INTO credential_slots VALUES ('slot-1', 'principal-1', 'unknown-provider', 'legacy-slot', 'disabled', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);
INSERT INTO credential_slot_revisions VALUES ('revision-1', 'slot-1', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z');
INSERT INTO routes VALUES ('route-1', 'principal-1', 'slot-1', 'legacy-route', 'openai', 'model', 'default', 'active', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);`)
require.NoError(t, err)
require.NoError(t, db.Close())
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(newFakeKeyRegistry()))
require.Nil(t, store)
require.ErrorIs(t, err, ErrIncompatibleProfile)
require.NotContains(t, err.Error(), "opaque")
}
func TestCredentialSlotMigrationRejectsActiveRouteWithoutSlot(t *testing.T) {
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "orphan-route-intermediate.db")
db, err := sql.Open(dialectSQLite, dbPath)
require.NoError(t, err)
_, err = db.Exec(`
CREATE TABLE principals (id TEXT PRIMARY KEY, alias TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE credential_slots (
id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, vendor TEXT NOT NULL, alias TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL,
algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, UNIQUE(principal_id, alias), UNIQUE(principal_id, id));
CREATE TABLE credential_slot_revisions (id TEXT PRIMARY KEY, slot_id TEXT NOT NULL, revision INTEGER NOT NULL, algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB, created_at TEXT NOT NULL, UNIQUE(slot_id, revision));
CREATE TABLE routes (id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, slot_id TEXT NOT NULL, alias TEXT, profile_id TEXT NOT NULL, upstream_model TEXT NOT NULL, resource_selector TEXT NOT NULL DEFAULT 'default', status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT);
INSERT INTO principals VALUES ('principal-1', 'legacy', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
INSERT INTO routes VALUES ('route-1', 'principal-1', 'nonexistent-slot', 'legacy-route', 'openai', 'model', 'default', 'active', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);`)
require.NoError(t, err)
require.NoError(t, db.Close())
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(newFakeKeyRegistry()))
require.Nil(t, store)
require.ErrorIs(t, err, ErrIncompatibleProfile)
require.NotContains(t, err.Error(), "opaque")
}