Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
460 changes: 460 additions & 0 deletions src/__tests__/accounts.test.ts

Large diffs are not rendered by default.

135 changes: 135 additions & 0 deletions src/__tests__/auth-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, mock, test } from 'bun:test'

mock.module('../plugin/sync/kiro-cli.js', () => ({
syncFromKiroCli: () => Promise.resolve(),
writeToKiroCli: () => Promise.resolve()
}))
mock.module('../kiro/auth.js', () => ({
decodeRefreshToken: (t: string) => ({ refreshToken: t }),
encodeRefreshToken: (p: any) => p.refreshToken,
accessTokenExpired: () => false
}))

import { AuthHandler } from '../core/auth/auth-handler.js'
import type { KiroAuthDetails, ManagedAccount } from '../plugin/types.js'

function makeAccount(overrides: Partial<ManagedAccount> = {}): ManagedAccount {
return {
id: 'acc-1',
email: 'test@example.com',
authMethod: 'idc',
region: 'eu-central-1',
refreshToken: 'r',
accessToken: 'a',
expiresAt: Date.now() + 3600000,
rateLimitResetTime: 0,
isHealthy: true,
failCount: 0,
lastUsed: 0,
usedCount: 0,
limitCount: 0,
...overrides
}
}

function makeAuth(): KiroAuthDetails {
return {
refresh: 'refresh-token',
access: 'access-token',
expires: Date.now() + 3600000, // not expired -> no refresh attempted
authMethod: 'idc',
region: 'eu-central-1',
profileArn: 'arn:aws:codewhisperer:eu-central-1:000000:profile/ABC'
}
}

function makeManager(acc: ManagedAccount) {
return {
getAccounts: () => [acc],
toAuthDetails: () => makeAuth(),
updateUsage: () => {}
}
}

const fakeRepo: any = {
batchSave: async () => {},
invalidateCache: () => {},
findAll: async () => []
}

const CREDIT_RESPONSE = JSON.stringify({
usageBreakdownList: [
{
freeTrialInfo: null,
currentUsage: 70,
currentUsageWithPrecision: 70.45,
usageLimit: 10000,
usageLimitWithPrecision: 10000,
displayNamePlural: 'Credits',
resourceType: 'CREDIT'
}
],
userInfo: { email: 'test@example.com' }
})

describe('AuthHandler.refreshUsageFromApi', () => {
test('fetches live usage and updates the account with dashboard credits', async () => {
const acc = makeAccount({ usedCount: 4292, limitCount: 10000 }) // stale prior-period value
const handler = new AuthHandler(
{ usage_tracking_enabled: true, token_expiry_buffer_ms: 300000, auto_sync_kiro_cli: false },
fakeRepo
)
handler.setAccountManager(makeManager(acc))

const original = globalThis.fetch
globalThis.fetch = mock(async () => new Response(CREDIT_RESPONSE, { status: 200 })) as any
try {
await handler.refreshUsageFromApi()
expect(acc.usedCount).toBe(70.45)
expect(acc.limitCount).toBe(10000)
} finally {
globalThis.fetch = original
}
})

test('keeps stored value when the live fetch fails', async () => {
const acc = makeAccount({ usedCount: 70.45, limitCount: 10000 })
const handler = new AuthHandler(
{ usage_tracking_enabled: true, token_expiry_buffer_ms: 300000, auto_sync_kiro_cli: false },
fakeRepo
)
handler.setAccountManager(makeManager(acc))

const original = globalThis.fetch
globalThis.fetch = mock(async () => new Response('boom', { status: 500 })) as any
try {
await handler.refreshUsageFromApi()
expect(acc.usedCount).toBe(70.45) // unchanged
} finally {
globalThis.fetch = original
}
})

test('is a one-time guard (skips the second call)', async () => {
const acc = makeAccount()
const handler = new AuthHandler(
{ usage_tracking_enabled: true, token_expiry_buffer_ms: 300000, auto_sync_kiro_cli: false },
fakeRepo
)
handler.setAccountManager(makeManager(acc))

let calls = 0
const original = globalThis.fetch
globalThis.fetch = mock(async () => {
calls++
return new Response(CREDIT_RESPONSE, { status: 200 })
}) as any
try {
await handler.refreshUsageFromApi()
await handler.refreshUsageFromApi()
expect(calls).toBe(1)
} finally {
globalThis.fetch = original
}
})
})
71 changes: 71 additions & 0 deletions src/__tests__/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from 'bun:test'
import { isPermanentError } from '../plugin/health.js'

describe('isPermanentError', () => {
test('returns false for undefined', () => {
expect(isPermanentError(undefined)).toBe(false)
})

test('returns false for empty string', () => {
expect(isPermanentError('')).toBe(false)
})

test('returns false for generic error', () => {
expect(isPermanentError('Internal Server Error')).toBe(false)
expect(isPermanentError('Rate limited')).toBe(false)
expect(isPermanentError('Network timeout')).toBe(false)
})

test('detects Invalid refresh token', () => {
expect(isPermanentError('Invalid refresh token')).toBe(true)
expect(isPermanentError('Error: Invalid refresh token provided')).toBe(true)
})

test('detects Invalid grant provided', () => {
expect(isPermanentError('Invalid grant provided')).toBe(true)
})

test('detects invalid_grant', () => {
expect(isPermanentError('invalid_grant')).toBe(true)
expect(isPermanentError('error: invalid_grant')).toBe(true)
})

test('detects ExpiredTokenException', () => {
expect(isPermanentError('ExpiredTokenException')).toBe(true)
expect(isPermanentError('AWS: ExpiredTokenException: token expired')).toBe(true)
})

test('detects InvalidTokenException', () => {
expect(isPermanentError('InvalidTokenException')).toBe(true)
})

test('detects ExpiredClientException', () => {
expect(isPermanentError('ExpiredClientException')).toBe(true)
})

test('detects Client is expired', () => {
expect(isPermanentError('Client is expired')).toBe(true)
})

test('detects HTTP_401', () => {
expect(isPermanentError('HTTP_401')).toBe(true)
expect(isPermanentError('error HTTP_401 Unauthorized')).toBe(true)
})

test('does not treat HTTP_403 as permanent (token expiry — should refresh, not reauth)', () => {
// HTTP_403 from Kiro means the access token expired mid-request.
// This is recoverable via token refresh, not a permanent error.
expect(isPermanentError('HTTP_403')).toBe(false)
expect(isPermanentError('error HTTP_403 Forbidden')).toBe(false)
})

test('does not treat bearer token invalid as permanent (handled in error-handler with refresh)', () => {
// bearer token invalid triggers a forced token refresh in ErrorHandler, not permanent unhealthy.
expect(isPermanentError('The bearer token included in the request is invalid')).toBe(false)
expect(isPermanentError('bearer token included in the request is invalid')).toBe(false)
})

test('detects Account Suspended', () => {
expect(isPermanentError('Account Suspended')).toBe(true)
})
})
131 changes: 131 additions & 0 deletions src/__tests__/kiro-cli-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, test } from 'bun:test'
import {
findClientCredsRecursive,
getCliDbPath,
makePlaceholderEmail,
normalizeExpiresAt,
safeJsonParse
} from '../plugin/sync/kiro-cli-parser.js'

// ── getCliDbPath ──────────────────────────────────────────────────────────────

describe('getCliDbPath', () => {
test('respects KIROCLI_DB_PATH override', () => {
process.env.KIROCLI_DB_PATH = '/custom/path.db'
expect(getCliDbPath()).toBe('/custom/path.db')
delete process.env.KIROCLI_DB_PATH
})

test('returns a string path without override', () => {
delete process.env.KIROCLI_DB_PATH
const path = getCliDbPath()
expect(typeof path).toBe('string')
expect(path.length).toBeGreaterThan(0)
})
})

// ── safeJsonParse ─────────────────────────────────────────────────────────────

describe('safeJsonParse', () => {
test('parses valid JSON string', () => {
expect(safeJsonParse('{"key":"value"}')).toEqual({ key: 'value' })
})

test('returns null for invalid JSON', () => {
expect(safeJsonParse('not json')).toBeNull()
expect(safeJsonParse('{')).toBeNull()
})

test('returns null for non-string input', () => {
expect(safeJsonParse(42)).toBeNull()
expect(safeJsonParse(null)).toBeNull()
expect(safeJsonParse(undefined)).toBeNull()
expect(safeJsonParse({})).toBeNull()
})
})

// ── normalizeExpiresAt ────────────────────────────────────────────────────────

describe('normalizeExpiresAt', () => {
test('ms timestamp stays as-is', () => {
const ms = 1700000000000
expect(normalizeExpiresAt(ms)).toBe(ms)
})

test('seconds timestamp is converted to ms', () => {
const sec = 1700000000 // < 10_000_000_000
expect(normalizeExpiresAt(sec)).toBe(sec * 1000)
})

test('ISO date string is converted to ms', () => {
const iso = '2024-01-01T00:00:00.000Z'
const expected = new Date(iso).getTime()
expect(normalizeExpiresAt(iso)).toBe(expected)
})

test('numeric string is converted', () => {
expect(normalizeExpiresAt('1700000000')).toBe(1700000000 * 1000)
})

test('returns 0 for invalid input', () => {
expect(normalizeExpiresAt(null)).toBe(0)
expect(normalizeExpiresAt('')).toBe(0)
expect(normalizeExpiresAt('not-a-date')).toBe(0)
})
})

// ── findClientCredsRecursive ──────────────────────────────────────────────────

describe('findClientCredsRecursive', () => {
test('finds flat clientId/clientSecret', () => {
const result = findClientCredsRecursive({ client_id: 'cid', client_secret: 'csec' })
expect(result).toEqual({ clientId: 'cid', clientSecret: 'csec' })
})

test('finds camelCase variant', () => {
const result = findClientCredsRecursive({ clientId: 'cid', clientSecret: 'csec' })
expect(result).toEqual({ clientId: 'cid', clientSecret: 'csec' })
})

test('finds nested credentials', () => {
const result = findClientCredsRecursive({
nested: { deeper: { client_id: 'n-id', client_secret: 'n-sec' } }
})
expect(result).toEqual({ clientId: 'n-id', clientSecret: 'n-sec' })
})

test('finds credentials inside array', () => {
const result = findClientCredsRecursive([
{ unrelated: true },
{ client_id: 'arr-id', client_secret: 'arr-sec' }
])
expect(result).toEqual({ clientId: 'arr-id', clientSecret: 'arr-sec' })
})

test('returns empty object when not found', () => {
expect(findClientCredsRecursive({})).toEqual({})
expect(findClientCredsRecursive(null)).toEqual({})
expect(findClientCredsRecursive('string')).toEqual({})
})
})

// ── makePlaceholderEmail ──────────────────────────────────────────────────────

describe('makePlaceholderEmail', () => {
test('returns a valid placeholder email', () => {
const email = makePlaceholderEmail('idc', 'eu-central-1', 'cid', 'arn')
expect(email).toMatch(/^idc-placeholder\+[a-f0-9]+@awsapps\.local$/)
})

test('same inputs produce same email (deterministic)', () => {
const a = makePlaceholderEmail('idc', 'us-east-1', 'c1', 'arn1')
const b = makePlaceholderEmail('idc', 'us-east-1', 'c1', 'arn1')
expect(a).toBe(b)
})

test('different inputs produce different emails', () => {
const a = makePlaceholderEmail('idc', 'us-east-1', 'c1', 'arn1')
const b = makePlaceholderEmail('idc', 'eu-central-1', 'c1', 'arn1')
expect(a).not.toBe(b)
})
})
Loading