iop/apps/control-plane/internal/credentialstore/route_test.go
toki 61d4e76fe8 feat(protocol-profile): GLM Coding Plan 프로필을 분리한다
General API와 Coding Plan이 서로 다른 엔드포인트와 사용량 경계를 유지하도록 프로필, credential route, provider mapping, full-cycle 검증을 함께 반영한다.
2026-08-02 13:24:42 +09:00

458 lines
17 KiB
Go

package credentialstore
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"iop/packages/go/config"
)
func setupRouteTestFixture(t *testing.T) (*Store, *IssuedPrincipal, *CredentialSlotRecord, *CredentialSlotRecord) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test_routes.db")
ctx := context.Background()
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
require.NoError(t, err)
t.Cleanup(func() { _ = store.Close() })
p, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "route-principal"})
require.NoError(t, err)
env := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "k1",
KeyVersion: 1,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher1"),
}
openaiSlot, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "openai-slot-1",
Envelope: env,
})
require.NoError(t, err)
anthropicSlot, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "anthropic",
CredentialKind: CredentialKindAPIKey,
Alias: "anthropic-slot-1",
Envelope: env,
})
require.NoError(t, err)
return store, p, openaiSlot, anthropicSlot
}
func TestRoutesDisambiguateSameUpstreamModelBySlot(t *testing.T) {
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
dbPath := filepath.Join(t.TempDir(), "disambig.db")
ctx := context.Background()
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
require.NoError(t, err)
defer store.Close()
p, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "p-disambig"})
require.NoError(t, err)
env := SecretEnvelope{
Algorithm: "AES-256-GCM",
KeyID: "k1",
KeyVersion: 1,
Nonce: []byte("123456789012"),
Ciphertext: []byte("cipher1"),
}
slotA, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "slot-a",
Envelope: env,
})
require.NoError(t, err)
slotB, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "slot-b",
Envelope: env,
})
require.NoError(t, err)
// Create Route 1 referencing slotA for gpt-4o
route1, err := store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: slotA.ID,
Alias: "route-gpt4o-a",
ProfileID: "openai",
UpstreamModel: "gpt-4o",
})
require.NoError(t, err)
require.Equal(t, slotA.ID, route1.SlotID)
// Create Route 2 referencing slotB for gpt-4o
route2, err := store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: slotB.ID,
Alias: "route-gpt4o-b",
ProfileID: "openai",
UpstreamModel: "gpt-4o",
})
require.NoError(t, err)
require.Equal(t, slotB.ID, route2.SlotID)
routes, err := store.ListRoutes(ctx, p.Principal.ID)
require.NoError(t, err)
require.Len(t, routes, 2)
}
func TestSlotSupportsMultipleCompatibleRoutes(t *testing.T) {
store, p, openaiSlot, _ := setupRouteTestFixture(t)
ctx := context.Background()
r1, err := store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: openaiSlot.ID,
Alias: "route-gpt4o",
ProfileID: "openai",
UpstreamModel: "gpt-4o",
})
require.NoError(t, err)
r2, err := store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: openaiSlot.ID,
Alias: "route-gpt4o-mini",
ProfileID: "openai",
UpstreamModel: "gpt-4o-mini",
})
require.NoError(t, err)
slotRoutes, err := store.ListRoutesBySlot(ctx, p.Principal.ID, openaiSlot.ID)
require.NoError(t, err)
require.Len(t, slotRoutes, 2)
require.Equal(t, r1.ID, slotRoutes[0].ID)
require.Equal(t, r2.ID, slotRoutes[1].ID)
}
func TestRouteRejectsCrossPrincipalSlot(t *testing.T) {
store, _, openaiSlotP1, _ := setupRouteTestFixture(t)
ctx := context.Background()
p2, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "principal-2"})
require.NoError(t, err)
// Attempting to create a route for P2 using P1's slot must fail
_, err = store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p2.Principal.ID,
SlotID: openaiSlotP1.ID,
Alias: "p2-route",
ProfileID: "openai",
UpstreamModel: "gpt-4o",
})
require.Error(t, err)
require.ErrorIs(t, err, ErrCrossPrincipalSlot)
}
func TestRouteRejectsIncompatibleProfile(t *testing.T) {
store, p, openaiSlot, anthropicSlot := setupRouteTestFixture(t)
ctx := context.Background()
// OpenAI slot with Anthropic profile should fail
_, err := store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: openaiSlot.ID,
Alias: "bad-route-1",
ProfileID: "anthropic",
UpstreamModel: "claude-3-5-sonnet",
})
require.Error(t, err)
require.ErrorIs(t, err, ErrIncompatibleProfile)
// Anthropic slot with OpenAI profile should fail
_, err = store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: anthropicSlot.ID,
Alias: "bad-route-2",
ProfileID: "openai",
UpstreamModel: "gpt-4o",
})
require.Error(t, err)
require.ErrorIs(t, err, ErrIncompatibleProfile)
}
func TestRouteAliasCollisionFailsClosed(t *testing.T) {
store, p, openaiSlot, _ := setupRouteTestFixture(t)
ctx := context.Background()
_, err := store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: openaiSlot.ID,
Alias: "my-route-alias",
ProfileID: "openai",
UpstreamModel: "gpt-4o",
})
require.NoError(t, err)
_, err = store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: openaiSlot.ID,
Alias: "my-route-alias",
ProfileID: "openai",
UpstreamModel: "gpt-4o-mini",
})
require.Error(t, err)
require.ErrorIs(t, err, ErrRouteAliasAlreadyExists)
}
func TestSlotSupportsCompatibleChatAndMessagesProfiles(t *testing.T) {
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, p := setupTestStoreWithRegistry(t, reg)
ctx := context.Background()
slot, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID, Vendor: "minimax", CredentialKind: CredentialKindBearer, Alias: "minimax",
Envelope: SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")},
})
require.NoError(t, err)
chat, err := store.CreateRoute(ctx, CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, Alias: "mini-chat", ProfileID: "minimax_chat", UpstreamModel: "MiniMax-M2"})
require.NoError(t, err)
messages, err := store.CreateRoute(ctx, CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, Alias: "mini-messages", ProfileID: "minimax_messages", UpstreamModel: "MiniMax-M2"})
require.NoError(t, err)
require.NotEqual(t, chat.ProfileID, messages.ProfileID)
routes, err := store.ListRoutesBySlot(ctx, p.Principal.ID, slot.ID)
require.NoError(t, err)
require.Len(t, routes, 2)
}
// TestGLMSlotSupportsGeneralAndCodingProfiles verifies that a single
// glm/bearer credential slot can bind two distinct routes — one for General
// API and one for Coding Plan — each with its own alias, profile, and
// resource selector, while a different vendor slot rejects glm_coding.
// Official endpoint distinction rechecked on 2026-08-02:
// https://docs.z.ai/api-reference/introduction
// https://docs.z.ai/scenario-example/develop-tools/others
func TestGLMSlotSupportsGeneralAndCodingProfiles(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-glm"),
}
slot, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "glm",
CredentialKind: CredentialKindBearer,
Alias: "glm-slot",
Envelope: env,
})
require.NoError(t, err)
apiRoute, err := store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: slot.ID,
Alias: "glm-5.1-api",
ProfileID: "glm",
UpstreamModel: "glm-5.1",
ResourceSelector: "glm-api",
})
require.NoError(t, err)
require.Equal(t, "glm", apiRoute.ProfileID)
require.Equal(t, "glm-5.1-api", apiRoute.Alias)
require.Equal(t, "glm-api", apiRoute.ResourceSelector)
codingRoute, err := store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: slot.ID,
Alias: "glm-5.1-coding",
ProfileID: "glm_coding",
UpstreamModel: "glm-5.1",
ResourceSelector: "glm-coding",
})
require.NoError(t, err)
require.Equal(t, "glm_coding", codingRoute.ProfileID)
require.Equal(t, "glm-5.1-coding", codingRoute.Alias)
require.Equal(t, "glm-coding", codingRoute.ResourceSelector)
require.NotEqual(t, apiRoute.ID, codingRoute.ID, "two routes must have distinct IDs")
routes, err := store.ListRoutes(ctx, p.Principal.ID)
require.NoError(t, err)
require.Len(t, routes, 2)
routesBySlot, err := store.ListRoutesBySlot(ctx, p.Principal.ID, slot.ID)
require.NoError(t, err)
require.Len(t, routesBySlot, 2)
// A different-vendor slot must reject glm_coding.
otherSlot, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID,
Vendor: "openai",
CredentialKind: CredentialKindBearer,
Alias: "openai-slot",
Envelope: env,
})
require.NoError(t, err)
_, err = store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: otherSlot.ID,
Alias: "openai-glm-coding",
ProfileID: "glm_coding",
UpstreamModel: "glm-5.1",
})
require.ErrorIs(t, err, ErrIncompatibleProfile)
}
func TestRouteRejectsUnknownVendorCredentialTuple(t *testing.T) {
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, p := setupTestStoreWithRegistry(t, reg)
slot, err := store.CreateSlot(context.Background(), CreateSlotInput{
PrincipalID: p.Principal.ID, Vendor: "unknown-provider", CredentialKind: CredentialKindBearer, Alias: "unknown",
Envelope: SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")},
})
require.NoError(t, err)
_, err = store.CreateRoute(context.Background(), CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, ProfileID: "openai", UpstreamModel: "model"})
require.ErrorIs(t, err, ErrIncompatibleProfile)
mismatched, err := store.CreateSlot(context.Background(), CreateSlotInput{
PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindAPIKey, Alias: "mismatched",
Envelope: SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque-2")},
})
require.NoError(t, err)
_, err = store.CreateRoute(context.Background(), CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: mismatched.ID, ProfileID: "openai", UpstreamModel: "model"})
require.ErrorIs(t, err, ErrIncompatibleProfile)
}
func TestRouteResolutionUsesImmutableProfileCatalog(t *testing.T) {
store, p, slot, _ := setupRouteTestFixture(t)
ctx := context.Background()
original, ok := config.BuiltInProtocolProfiles["openai"]
require.True(t, ok)
delete(config.BuiltInProtocolProfiles, "openai")
t.Cleanup(func() { config.BuiltInProtocolProfiles["openai"] = original })
route, err := store.CreateRoute(ctx, CreateRouteInput{
PrincipalID: p.Principal.ID,
SlotID: slot.ID,
ProfileID: "openai",
UpstreamModel: "gpt-4o",
})
require.NoError(t, err)
require.Equal(t, "openai", route.ProfileID)
}
func TestRouteRejectsInactiveSlot(t *testing.T) {
store, p, slot, _ := setupRouteTestFixture(t)
ctx := context.Background()
first, err := store.CreateRoute(ctx, CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, ProfileID: "openai", UpstreamModel: "gpt-4o"})
require.NoError(t, err)
loaded, err := store.GetSlot(ctx, p.Principal.ID, slot.ID)
require.NoError(t, err)
disabled, err := store.DisableSlot(ctx, p.Principal.ID, slot.ID, loaded.Revision)
require.NoError(t, err)
_, err = store.CreateRoute(ctx, CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, ProfileID: "openai", UpstreamModel: "gpt-4o-mini"})
require.ErrorIs(t, err, ErrSlotNotActive)
revoked, err := store.RevokeSlot(ctx, p.Principal.ID, slot.ID, disabled.Revision)
require.NoError(t, err)
_, err = store.CreateRoute(ctx, CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, ProfileID: "openai", UpstreamModel: "gpt-4.1"})
require.ErrorIs(t, err, ErrSlotRevoked)
require.NotEmpty(t, first.ID)
require.Equal(t, StatusRevoked, revoked.Status)
}
func TestEnableRouteRejectsInactiveSlot(t *testing.T) {
store, p, slot, _ := setupRouteTestFixture(t)
ctx := context.Background()
route, err := store.CreateRoute(ctx, CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, ProfileID: "openai", UpstreamModel: "gpt-4o"})
require.NoError(t, err)
disabledRoute, err := store.DisableRoute(ctx, p.Principal.ID, route.ID, route.Revision)
require.NoError(t, err)
loaded, err := store.GetSlot(ctx, p.Principal.ID, slot.ID)
require.NoError(t, err)
disabledSlot, err := store.DisableSlot(ctx, p.Principal.ID, slot.ID, loaded.Revision)
require.NoError(t, err)
_, err = store.EnableRoute(ctx, p.Principal.ID, route.ID, disabledRoute.Revision)
require.ErrorIs(t, err, ErrSlotNotActive)
_, err = store.EnableSlot(ctx, p.Principal.ID, slot.ID, disabledSlot.Revision)
require.NoError(t, err)
enabledRoute, err := store.EnableRoute(ctx, p.Principal.ID, route.ID, disabledRoute.Revision)
require.NoError(t, err)
require.Equal(t, StatusActive, enabledRoute.Status)
}
func TestUpdateRouteUsesRevisionCAS(t *testing.T) {
store, p, slot, _ := setupRouteTestFixture(t)
ctx := context.Background()
route, err := store.CreateRoute(ctx, CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, Alias: "before", ProfileID: "openai", UpstreamModel: "gpt-4o"})
require.NoError(t, err)
updated, err := store.UpdateRoute(ctx, UpdateRouteInput{PrincipalID: p.Principal.ID, RouteID: route.ID, CurrentRevision: route.Revision, SlotID: slot.ID, Alias: " after ", ProfileID: "openai", UpstreamModel: "gpt-4.1", ResourceSelector: "preferred"})
require.NoError(t, err)
require.Equal(t, int64(1), updated.Revision)
require.Equal(t, "after", updated.Alias)
require.Equal(t, "gpt-4.1", updated.UpstreamModel)
require.Equal(t, "preferred", updated.ResourceSelector)
_, err = store.UpdateRoute(ctx, UpdateRouteInput{PrincipalID: p.Principal.ID, RouteID: route.ID, CurrentRevision: route.Revision, SlotID: slot.ID, Alias: "stale", ProfileID: "openai", UpstreamModel: "gpt-4o"})
require.ErrorIs(t, err, ErrRevisionMismatch)
}
func TestRouteAliasCannotShadowRouteID(t *testing.T) {
store, p, slot, _ := setupRouteTestFixture(t)
first, err := store.CreateRoute(context.Background(), CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, Alias: "first", ProfileID: "openai", UpstreamModel: "gpt-4o"})
require.NoError(t, err)
_, err = store.CreateRoute(context.Background(), CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, Alias: first.ID, ProfileID: "openai", UpstreamModel: "gpt-4.1"})
require.ErrorIs(t, err, ErrRouteAliasAlreadyExists)
}
func TestRouteLifecyclePersistsAcrossReopen(t *testing.T) {
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "route-lifecycle.db")
reg := newFakeKeyRegistry()
reg.RegisterKey("k1", 1)
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
require.NoError(t, err)
p, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "route-reopen"})
require.NoError(t, err)
slot, err := store.CreateSlot(ctx, CreateSlotInput{
PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "reopen-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: p.Principal.ID, SlotID: slot.ID, Alias: "reopen-route", ProfileID: "openai", UpstreamModel: "gpt-4o", ResourceSelector: "primary"})
require.NoError(t, err)
disabled, err := store.DisableRoute(ctx, p.Principal.ID, route.ID, route.Revision)
require.NoError(t, err)
require.NoError(t, store.Close())
reopened, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
require.NoError(t, err)
defer reopened.Close()
loaded, err := reopened.GetRoute(ctx, p.Principal.ID, route.ID)
require.NoError(t, err)
require.Equal(t, StatusDisabled, loaded.Status)
require.Equal(t, disabled.Revision, loaded.Revision)
require.Equal(t, "reopen-route", loaded.Alias)
require.Equal(t, "primary", loaded.ResourceSelector)
}