From f3f5d439e5b48e410c0c6684f433503dbcae9d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Thu, 10 Sep 2026 13:44:12 +0200 Subject: [PATCH 01/15] test: pin auth behavior before the multi-account refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage-0 of #1297. The keyring backend — the default for real users — was never reached through a command: useAuthSetup and run-cli both pin APIFY_DISABLE_KEYRING=1, and the auth commands only ran under test:api. - Shared @napi-rs/keyring fake in test/__setup__/keyring-mock.ts, plus a useKeyringBackend() hook so one file can cover both backends - New test/local/commands/auth.test.ts stubs apify-client, so login, logout and auth token now run in test:local on both backends - credentials.test.ts: writeFileSync spy so the skipIfUnchanged tests can fail, 0o600 assertions, keyring write failures for setToken and setProxyPassword, the stale-credentials throw, resolveToken happy path - e2e auth token now compares stdout to the token, not to length > 0 Every new test was mutation-verified against the branch it covers. Closes #1387 Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 24 ++++ test/__setup__/hooks/useAuthSetup.ts | 20 +++ test/__setup__/keyring-mock.ts | 45 +++++++ test/e2e/commands/auth/login.test.ts | 2 +- test/local/commands/auth.test.ts | 162 +++++++++++++++++++++++ test/local/lib/credentials.test.ts | 191 +++++++++++++++++++++------ 6 files changed, 402 insertions(+), 42 deletions(-) create mode 100644 test/__setup__/keyring-mock.ts create mode 100644 test/local/commands/auth.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8da9660ab..ec409fd7d 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. 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..41ce548c2 100644 --- a/test/__setup__/hooks/useAuthSetup.ts +++ b/test/__setup__/hooks/useAuthSetup.ts @@ -56,6 +56,26 @@ export function useAuthSetup({ cleanup = true, perTest = true }: UseAuthSetupOpt }); } +/** + * Switches the enclosing `describe` to the keyring backend, overriding the file backend + * that {@link useAuthSetup} pins. The file must mock `@napi-rs/keyring` with + * `test/__setup__/keyring-mock.ts`; without it the hook throws rather than let the test + * write to the real OS keyring. + */ +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..636217742 --- /dev/null +++ b/test/__setup__/keyring-mock.ts @@ -0,0 +1,45 @@ +/** + * Fake `@napi-rs/keyring` for tests. Install it with + * `vi.mock('@napi-rs/keyring', () => import('/keyring-mock.js'))` and import the + * state below normally — the factory's dynamic import resolves to the same module instance. + */ + +export const KEYRING_TOKEN_KEY = 'com.apify.cli:token'; +export const KEYRING_PROXY_PASSWORD_KEY = 'com.apify.cli:proxy-password'; + +/** Stored secrets, keyed `${service}:${account}`. */ +export const keyringStore = new Map(); + +/** Keys for which `setPassword` throws, so the file fallback can be exercised. */ +export const keyringFailures = new Set(); + +/** Keys of successful writes, in order. Lets tests count how often a secret was actually written. */ +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..cb8a8e17f --- /dev/null +++ b/test/local/commands/auth.test.ts @@ -0,0 +1,162 @@ +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, + }, +})); + +// The auth commands only need `client.token` and `user('me').get()`, so a stub client lets +// them run in test:local — no TEST_USER_TOKEN, no network. +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('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..edd5dd28a 100644 --- a/test/local/lib/credentials.test.ts +++ b/test/local/lib/credentials.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { cryptoRandomObjectId } from '@apify/utilities'; @@ -14,31 +14,28 @@ 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')); + +// Passthrough spy — lets the skipIfUnchanged tests tell "skipped the write" apart from +// "wrote the same bytes again", which comparing file contents cannot. +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(); }); @@ -104,19 +101,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 +142,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 +160,63 @@ 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'); + + // The proxy key never fails, but the backend already downgraded for the process. + 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 +228,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 +256,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 +269,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 +279,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 +297,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 +336,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(); + }); }); }); From 8c9dce4666ee13518b57e260d482dd50ac47d075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Thu, 10 Sep 2026 13:57:39 +0200 Subject: [PATCH 02/15] test: pin the marker branch and the account-switch merge From the second staff review: - getBackend() reading a persisted secretsBackend marker is what makes a keyring downgrade survive across processes; it had no test - every login in auth.test.ts used one identity, so the merge in getLoggedClient was never given a differing user. Stage-1 rewrites exactly that merge Also imports process in credentials.test.ts and notes the nesting constraint on useKeyringBackend. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 2 +- test/local/commands/auth.test.ts | 13 +++++++++++++ test/local/lib/credentials.test.ts | 7 +++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec409fd7d..6a514e691 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,7 +126,7 @@ API-dependent test cases must have `[api]` in the test name and live in `test/ap ### `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. Both backends can then live in one file. +`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"; diff --git a/test/local/commands/auth.test.ts b/test/local/commands/auth.test.ts index cb8a8e17f..862a2c922 100644 --- a/test/local/commands/auth.test.ts +++ b/test/local/commands/auth.test.ts @@ -105,6 +105,19 @@ describe('auth commands', () => { 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: fields the new account does not have survive the merge in getLoggedClient. + expect(authFile.email).toBe('me@example.com'); + }); + it('login with an invalid token stores nothing', async () => { clientState.fail = true; await login('bad-token'); diff --git a/test/local/lib/credentials.test.ts b/test/local/lib/credentials.test.ts index edd5dd28a..a639549b9 100644 --- a/test/local/lib/credentials.test.ts +++ b/test/local/lib/credentials.test.ts @@ -1,5 +1,6 @@ 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'; @@ -68,6 +69,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'); From 1e646ca8cfe5ab634e8c4d89e7433b244c77e913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Thu, 10 Sep 2026 14:02:57 +0200 Subject: [PATCH 03/15] test: trim comments in the new auth tests Co-Authored-By: Claude Opus 5 --- test/__setup__/hooks/useAuthSetup.ts | 6 ++---- test/__setup__/keyring-mock.ts | 8 ++------ test/local/commands/auth.test.ts | 5 ++--- test/local/lib/credentials.test.ts | 4 +--- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/test/__setup__/hooks/useAuthSetup.ts b/test/__setup__/hooks/useAuthSetup.ts index 41ce548c2..aa0ae48e4 100644 --- a/test/__setup__/hooks/useAuthSetup.ts +++ b/test/__setup__/hooks/useAuthSetup.ts @@ -57,10 +57,8 @@ export function useAuthSetup({ cleanup = true, perTest = true }: UseAuthSetupOpt } /** - * Switches the enclosing `describe` to the keyring backend, overriding the file backend - * that {@link useAuthSetup} pins. The file must mock `@napi-rs/keyring` with - * `test/__setup__/keyring-mock.ts`; without it the hook throws rather than let the test - * write to the real OS keyring. + * 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 () => { diff --git a/test/__setup__/keyring-mock.ts b/test/__setup__/keyring-mock.ts index 636217742..902896793 100644 --- a/test/__setup__/keyring-mock.ts +++ b/test/__setup__/keyring-mock.ts @@ -1,19 +1,15 @@ /** - * Fake `@napi-rs/keyring` for tests. Install it with - * `vi.mock('@napi-rs/keyring', () => import('/keyring-mock.js'))` and import the - * state below normally — the factory's dynamic import resolves to the same module instance. + * 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'; -/** Stored secrets, keyed `${service}:${account}`. */ export const keyringStore = new Map(); -/** Keys for which `setPassword` throws, so the file fallback can be exercised. */ export const keyringFailures = new Set(); -/** Keys of successful writes, in order. Lets tests count how often a secret was actually written. */ export const keyringSetKeys: string[] = []; export class Entry { diff --git a/test/local/commands/auth.test.ts b/test/local/commands/auth.test.ts index 862a2c922..43a012881 100644 --- a/test/local/commands/auth.test.ts +++ b/test/local/commands/auth.test.ts @@ -22,8 +22,7 @@ const { clientState } = vi.hoisted(() => ({ }, })); -// The auth commands only need `client.token` and `user('me').get()`, so a stub client lets -// them run in test:local — no TEST_USER_TOKEN, no network. +// Stubbing the client is what lets the auth commands run in test:local. vi.mock('apify-client', async (importOriginal) => { const actual = await importOriginal(); @@ -114,7 +113,7 @@ describe('auth commands', () => { const authFile = readAuthFile(); expect(authFile).toMatchObject({ token: 'apify_api_other_token', id: 'uid2', username: 'other' }); - // Known gap: fields the new account does not have survive the merge in getLoggedClient. + // Known gap: getLoggedClient merges, so the old account's extra fields survive. expect(authFile.email).toBe('me@example.com'); }); diff --git a/test/local/lib/credentials.test.ts b/test/local/lib/credentials.test.ts index a639549b9..d11d0f111 100644 --- a/test/local/lib/credentials.test.ts +++ b/test/local/lib/credentials.test.ts @@ -27,8 +27,7 @@ import { vi.mock('@napi-rs/keyring', () => import('../../__setup__/keyring-mock.js')); -// Passthrough spy — lets the skipIfUnchanged tests tell "skipped the write" apart from -// "wrote the same bytes again", which comparing file contents cannot. +// 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) }; @@ -196,7 +195,6 @@ describe('credentials', () => { keyringFailures.add(KEYRING_TOKEN_KEY); await setToken('tok_123'); - // The proxy key never fails, but the backend already downgraded for the process. await setProxyPassword('pw_abc'); expect(keyringStore.get(KEYRING_PROXY_PASSWORD_KEY)).toBeUndefined(); expect(readAuthFile().proxy).toEqual({ password: 'pw_abc' }); From 8072cee319e8b0361f89fe5b42ee4ee3feb123ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Fri, 11 Sep 2026 10:44:35 +0200 Subject: [PATCH 04/15] refactor: stop credential reads from writing credentials `getLoggedClient()` both resolved a token and persisted it. Any command given a token other than the stored one overwrote the stored login with it, and rewrote `username`/`id` to match, so a transient token replaced the saved account. Splits the pair: - `resolveAuth()` in the new `src/lib/auth.ts` reads only. Order is `--token` flag, then stored login. - `loginWithToken()` authenticates and saves. Only `apify login` calls it, and it now replaces the stored account instead of merging into it, so fields the new account lacks cannot linger from the old one. - `getLoggedClient()` verifies the token and returns a client. It writes nothing. Commands that read `username`/`id` to address API resources now go through `getCurrentUserInfo()`, which returns auth.json for a stored token and the account behind a one-off token otherwise. They previously relied on `getLoggedClient()` refreshing auth.json, which is the write that just went away. Co-Authored-By: Claude Opus 5 --- src/commands/actors/call.ts | 4 +- src/commands/actors/pull.ts | 4 +- src/commands/actors/push.ts | 4 +- src/commands/actors/search.ts | 3 +- src/commands/actors/start.ts | 4 +- src/commands/auth/login.ts | 5 +- src/commands/create.ts | 6 +- src/commands/datasets/get-items.ts | 4 +- src/commands/datasets/ls.ts | 4 +- src/commands/info.ts | 4 +- src/commands/key-value-stores/ls.ts | 4 +- src/commands/run.ts | 4 +- src/commands/task/publish.ts | 4 +- src/commands/task/run.ts | 4 +- src/commands/task/unpublish.ts | 4 +- src/lib/actor.ts | 3 +- src/lib/auth.ts | 109 ++++++++++++++++++ src/lib/commands/resolve-actor-context.ts | 4 +- src/lib/commands/storages.ts | 4 +- src/lib/utils.ts | 107 ++++++----------- test/__setup__/config.ts | 2 +- test/__setup__/hooks/useAuthSetup.ts | 7 +- test/e2e/commands/builds/lifecycle.test.ts | 2 +- test/e2e/commands/datasets/lifecycle.test.ts | 2 +- .../key-value-stores/lifecycle.test.ts | 2 +- test/e2e/commands/runs/lifecycle.test.ts | 2 +- test/local/commands/auth.test.ts | 4 +- test/local/lib/credentials.test.ts | 3 +- 28 files changed, 198 insertions(+), 115 deletions(-) create mode 100644 src/lib/auth.ts diff --git a/src/commands/actors/call.ts b/src/commands/actors/call.ts index e5f31e25d..01363f7ba 100644 --- a/src/commands/actors/call.ts +++ b/src/commands/actors/call.ts @@ -20,7 +20,7 @@ import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands import { finalizeRun, runUrl } from '../../lib/commands/run-result.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { error, simpleLog } from '../../lib/outputs.js'; -import { getLocalConfig, getLocalUserInfo, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; +import { getLocalConfig, getCurrentUserInfo, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; export class ActorsCallCommand extends ApifyCommand { static override name = 'call' as const; @@ -102,7 +102,7 @@ export class ActorsCallCommand extends ApifyCommand { const cwd = process.cwd(); const localConfig = getLocalConfig(cwd) || {}; const apifyClient = await getLoggedClientOrThrow(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const usernameOrId = userInfo.username || (userInfo.id as string); if (this.flags.json && this.flags.outputDataset) { diff --git a/src/commands/actors/pull.ts b/src/commands/actors/pull.ts index 3d5d34027..37b0ba5a9 100644 --- a/src/commands/actors/pull.ts +++ b/src/commands/actors/pull.ts @@ -12,7 +12,7 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { useActorConfig } from '../../lib/hooks/useActorConfig.js'; import { error, success } from '../../lib/outputs.js'; -import { downloadZip, getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { downloadZip, getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; const extractGitHubZip = async (url: string, directoryPath: string) => { const zipFile = await downloadZip(url); @@ -80,7 +80,7 @@ export class ActorsPullCommand extends ApifyCommand { const { config: actorConfig } = actorConfigResult.unwrap(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const apifyClient = await getLoggedClientOrThrow(); const isActorAutomaticallyDetected = !this.args.actorId; diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 2c7a5a7b5..e642c9a7d 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -29,7 +29,7 @@ import { createActZip, createSourceFiles, getActorLocalFilePaths, - getLocalUserInfo, + getCurrentUserInfo, getLoggedClientOrThrow, outputJobLog, parseWaitForFinishMillis, @@ -287,7 +287,7 @@ export class ActorsPushCommand extends ApifyCommand { const { config: actorConfig } = actorConfigResult.unwrap(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const isOrganizationLoggedIn = !!userInfo.organizationOwnerUserId; const redirectUrlPart = isOrganizationLoggedIn ? `/organization/${userInfo.id}` : ''; diff --git a/src/commands/actors/search.ts b/src/commands/actors/search.ts index 49712a95e..15b663354 100644 --- a/src/commands/actors/search.ts +++ b/src/commands/actors/search.ts @@ -1,13 +1,14 @@ import { ApifyClient } from 'apify-client'; import chalk from 'chalk'; +import { getApifyClientOptions } from '../../lib/auth.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; import { CommandExitCodes } from '../../lib/consts.js'; import { error, info, simpleLog } from '../../lib/outputs.js'; -import { getApifyClientOptions, printJsonToStdout } from '../../lib/utils.js'; +import { printJsonToStdout } from '../../lib/utils.js'; const pricingModelLabels: Record = { FREE: 'Free', diff --git a/src/commands/actors/start.ts b/src/commands/actors/start.ts index 164d04c6e..3ff5cb45e 100644 --- a/src/commands/actors/start.ts +++ b/src/commands/actors/start.ts @@ -12,7 +12,7 @@ import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands import { getConsoleUrl } from '../../lib/console-url.js'; import { LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { simpleLog } from '../../lib/outputs.js'; -import { getLocalConfig, getLocalUserInfo, getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; +import { getLocalConfig, getCurrentUserInfo, getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; import { ActorsCallCommand } from './call.js'; export class ActorsStartCommand extends ApifyCommand { @@ -73,7 +73,7 @@ export class ActorsStartCommand extends ApifyCommand const cwd = process.cwd(); const localConfig = getLocalConfig(cwd) || {}; const apifyClient = await getLoggedClientOrThrow(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const usernameOrId = userInfo.username || (userInfo.id as string); const { diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index d1799d1c6..ad8b89d7f 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -7,6 +7,7 @@ import open from 'open'; import { cryptoRandomObjectId } from '@apify/utilities'; +import { loginWithToken } from '../../lib/auth.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { getConsoleIntegrationsUrl, getConsoleUrl } from '../../lib/console-url.js'; @@ -17,7 +18,7 @@ import { useMaskedInput } from '../../lib/hooks/user-confirmations/useMaskedInpu import { useSelectFromList } from '../../lib/hooks/user-confirmations/useSelectFromList.js'; import { createLocalApiServer } from '../../lib/local-api-server.js'; import { error, info, success } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClient, tildify } from '../../lib/utils.js'; +import { getLocalUserInfo, tildify } from '../../lib/utils.js'; // When logging in against a local Console instance (local platform development), validate the token // against the local API rather than production. @@ -28,7 +29,7 @@ const API_VERSION = 'v1'; const tryToLogin = async (token: string) => { const apiBaseUrl = getConsoleUrl().includes('localhost') ? LOCAL_API_BASE_URL : undefined; - const isUserLogged = await getLoggedClient(token, apiBaseUrl); + const isUserLogged = await loginWithToken(token, apiBaseUrl); const userInfo = await getLocalUserInfo(); if (isUserLogged) { diff --git a/src/commands/create.ts b/src/commands/create.ts index 1e4f05183..1ddb2ef14 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -50,7 +50,7 @@ import { LANGUAGE_FLAG_CHOICES, USE_CASE_FLAG_CHOICES } from '../lib/templates/c import { downloadAndUnzip, getJsonFileContent, - getLocalUserInfo, + getCurrentUserInfo, getLoggedClientOrThrow, isNodeVersionSupported, isPythonVersionSupported, @@ -317,8 +317,8 @@ export class CreateCommand extends ApifyCommand { ? { provider: gitProvider, client: await getLoggedClientOrThrow(), - // Read after the client, which refreshes auth.json from the token the run resolved. - account: toGitAccount(await getLocalUserInfo()), + // Read after the client, whose lookup caches the account for the token the run resolved. + account: toGitAccount(await getCurrentUserInfo()), // Omitted means on: the webhook is what makes a Git-sourced Actor rebuild on a push. autoBuild: this.flags.autoBuild !== 'off', ...parseGitRepoFlag(gitRepo, actorName), diff --git a/src/commands/datasets/get-items.ts b/src/commands/datasets/get-items.ts index 93bf6847f..d06cf9555 100644 --- a/src/commands/datasets/get-items.ts +++ b/src/commands/datasets/get-items.ts @@ -4,7 +4,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { error, simpleLog } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; const downloadFormatToContentType: Record = { [DownloadItemsFormat.JSON]: 'application/json', @@ -106,7 +106,7 @@ export class DatasetsGetItems extends ApifyCommand { }; } - const info = await getLocalUserInfo(); + const info = await getCurrentUserInfo(); const byName = await client .dataset(`${info.username!}/${datasetId}`) diff --git a/src/commands/datasets/ls.ts b/src/commands/datasets/ls.ts index 2a6102b39..793647a0a 100644 --- a/src/commands/datasets/ls.ts +++ b/src/commands/datasets/ls.ts @@ -5,7 +5,7 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; import { info, simpleLog } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; const table = new ResponsiveTable({ allColumns: ['Dataset ID', 'Name', 'Items', 'Size', 'Created', 'Modified'], @@ -58,7 +58,7 @@ export class DatasetsLsCommand extends ApifyCommand { const { desc, offset, limit, json, unnamed } = this.flags; const client = await getLoggedClientOrThrow(); - const user = await getLocalUserInfo(); + const user = await getCurrentUserInfo(); const rawDatasetList = await client.datasets().list({ desc, offset, limit, unnamed }); diff --git a/src/commands/info.ts b/src/commands/info.ts index 63c78402d..9f043a259 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { ApifyCommand } from '../lib/command-framework/apify-command.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../lib/utils.js'; export class InfoCommand extends ApifyCommand { static override name = 'info' as const; @@ -21,7 +21,7 @@ export class InfoCommand extends ApifyCommand { async run() { await getLoggedClientOrThrow(); - const info = await getLocalUserInfo(); + const info = await getCurrentUserInfo(); if (info) { const niceInfo = { diff --git a/src/commands/key-value-stores/ls.ts b/src/commands/key-value-stores/ls.ts index 880d595af..e516df91d 100644 --- a/src/commands/key-value-stores/ls.ts +++ b/src/commands/key-value-stores/ls.ts @@ -5,7 +5,7 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; import { info, simpleLog } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; const table = new ResponsiveTable({ allColumns: ['Store ID', 'Name', 'Size', 'Created', 'Modified'], @@ -55,7 +55,7 @@ export class KeyValueStoresLsCommand extends ApifyCommand { async run() { const cwd = process.cwd(); - const { proxy, id: userId, token } = await getLocalUserInfo(); + const { proxy, id: userId, token } = await getCurrentUserInfo(); const localConfigResult = await useActorConfig({ cwd }); diff --git a/src/commands/task/publish.ts b/src/commands/task/publish.ts index bd43ea488..297f49fa3 100644 --- a/src/commands/task/publish.ts +++ b/src/commands/task/publish.ts @@ -7,7 +7,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { CommandExitCodes } from '../../lib/consts.js'; import { error, success } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskPublishCommand extends ApifyCommand { static override name = 'publish' as const; @@ -40,7 +40,7 @@ export class TaskPublishCommand extends ApifyCommand async run() { const apifyClient = await getLoggedClientOrThrow(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const usernameOrId = userInfo.username || (userInfo.id as string); const { taskId } = this.args; diff --git a/src/commands/task/run.ts b/src/commands/task/run.ts index e4e9add60..1e6d2055f 100644 --- a/src/commands/task/run.ts +++ b/src/commands/task/run.ts @@ -4,7 +4,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; import { finalizeRun } from '../../lib/commands/run-result.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskRunCommand extends ApifyCommand { static override name = 'run' as const; @@ -39,7 +39,7 @@ export class TaskRunCommand extends ApifyCommand { async run() { const apifyClient = await getLoggedClientOrThrow(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const usernameOrId = userInfo.username || (userInfo.id as string); const { id: taskId, userFriendlyId, title } = await this.resolveTaskId(apifyClient, usernameOrId); diff --git a/src/commands/task/unpublish.ts b/src/commands/task/unpublish.ts index 26c2a310e..b272dc60c 100644 --- a/src/commands/task/unpublish.ts +++ b/src/commands/task/unpublish.ts @@ -7,7 +7,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { CommandExitCodes } from '../../lib/consts.js'; import { error, success } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskUnpublishCommand extends ApifyCommand { static override name = 'unpublish' as const; @@ -39,7 +39,7 @@ export class TaskUnpublishCommand extends ApifyCommand stored login. + * + * Read-only by contract: no caller of this function persists anything. Only `apify login` + * writes credentials, through {@link loginWithToken}. + */ +export const resolveAuth = async (explicitToken?: string): Promise => { + if (explicitToken) { + return { token: explicitToken, source: 'flag' }; + } + + await ensureMigrated(); + + const storedToken = await getToken(); + if (storedToken) { + return { token: storedToken, source: 'stored' }; + } + + return undefined; +}; + +type CJSAxiosHeaders = import('axios', { with: { 'resolution-mode': 'require' } }).AxiosRequestConfig['headers']; + +/** + * Returns options for ApifyClient + */ +export const getApifyClientOptions = async (token?: string, apiBaseUrl?: string): Promise => { + const auth = await resolveAuth(token); + + return { + token: auth?.token, + baseUrl: apiBaseUrl || process.env.APIFY_CLIENT_BASE_URL, + requestInterceptors: [ + (config) => { + config.headers ??= new AxiosHeaders() as CJSAxiosHeaders; + + for (const [key, value] of Object.entries(APIFY_CLIENT_DEFAULT_HEADERS)) { + config.headers![key] = value; + } + + return config; + }, + ], + }; +}; + +/** + * Authenticates `token` and saves it together with the account metadata. This is the only + * credential writer in the CLI — every other code path resolves tokens without persisting them. + * + * Returns `null` when the token is rejected, in which case nothing is written. + */ +export async function loginWithToken(token: string, apiBaseUrl?: string): Promise { + const apifyClient = new ApifyClient(await getApifyClientOptions(token, apiBaseUrl)); + + let userInfo; + try { + userInfo = await apifyClient.user('me').get(); + } catch (err) { + cliDebugPrint('[loginWithToken] error getting user info', { error: err, apiBaseUrl }); + return null; + } + + // Replaces the previous account rather than merging into it, so fields the new account + // does not have (email, organizationOwnerUserId) cannot linger from the old one. + const fileContents: Record = { ...userInfo, secretsBackend: await getBackend() }; + delete fileContents.token; + if (fileContents.proxy && typeof fileContents.proxy === 'object') { + const { password: _password, ...rest } = fileContents.proxy as { password?: string }; + if (Object.keys(rest).length > 0) { + fileContents.proxy = rest; + } else { + delete fileContents.proxy; + } + } + + ensureApifyDirectory(AUTH_FILE_PATH()); + writeFileSync(AUTH_FILE_PATH(), JSON.stringify(fileContents, null, '\t'), { mode: 0o600 }); + + // Secrets are written after the metadata file so the file backend, which stores them in + // auth.json too, is not overwritten. `skipIfUnchanged` avoids a macOS Keychain prompt + // when the value already matches. + await setToken(token, { skipIfUnchanged: true }); + + const proxyPassword = userInfo.proxy?.password; + if (proxyPassword) { + await setProxyPassword(proxyPassword, { skipIfUnchanged: true }); + } + + return apifyClient; +} diff --git a/src/lib/commands/resolve-actor-context.ts b/src/lib/commands/resolve-actor-context.ts index 22d45e34a..bc0ed27c7 100644 --- a/src/lib/commands/resolve-actor-context.ts +++ b/src/lib/commands/resolve-actor-context.ts @@ -2,7 +2,7 @@ import process from 'node:process'; import type { ApifyClient } from 'apify-client'; -import { getLocalConfig, getLocalUserInfo } from '../utils.js'; +import { getLocalConfig, getCurrentUserInfo } from '../utils.js'; /** * Tries to resolve what actor the command ran points to. If an actor id is provided via command line, attempt to resolve it, @@ -17,7 +17,7 @@ export async function resolveActorContext({ providedActorNameOrId: string | undefined; client: ApifyClient; }) { - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const usernameOrId = userInfo.username || (userInfo.id as string); const localConfig = getLocalConfig(process.cwd()) || {}; diff --git a/src/lib/commands/storages.ts b/src/lib/commands/storages.ts index f3d043de3..1fbd6cdc7 100644 --- a/src/lib/commands/storages.ts +++ b/src/lib/commands/storages.ts @@ -1,6 +1,6 @@ import type { ApifyClient, Dataset, DatasetClient, KeyValueStore, KeyValueStoreClient } from 'apify-client'; -import { getLocalUserInfo } from '../utils.js'; +import { getCurrentUserInfo } from '../utils.js'; type ReturnTypeForStorage = T extends 'dataset' ? { @@ -25,7 +25,7 @@ async function tryToGetStorage( } as ReturnTypeForStorage; } - const info = await getLocalUserInfo(); + const info = await getCurrentUserInfo(); const byName = await client[storageType](`${info.username!}/${id}`) .get() diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 73502f9be..284bb1873 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -9,9 +9,9 @@ import { DurationFormatter as SapphireDurationFormatter, TimeTypes } from '@sapp import { Timestamp } from '@sapphire/timestamp'; import AdmZip from 'adm-zip'; import _Ajv2019 from 'ajv/dist/2019.js'; -import { type ActorRun, ApifyClient, type ApifyClientOptions, type Build } from 'apify-client'; +import { type ActorRun, ApifyClient, type Build } from 'apify-client'; import { ZipArchive } from 'archiver'; -import axios, { AxiosHeaders } from 'axios'; +import axios from 'axios'; import escapeStringRegexp from 'escape-string-regexp'; import ignoreModule, { type Ignore } from 'ignore'; import { getEncoding } from 'istextorbinary'; @@ -32,8 +32,8 @@ import { SOURCE_FILE_FORMATS, } from '@apify/consts'; +import { getApifyClientOptions, resolveAuth } from './auth.js'; import { - APIFY_CLIENT_DEFAULT_HEADERS, AUTH_FILE_PATH, CommandExitCodes, DEFAULT_LOCAL_STORAGE_DIR, @@ -41,8 +41,8 @@ import { MINIMUM_SUPPORTED_PYTHON_VERSION, SUPPORTED_NODEJS_VERSION, } from './consts.js'; -import { ensureMigrated, getBackend, getProxyPassword, getToken, setProxyPassword, setToken } from './credentials.js'; -import { deleteFile, ensureApifyDirectory, ensureFolderExistsSync, rimrafPromised } from './files.js'; +import { ensureMigrated, getBackend, getProxyPassword, getToken } from './credentials.js'; +import { deleteFile, ensureFolderExistsSync, rimrafPromised } from './files.js'; import { useCLIMetadata } from './hooks/useCLIMetadata.js'; import { inputFileRegExp, TEMP_INPUT_KEY_PREFIX } from './input-key.js'; import type { AuthJSON } from './types.js'; @@ -129,89 +129,54 @@ export async function getLoggedClientOrThrow() { return loggedClient; } -const resolveToken = async (existingToken?: string): Promise => { - if (existingToken) return existingToken; - await ensureMigrated(); - return getToken(); -}; - -type CJSAxiosHeaders = import('axios', { with: { 'resolution-mode': 'require' } }).AxiosRequestConfig['headers']; +let cachedUserInfo: { token: string; userInfo: AuthJSON } | undefined; -/** - * Returns options for ApifyClient - */ -export const getApifyClientOptions = async (token?: string, apiBaseUrl?: string): Promise => { - const resolvedToken = await resolveToken(token); - - return { - token: resolvedToken, - baseUrl: apiBaseUrl || process.env.APIFY_CLIENT_BASE_URL, - requestInterceptors: [ - (config) => { - config.headers ??= new AxiosHeaders() as CJSAxiosHeaders; - - for (const [key, value] of Object.entries(APIFY_CLIENT_DEFAULT_HEADERS)) { - config.headers![key] = value; - } - - return config; - }, - ], - }; -}; +/** Test-only: drop the in-memory account metadata so each test starts fresh. */ +export function __resetUserInfoCacheForTests() { + cachedUserInfo = undefined; +} /** - * Gets instance of ApifyClient for token or for params from global auth file. + * Gets instance of ApifyClient for the token the current command resolved, or `null` when no + * token is available or the API rejected it. * - * Refreshes the user metadata in auth.json each run. Secrets (token, proxy.password) only - * get written when their value actually changes — avoids macOS Keychain prompts on every command. + * Read-only: the resolved token is never persisted. Only `apify login` writes credentials. */ export async function getLoggedClient(token?: string, apiBaseUrl?: string) { - const resolvedToken = await resolveToken(token); + const auth = await resolveAuth(token); + if (!auth) return null; - const apifyClient = new ApifyClient(await getApifyClientOptions(resolvedToken, apiBaseUrl)); + const apifyClient = new ApifyClient(await getApifyClientOptions(auth.token, apiBaseUrl)); - let userInfo; try { - userInfo = await apifyClient.user('me').get(); + const userInfo = (await apifyClient.user('me').get()) as AuthJSON; + cachedUserInfo = { token: auth.token, userInfo }; } catch (err) { cliDebugPrint('[getLoggedClient] error getting user info', { error: err, apiBaseUrl }); return null; } - if (apifyClient.token) { - await setToken(apifyClient.token, { skipIfUnchanged: true }); - } + return apifyClient; +} - const proxyPassword = userInfo.proxy?.password; - if (proxyPassword) { - await setProxyPassword(proxyPassword, { skipIfUnchanged: true }); - } +/** + * Account metadata for the token the current command resolved. + * + * A one-off `--token` has no entry in auth.json, so the account is read from the API instead. + * In practice the value is already cached by the {@link getLoggedClient} call such commands + * make first. + */ +export async function getCurrentUserInfo(): Promise { + const auth = await resolveAuth(); + if (!auth || auth.source === 'stored') return getLocalUserInfo(); - ensureApifyDirectory(AUTH_FILE_PATH()); - const existingFile = (() => { - try { - return JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf-8')) as Record; - } catch { - return {}; - } - })(); - const backend = await getBackend(); - const fileContents: Record = { ...existingFile, ...userInfo, secretsBackend: backend }; - if (backend === 'keyring') { - delete fileContents.token; - if (fileContents.proxy && typeof fileContents.proxy === 'object') { - const { password: _password, ...rest } = fileContents.proxy as { password?: string }; - if (Object.keys(rest).length > 0) { - fileContents.proxy = rest; - } else { - delete fileContents.proxy; - } - } - } - writeFileSync(AUTH_FILE_PATH(), JSON.stringify(fileContents, null, '\t'), { mode: 0o600 }); + if (cachedUserInfo?.token === auth.token) return cachedUserInfo.userInfo; - return apifyClient; + const apifyClient = new ApifyClient(await getApifyClientOptions(auth.token)); + const userInfo = (await apifyClient.user('me').get()) as AuthJSON; + cachedUserInfo = { token: auth.token, userInfo }; + + return userInfo; } export const getLocalConfigPath = (cwd: string) => join(cwd, LOCAL_CONFIG_PATH); diff --git a/test/__setup__/config.ts b/test/__setup__/config.ts index 3a62b6eeb..f0420ff5a 100644 --- a/test/__setup__/config.ts +++ b/test/__setup__/config.ts @@ -3,7 +3,7 @@ import { EOL } from 'node:os'; import { ApifyClient } from 'apify-client'; import { isCI } from 'ci-info'; -import { getApifyClientOptions } from '../../src/lib/utils.js'; +import { getApifyClientOptions } from '../../src/lib/auth.js'; const { TEST_USER_TOKEN: ENV_TEST_USER_TOKEN } = process.env; diff --git a/test/__setup__/hooks/useAuthSetup.ts b/test/__setup__/hooks/useAuthSetup.ts index aa0ae48e4..9e0907b48 100644 --- a/test/__setup__/hooks/useAuthSetup.ts +++ b/test/__setup__/hooks/useAuthSetup.ts @@ -9,7 +9,7 @@ import { LoginCommand } from '../../../src/commands/login.js'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; import { GLOBAL_CONFIGS_FOLDER } from '../../../src/lib/consts.js'; import { __resetCredentialsForTests } from '../../../src/lib/credentials.js'; -import { getLocalUserInfo } from '../../../src/lib/utils.js'; +import { __resetUserInfoCacheForTests, getLocalUserInfo } from '../../../src/lib/utils.js'; export interface UseAuthSetupOptions { /** @@ -43,7 +43,10 @@ export function useAuthSetup({ cleanup = true, perTest = true }: UseAuthSetupOpt // Tests pin to the file backend so they don't touch the real OS keyring. // Unit tests for credentials.ts override this explicitly. vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + // The resolver reads APIFY_TOKEN, so a token in the developer's shell would leak into tests. + vitest.stubEnv('APIFY_TOKEN', ''); __resetCredentialsForTests(); + __resetUserInfoCacheForTests(); }); after(async () => { @@ -52,6 +55,7 @@ export function useAuthSetup({ cleanup = true, perTest = true }: UseAuthSetupOpt } __resetCredentialsForTests(); + __resetUserInfoCacheForTests(); vitest.unstubAllEnvs(); }); } @@ -71,6 +75,7 @@ export function useKeyringBackend() { vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); __resetCredentialsForTests(); + __resetUserInfoCacheForTests(); }); } diff --git a/test/e2e/commands/builds/lifecycle.test.ts b/test/e2e/commands/builds/lifecycle.test.ts index 29e9c5644..154b4ec1b 100644 --- a/test/e2e/commands/builds/lifecycle.test.ts +++ b/test/e2e/commands/builds/lifecycle.test.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { ApifyClient } from 'apify-client'; -import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { getApifyClientOptions } from '../../../../src/lib/auth.js'; import { runCli } from '../../__helpers__/run-cli.js'; import { createTestActor, removeTestActor, type TestActor } from '../../__helpers__/test-actor.js'; diff --git a/test/e2e/commands/datasets/lifecycle.test.ts b/test/e2e/commands/datasets/lifecycle.test.ts index b0b2deecf..656c0b754 100644 --- a/test/e2e/commands/datasets/lifecycle.test.ts +++ b/test/e2e/commands/datasets/lifecycle.test.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { ApifyClient } from 'apify-client'; -import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { getApifyClientOptions } from '../../../../src/lib/auth.js'; import { runCli } from '../../__helpers__/run-cli.js'; describe('[e2e][api] datasets namespace', () => { diff --git a/test/e2e/commands/key-value-stores/lifecycle.test.ts b/test/e2e/commands/key-value-stores/lifecycle.test.ts index f01399a4b..0c477243c 100644 --- a/test/e2e/commands/key-value-stores/lifecycle.test.ts +++ b/test/e2e/commands/key-value-stores/lifecycle.test.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { ApifyClient } from 'apify-client'; -import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { getApifyClientOptions } from '../../../../src/lib/auth.js'; import { runCli } from '../../__helpers__/run-cli.js'; describe('[e2e][api] key-value-stores namespace', () => { diff --git a/test/e2e/commands/runs/lifecycle.test.ts b/test/e2e/commands/runs/lifecycle.test.ts index aa99a05ad..cb2ff4a21 100644 --- a/test/e2e/commands/runs/lifecycle.test.ts +++ b/test/e2e/commands/runs/lifecycle.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { ApifyClient } from 'apify-client'; -import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { getApifyClientOptions } from '../../../../src/lib/auth.js'; import { runCli } from '../../__helpers__/run-cli.js'; import { createTestActor, removeTestActor, type TestActor } from '../../__helpers__/test-actor.js'; import { TestTmpRoot } from '../../__helpers__/tmp.js'; diff --git a/test/local/commands/auth.test.ts b/test/local/commands/auth.test.ts index 43a012881..72bd10d18 100644 --- a/test/local/commands/auth.test.ts +++ b/test/local/commands/auth.test.ts @@ -113,8 +113,8 @@ describe('auth commands', () => { 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'); + // The new account has no email, so the old one must not linger. + expect(authFile.email).toBeUndefined(); }); it('login with an invalid token stores nothing', async () => { diff --git a/test/local/lib/credentials.test.ts b/test/local/lib/credentials.test.ts index d11d0f111..771c90ed5 100644 --- a/test/local/lib/credentials.test.ts +++ b/test/local/lib/credentials.test.ts @@ -4,6 +4,7 @@ import process from 'node:process'; import { cryptoRandomObjectId } from '@apify/utilities'; +import { getApifyClientOptions } from '../../../src/lib/auth.js'; import { AUTH_FILE_PATH, GLOBAL_CONFIGS_FOLDER } from '../../../src/lib/consts.js'; import { __resetCredentialsForTests, @@ -15,7 +16,7 @@ import { setProxyPassword, setToken, } from '../../../src/lib/credentials.js'; -import { getApifyClientOptions, getLocalUserInfo } from '../../../src/lib/utils.js'; +import { getLocalUserInfo } from '../../../src/lib/utils.js'; import { KEYRING_PROXY_PASSWORD_KEY, KEYRING_TOKEN_KEY, From ec223d21674fb839e97a0760dd876b4cca0155a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Fri, 11 Sep 2026 10:52:32 +0200 Subject: [PATCH 05/15] feat: one token resolution order across every command Three resolvers decided which token a command used, and they disagreed: `resolveToken` ignored `APIFY_TOKEN`, `mcp install` honored it, the `actor` entrypoint required it. The same shell gave three answers depending on the command. There is now one order, the one `mcp install` and the `actor` entrypoint already used: token the command was given -> APIFY_TOKEN -> stored login `APIFY_TOKEN` is the way to run a command as a different account. Only `login` and `mcp install` take a token of their own, through `--token`, because both write it somewhere rather than just authenticating with it. Adding a second, flag-shaped way to do what the env var already does would leave two mechanisms for one thing. - `getApifyTokenFromEnvOrAuthFile` and `resolveApifyToken` are gone, folded into `resolveAuth`. No special case is needed for the `actor` entrypoint: inside a platform run there is no stored login, so `APIFY_TOKEN` wins on its own. - `apify auth token` prints the token that would be used, not the stored one. It only looked right before because reads overwrote the stored token. - `apify info` names the source, so an `APIFY_TOKEN` that overrides a stored login is visible rather than silent. - `apify run` passes the resolved token to the child instead of the stored one, and no longer overwrites an inherited `APIFY_TOKEN`. - A rejected token names its source. A 401 or 403 says the token was rejected; any other failure says the API request failed, so an unreachable API is not reported as a bad token. - `apify login` ignores `APIFY_TOKEN`. Logging in stays explicit. Closes #720 Co-Authored-By: Claude Opus 5 --- docs/reference.md | 11 +- src/commands/actor/charge.ts | 9 +- src/commands/auth/login.ts | 5 +- src/commands/auth/token.ts | 14 +- src/commands/info.ts | 22 ++- src/commands/mcp/install.ts | 34 ++-- src/commands/run.ts | 16 +- src/lib/actor.ts | 31 +--- src/lib/auth.ts | 51 +++++- src/lib/utils.ts | 29 +-- test/e2e/__helpers__/run-cli.ts | 3 + test/local/commands/auth.test.ts | 25 ++- test/local/commands/create-git-source.test.ts | 3 + test/local/commands/push-git-source.test.ts | 2 +- test/local/lib/auth.test.ts | 169 ++++++++++++++++++ test/local/lib/credentials.test.ts | 2 + 16 files changed, 330 insertions(+), 96 deletions(-) create mode 100644 test/local/lib/auth.test.ts diff --git a/docs/reference.md b/docs/reference.md index e7cf2fb73..f61b558bf 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -149,7 +149,8 @@ SUBCOMMANDS to '~/.apify/auth.json'. auth logout Removes authentication by deleting your API token and account information from '~/.apify/auth.json'. - auth token Prints the current API token for the Apify CLI. + auth token Prints the API token the CLI would use, resolved from + APIFY_TOKEN or the stored login. ``` ##### `apify auth login` / `apify login` @@ -168,7 +169,8 @@ USAGE FLAGS -m, --method=