fix(quota): reject tier-lock for non-managed providers

Only managed-quota providers (agy/claude/codex/gemini/ghcp) enforce tier_lock;
validate the provider against that set so locking a non-managed provider returns
400 instead of persisting a silently-unenforced config entry.
This commit is contained in:
Tam Nhu Tran
2026-06-07 16:38:18 -04:00
parent 63d10cb2d0
commit b9a1084bd4
2 changed files with 51 additions and 0 deletions
+16
View File
@@ -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<string> = new Set<AccountTier>([
@@ -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)' });
@@ -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' });