tests/cli-login-key.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it, vi } from 'vitest'
import {
aggregateCliKeyPolicy,
canonicalizeCliBindings,
createMcpOAuthKey,
getCliLoginDestination,
isCliAiQuery,
isCliLoginPath,
isMatchingCliLoginEvent,
isAgentClaimPath,
isMcpAuthorizePath,
isValidCliLoginSession,
mcpOAuthKeyExpiresAt,
mcpOAuthKeyName,
nextManagedCliKeyName,
prepareCliLoginKey,
resolveCliKeyEligibility,
roleForCliKey,
shouldShowCliLoginGuidance,
} from '../src/services/cliLogin'
const now = new Date('2026-08-15T12:00:00.000Z')
function org(overrides: Record<string, unknown> = {}) {
return {
'gid': 'org-a',
'name': 'Alpha',
'role': 'org_admin',
'is_invite': false,
'enforcing_2fa': false,
'2fa_has_access': true,
'password_policy_config': null,
'password_has_access': true,
'enforce_hashed_api_keys': false,
'require_apikey_expiration': false,
'max_apikey_expiration_days': null,
...overrides,
}
}
function dependencies(overrides: Record<string, unknown> = {}) {
return {
hasRequiredPermissions: vi.fn(async () => true),
listMetadata: vi.fn(async () => []),
listOwnedKeys: vi.fn(async () => []),
listBindings: vi.fn(async () => []),
createKey: vi.fn(async () => ({ key: 'new-secret' })),
...overrides,
}
}
describe('cLI login key model', () => {
it.concurrent.each([
['owner', 'org_super_admin'],
['org_super_admin', 'org_super_admin'],
['org_admin', 'org_admin'],
['org_member', null],
['org_billing_admin', null],
['custom_role', null],
])('maps %s without guessing', (role, expected) => {
expect(roleForCliKey(role)).toBe(expected)
})
it.concurrent('combines hashing with the strictest expiration and clock margin', () => {
expect(aggregateCliKeyPolicy([
org({ enforce_hashed_api_keys: true, max_apikey_expiration_days: 30 }),
org({ gid: 'org-b', require_apikey_expiration: true, max_apikey_expiration_days: 90 }),
], now)).toEqual({
hashed: true,
expiresAt: '2026-09-14T11:59:00.000Z',
})
})
it.concurrent('uses 365 days only when expiration is required and no positive max exists', () => {
expect(aggregateCliKeyPolicy([
org({ require_apikey_expiration: true, max_apikey_expiration_days: null }),
], now)).toEqual({
hashed: false,
expiresAt: '2027-08-15T11:59:00.000Z',
})
})
it.concurrent('parses only exact managed names and fills the first gap', () => {
expect(nextManagedCliKeyName([
'Capgo CLI',
'Capgo CLI (3)',
'Capgo CLI copy',
'Capgo CLI (01)',
])).toBe('Capgo CLI (2)')
})
it.concurrent('shows CLI login guidance until 24 hours after the first managed key', () => {
const guidanceNow = new Date('2026-08-20T12:00:00.000Z')
expect(shouldShowCliLoginGuidance([], guidanceNow)).toBe(true)
expect(shouldShowCliLoginGuidance([
{ name: 'Manually named key', created_at: '2026-08-01T00:00:00.000Z' },
], guidanceNow)).toBe(true)
expect(shouldShowCliLoginGuidance([
{ name: 'Capgo CLI', created_at: '2026-08-19T13:00:00.000Z' },
], guidanceNow)).toBe(true)
expect(shouldShowCliLoginGuidance([
{ name: 'Capgo CLI (2)', created_at: '2026-08-20T11:00:00.000Z' },
{ name: 'Capgo CLI', created_at: '2026-08-19T11:59:59.000Z' },
], guidanceNow)).toBe(false)
})
it.concurrent('canonicalizes all scope fields so extra app or channel access is not equal', () => {
const expected = canonicalizeCliBindings([
{ role_name: 'org_admin', scope_type: 'org', org_id: 'org-a' },
])
expect(canonicalizeCliBindings([
{ role_name: 'org_admin', scope_type: 'org', org_id: 'org-a' },
{ role_name: 'app_admin', scope_type: 'app', org_id: 'org-a', app_id: 'app-a' },
])).not.toEqual(expected)
})
it.concurrent('validates high-entropy sessions and matches only the exact login event', () => {
const session = 'AbCdEfGhIjKlMnOpQrStUv'
expect(isValidCliLoginSession(session)).toBe(true)
expect(isValidCliLoginSession('short')).toBe(false)
expect(isCliAiQuery('1')).toBe(true)
expect(isCliAiQuery('1/')).toBe(true)
expect(isCliAiQuery(['1/'])).toBe(true)
expect(isCliAiQuery('0')).toBe(false)
expect(isCliLoginPath('/login-cli')).toBe(true)
expect(isCliLoginPath('/login-cli/')).toBe(true)
expect(isCliLoginPath('/login')).toBe(false)
expect(isMatchingCliLoginEvent({
event: 'User CLI login',
channel: 'user-login',
description: `cli-login:${session}`,
}, session)).toBe(true)
expect(isMatchingCliLoginEvent({
event: 'User CLI login',
channel: 'user-login',
description: 'cli-login:different',
}, session)).toBe(false)
})
it.concurrent('offers onboarding only for one accepted org and one pending app', () => {
expect(getCliLoginDestination(1, [
{ app_id: 'com.demo.app', need_onboarding: true },
])).toBe('/app/com.demo.app/getting-started')
expect(getCliLoginDestination(2, [
{ app_id: 'com.demo.app', need_onboarding: true },
])).toBe('/dashboard')
expect(getCliLoginDestination(1, [
{ app_id: 'com.demo.app', need_onboarding: false },
])).toBe('/dashboard')
})
})
describe('prepareCliLoginKey', () => {
it.concurrent('skips unsupported, invited, security-blocked, and permission-blocked orgs', async () => {
const io = dependencies({
hasRequiredPermissions: vi.fn(async (orgId: string) => orgId !== 'blocked'),
})
const result = await prepareCliLoginKey([
org(),
org({ gid: 'member', name: 'Member', role: 'org_member' }),
org({ gid: 'invite', name: 'Invite', is_invite: true }),
org({ 'gid': 'security', 'name': 'Security', 'enforcing_2fa': true, '2fa_has_access': false }),
org({ gid: 'blocked', name: 'Blocked' }),
], io, now)
expect(result.status).toBe('ready')
expect(result.skippedOrganizations).toEqual([
{ id: 'member', name: 'Member' },
{ id: 'invite', name: 'Invite' },
{ id: 'security', name: 'Security' },
{ id: 'blocked', name: 'Blocked' },
])
expect(io.createKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'Capgo CLI',
global_permissions: [],
bindings: [{ role_name: 'org_admin', scope_type: 'org', org_id: 'org-a' }],
}))
})
it.concurrent('reuses the exact plaintext key and rejects global permissions', async () => {
const exact = {
id: 1,
name: 'Capgo CLI',
rbac_id: 'rbac-1',
created_at: '2026-08-01T00:00:00Z',
expires_at: null,
is_hashed_key: false,
global_permissions: [],
}
const io = dependencies({
listMetadata: vi.fn(async () => [exact]),
listOwnedKeys: vi.fn(async () => [{ ...exact, key: 'existing-secret' }]),
listBindings: vi.fn(async () => [{
principal_id: 'rbac-1',
role_name: 'org_admin',
scope_type: 'org',
org_id: 'org-a',
}]),
})
await expect(prepareCliLoginKey([org()], io, now)).resolves.toMatchObject({
status: 'ready',
secret: 'existing-secret',
reused: true,
})
expect(io.createKey).not.toHaveBeenCalled()
io.listMetadata.mockResolvedValue([{ ...exact, global_permissions: ['org.create'] }])
await expect(prepareCliLoginKey([org()], io, now)).resolves.toMatchObject({
status: 'ready',
secret: 'new-secret',
reused: false,
})
})
it.concurrent('rejects extra or expired bindings, expired keys, and plaintext keys under a hashed policy', async () => {
const metadata = {
id: 1,
name: 'Capgo CLI',
rbac_id: 'rbac-1',
created_at: '2026-08-01T00:00:00Z',
expires_at: null,
is_hashed_key: false,
global_permissions: [],
}
const io = dependencies({
listMetadata: vi.fn(async () => [metadata]),
listOwnedKeys: vi.fn(async () => [{ ...metadata, key: 'existing-secret' }]),
listBindings: vi.fn(async () => [
{ principal_id: 'rbac-1', role_name: 'org_admin', scope_type: 'org', org_id: 'org-a' },
{ principal_id: 'rbac-1', role_name: 'app_admin', scope_type: 'app', org_id: 'org-a', app_id: 'app-a' },
]),
})
await expect(prepareCliLoginKey([org()], io, now)).resolves.toMatchObject({ reused: false })
io.listBindings.mockResolvedValue([
{
principal_id: 'rbac-1',
role_name: 'org_admin',
scope_type: 'org',
org_id: 'org-a',
expires_at: '2026-08-14T00:00:00Z',
},
])
await expect(prepareCliLoginKey([org()], io, now)).resolves.toMatchObject({ reused: false })
io.listBindings.mockResolvedValue([
{ principal_id: 'rbac-1', role_name: 'org_admin', scope_type: 'org', org_id: 'org-a' },
])
io.listOwnedKeys.mockResolvedValue([{
...metadata,
key: 'existing-secret',
expires_at: '2026-08-14T00:00:00Z',
}])
await expect(prepareCliLoginKey([org()], io, now)).resolves.toMatchObject({ reused: false })
io.listOwnedKeys.mockResolvedValue([{ ...metadata, key: 'existing-secret' }])
await expect(prepareCliLoginKey([
org({ enforce_hashed_api_keys: true }),
], io, now)).resolves.toMatchObject({ reused: false })
})
it.concurrent('reuses a key that expires exactly at the organization maximum', async () => {
const exact = {
id: 1,
name: 'Capgo CLI',
rbac_id: 'rbac-1',
created_at: '2026-08-01T00:00:00Z',
expires_at: '2026-09-14T12:00:00Z',
is_hashed_key: false,
global_permissions: [],
}
const io = dependencies({
listMetadata: vi.fn(async () => [exact]),
listOwnedKeys: vi.fn(async () => [{ ...exact, key: 'existing-secret' }]),
listBindings: vi.fn(async () => [{
principal_id: 'rbac-1',
role_name: 'org_admin',
scope_type: 'org',
org_id: 'org-a',
}]),
})
await expect(prepareCliLoginKey([
org({ require_apikey_expiration: true, max_apikey_expiration_days: 30 }),
], io, now)).resolves.toMatchObject({ reused: true, secret: 'existing-secret' })
})
it.concurrent('reserves exact managed names even when a hashed key cannot be reused', async () => {
const io = dependencies({
listOwnedKeys: vi.fn(async () => [
{ id: 1, name: 'Capgo CLI', key: null, rbac_id: 'one', created_at: '', expires_at: null },
{ id: 2, name: 'Capgo CLI (2)', key: null, rbac_id: 'two', created_at: '', expires_at: null },
{ id: 3, name: 'Capgo CLI old', key: 'x', rbac_id: 'three', created_at: '', expires_at: null },
]),
})
await prepareCliLoginKey([org()], io, now)
expect(io.createKey).toHaveBeenCalledWith(expect.objectContaining({ name: 'Capgo CLI (3)' }))
})
it.concurrent('does not call key APIs when every organization is skipped', async () => {
const io = dependencies()
await expect(prepareCliLoginKey([
org({ role: 'org_member' }),
], io, now)).resolves.toEqual({
status: 'empty',
skippedOrganizations: [{ id: 'org-a', name: 'Alpha' }],
})
expect(io.listMetadata).not.toHaveBeenCalled()
expect(io.createKey).not.toHaveBeenCalled()
})
})
describe('mCP OAuth key model', () => {
it.concurrent('only offers organizations that pass the CLI key eligibility rules', async () => {
const result = await resolveCliKeyEligibility([
org(),
org({ gid: 'org-b', name: 'Beta', role: 'org_member' }),
org({ gid: 'org-c', name: 'Gamma', is_invite: true }),
], dependencies())
expect(result.eligible.map(organization => organization.gid)).toEqual(['org-a'])
expect(result.skippedOrganizations).toEqual([{ id: 'org-b', name: 'Beta' }, { id: 'org-c', name: 'Gamma' }])
})
it.concurrent('mints a dedicated key per client bound to the selected organizations and policy', async () => {
const deps = dependencies()
const key = await createMcpOAuthKey([
org(),
org({ gid: 'org-b', role: 'owner', enforce_hashed_api_keys: true, require_apikey_expiration: true, max_apikey_expiration_days: 30 }),
], deps, 'Lovable', now)
expect(key).toBe('new-secret')
expect(deps.createKey).toHaveBeenCalledWith({
name: 'MCP · Lovable',
hashed: true,
expires_at: aggregateCliKeyPolicy([org({ require_apikey_expiration: true, max_apikey_expiration_days: 30 })], now).expiresAt,
bindings: [
{ role_name: 'org_admin', scope_type: 'org', org_id: 'org-a' },
{ role_name: 'org_super_admin', scope_type: 'org', org_id: 'org-b' },
],
global_permissions: [],
})
})
it.concurrent('always gives OAuth keys a lifetime of at most 90 days', () => {
expect(mcpOAuthKeyExpiresAt([org()], now)).toBe(new Date(now.getTime() + 90 * 86_400_000 - 60_000).toISOString())
expect(mcpOAuthKeyExpiresAt([org({ require_apikey_expiration: true, max_apikey_expiration_days: 7 })], now))
.toBe(new Date(now.getTime() + 7 * 86_400_000 - 60_000).toISOString())
expect(mcpOAuthKeyExpiresAt([org({ require_apikey_expiration: true, max_apikey_expiration_days: 365 })], now))
.toBe(new Date(now.getTime() + 90 * 86_400_000 - 60_000).toISOString())
})
it.concurrent('refuses to mint a key without organizations', async () => {
await expect(createMcpOAuthKey([], dependencies(), 'Lovable', now)).rejects.toThrow()
})
it.concurrent('names keys and recognizes the consent route', () => {
expect(mcpOAuthKeyName(' ')).toBe('MCP · client')
expect(isMcpAuthorizePath('/oauth/authorize/')).toBe(true)
expect(isMcpAuthorizePath('/oauth')).toBe(false)
expect(isAgentClaimPath('/agent/claim/')).toBe(true)
expect(isAgentClaimPath('/agent')).toBe(false)
})
})