import { describe, expect, it, vi } from 'vitest'
import { IamHttpAdapter } from '../index'
type A = 'read'
type R = 'post'
type Ro = 'admin' | 'viewer' | 'editor'
type S = 'org-1' | 'org-2'
function makeJsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
json: async () => body,
} as unknown as Response
}
function buildAdapter(handler: (path: string) => unknown): IamHttpAdapter {
const fetch = vi.fn(async (url: string) => {
const path = new URL(url).pathname
return makeJsonResponse(handler(path))
}) as unknown as typeof globalThis.fetch
return new IamHttpAdapter({ baseUrl: 'https://api.example.com', fetch, retries: 0 })
}
describe('IamHttpAdapter subject-data shape validation', () => {
describe('getSubjectAttributes', () => {
it('rejects a string response (the corruption-as-string class)', async () => {
const adapter = buildAdapter(() => 'admin=true')
await expect(adapter.getSubjectAttributes('user-1')).rejects.toThrow(
/getSubjectAttributes for "user-1" returned string \(expected JSON object\)/,
)
})
it('rejects a null response (auth API server returned `null` for missing user)', async () => {
const adapter = buildAdapter(() => null)
await expect(adapter.getSubjectAttributes('user-1')).rejects.toThrow(
/getSubjectAttributes for "user-1" returned null/,
)
})
it('rejects an array response (server collapsed scoped+unscoped into one list)', async () => {
const adapter = buildAdapter(() => [])
await expect(adapter.getSubjectAttributes('user-1')).rejects.toThrow(
/getSubjectAttributes for "user-1" returned array/,
)
})
it('rejects a number response', async () => {
const adapter = buildAdapter(() => 42)
await expect(adapter.getSubjectAttributes('user-1')).rejects.toThrow(
/getSubjectAttributes for "user-1" returned number/,
)
})
it('accepts a valid object', async () => {
const adapter = buildAdapter(() => ({ tier: 'gold', verified: true }))
const attrs = await adapter.getSubjectAttributes('user-1')
expect(attrs).toEqual({ tier: 'gold', verified: true })
})
it('accepts an empty object', async () => {
const adapter = buildAdapter(() => ({}))
const attrs = await adapter.getSubjectAttributes('user-1')
expect(attrs).toEqual({})
})
})
describe('getSubjectRoles', () => {
it('rejects a string response (substring-bypass class)', async () => {
const adapter = buildAdapter(() => 'admin-extra')
await expect(adapter.getSubjectRoles('user-1')).rejects.toThrow(
/getSubjectRoles for "user-1" returned string \(expected JSON array\)/,
)
})
it('rejects an object response', async () => {
const adapter = buildAdapter(() => ({ 0: 'admin' }))
await expect(adapter.getSubjectRoles('user-1')).rejects.toThrow(/getSubjectRoles for "user-1" returned object/)
})
it('rejects a null response', async () => {
const adapter = buildAdapter(() => null)
await expect(adapter.getSubjectRoles('user-1')).rejects.toThrow(/getSubjectRoles for "user-1" returned null/)
})
it('accepts a valid string array', async () => {
const adapter = buildAdapter(() => ['admin', 'viewer'])
const roles = await adapter.getSubjectRoles('user-1')
expect(roles).toEqual(['admin', 'viewer'])
})
it('accepts an empty array', async () => {
const adapter = buildAdapter(() => [])
const roles = await adapter.getSubjectRoles('user-1')
expect(roles).toEqual([])
})
it('drops non-string entries silently (one bad row != full denial)', async () => {
const adapter = buildAdapter(() => ['admin', 42, null, 'viewer', { id: 'editor' }, ''])
const roles = await adapter.getSubjectRoles('user-1')
expect(roles).toEqual(['admin', 'viewer'])
})
})
describe('getSubjectScopedRoles', () => {
it('rejects a non-array response', async () => {
const adapter = buildAdapter(() => ({ role: 'admin', scope: 'org-1' }))
await expect(adapter.getSubjectScopedRoles('user-1')).rejects.toThrow(
/getSubjectScopedRoles for "user-1" returned object/,
)
})
it('accepts a valid array', async () => {
const adapter = buildAdapter(() => [
{ role: 'admin', scope: 'org-1' },
{ role: 'viewer', scope: 'org-2' },
])
const sr = await adapter.getSubjectScopedRoles('user-1')
expect(sr).toEqual([
{ role: 'admin', scope: 'org-1' },
{ role: 'viewer', scope: 'org-2' },
])
})
it('drops entries with missing or wrong-type role', async () => {
const adapter = buildAdapter(() => [
{ role: 'admin', scope: 'org-1' },
{ scope: 'org-2' }, // no role
{ role: 42, scope: 'org-2' }, // wrong type role
{ role: '', scope: 'org-2' }, // empty role
{ role: 'viewer', scope: 'org-2' },
])
const sr = await adapter.getSubjectScopedRoles('user-1')
expect(sr).toEqual([
{ role: 'admin', scope: 'org-1' },
{ role: 'viewer', scope: 'org-2' },
])
})
it('drops entries with missing or wrong-type scope (unscoped form belongs in getSubjectRoles)', async () => {
const adapter = buildAdapter(() => [
{ role: 'admin', scope: 'org-1' },
{ role: 'editor' }, // no scope
{ role: 'viewer', scope: 42 }, // wrong type scope
{ role: 'viewer', scope: '' }, // empty scope
])
const sr = await adapter.getSubjectScopedRoles('user-1')
expect(sr).toEqual([{ role: 'admin', scope: 'org-1' }])
})
it('drops null / primitive / array entries silently', async () => {
const adapter = buildAdapter(() => [null, 'admin', 42, [], { role: 'editor', scope: 'org-1' }])
const sr = await adapter.getSubjectScopedRoles('user-1')
expect(sr).toEqual([{ role: 'editor', scope: 'org-1' }])
})
})
describe('error text safety', () => {
it('error text names the subjectId but not the offending value', async () => {
const adapter = buildAdapter(() => 'attacker-payload-with-credentials')
try {
await adapter.getSubjectAttributes('user-99')
throw new Error('expected throw')
} catch (err) {
const msg = (err as Error).message
expect(msg).toContain('user-99')
expect(msg).toContain('string')
expect(msg).not.toContain('attacker-payload-with-credentials')
}
})
})
})