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
11 changes: 10 additions & 1 deletion src/_fixtures/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MigrateAuthResult } from '@doist/cli-core/auth'
import type { Config } from '../lib/config.js'
import type { Config, StoredUser } from '../lib/config.js'
import type { OutlineAccount } from '../lib/outline-account.js'

/** Canonical persisted `OutlineAccount` used across auth tests. */
Expand All @@ -11,6 +11,15 @@ export const STORED_ACCOUNT: OutlineAccount = {
teamName: 'Analytics',
}

/** Snake-case `StoredUser` twin of {@link STORED_ACCOUNT}, derived so the two can't drift. */
export const STORED_USER_ADA: StoredUser = {
Comment thread
scottlovegrove marked this conversation as resolved.
id: STORED_ACCOUNT.id,
name: STORED_ACCOUNT.label,
base_url: STORED_ACCOUNT.baseUrl,
oauth_client_id: STORED_ACCOUNT.oauthClientId,
team_name: STORED_ACCOUNT.teamName,
}

/** Secondary persisted `OutlineAccount` on a different instance — for multi-account tests. */
export const STORED_ACCOUNT_BOB: OutlineAccount = {
id: 'bob-uuid',
Expand Down
35 changes: 35 additions & 0 deletions src/_fixtures/proxy-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const PROXY_ENV_KEYS = [
'HTTP_PROXY',
'http_proxy',
'HTTPS_PROXY',
'https_proxy',
'NO_PROXY',
'no_proxy',
] as const

type ProxyEnvSnapshot = Map<string, string | undefined>

/** Snapshot the current proxy env. Take this per suite so restore is order-independent. */
export function captureProxyEnv(): ProxyEnvSnapshot {
return new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]]))
}

/** Unset every proxy env var so transport-selection tests start from a clean slate. */
export function clearProxyEnv(): void {
for (const key of PROXY_ENV_KEYS) {
delete process.env[key]
}
}

/** Restore the proxy env to a snapshot taken by {@link captureProxyEnv}. */
export function restoreProxyEnv(snapshot: ProxyEnvSnapshot): void {
for (const key of PROXY_ENV_KEYS) {
const value = snapshot.get(key)
if (value === undefined) {
delete process.env[key]
continue
}

process.env[key] = value
}
}
40 changes: 40 additions & 0 deletions src/_fixtures/testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { type MockInstance, vi } from 'vitest'

/**
* A `captureConsole`/`captureStream` spy's recorded calls as one string per
* call (space-joined args, matching how chalk's styled fragments arrive).
*/
export function lines(spy: MockInstance): string[] {
return spy.mock.calls.map((args) => args.join(' '))
}

/** Same as {@link lines} but newline-joined into a single string. */
export function linesText(spy: MockInstance): string {
return lines(spy).join('\n')
}

/**
* Spy on `process.exit` so it throws `process.exit(<code>)` instead of killing
* the test runner. Pair with `.rejects.toThrow('process.exit(1)')`.
*/
export function mockProcessExit(): MockInstance {
return vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit(${code})`)
})
}

/**
* Default `../lib/auth.js` mock for command-surface tests: a logged-in
* config-file user on the test instance. Keys mirror the real `auth.ts`
* exports; pass `overrides` for the few tests that need dynamic behaviour.
* Use as `vi.mock('../lib/auth.js', () => mockOutlineAuthModule())`.
*/
export function mockOutlineAuthModule(overrides: Record<string, unknown> = {}) {
return {
getApiToken: async () => 'test-token',
getBaseUrl: async () => 'https://test.outline.com',
getOAuthClientId: async () => undefined,
getActiveTokenSource: async () => 'config-file' as const,
...overrides,
}
}
50 changes: 23 additions & 27 deletions src/commands/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { captureConsole, createTestProgram } from '@doist/cli-core/testing'
import type { Command } from 'commander'
import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { STORED_ACCOUNT, STORED_ACCOUNT_BOB } from '../_fixtures/auth.js'
import { linesText } from '../_fixtures/testing.js'
import { CliError } from '../lib/errors.js'

// `account` consumes two auth-provider exports: the token store (list/use/remove
Expand All @@ -25,15 +26,16 @@ vi.mock('../lib/auth-provider.js', async (importOriginal) => {
}
})

function lines(spy: MockInstance): string {
return spy.mock.calls.map((args) => args.join(' ')).join('\n')
}

async function buildProgram(): Promise<Command> {
const { registerAccountCommand } = await import('./account.js')
return createTestProgram(registerAccountCommand)
}

const STORED_LIST = [
{ account: STORED_ACCOUNT, isDefault: true },
{ account: STORED_ACCOUNT_BOB, isDefault: false },
]

let logSpy: MockInstance
let errSpy: MockInstance

Expand All @@ -55,13 +57,10 @@ afterEach(() => {
describe('account command', () => {
describe('list', () => {
it('renders all stored accounts with the default marker', async () => {
storeMocks.list.mockResolvedValue([
{ account: STORED_ACCOUNT, isDefault: true },
{ account: STORED_ACCOUNT_BOB, isDefault: false },
])
storeMocks.list.mockResolvedValue(STORED_LIST)
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account', 'list'])
const out = lines(logSpy)
const out = linesText(logSpy)
expect(out).toContain('Ada')
expect(out).toContain('Bob')
expect(out).toContain(`id:${STORED_ACCOUNT.id}`)
Expand All @@ -72,24 +71,21 @@ describe('account command', () => {
storeMocks.list.mockResolvedValue([])
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account', 'list'])
expect(lines(logSpy)).toMatch(/No stored accounts/)
expect(linesText(logSpy)).toMatch(/No stored accounts/)
})

it('runs by default when no subcommand is given (ol account)', async () => {
storeMocks.list.mockResolvedValue([{ account: STORED_ACCOUNT, isDefault: true }])
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account'])
expect(lines(logSpy)).toContain('Ada')
expect(linesText(logSpy)).toContain('Ada')
})

it('emits a {accounts, default} envelope under --json', async () => {
storeMocks.list.mockResolvedValue([
{ account: STORED_ACCOUNT, isDefault: true },
{ account: STORED_ACCOUNT_BOB, isDefault: false },
])
storeMocks.list.mockResolvedValue(STORED_LIST)
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account', 'list', '--json'])
const payload = JSON.parse(lines(logSpy))
const payload = JSON.parse(linesText(logSpy))
expect(payload.default).toBe(STORED_ACCOUNT.id)
expect(payload.accounts).toHaveLength(2)
expect(payload.accounts[0]).toMatchObject({ id: STORED_ACCOUNT.id, isDefault: true })
Expand All @@ -104,7 +100,7 @@ describe('account command', () => {
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account', 'use', STORED_ACCOUNT_BOB.id])
expect(storeMocks.setDefault).toHaveBeenCalledWith(STORED_ACCOUNT_BOB.id)
expect(lines(logSpy)).toContain(`Default account set to ${STORED_ACCOUNT_BOB.id}`)
expect(linesText(logSpy)).toContain(`Default account set to ${STORED_ACCOUNT_BOB.id}`)
})

it('propagates ACCOUNT_NOT_FOUND from setDefault for an unknown ref', async () => {
Expand All @@ -125,14 +121,14 @@ describe('account command', () => {
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account', 'remove', 'bob'])
expect(storeMocks.clear).toHaveBeenCalledWith('bob')
expect(lines(logSpy)).toContain('Removed Bob')
expect(linesText(logSpy)).toContain('Removed Bob')
})

it('notes a cleared default when removing the default account', async () => {
storeMocks.clear.mockResolvedValue({ account: STORED_ACCOUNT, wasDefault: true })
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account', 'remove', STORED_ACCOUNT.id])
const out = lines(logSpy)
const out = linesText(logSpy)
expect(out).toContain('Removed Ada')
expect(out).toMatch(/Cleared default account/)
})
Expand All @@ -153,7 +149,7 @@ describe('account command', () => {
})
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account', 'remove', STORED_ACCOUNT.id])
expect(lines(errSpy)).toContain('OS keyring unavailable')
expect(linesText(errSpy)).toContain('OS keyring unavailable')
})
})

Expand All @@ -166,7 +162,7 @@ describe('account command', () => {
})
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account', 'current'])
const out = lines(logSpy)
const out = linesText(logSpy)
expect(out).toContain('Ada')
expect(out).toContain(STORED_ACCOUNT.baseUrl)
})
Expand All @@ -179,7 +175,7 @@ describe('account command', () => {
})
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account', 'current', '--json'])
const payload = JSON.parse(lines(logSpy))
const payload = JSON.parse(linesText(logSpy))
expect(payload.source).toBe('stored')
expect(payload.account).toMatchObject({ id: STORED_ACCOUNT.id, isDefault: true })
expect(payload.account).not.toHaveProperty('oauthClientId')
Expand All @@ -198,33 +194,33 @@ describe('account command', () => {
const program = await buildProgram()
await program.parseAsync(['node', 'ol', 'account', 'current'])
expect(resolveMock).toHaveBeenCalledWith('Bob')
const out = lines(logSpy)
const out = linesText(logSpy)
expect(out).toContain('Bob')
expect(out).not.toContain('OUTLINE_API_TOKEN')
})

it('reports the env-token source (human + --json)', async () => {
resolveMock.mockResolvedValue({ source: 'env' })
await (await buildProgram()).parseAsync(['node', 'ol', 'account', 'current'])
expect(lines(logSpy)).toContain('OUTLINE_API_TOKEN')
expect(linesText(logSpy)).toContain('OUTLINE_API_TOKEN')

logSpy.mockClear()
resolveMock.mockResolvedValue({ source: 'env' })
await (await buildProgram()).parseAsync(['node', 'ol', 'account', 'current', '--json'])
expect(JSON.parse(lines(logSpy))).toEqual({ source: 'env' })
expect(JSON.parse(linesText(logSpy))).toEqual({ source: 'env' })
})

it('reports the legacy source (human + --json)', async () => {
resolveMock.mockResolvedValue({ source: 'legacy' })
await (await buildProgram()).parseAsync(['node', 'ol', 'account', 'current'])
expect(lines(logSpy)).toMatch(/legacy single-user credentials/)
expect(linesText(logSpy)).toMatch(/legacy single-user credentials/)

logSpy.mockClear()
resolveMock.mockResolvedValue({ source: 'legacy' })
await (
await buildProgram()
).parseAsync(['node', 'ol', 'account', 'current', '--ndjson'])
expect(JSON.parse(lines(logSpy))).toEqual({ source: 'legacy' })
expect(JSON.parse(linesText(logSpy))).toEqual({ source: 'legacy' })
})

it('throws NOT_AUTHENTICATED when nothing is active', async () => {
Expand Down
32 changes: 12 additions & 20 deletions src/commands/auth-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { captureConsole, createTestProgram } from '@doist/cli-core/testing'
import { Command } from 'commander'
import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTH_INFO, TWO_USER_CONFIG } from '../_fixtures/auth.js'

vi.mock('../lib/auth.js', () => ({
getApiToken: async () => 'test-token',
getBaseUrl: async () => 'https://test.outline.com',
getOAuthClientId: async () => undefined,
getActiveTokenSource: async () =>
process.env.OUTLINE_API_TOKEN ? ('env' as const) : ('secure-store' as const),
// status resolves the live token for the selected account via this; echo
// the snapshot token back (no refresh in these command-surface tests).
refreshedTokenForStatus: async (_account: unknown, fallback: string) => fallback,
}))
import { lines, mockOutlineAuthModule } from '../_fixtures/testing.js'

vi.mock('../lib/auth.js', () =>
mockOutlineAuthModule({
getActiveTokenSource: async () =>
process.env.OUTLINE_API_TOKEN ? ('env' as const) : ('secure-store' as const),
// status resolves the live token for the selected account via this; echo
// the snapshot token back (no refresh in these command-surface tests).
refreshedTokenForStatus: async (_account: unknown, fallback: string) => fallback,
}),
)

vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() }))

Expand All @@ -32,14 +32,6 @@ vi.mock('@doist/cli-core/auth', async () => ({
attachLoginCommand: vi.fn(),
}))

/**
* Read a `captureConsole` spy's recorded calls as joined lines, matching how
* chalk's styled fragments arrive (one console call → one space-joined line).
*/
function lines(spy: MockInstance): string[] {
return spy.mock.calls.map((args) => args.join(' '))
}

async function captureAttachOptions() {
const { attachLoginCommand } = await import('@doist/cli-core/auth')
const login = new Command('login')
Expand Down
11 changes: 4 additions & 7 deletions src/commands/auth-token.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { captureConsole, captureStream, createTestProgram } from '@doist/cli-core/testing'
import type { Command } from 'commander'
import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTH_INFO, STORED_ACCOUNT, STORED_ACCOUNT_BOB } from '../_fixtures/auth.js'
import { linesText } from '../_fixtures/testing.js'
import type { CliError } from '../lib/errors.js'

// `auth token` save drives the raw store's `set` + `getLastStorageResult`;
Expand All @@ -27,10 +28,6 @@ vi.mock('../lib/auth-provider.js', async (importOriginal) => {

vi.mock('../lib/api.js', () => ({ apiRequest: vi.fn() }))

function lines(spy: MockInstance): string {
return spy.mock.calls.map((args) => args.join(' ')).join('\n')
}

async function buildProgram(): Promise<Command> {
const { registerAuthCommand } = await import('./auth.js')
return createTestProgram(registerAuthCommand)
Expand Down Expand Up @@ -94,7 +91,7 @@ describe('auth token (save)', () => {
},
'tok-paste',
)
expect(lines(log)).toContain('Saved token for Ada Lovelace (Analytics)')
expect(linesText(log)).toContain('Saved token for Ada Lovelace (Analytics)')
})

it('collapses any auth.info failure into a leak-free AUTH_VERIFICATION_FAILED', async () => {
Expand Down Expand Up @@ -173,7 +170,7 @@ describe('auth token (save)', () => {
])

expect(storeMocks.set).toHaveBeenCalled()
expect(lines(log)).toEqual('')
expect(linesText(log)).toEqual('')
})
})

Expand Down
Loading
Loading