Skip to content
Merged
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
56 changes: 39 additions & 17 deletions README.md

Large diffs are not rendered by default.

354 changes: 351 additions & 3 deletions src/auth/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CliError } from '../errors.js'
import { formatJson, formatNdjson } from '../json.js'
import {
type AttachAccountCurrentCommandOptions,
type AttachAccountListCommandOptions,
type AttachAccountRemoveCommandOptions,
type AttachAccountUseCommandOptions,
attachAccountCurrentCommand,
attachAccountListCommand,
attachAccountRemoveCommand,
attachAccountUseCommand,
} from './account.js'
import type { TokenStore } from './types.js'
Expand All @@ -33,6 +37,8 @@ function buildStore(initial: Entry[] = bothAccounts): {
store: TokenStore<Account>
listSpy: ReturnType<typeof vi.fn>
setDefaultSpy: ReturnType<typeof vi.fn>
activeSpy: ReturnType<typeof vi.fn>
clearSpy: ReturnType<typeof vi.fn>
} {
const entries = initial.map((entry) => ({ ...entry }))
const listSpy = vi.fn(async () =>
Expand All @@ -43,14 +49,37 @@ function buildStore(initial: Entry[] = bothAccounts): {
if (!target) throw new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`)
for (const entry of entries) entry.isDefault = entry === target
})
// No ref → the default entry (selector-less `current`); a ref → match by
// id/email/label. Returns null on miss so the attachers can translate it.
const activeSpy = vi.fn(async (ref?: string) => {
const target =
ref === undefined
? entries.find((entry) => entry.isDefault)
: entries.find((entry) => matches(entry, ref))
return target ? { token: `token-${target.account.id}`, account: target.account } : null
})
// Token-free removal: match by id/email/label (or the default when no ref),
// drop the entry, and report the removed account + its prior default bit —
// mirroring the `TokenStore.clear` contract `attachAccountRemoveCommand`
// relies on. Returns `null` on a miss (no-op).
const clearSpy = vi.fn(
async (ref?: string): Promise<{ account: Account; wasDefault: boolean } | null> => {
const idx = entries.findIndex((entry) =>
ref === undefined ? entry.isDefault : matches(entry, ref),
)
if (idx === -1) return null
const [removed] = entries.splice(idx, 1)
return { account: removed.account, wasDefault: removed.isDefault }
},
)
const store: TokenStore<Account> = {
active: vi.fn(async () => null),
active: activeSpy,
set: vi.fn(),
clear: vi.fn(),
clear: clearSpy,
list: listSpy,
setDefault: setDefaultSpy,
}
return { store, listSpy, setDefaultSpy }
return { store, listSpy, setDefaultSpy, activeSpy, clearSpy }
}

function buildList(
Expand Down Expand Up @@ -83,6 +112,36 @@ function buildUse(
return { program, command }
}

function buildCurrent(
overrides: Partial<AttachAccountCurrentCommandOptions<Account>> = {},
store?: TokenStore<Account>,
): { program: Command; command: Command } {
const resolvedStore = store ?? buildStore().store
const program = new Command()
program.exitOverride()
const account = program.command('account')
const command = attachAccountCurrentCommand<Account>(account, {
store: resolvedStore,
...overrides,
})
return { program, command }
}

function buildRemove(
overrides: Partial<AttachAccountRemoveCommandOptions<Account>> = {},
store?: TokenStore<Account>,
): { program: Command; command: Command } {
const resolvedStore = store ?? buildStore().store
const program = new Command()
program.exitOverride()
const account = program.command('account')
const command = attachAccountRemoveCommand<Account>(account, {
store: resolvedStore,
...overrides,
})
return { program, command }
}

describe('attachAccountListCommand', () => {
let logSpy: ReturnType<typeof vi.spyOn>

Expand Down Expand Up @@ -419,3 +478,292 @@ describe('attachAccountUseCommand', () => {
expect(command.name()).toBe('use')
})
})

describe('attachAccountCurrentCommand', () => {
let logSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
})

afterEach(() => {
logSpy.mockRestore()
})

it('renders the default human line with a (default) marker for the active account', async () => {
const { program } = buildCurrent()

await program.parseAsync(['node', 'cli', 'account', 'current'])

expect(logSpy).toHaveBeenCalledWith('Alice (id:1) (default)')
})

it('omits the marker when the active account is not the default', async () => {
const store: TokenStore<Account> = {
active: vi.fn(async () => ({ token: 't', account: a1 })),
set: vi.fn(),
clear: vi.fn(),
list: vi.fn(async () => [
{ account: a1, isDefault: false },
{ account: a2, isDefault: true },
]),
setDefault: vi.fn(),
}
const { program } = buildCurrent({}, store)

await program.parseAsync(['node', 'cli', 'account', 'current'])

expect(logSpy).toHaveBeenCalledWith('Alice (id:1)')
})

it('passes account + isDefault to a custom renderText', async () => {
const renderText = vi.fn(() => 'custom line')
const { program } = buildCurrent({ renderText })

await program.parseAsync(['node', 'cli', 'account', 'current'])

expect(renderText).toHaveBeenCalledWith({
account: a1,
isDefault: true,
view: { json: false, ndjson: false },
flags: {},
})
expect(logSpy).toHaveBeenCalledWith('custom line')
})

it('emits the default { account, isDefault } payload under --json', async () => {
Comment thread
scottlovegrove marked this conversation as resolved.
const { program } = buildCurrent()

await program.parseAsync(['node', 'cli', 'account', 'current', '--json'])

expect(logSpy).toHaveBeenCalledWith(formatJson({ account: a1, isDefault: true }))
})

it('shapes the --json payload via renderJson', async () => {
const renderJson = vi.fn(({ account }: { account: Account }) => ({ email: account.email }))
const { program } = buildCurrent({ renderJson })

await program.parseAsync(['node', 'cli', 'account', 'current', '--json'])

expect(renderJson).toHaveBeenCalledWith({ account: a1, isDefault: true, flags: {} })
expect(logSpy).toHaveBeenCalledWith(formatJson({ email: 'alice@b' }))
})

it('emits a single payload object under --ndjson', async () => {
const { program } = buildCurrent()

await program.parseAsync(['node', 'cli', 'account', 'current', '--ndjson'])

expect(logSpy).toHaveBeenCalledWith(formatNdjson([{ account: a1, isDefault: true }]))
})

it('prefers --json over --ndjson when both flags are passed', async () => {
const { program } = buildCurrent()

await program.parseAsync(['node', 'cli', 'account', 'current', '--json', '--ndjson'])

expect(logSpy).toHaveBeenCalledOnce()
expect(logSpy).toHaveBeenCalledWith(formatJson({ account: a1, isDefault: true }))
})

// Covers both non-serializable shapes: a top-level `undefined`
// (`JSON.stringify` returns `undefined`) and a value that makes
// `JSON.stringify` *throw* (a `BigInt`). Both must surface as INVALID_TYPE
// in either machine mode rather than leaking a raw TypeError.
it.each([
['top-level undefined', () => undefined],
['a throwing BigInt', () => ({ count: 1n })],
])('throws INVALID_TYPE in both machine modes for %s', async (_label, renderJson) => {
for (const mode of ['--json', '--ndjson'] as const) {
const { program } = buildCurrent({ renderJson })
await expect(
program.parseAsync(['node', 'cli', 'account', 'current', mode]),
).rejects.toMatchObject({ constructor: CliError, code: 'INVALID_TYPE' })
}
})

it('invokes onNotAuthenticated when nothing is active', async () => {
const onNotAuthenticated = vi.fn()
const { program } = buildCurrent({ onNotAuthenticated }, buildStore([]).store)

await program.parseAsync(['node', 'cli', 'account', 'current'])

expect(onNotAuthenticated).toHaveBeenCalledWith({
view: { json: false, ndjson: false },
flags: {},
})
expect(logSpy).not.toHaveBeenCalled()
})

it('throws NOT_AUTHENTICATED when nothing is active and no hook is supplied', async () => {
const { program } = buildCurrent({}, buildStore([]).store)

await expect(
program.parseAsync(['node', 'cli', 'account', 'current']),
).rejects.toMatchObject({ constructor: CliError, code: 'NOT_AUTHENTICATED' })
})

it('prefers store.activeAccount over active() + list() when implemented', async () => {
const built = buildStore()
built.store.activeAccount = vi.fn(async () => ({ account: a2, isDefault: false }))
const { program } = buildCurrent({}, built.store)

await program.parseAsync(['node', 'cli', 'account', 'current'])

expect(built.store.activeAccount).toHaveBeenCalledOnce()
expect(built.activeSpy).not.toHaveBeenCalled()
expect(built.listSpy).not.toHaveBeenCalled()
expect(logSpy).toHaveBeenCalledWith('Bob (id:2)')
})

it('returns the new Command so the consumer can chain', () => {
const { command } = buildCurrent()

expect(command.name()).toBe('current')
})
})

describe('attachAccountRemoveCommand', () => {
let logSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
})

afterEach(() => {
logSpy.mockRestore()
})

it('removes the matched account by ref and marks it as the former default', async () => {
const built = buildStore()
const { program } = buildRemove({}, built.store)

// Invoked by email; the store matches + clears it token-free.
await program.parseAsync(['node', 'cli', 'account', 'remove', 'alice@b'])

expect(built.clearSpy).toHaveBeenCalledWith('alice@b')
expect(await built.store.list()).toEqual([{ account: a2, isDefault: false }])
expect(logSpy).toHaveBeenCalledOnce()
expect(logSpy).toHaveBeenCalledWith('✓ Removed Alice (default)')
})

it('omits the (default) marker when the removed account was not the default', async () => {
const built = buildStore()
const { program } = buildRemove({}, built.store)

await program.parseAsync(['node', 'cli', 'account', 'remove', 'bob@b'])

expect(logSpy).toHaveBeenCalledOnce()
expect(logSpy).toHaveBeenCalledWith('✓ Removed Bob')
})

it('throws ACCOUNT_NOT_FOUND and removes nothing when the ref misses', async () => {
const built = buildStore()
const { program } = buildRemove({}, built.store)

await expect(
program.parseAsync(['node', 'cli', 'account', 'remove', 'ghost']),
).rejects.toMatchObject({ constructor: CliError, code: 'ACCOUNT_NOT_FOUND' })
expect(await built.store.list()).toHaveLength(2)
})

it('removes an account whose token is unreadable, never touching active()', async () => {
const built = buildStore()
// A broken keyring entry: `active()` would throw AUTH_STORE_READ_FAILED,
// but `remove` must still clear it. If the attacher called active() this
// would surface that error instead of removing the record.
built.store.active = vi.fn(async () => {
throw new CliError('AUTH_STORE_READ_FAILED', 'keyring offline')
})
const { program } = buildRemove({}, built.store)

await program.parseAsync(['node', 'cli', 'account', 'remove', 'alice@b'])

expect(built.store.active).not.toHaveBeenCalled()
expect(await built.store.list()).toEqual([{ account: a2, isDefault: false }])
expect(logSpy).toHaveBeenCalledWith('✓ Removed Alice (default)')
})

it('emits { ok, removed } with the canonical id under --json', async () => {
const { program } = buildRemove()

await program.parseAsync(['node', 'cli', 'account', 'remove', 'bob@b', '--json'])

expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, removed: '2' }))
})

it('prefers --json over --ndjson when both flags are passed', async () => {
const { program } = buildRemove()

await program.parseAsync([
'node',
'cli',
'account',
'remove',
'bob@b',
'--json',
'--ndjson',
])

expect(logSpy).toHaveBeenCalledOnce()
expect(logSpy).toHaveBeenCalledWith(formatJson({ ok: true, removed: '2' }))
})

it('is silent under --ndjson but still clears and runs onRemoved', async () => {
Comment thread
scottlovegrove marked this conversation as resolved.
const built = buildStore()
const onRemoved = vi.fn()
const { program } = buildRemove({ onRemoved }, built.store)

await program.parseAsync(['node', 'cli', 'account', 'remove', 'bob@b', '--ndjson'])

expect(await built.store.list()).toEqual([{ account: a1, isDefault: true }])
expect(logSpy).not.toHaveBeenCalled()
expect(onRemoved).toHaveBeenCalledOnce()
})

it('passes the removed account + wasDefault to renderText and onRemoved', async () => {
const renderText = vi.fn(() => 'gone')
const onRemoved = vi.fn()
const { program } = buildRemove({ renderText, onRemoved })

await program.parseAsync(['node', 'cli', 'account', 'remove', 'alice@b'])

const expectedCtx = {
account: a1,
ref: 'alice@b',
wasDefault: true,
view: { json: false, ndjson: false },
flags: {},
}
expect(renderText).toHaveBeenCalledWith(expectedCtx)
expect(onRemoved).toHaveBeenCalledWith(expectedCtx)
expect(logSpy).toHaveBeenCalledWith('gone')
})

it('emits the success line before awaiting onRemoved', async () => {
let releaseHook!: () => void
const hookGate = new Promise<void>((resolve) => {
releaseHook = resolve
})
const onRemoved = vi.fn(() => hookGate)
const { program } = buildRemove({ onRemoved })

const parsed = program
.parseAsync(['node', 'cli', 'account', 'remove', 'bob@b'])
.then(() => 'done')
await vi.waitFor(() => expect(onRemoved).toHaveBeenCalled())

// Success line is already out, but the command is still parked on the hook.
expect(logSpy).toHaveBeenCalledWith('✓ Removed Bob')
expect(await Promise.race([parsed, Promise.resolve('pending')])).toBe('pending')

releaseHook()
expect(await parsed).toBe('done')
})

it('returns the new Command so the consumer can chain', () => {
const { command } = buildRemove()

expect(command.name()).toBe('remove')
})
})
Loading
Loading