diff --git a/docs/reference.md b/docs/reference.md index dab7d12a9..6e123da75 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -147,8 +147,8 @@ DESCRIPTION SUBCOMMANDS auth login Authenticates your Apify account and saves credentials to '~/.apify/auth.json'. - auth logout Removes authentication by deleting your API token and - account information from '~/.apify/auth.json'. + auth logout Logs out of the active account by deleting its API + token and account information from '~/.apify/auth.json'. auth token Prints the API token the CLI authenticates with, resolved from APIFY_TOKEN or the token from 'apify login'. ``` @@ -177,8 +177,9 @@ FLAGS ```sh DESCRIPTION - Removes authentication by deleting your API token and account information from - '~/.apify/auth.json'. + Logs out of the active account by deleting its API token and account + information from '~/.apify/auth.json'. + If other accounts are stored, the most recently logged-in one becomes active. Run 'apify login' to authenticate again. USAGE diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index a1bf2aea4..cc5da7399 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -8,6 +8,7 @@ import open from 'open'; import { APIFY_ENV_VARS } from '@apify/consts'; import { cryptoRandomObjectId } from '@apify/utilities'; +import { profileLabel, readAuthFile } from '../../lib/auth-file.js'; import { invalidEnvTokenMessage, loginWithToken, readEnvToken } from '../../lib/auth.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Flags } from '../../lib/command-framework/flags.js'; @@ -48,6 +49,13 @@ const tryToLogin = async (token: string) => { success({ message: `You are logged in to Apify as ${userInfo.username || userInfo.id}. ${chalk.gray(`Your token is stored in ${tokenLocation}.`)}`, }); + + const others = Object.entries(readAuthFile().profiles ?? {}) + .filter(([id]) => id !== userInfo.id) + .map(([id, profile]) => profileLabel({ id, ...profile })); + if (others.length > 0) { + info({ message: `Other stored accounts: ${others.join(', ')}.` }); + } } else { process.exitCode = CommandExitCodes.MissingAuth; error({ diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 0b7fa8f61..320e17090 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -2,7 +2,7 @@ import process from 'node:process'; import { APIFY_ENV_VARS } from '@apify/consts'; -import { getActiveProfileId, removeActiveProfile } from '../../lib/auth-file.js'; +import { getActiveProfileId, profileLabel, removeActiveProfile } from '../../lib/auth-file.js'; import { invalidEnvTokenMessage, readEnvToken } from '../../lib/auth.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { AUTH_FILE_PATH, CommandExitCodes } from '../../lib/consts.js'; @@ -20,7 +20,8 @@ export class AuthLogoutCommand extends ApifyCommand { static override name = 'logout' as const; static override description = - `Removes authentication by deleting your API token and account information from '${tildify(AUTH_FILE_PATH())}'.\n` + + `Logs out of the active account by deleting its API token and account information from '${tildify(AUTH_FILE_PATH())}'.\n` + + `If other accounts are stored, the most recently logged-in one becomes active.\n` + `Run 'apify login' to authenticate again.`; static override group = 'Authentication'; @@ -43,18 +44,26 @@ export class AuthLogoutCommand extends ApifyCommand { const leftovers = await clearKeyringSecrets(activeProfileId); let profileError: unknown = null; + let result: ReturnType = {}; try { - removeActiveProfile(); + result = removeActiveProfile(); } catch (err) { profileError = err; } - // The account is off disk whenever the profile step succeeded, so the telemetry ID goes too. - if (!profileError) await updateUserId(null); + const { removed, active } = result; + + // The account is off disk whenever the profile step succeeded, so the telemetry ID follows + // whichever account is active now. + if (!profileError) await updateUserId(active?.id ?? null); if (leftovers.length || profileError) { error({ message: partialLogoutMessage(leftovers, profileError) }); process.exitCode = CommandExitCodes.RunFailed; + } else if (active) { + success({ + message: `You are logged out${removed ? ` of ${profileLabel(removed)}` : ''}. ${profileLabel(active)} is now the active account.`, + }); } else { success({ message: 'You are logged out from your Apify account.' }); } diff --git a/src/lib/auth-file.ts b/src/lib/auth-file.ts index ef5425b03..ac29efc12 100644 --- a/src/lib/auth-file.ts +++ b/src/lib/auth-file.ts @@ -18,7 +18,7 @@ export const AUTH_BACKUP_FILE_PATH = () => `${AUTH_FILE_PATH()}.v1.bak`; */ export interface AuthProfile { username?: string; - /** Human label for `--profile `. Unused until profiles get names. */ + /** Human label for `--profile `. */ name: string | null; /** Set means the profile is an organization rather than a personal account. */ organizationOwnerUserId?: string; @@ -250,6 +250,10 @@ export function lookUpActiveProfile(): ActiveProfileLookup { return { profile: { id: file.activeProfile, ...profile } }; } +export function profileLabel(profile: AuthProfile & { id: string }) { + return profile.name ?? profile.username ?? profile.id; +} + export function getActiveProfile(): (AuthProfile & { id: string }) | undefined { return lookUpActiveProfile().profile; } @@ -329,51 +333,71 @@ function updateProfile(userId: string, edit: (profile: AuthProfile) => void) { } /** - * Replaces the file with this one account, dropping any previous profile and its secrets. Nothing - * puts a second profile there yet; additive login is #1386. Dropping the old secrets is what keeps - * the write safe: the caller writes the new token next, so a failure there leaves nobody logged in - * rather than the old token beside the new name. + * Adds the account, or updates it in place when it is already stored, and makes it active. Other + * profiles are kept. A stored profile keeps its file-backend secrets until the caller writes the + * new ones, so a failed write leaves the old login in place rather than none. */ -export function replaceStoredAccount(userId: string, profile: AuthProfile) { +export function upsertProfile(userId: string, profile: AuthProfile) { assertSupportedAuthFileVersion(); - // The snapshot described the account being replaced, and is never refreshed, so keeping it - // would leave one user's details on disk under another user's login. - rmSync(AUTH_BACKUP_FILE_PATH(), { force: true, maxRetries: 10, retryDelay: 100 }); + const current = readAuthFile(); + // A file the migration could not bring to v2 has no profiles to keep. + const file: AuthFile = current.version === AUTH_FILE_VERSION ? current : { version: AUTH_FILE_VERSION }; + file.profiles ??= {}; + // Unkeyed secrets belong to the account that was active, and re-keying would file them under this one. + delete file.token; + delete file.proxy; - writeAuthFile({ - version: AUTH_FILE_VERSION, - activeProfile: userId, - profiles: { [userId]: profile }, - }); + const existing = file.profiles[userId]; + file.profiles[userId] = { + ...profile, + ...(existing?.token ? { token: existing.token } : {}), + ...(existing?.proxy ? { proxy: existing.proxy } : {}), + }; + + file.activeProfile = userId; + writeAuthFile(file); } /** - * Drops the active profile together with the secrets stored beside it. The file and the v1 backup - * go away once no profile is left, so logging out leaves no token on disk. + * Drops the active profile together with the secrets stored beside it. The profile with the most + * recent `loggedInAt` becomes active. The file and the v1 backup go away once no profile is left, + * so logging out leaves no token on disk. */ -export function removeActiveProfile() { +export function removeActiveProfile(): { + removed?: AuthProfile & { id: string }; + active?: AuthProfile & { id: string }; +} { const file = readAuthFile(); // No version guard: refusing to discard a file this CLI cannot read leaves no way out. It goes // whole rather than edited, which would leave something worse than either outcome. if (file.version !== AUTH_FILE_VERSION) { discardAuthFiles(); - return; + return {}; } - const active = file.activeProfile; - if (active && file.profiles) delete file.profiles[active]; + const removedId = file.activeProfile; + const removedProfile = removedId ? file.profiles?.[removedId] : undefined; + const removed = removedId && removedProfile ? { id: removedId, ...removedProfile } : undefined; + + if (removedId && file.profiles) delete file.profiles[removedId]; delete file.activeProfile; delete file.token; delete file.proxy; - if (Object.keys(file.profiles ?? {}).length === 0) { + const [nextId] = Object.entries(file.profiles ?? {}) + .sort(([, a], [, b]) => (b.loggedInAt ?? '').localeCompare(a.loggedInAt ?? '')) + .map(([id]) => id); + + if (!nextId) { discardAuthFiles(); - return; + return { removed }; } + file.activeProfile = nextId; writeAuthFile(file); + return { removed, active: { id: nextId, ...file.profiles![nextId] } }; } function discardAuthFiles() { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 55e870080..bc12a4122 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,7 +6,7 @@ import { AxiosHeaders } from 'axios'; import { APIFY_ENV_VARS } from '@apify/consts'; -import { getActiveProfileId, replaceStoredAccount } from './auth-file.js'; +import { getActiveProfileId, upsertProfile } from './auth-file.js'; import { APIFY_CLIENT_DEFAULT_HEADERS, AUTH_FILE_PATH, CommandExitCodes } from './consts.js'; import { clearKeyringSecrets, @@ -179,12 +179,13 @@ export async function loginWithToken( const proxyPassword = userInfo.proxy?.password; - const previousUserId = getActiveProfileId(); + // Brings a stored account to the current shape first, or the upsert below would find nothing to keep. + await ensureCredentialsCurrent(); const { organizationOwnerUserId } = userInfo as { organizationOwnerUserId?: string }; - replaceStoredAccount(userInfo.id, { + upsertProfile(userInfo.id, { username: userInfo.username, - name: null, + name: userInfo.username || userInfo.id, ...(organizationOwnerUserId ? { organizationOwnerUserId } : {}), authMethod: 'token', expiresAt: null, @@ -192,12 +193,11 @@ export async function loginWithToken( loggedInAt: new Date().toISOString(), }); - // Only once the switch is on disk: a failed write leaves auth.json naming the previous account, - // whose entries nothing else can find. The fixed names go every time, stale by then either way. - const staleUserId = previousUserId === userInfo.id ? undefined : previousUserId; - const leftovers = await clearKeyringSecrets(staleUserId); + // Only once the profile is on disk: a failed write leaves the previous account active, and it may + // still read the fixed names. Its keyed entries stay, because that account is still stored. + const leftovers = await clearKeyringSecrets(); - // After the account, which drops the previous secrets. `skipIfUnchanged` avoids a Keychain prompt. + // After the profile, which says where its secrets go. `skipIfUnchanged` avoids a Keychain prompt. await setSecret(userInfo.id, 'token', token, { skipIfUnchanged: true }); if (proxyPassword) { diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index e97d4d3a1..598a44403 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -248,9 +248,8 @@ async function moveKeyringSecretsToFile(userId: string): Promise { } /** - * Forget one of an account's secrets. Called for a proxy password when the account has none, so - * the previous account's does not survive a re-login — the keyring outlives the auth.json rewrite - * that replaces everything else. + * Forget one of an account's secrets. Called for a proxy password when the account has none, so a + * re-login does not keep one the account no longer has. * * Returns the secret this left behind, or null: reads hit the keyring first, so a refused delete * keeps serving a password the account no longer has. @@ -450,9 +449,6 @@ export async function ensureSecretsKeyed(): Promise { * file into its current shape, then the secrets onto keys that carry the user ID. The order is a * dependency chain — keying by user needs the user ID the shape migration produces. * - * `loginWithToken()` does not call it: it replaces the file wholesale, so there is nothing to - * bring forward, and it clears the old keyring names itself. - * * Each step is single-flight, so repeat calls cost nothing. */ export async function ensureCredentialsCurrent(): Promise { diff --git a/test/local/commands/auth.test.ts b/test/local/commands/auth.test.ts index ad9af0971..d9c7be263 100644 --- a/test/local/commands/auth.test.ts +++ b/test/local/commands/auth.test.ts @@ -1,14 +1,14 @@ -import { chmodSync, existsSync, statSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; import process from 'node:process'; import { APIFY_ENV_VARS } from '@apify/consts'; -import { __resetAuthFileForTests } from '../../../src/lib/auth-file.js'; +import { __resetAuthFileForTests, AUTH_BACKUP_FILE_PATH, type AuthProfile } from '../../../src/lib/auth-file.js'; import { AUTH_FILE_PATH, CommandExitCodes, GLOBAL_CONFIGS_FOLDER } from '../../../src/lib/consts.js'; import { __resetCredentialsForTests, ensureSecretsKeyed, getSecret } from '../../../src/lib/credentials.js'; import { tildify } from '../../../src/lib/utils.js'; import { clientState, resetApifyClientMock } from '../../__setup__/apify-client-mock.js'; -import { readActiveProfile, readAuthFile } from '../../__setup__/auth-file.js'; +import { readActiveProfile, readAuthFile, v1AuthFile } from '../../__setup__/auth-file.js'; import { useAuthSetup, useKeyringBackend } from '../../__setup__/hooks/useAuthSetup.js'; import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; import { @@ -42,6 +42,19 @@ const { testRunCommand } = await import('../../../src/lib/command-framework/apif const TOKEN = 'apify_api_test_token'; +const PROFILE: AuthProfile = { + name: null, + authMethod: 'token', + expiresAt: null, + hasRefreshToken: false, + loggedInAt: null, +}; + +const writeAuthFile = (data: unknown) => { + mkdirSync(GLOBAL_CONFIGS_FOLDER(), { recursive: true }); + writeFileSync(AUTH_FILE_PATH(), JSON.stringify(data)); +}; + const login = (token = TOKEN) => testRunCommand(AuthLoginCommand, { flags_token: token }); describe('auth commands', () => { @@ -60,7 +73,7 @@ describe('auth commands', () => { expect(readActiveProfile()).toEqual({ id: 'uid', username: 'me', - name: null, + name: 'me', authMethod: 'token', expiresAt: null, hasRefreshToken: false, @@ -92,8 +105,7 @@ describe('auth commands', () => { expect(await getSecret('uid', 'token')).toBeUndefined(); }); - it('logging in as another account replaces the stored profile', async () => { - clientState.user = { id: 'uid', username: 'me', email: 'me@example.com' }; + it('logging in as another account adds a profile and makes it active', async () => { await login(); clientState.user = { id: 'uid2', username: 'other' }; @@ -101,9 +113,90 @@ describe('auth commands', () => { const authFile = readAuthFile(); expect(authFile).toMatchObject({ activeProfile: 'uid2' }); - // Additive login is a later stage; until then the old profile must not linger. - expect(Object.keys(authFile.profiles!)).toEqual(['uid2']); - expect(readActiveProfile()).toMatchObject({ username: 'other', token: 'apify_api_other_token' }); + expect(Object.keys(authFile.profiles!).sort()).toEqual(['uid', 'uid2']); + expect(authFile.profiles!.uid).toMatchObject({ name: 'me', token: TOKEN }); + expect(readActiveProfile()).toMatchObject({ name: 'other', token: 'apify_api_other_token' }); + expect(lastErrorMessage()).toContain('Other stored accounts: me.'); + }); + + it('logging in again to a stored account updates it in place and makes it active', async () => { + await login(); + const firstLoginAt = readActiveProfile()!.loggedInAt!; + + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + clientState.user = { id: 'uid', username: 'me-renamed' }; + await new Promise((resolve) => setTimeout(resolve, 5)); + await login('apify_api_rotated_token'); + + const authFile = readAuthFile(); + expect(Object.keys(authFile.profiles!).sort()).toEqual(['uid', 'uid2']); + expect(readActiveProfile()).toMatchObject({ + id: 'uid', + name: 'me-renamed', + token: 'apify_api_rotated_token', + }); + expect(readActiveProfile()!.loggedInAt! > firstLoginAt).toBe(true); + }); + + it('logout with two accounts makes the other one active and says so', async () => { + await login(); + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + await testRunCommand(AuthLogoutCommand, {}); + + expect(lastErrorMessage()).toContain('You are logged out of other. me is now the active account.'); + expect(readAuthFile().profiles).not.toHaveProperty('uid2'); + expect(readActiveProfile()).toMatchObject({ id: 'uid', token: TOKEN }); + + await testRunCommand(AuthTokenCommand, {}); + expect(lastLogMessage()).toBe(TOKEN); + }); + + it('logout makes the most recently logged-in remaining account active', async () => { + writeAuthFile({ + version: 2, + activeProfile: 'uid', + secretsBackend: 'file', + profiles: { + uid: { ...PROFILE, name: 'me', loggedInAt: '2026-03-01T00:00:00.000Z', token: TOKEN }, + old: { ...PROFILE, name: 'old', loggedInAt: null, token: 't-old' }, + recent: { ...PROFILE, name: 'recent', loggedInAt: '2026-02-01T00:00:00.000Z', token: 't-recent' }, + older: { ...PROFILE, name: 'older', loggedInAt: '2026-01-01T00:00:00.000Z', token: 't-older' }, + }, + }); + + await testRunCommand(AuthLogoutCommand, {}); + + expect(readAuthFile().activeProfile).toBe('recent'); + }); + + it('logout with a dangling active profile still names the account that becomes active', async () => { + writeAuthFile({ + version: 2, + activeProfile: 'gone', + secretsBackend: 'file', + profiles: { uid: { ...PROFILE, name: 'me', token: TOKEN } }, + }); + + await testRunCommand(AuthLogoutCommand, {}); + + expect(lastErrorMessage()).toContain('You are logged out. me is now the active account.'); + expect(readAuthFile().activeProfile).toBe('uid'); + }); + + it('a migrated v1 account survives logging in to a second account', async () => { + writeAuthFile(v1AuthFile()); + + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + const authFile = readAuthFile(); + expect(authFile).toMatchObject({ version: 2, activeProfile: 'uid2' }); + expect(authFile.profiles!.uid).toMatchObject({ username: 'me', token: 'apify_api_v1_token' }); + expect(existsSync(AUTH_BACKUP_FILE_PATH())).toBe(true); }); it('login with an invalid token stores nothing and fails the command', async () => { @@ -211,30 +304,81 @@ describe('auth commands', () => { expect(readActiveProfile()).toMatchObject({ id: 'uid', username: 'me' }); }); - it('logging in as an account with no proxy password forgets the previous one', async () => { + it('logging in again with no proxy password forgets the previous one', async () => { await login(); expect(keyringStore.get(PROXY_PASSWORD_KEY)).toBe('pw'); - clientState.user = { id: 'uid2', username: 'other' }; - await login('apify_api_other_token'); + clientState.user = { id: 'uid', username: 'me' }; + await login(); - // The keyring outlives the auth.json rewrite, so without an explicit delete the child - // Actor would run with the previous account's proxy credential. expect(keyringStore.has(PROXY_PASSWORD_KEY)).toBe(false); - expect(keyringStore.has(keyringProxyPasswordKey('uid2'))).toBe(false); }); - it('switching accounts clears the outgoing account entries', async () => { + it('logging in to a second account keeps the first account entries', async () => { await login(); - expect(keyringStore.get(TOKEN_KEY)).toBe(TOKEN); clientState.user = { id: 'uid2', username: 'other', proxy: { password: 'pw2' } }; await login('apify_api_other_token'); - // auth.json no longer names uid, and the CLI never enumerates the keyring, so anything left - // under its key would be unreachable for good. - expect(keyringStore.get(TOKEN_KEY)).toBeUndefined(); + expect(keyringStore.get(TOKEN_KEY)).toBe(TOKEN); + expect(keyringStore.get(PROXY_PASSWORD_KEY)).toBe('pw'); expect(keyringStore.get(keyringTokenKey('uid2'))).toBe('apify_api_other_token'); + expect(keyringStore.get(keyringProxyPasswordKey('uid2'))).toBe('pw2'); + }); + + it('a second login with the keyring disabled leaves the first account on the keyring', async () => { + await login(); + + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + __resetCredentialsForTests(); + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + const authFile = readAuthFile(); + expect(authFile).not.toHaveProperty('secretsBackend'); + expect(authFile.profiles!.uid2).toMatchObject({ token: 'apify_api_other_token' }); + expect(authFile.profiles!.uid).not.toHaveProperty('token'); + + vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); + __resetCredentialsForTests(); + await testRunCommand(AuthLogoutCommand, {}); + + expect(await getSecret('uid', 'token')).toBe(TOKEN); + }); + + it('logging in again with the keyring disabled keeps reading the new token once it is enabled', async () => { + await login(); + + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + __resetCredentialsForTests(); + await login('apify_api_rotated_token'); + + vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); + __resetCredentialsForTests(); + expect(await getSecret('uid', 'token')).toBe('apify_api_rotated_token'); + }); + + it('logging in to a second account drops unkeyed entries left by the first', async () => { + await login(); + keyringStore.set(LEGACY_KEYRING_TOKEN_KEY, TOKEN); + + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + expect(keyringStore.has(LEGACY_KEYRING_TOKEN_KEY)).toBe(false); + }); + + it('logout with two accounts clears only the outgoing account entries', async () => { + await login(); + clientState.user = { id: 'uid2', username: 'other', proxy: { password: 'pw2' } }; + await login('apify_api_other_token'); + + await testRunCommand(AuthLogoutCommand, {}); + + expect(keyringStore.has(keyringTokenKey('uid2'))).toBe(false); + expect(keyringStore.has(keyringProxyPasswordKey('uid2'))).toBe(false); + expect(keyringStore.get(TOKEN_KEY)).toBe(TOKEN); + expect(readActiveProfile()).toMatchObject({ id: 'uid' }); }); it('logging in again as the same account keeps its entries', async () => { @@ -291,19 +435,20 @@ describe('auth commands', () => { }, ); - it('login warns when the previous account entries cannot be removed', async () => { + it('login keeps the previous account entries and warns about fixed-name ones it cannot remove', async () => { await login(); + keyringStore.set(LEGACY_KEYRING_TOKEN_KEY, 'stale'); + keyringFailures.add(LEGACY_KEYRING_TOKEN_KEY); clientState.user = { id: 'uid2', username: 'other' }; - keyringFailures.add(TOKEN_KEY); await login('apify_api_other_token'); expect(readActiveProfile()).toMatchObject({ id: 'uid2' }); + expect(keyringStore.get(TOKEN_KEY)).toBe(TOKEN); const printed = [...logMessages.log, ...logMessages.error].join('\n'); expect(printed).toContain( - 'Secrets this login could not remove are still in the OS keyring at com.apify.cli.token/uid;', + 'Secrets this login could not remove are still in the OS keyring at com.apify.cli/token;', ); - expect(printed).not.toContain('uid2'); }); it('does not hand one account the previous account secret', async () => { diff --git a/test/local/lib/auth-file.test.ts b/test/local/lib/auth-file.test.ts index 31444e134..96d65f279 100644 --- a/test/local/lib/auth-file.test.ts +++ b/test/local/lib/auth-file.test.ts @@ -9,7 +9,7 @@ import { getActiveProfile, lookUpActiveProfile, removeActiveProfile, - replaceStoredAccount, + upsertProfile, } from '../../../src/lib/auth-file.js'; import { resolveAuth } from '../../../src/lib/auth.js'; import { AUTH_FILE_PATH, GLOBAL_CONFIGS_FOLDER } from '../../../src/lib/consts.js'; @@ -274,7 +274,7 @@ describe('auth.json v2', () => { const newer = { version: 3, activeProfile: 'uid', profiles: { uid: { username: 'me' } } }; write(newer); - expect(() => replaceStoredAccount('uid2', V2_PROFILE)).toThrow('written by a newer Apify CLI'); + expect(() => upsertProfile('uid2', V2_PROFILE, 'file')).toThrow('written by a newer Apify CLI'); expect(readAuthFile()).toEqual(newer); }); @@ -294,53 +294,69 @@ describe('auth.json v2', () => { }); }); - describe('replacing the stored account', () => { - it('drops the previous account and its secrets', () => { + describe('adding or updating a profile', () => { + it('keeps the other profiles and makes the new one active', () => { write({ version: 2, activeProfile: 'old', - profiles: { old: { ...V2_PROFILE, username: 'old' } }, + profiles: { old: { ...V2_PROFILE, username: 'old', token: 'apify_api_old' } }, secretsBackend: 'file', - token: 'apify_api_old', - proxy: { password: 'old_pw' }, }); - replaceStoredAccount('new', { ...V2_PROFILE, username: 'new' }); + upsertProfile('new', { ...V2_PROFILE, username: 'new' }); const file = readAuthFile(); - expect(Object.keys(file.profiles!)).toEqual(['new']); + expect(Object.keys(file.profiles!).sort()).toEqual(['new', 'old']); expect(file.activeProfile).toBe('new'); - // A leftover token beside the new account authenticates as the wrong user. - expect(file).not.toHaveProperty('token'); - expect(file).not.toHaveProperty('proxy'); + expect(file.profiles!.old).toMatchObject({ token: 'apify_api_old' }); + }); + + it('keeps a stored profile file secrets when it logs in again, until the caller writes new ones', () => { + write({ + version: 2, + activeProfile: 'uid', + profiles: { uid: { ...V2_PROFILE, username: 'me', token: 'tok', proxy: { password: 'pw' } } }, + }); + + upsertProfile('uid', { ...V2_PROFILE, username: 'renamed' }); + + expect(readActiveProfile()).toMatchObject({ username: 'renamed', token: 'tok', proxy: { password: 'pw' } }); }); - it('leaves no token when the caller never writes one', async () => { + it('writes no secrets backend marker', () => { + write({ version: 2, activeProfile: 'old', profiles: { old: { ...V2_PROFILE, username: 'old' } } }); + + upsertProfile('new', { ...V2_PROFILE, username: 'new' }); + + expect(readAuthFile()).not.toHaveProperty('secretsBackend'); + expect(readAuthFile().profiles!.new).not.toHaveProperty('secretsBackend'); + }); + + it('drops unkeyed secrets so they are never keyed to the new account', async () => { write({ version: 2, activeProfile: 'old', profiles: { old: { ...V2_PROFILE, username: 'old' } }, secretsBackend: 'file', token: 'apify_api_old', + proxy: { password: 'old_pw' }, }); - replaceStoredAccount('new', { ...V2_PROFILE, username: 'new' }); + upsertProfile('new', { ...V2_PROFILE, username: 'new' }); - // Logged out, rather than logged in as the account that just went away. + const file = readAuthFile(); + expect(file).not.toHaveProperty('token'); + expect(file).not.toHaveProperty('proxy'); await expect(getSecret('new', 'token')).resolves.toBeUndefined(); }); - }); - describe('replacing the stored account', () => { - it('removes the snapshot of the account it replaced', async () => { + it('keeps the v1 snapshot, since the migrated account is still stored', async () => { write(v1AuthFile({ secretsBackend: 'file' })); await ensureAuthFileCurrent(); - expect(existsSync(AUTH_BACKUP_FILE_PATH())).toBe(true); - replaceStoredAccount('other', { ...V2_PROFILE, username: 'other' }); + upsertProfile('other', { ...V2_PROFILE, username: 'other' }); - // It described the previous account and is never refreshed, so it must not survive. - expect(existsSync(AUTH_BACKUP_FILE_PATH())).toBe(false); + expect(existsSync(AUTH_BACKUP_FILE_PATH())).toBe(true); }); });