diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8da9660ab..6a514e691 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,6 +124,30 @@ useAuthSetup(); API-dependent test cases must have `[api]` in the test name and live in `test/api/`. Files outside `test/api/` may mix local and `[api]` tests — the `test:local` script skips the `[api]` ones by name. +### `useKeyringBackend` + +`useAuthSetup` pins the file backend so tests never reach the real OS keyring. To cover the keyring backend instead, mock `@napi-rs/keyring` with the shared fake in `test/__setup__/keyring-mock.ts` and call `useKeyringBackend()` inside the `describe` that needs it. It must be nested inside a `describe`, so its `beforeEach` runs after the one `useAuthSetup` registers. Both backends can then live in one file. + +```typescript +import { useAuthSetup, useKeyringBackend } from "./__setup__/hooks/useAuthSetup.js"; +import { keyringStore, resetKeyringMock } from "./__setup__/keyring-mock.js"; + +vi.mock("@napi-rs/keyring", () => import("./__setup__/keyring-mock.js")); + +useAuthSetup(); +beforeEach(resetKeyringMock); + +describe("keyring backend", () => { + useKeyringBackend(); + + it("stores the token in the keyring", async () => { + // ... expect(keyringStore.get(KEYRING_TOKEN_KEY)).toBe("tok"); + }); +}); +``` + +The fake exposes `keyringStore` (the stored secrets), `keyringFailures` (keys whose write should throw), `keyringSetKeys` (keys of successful writes, in order) and `resetKeyringMock()` — the hook does not reset the fake for you, so call it yourself between tests. + ### `useTempPath` Creates (and cleans up) a temporary directory, and optionally mocks `process.cwd()` so commands run as if executed there. diff --git a/test/__setup__/hooks/useAuthSetup.ts b/test/__setup__/hooks/useAuthSetup.ts index 8753bfef0..aa0ae48e4 100644 --- a/test/__setup__/hooks/useAuthSetup.ts +++ b/test/__setup__/hooks/useAuthSetup.ts @@ -56,6 +56,24 @@ export function useAuthSetup({ cleanup = true, perTest = true }: UseAuthSetupOpt }); } +/** + * Switches the enclosing `describe` to the keyring backend, which {@link useAuthSetup} pins off. + * Throws unless the file mocks `@napi-rs/keyring` with `test/__setup__/keyring-mock.ts`. + */ +export function useKeyringBackend() { + beforeEach(async () => { + const keyring = await import('@napi-rs/keyring').catch(() => null); + if (!keyring || !('resetKeyringMock' in keyring)) { + throw new Error( + "useKeyringBackend() would write to the real OS keyring. Add vi.mock('@napi-rs/keyring', () => import('/keyring-mock.js')) to this file.", + ); + } + + vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); + __resetCredentialsForTests(); + }); +} + export async function safeLogin(tokenOverride?: string) { const { TEST_USER_TOKEN } = await import('../config.js'); diff --git a/test/__setup__/keyring-mock.ts b/test/__setup__/keyring-mock.ts new file mode 100644 index 000000000..902896793 --- /dev/null +++ b/test/__setup__/keyring-mock.ts @@ -0,0 +1,41 @@ +/** + * Fake `@napi-rs/keyring`. Install with + * `vi.mock('@napi-rs/keyring', () => import('/keyring-mock.js'))`. + */ + +export const KEYRING_TOKEN_KEY = 'com.apify.cli:token'; +export const KEYRING_PROXY_PASSWORD_KEY = 'com.apify.cli:proxy-password'; + +export const keyringStore = new Map(); + +export const keyringFailures = new Set(); + +export const keyringSetKeys: string[] = []; + +export class Entry { + private key: string; + + constructor(service: string, account: string) { + this.key = `${service}:${account}`; + } + + getPassword(): string | null { + return keyringStore.get(this.key) ?? null; + } + + setPassword(password: string): void { + if (keyringFailures.has(this.key)) throw new Error('simulated keyring failure'); + keyringStore.set(this.key, password); + keyringSetKeys.push(this.key); + } + + deletePassword(): boolean { + return keyringStore.delete(this.key); + } +} + +export function resetKeyringMock() { + keyringStore.clear(); + keyringFailures.clear(); + keyringSetKeys.length = 0; +} diff --git a/test/e2e/commands/auth/login.test.ts b/test/e2e/commands/auth/login.test.ts index 4a2b97f02..9fa51b2ec 100644 --- a/test/e2e/commands/auth/login.test.ts +++ b/test/e2e/commands/auth/login.test.ts @@ -33,6 +33,6 @@ describe('[e2e][api] auth login & token', () => { const result = await runCli('apify', ['auth', 'token'], { env: authEnv }); expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); - expect(result.stdout.trim().length).toBeGreaterThan(0); + expect(result.stdout.trim()).toBe(token); }); }); diff --git a/test/local/commands/auth.test.ts b/test/local/commands/auth.test.ts new file mode 100644 index 000000000..43a012881 --- /dev/null +++ b/test/local/commands/auth.test.ts @@ -0,0 +1,174 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import process from 'node:process'; + +import { AUTH_FILE_PATH } from '../../../src/lib/consts.js'; +import { getToken } from '../../../src/lib/credentials.js'; +import { useAuthSetup, useKeyringBackend } from '../../__setup__/hooks/useAuthSetup.js'; +import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; +import { + KEYRING_PROXY_PASSWORD_KEY, + KEYRING_TOKEN_KEY, + keyringSetKeys, + keyringStore, + resetKeyringMock, +} from '../../__setup__/keyring-mock.js'; + +vi.mock('@napi-rs/keyring', () => import('../../__setup__/keyring-mock.js')); + +const { clientState } = vi.hoisted(() => ({ + clientState: { + user: {} as Record, + fail: false, + }, +})); + +// Stubbing the client is what lets the auth commands run in test:local. +vi.mock('apify-client', async (importOriginal) => { + const actual = await importOriginal(); + + class FakeApifyClient { + token?: string; + + constructor(options: { token?: string }) { + this.token = options.token; + } + + user() { + return { + get: async () => { + if (clientState.fail) throw new Error('401'); + return clientState.user; + }, + }; + } + } + + return { ...actual, ApifyClient: FakeApifyClient }; +}); + +useAuthSetup(); +const { lastLogMessage, lastErrorMessage } = useConsoleSpy(); + +const { AuthLoginCommand } = await import('../../../src/commands/auth/login.js'); +const { AuthLogoutCommand } = await import('../../../src/commands/auth/logout.js'); +const { AuthTokenCommand } = await import('../../../src/commands/auth/token.js'); +const { testRunCommand } = await import('../../../src/lib/command-framework/apify-command.js'); + +const TOKEN = 'apify_api_test_token'; + +const readAuthFile = () => JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf-8')); +const login = (token = TOKEN) => testRunCommand(AuthLoginCommand, { flags_token: token }); + +describe('auth commands', () => { + beforeEach(() => { + resetKeyringMock(); + clientState.fail = false; + clientState.user = { + id: 'uid', + username: 'me', + proxy: { password: 'pw', groups: [{ name: 'g' }] }, + }; + }); + + describe('file backend', () => { + it('login stores the token and user metadata in auth.json', async () => { + await login(); + + expect(readAuthFile()).toMatchObject({ + token: TOKEN, + id: 'uid', + username: 'me', + secretsBackend: 'file', + }); + expect(lastErrorMessage()).toContain('You are logged in to Apify as me'); + }); + + it.skipIf(process.platform === 'win32')('login writes auth.json readable only by the owner', async () => { + await login(); + + expect(statSync(AUTH_FILE_PATH()).mode & 0o777).toBe(0o600); + }); + + it('auth token prints the stored token', async () => { + await login(); + await testRunCommand(AuthTokenCommand, {}); + + expect(lastLogMessage()).toBe(TOKEN); + }); + + it('logout removes the stored token and auth.json', async () => { + await login(); + await testRunCommand(AuthLogoutCommand, {}); + + expect(existsSync(AUTH_FILE_PATH())).toBe(false); + expect(await getToken()).toBeUndefined(); + }); + + it('logging in as another account replaces the stored metadata', async () => { + clientState.user = { id: 'uid', username: 'me', email: 'me@example.com' }; + await login(); + + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + const authFile = readAuthFile(); + expect(authFile).toMatchObject({ token: 'apify_api_other_token', id: 'uid2', username: 'other' }); + // Known gap: getLoggedClient merges, so the old account's extra fields survive. + expect(authFile.email).toBe('me@example.com'); + }); + + it('login with an invalid token stores nothing', async () => { + clientState.fail = true; + await login('bad-token'); + + expect(lastErrorMessage()).toContain('Login to Apify failed'); + expect(existsSync(AUTH_FILE_PATH())).toBe(false); + }); + }); + + describe('keyring backend', () => { + useKeyringBackend(); + + it('login stores the secrets in the keyring and keeps them out of auth.json', async () => { + await login(); + + expect(keyringStore.get(KEYRING_TOKEN_KEY)).toBe(TOKEN); + expect(keyringStore.get(KEYRING_PROXY_PASSWORD_KEY)).toBe('pw'); + + const authFile = readAuthFile(); + expect(authFile).toMatchObject({ id: 'uid', username: 'me', secretsBackend: 'keyring' }); + expect(authFile.token).toBeUndefined(); + expect(authFile.proxy).toEqual({ groups: [{ name: 'g' }] }); + }); + + it('login drops the proxy object from auth.json when it only held the password', async () => { + clientState.user.proxy = { password: 'pw' }; + await login(); + + expect(readAuthFile()).not.toHaveProperty('proxy'); + expect(keyringStore.get(KEYRING_PROXY_PASSWORD_KEY)).toBe('pw'); + }); + + it('logging in twice with the same token writes the keyring once', async () => { + await login(); + await login(); + + expect(keyringSetKeys.filter((key) => key === KEYRING_TOKEN_KEY)).toHaveLength(1); + }); + + it('auth token prints the token from the keyring', async () => { + await login(); + await testRunCommand(AuthTokenCommand, {}); + + expect(lastLogMessage()).toBe(TOKEN); + }); + + it('logout clears the keyring and removes auth.json', async () => { + await login(); + await testRunCommand(AuthLogoutCommand, {}); + + expect(keyringStore.size).toBe(0); + expect(existsSync(AUTH_FILE_PATH())).toBe(false); + }); + }); +}); diff --git a/test/local/lib/credentials.test.ts b/test/local/lib/credentials.test.ts index 4a0566ed8..d11d0f111 100644 --- a/test/local/lib/credentials.test.ts +++ b/test/local/lib/credentials.test.ts @@ -1,5 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; +import process from 'node:process'; import { cryptoRandomObjectId } from '@apify/utilities'; @@ -14,31 +15,27 @@ import { setProxyPassword, setToken, } from '../../../src/lib/credentials.js'; -import { getLocalUserInfo } from '../../../src/lib/utils.js'; - -const keyringStore = new Map(); -const keyringFailures = new Set(); - -vi.mock('@napi-rs/keyring', () => { - class Entry { - private key: string; - constructor(service: string, account: string) { - this.key = `${service}:${account}`; - } - getPassword(): string | null { - return keyringStore.get(this.key) ?? null; - } - setPassword(password: string): void { - if (keyringFailures.has(this.key)) throw new Error('simulated keyring failure'); - keyringStore.set(this.key, password); - } - deletePassword(): boolean { - return keyringStore.delete(this.key); - } - } - return { Entry }; +import { getApifyClientOptions, getLocalUserInfo } from '../../../src/lib/utils.js'; +import { + KEYRING_PROXY_PASSWORD_KEY, + KEYRING_TOKEN_KEY, + keyringFailures, + keyringSetKeys, + keyringStore, + resetKeyringMock, +} from '../../__setup__/keyring-mock.js'; + +vi.mock('@napi-rs/keyring', () => import('../../__setup__/keyring-mock.js')); + +// A rewrite is byte-identical, so only a spy can tell a skipped write from a repeated one. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, writeFileSync: vi.fn(actual.writeFileSync) }; }); +const writeFileSyncSpy = vi.mocked(writeFileSync); +const authFileWrites = () => writeFileSyncSpy.mock.calls.filter((call) => call[0] === AUTH_FILE_PATH()); + const writeAuthFile = (data: Record) => { mkdirSync(GLOBAL_CONFIGS_FOLDER(), { recursive: true }); writeFileSync(AUTH_FILE_PATH(), JSON.stringify(data)); @@ -49,8 +46,8 @@ const readAuthFile = () => JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf-8')); describe('credentials', () => { beforeEach(() => { vitest.stubEnv('__APIFY_INTERNAL_TEST_AUTH_PATH__', cryptoRandomObjectId(12)); - keyringStore.clear(); - keyringFailures.clear(); + resetKeyringMock(); + writeFileSyncSpy.mockClear(); __resetCredentialsForTests(); }); @@ -71,6 +68,12 @@ describe('credentials', () => { expect(await getBackend()).toBe('keyring'); }); + it('returns "file" when auth.json carries the marker, even if the keyring loads', async () => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); + writeAuthFile({ token: 'tok', secretsBackend: 'file' }); + expect(await getBackend()).toBe('file'); + }); + it('caches the backend choice for the rest of the process', async () => { vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); expect(await getBackend()).toBe('file'); @@ -104,19 +107,37 @@ describe('credentials', () => { expect(readAuthFile().proxy).toEqual({ password: 'new', groups: [{ name: 'g' }] }); }); - it('skipIfUnchanged is a no-op when the stored value matches', async () => { + it('skipIfUnchanged skips the write when the stored token matches', async () => { await setToken('tok_123'); - const before = readFileSync(AUTH_FILE_PATH(), 'utf-8'); + writeFileSyncSpy.mockClear(); await setToken('tok_123', { skipIfUnchanged: true }); - const after = readFileSync(AUTH_FILE_PATH(), 'utf-8'); - expect(after).toBe(before); + expect(authFileWrites()).toHaveLength(0); + }); + + it('skipIfUnchanged skips the write when the stored proxy password matches', async () => { + await setProxyPassword('pw_abc'); + writeFileSyncSpy.mockClear(); + await setProxyPassword('pw_abc', { skipIfUnchanged: true }); + expect(authFileWrites()).toHaveLength(0); }); it('skipIfUnchanged still writes when the value differs', async () => { await setToken('tok_123'); + writeFileSyncSpy.mockClear(); await setToken('tok_456', { skipIfUnchanged: true }); + expect(authFileWrites()).toHaveLength(1); expect(await getToken()).toBe('tok_456'); }); + + it('writes auth.json with mode 0600', async () => { + await setToken('tok_123'); + expect(writeFileSyncSpy).toHaveBeenCalledWith(AUTH_FILE_PATH(), expect.any(String), { mode: 0o600 }); + }); + + it.skipIf(process.platform === 'win32')('creates auth.json readable only by the owner', async () => { + await setToken('tok_123'); + expect(statSync(AUTH_FILE_PATH()).mode & 0o777).toBe(0o600); + }); }); describe('keyring backend', () => { @@ -127,14 +148,14 @@ describe('credentials', () => { it('round-trips the token through the keyring and keeps it out of auth.json', async () => { await setToken('tok_123'); expect(await getToken()).toBe('tok_123'); - expect(keyringStore.get('com.apify.cli:token')).toBe('tok_123'); + expect(keyringStore.get(KEYRING_TOKEN_KEY)).toBe('tok_123'); expect(existsSync(AUTH_FILE_PATH())).toBe(false); }); it('round-trips the proxy password through the keyring and keeps it out of auth.json', async () => { await setProxyPassword('pw_abc'); expect(await getProxyPassword()).toBe('pw_abc'); - expect(keyringStore.get('com.apify.cli:proxy-password')).toBe('pw_abc'); + expect(keyringStore.get(KEYRING_PROXY_PASSWORD_KEY)).toBe('pw_abc'); expect(existsSync(AUTH_FILE_PATH())).toBe(false); }); @@ -145,20 +166,62 @@ describe('credentials', () => { expect(await getToken()).toBeUndefined(); expect(await getProxyPassword()).toBeUndefined(); }); + + it('skipIfUnchanged skips the keyring write when the stored token matches', async () => { + await setToken('tok_123'); + await setToken('tok_123', { skipIfUnchanged: true }); + expect(keyringSetKeys.filter((key) => key === KEYRING_TOKEN_KEY)).toHaveLength(1); + expect(authFileWrites()).toHaveLength(0); + }); + + it('skipIfUnchanged skips the keyring write when the stored proxy password matches', async () => { + await setProxyPassword('pw_abc'); + await setProxyPassword('pw_abc', { skipIfUnchanged: true }); + expect(keyringSetKeys.filter((key) => key === KEYRING_PROXY_PASSWORD_KEY)).toHaveLength(1); + expect(authFileWrites()).toHaveLength(0); + }); + + it('falls back to auth.json when the keyring token write fails', async () => { + keyringFailures.add(KEYRING_TOKEN_KEY); + await setToken('tok_123'); + + expect(keyringStore.get(KEYRING_TOKEN_KEY)).toBeUndefined(); + expect(readAuthFile()).toEqual({ token: 'tok_123', secretsBackend: 'file' }); + expect(await getBackend()).toBe('file'); + expect(await getToken()).toBe('tok_123'); + }); + + it('keeps using auth.json for later writes after a keyring failure', async () => { + keyringFailures.add(KEYRING_TOKEN_KEY); + await setToken('tok_123'); + + await setProxyPassword('pw_abc'); + expect(keyringStore.get(KEYRING_PROXY_PASSWORD_KEY)).toBeUndefined(); + expect(readAuthFile().proxy).toEqual({ password: 'pw_abc' }); + }); + + it('falls back to auth.json when the keyring proxy password write fails', async () => { + keyringFailures.add(KEYRING_PROXY_PASSWORD_KEY); + await setProxyPassword('pw_abc'); + + expect(keyringStore.get(KEYRING_PROXY_PASSWORD_KEY)).toBeUndefined(); + expect(readAuthFile()).toEqual({ proxy: { password: 'pw_abc' }, secretsBackend: 'file' }); + expect(await getProxyPassword()).toBe('pw_abc'); + }); }); describe('clearKeyringSecrets()', () => { it('clears the keyring token entry even when APIFY_DISABLE_KEYRING=1 is set at logout time', async () => { vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); await setToken('tok_123'); - expect(keyringStore.get('com.apify.cli:token')).toBe('tok_123'); + expect(keyringStore.get(KEYRING_TOKEN_KEY)).toBe('tok_123'); __resetCredentialsForTests(); vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); expect(await getBackend()).toBe('file'); await clearKeyringSecrets(); - expect(keyringStore.get('com.apify.cli:token')).toBeUndefined(); + expect(keyringStore.get(KEYRING_TOKEN_KEY)).toBeUndefined(); }); }); @@ -170,6 +233,14 @@ describe('credentials', () => { expect(readAuthFile().token).toBe('tok'); }); + it('is a no-op when the marker says keyring and secrets are still in auth.json', async () => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); + writeAuthFile({ token: 'tok', proxy: { password: 'pw' }, secretsBackend: 'keyring' }); + await ensureMigrated(); + expect(keyringStore.get(KEYRING_TOKEN_KEY)).toBeUndefined(); + expect(readAuthFile()).toEqual({ token: 'tok', proxy: { password: 'pw' }, secretsBackend: 'keyring' }); + }); + it('is a no-op when there are no secrets to migrate', async () => { vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); await ensureMigrated(); @@ -190,8 +261,8 @@ describe('credentials', () => { vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); writeAuthFile({ token: 'tok', proxy: { password: 'pw' }, username: 'u' }); await ensureMigrated(); - expect(keyringStore.get('com.apify.cli:token')).toBe('tok'); - expect(keyringStore.get('com.apify.cli:proxy-password')).toBe('pw'); + expect(keyringStore.get(KEYRING_TOKEN_KEY)).toBe('tok'); + expect(keyringStore.get(KEYRING_PROXY_PASSWORD_KEY)).toBe('pw'); const file = readAuthFile(); expect(file.token).toBeUndefined(); expect(file.proxy).toBeUndefined(); @@ -203,7 +274,7 @@ describe('credentials', () => { vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); writeAuthFile({ token: 'tok', proxy: { password: 'pw', groups: [{ name: 'g' }] }, username: 'u' }); await ensureMigrated(); - expect(keyringStore.get('com.apify.cli:proxy-password')).toBe('pw'); + expect(keyringStore.get(KEYRING_PROXY_PASSWORD_KEY)).toBe('pw'); const file = readAuthFile(); expect(file.proxy).toEqual({ groups: [{ name: 'g' }] }); expect(file.secretsBackend).toBe('keyring'); @@ -213,7 +284,7 @@ describe('credentials', () => { vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); writeAuthFile({ proxy: { password: 'pw' }, username: 'u' }); await ensureMigrated(); - expect(keyringStore.get('com.apify.cli:proxy-password')).toBe('pw'); + expect(keyringStore.get(KEYRING_PROXY_PASSWORD_KEY)).toBe('pw'); const file = readAuthFile(); expect(file.proxy).toBeUndefined(); expect(file.username).toBe('u'); @@ -231,7 +302,7 @@ describe('credentials', () => { it('falls back to file backend when the proxy keyring write fails after token succeeds', async () => { vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); - keyringFailures.add('com.apify.cli:proxy-password'); + keyringFailures.add(KEYRING_PROXY_PASSWORD_KEY); writeAuthFile({ token: 'tok', proxy: { password: 'pw' }, username: 'u' }); await ensureMigrated(); const file = readAuthFile(); @@ -270,12 +341,55 @@ describe('credentials', () => { it('on keyring backend, overlays token and proxy password from keyring', async () => { vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); - keyringStore.set('com.apify.cli:token', 'tok_kr'); - keyringStore.set('com.apify.cli:proxy-password', 'pw_kr'); + keyringStore.set(KEYRING_TOKEN_KEY, 'tok_kr'); + keyringStore.set(KEYRING_PROXY_PASSWORD_KEY, 'pw_kr'); writeAuthFile({ username: 'me', id: 'uid', secretsBackend: 'keyring' }); const info = await getLocalUserInfo(); expect(info.token).toBe('tok_kr'); expect(info.proxy?.password).toBe('pw_kr'); }); + + it('returns an empty object when nothing is stored', async () => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + expect(await getLocalUserInfo()).toEqual({}); + }); + + it('on file backend, throws when a token is stored without user metadata', async () => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + writeAuthFile({ token: 'tok', secretsBackend: 'file' }); + await expect(getLocalUserInfo()).rejects.toThrow('Stale credentials found without user metadata'); + }); + + it('on keyring backend, throws when the keyring holds a token but auth.json is gone', async () => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); + keyringStore.set(KEYRING_TOKEN_KEY, 'tok_kr'); + await expect(getLocalUserInfo()).rejects.toThrow('Stale credentials found without user metadata'); + }); + }); + + describe('getApifyClientOptions()', () => { + beforeEach(() => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + }); + + it('resolves the stored token when nothing overrides it', async () => { + await setToken('tok_stored'); + expect((await getApifyClientOptions()).token).toBe('tok_stored'); + }); + + it('prefers an explicitly passed token over the stored one', async () => { + await setToken('tok_stored'); + expect((await getApifyClientOptions('tok_explicit')).token).toBe('tok_explicit'); + }); + + it('resolves a pre-migration auth.json and stamps the backend marker', async () => { + writeAuthFile({ username: 'me', id: 'uid', token: 'tok_legacy' }); + expect((await getApifyClientOptions()).token).toBe('tok_legacy'); + expect(readAuthFile().secretsBackend).toBe('file'); + }); + + it('resolves to undefined when no token is stored', async () => { + expect((await getApifyClientOptions()).token).toBeUndefined(); + }); }); });