diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 4a6520db..f00973cf 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -44,6 +44,10 @@ import { mutateConfig, } from '../../config/config-loader-facade'; import type { AccountTier } from '../../cliproxy/accounts/types'; +import { + isManagedQuotaProvider, + MANAGED_QUOTA_PROVIDERS, +} from '../../cliproxy/quota/quota-manager'; /** Valid account tier values for tier-lock validation */ const VALID_ACCOUNT_TIERS: ReadonlySet = new Set([ @@ -669,6 +673,18 @@ router.post('/tier-lock', (req: Request, res: Response): void => { return; } + // Fix #8: tier_lock is only enforced by quota-manager for managed-quota providers. + // Locking a non-managed provider persists a silently-unenforced entry. + // Reject with 400 to avoid misleading the caller. + if (!isManagedQuotaProvider(provider)) { + res.status(400).json({ + error: + `Provider "${provider}" does not support tier-lock. ` + + `Only managed-quota providers support it: ${[...MANAGED_QUOTA_PROVIDERS].join(', ')}.`, + }); + return; + } + // tier must be explicitly present in body (key exists), and must be string or null if (!('tier' in req.body)) { res.status(400).json({ error: 'Missing required field: tier (string | null)' }); diff --git a/tests/unit/web-server/tier-lock-routes.test.ts b/tests/unit/web-server/tier-lock-routes.test.ts index 55736614..0aaa875a 100644 --- a/tests/unit/web-server/tier-lock-routes.test.ts +++ b/tests/unit/web-server/tier-lock-routes.test.ts @@ -164,6 +164,41 @@ describe('POST /api/accounts/tier-lock', () => { expect(body.error).toMatch(/tier/i); }); + it('rejects a non-managed provider with 400 (fix #8)', async () => { + // Providers like 'kiro' or unknown CLIProxy providers accept isCLIProxyProvider() + // but quota-manager does not enforce tier_lock for them. Persisting a lock entry + // would silently have no effect, misleading the caller. Must 400. + // Note: 'kiro' passes isCLIProxyProvider but is NOT in MANAGED_QUOTA_PROVIDERS. + // We test with a provider that is valid (passes CLIProxy check) but not managed. + // In practice this means any provider added to CLIProxy that is not in + // MANAGED_QUOTA_PROVIDERS = ['agy', 'claude', 'codex', 'gemini', 'ghcp']. + // We use a string that is a known CLIProxy provider but not managed. + // Since the set of CLIProxy providers is dynamic we test the error message content. + const res = await postJson(baseUrl, '/api/accounts/tier-lock', { + provider: 'kiro', + tier: 'pro', + }); + // If kiro is a CLIProxy provider but not managed: expect 400 + // If kiro is not a CLIProxy provider at all: also 400 (from isCLIProxyProvider check) + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/provider/i); + }); + + it('accepts all five managed-quota providers (agy, claude, codex, gemini, ghcp)', async () => { + const managedProviders = ['agy', 'claude', 'codex', 'gemini', 'ghcp']; + for (const provider of managedProviders) { + const res = await postJson(baseUrl, '/api/accounts/tier-lock', { + provider, + tier: 'pro', + }); + // All managed providers should succeed (200) + expect(res.status).toBe(200); + const body = (await res.json()) as { provider: string; tier_lock: string | null }; + expect(body.provider).toBe(provider); + } + }); + it('persists tier_lock as per-provider map in the config', async () => { await postJson(baseUrl, '/api/accounts/tier-lock', { provider: 'agy', tier: 'pro' });