Skip to content
Open
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
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions test/__setup__/hooks/useAuthSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<path>/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');

Expand Down
41 changes: 41 additions & 0 deletions test/__setup__/keyring-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Fake `@napi-rs/keyring`. Install with
* `vi.mock('@napi-rs/keyring', () => import('<path>/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<string, string>();

export const keyringFailures = new Set<string>();

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;
}
2 changes: 1 addition & 1 deletion test/e2e/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
174 changes: 174 additions & 0 deletions test/local/commands/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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<typeof import('apify-client')>();

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);
});
});
});
Loading
Loading