diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 3c896a5..e70a1fa 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -357,8 +357,17 @@ async function persistSiteCredential( if (available) { const credentialRef = credentialRefForAlias(alias); - await store.set(credentialRef, staffTokenInput); - return { credentialRef }; + try { + await store.set(credentialRef, staffTokenInput); + return { credentialRef }; + } catch (err) { + if (!allowInsecureStorage) { + throw err; + } + // store.set() failed even though isAvailable() returned true (e.g. D-Bus + // service became unavailable between probe and write). Fall through to + // plaintext storage since --insecure-storage was explicitly requested. + } } if (!allowInsecureStorage) { diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index 1b5b9fc..6c226f1 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -176,7 +176,11 @@ function createLinuxAdapter(): CredentialStoreAdapter { LINUX_ATTR_REF, '__probe__', ]); - return probe.code === 0 || probe.code === 1; + // code 1 = credential not found (service healthy); code 0 = found. + // When the Secret Service is not activatable, secret-tool exits with a + // non-zero code AND writes a D-Bus error to stderr. A healthy "not found" + // response produces no stderr output, so any stderr indicates unavailability. + return (probe.code === 0 || probe.code === 1) && probe.stderr.trim() === ''; } catch { return false; } diff --git a/tests/credentials-and-security.test.ts b/tests/credentials-and-security.test.ts index 1d8fa21..cfa0052 100644 --- a/tests/credentials-and-security.test.ts +++ b/tests/credentials-and-security.test.ts @@ -7,6 +7,7 @@ import { readUserConfig } from '../src/lib/config.js'; import { setCredentialStoreForTests } from '../src/lib/credentials.js'; import { ExitCode } from '../src/lib/errors.js'; import { + createBrokenCredentialStore, createMemoryCredentialStore, createUnavailableCredentialStore, } from './helpers/mock-credentials.js'; @@ -136,6 +137,53 @@ describe('credential storage and security defaults', () => { expect(raw.sites?.myblog?.credentialRef).toBeUndefined(); }); + test('falls back to plaintext when store reports available but set throws and --insecure-storage is passed', async () => { + setCredentialStoreForTests(createBrokenCredentialStore()); + + await expect( + run([ + 'node', + 'ghst', + 'auth', + 'login', + '--non-interactive', + '--url', + 'https://myblog.ghost.io', + '--staff-token', + KEY, + '--site', + 'myblog', + '--insecure-storage', + ]), + ).resolves.toBe(ExitCode.SUCCESS); + + const raw = JSON.parse(await fs.readFile(path.join(configDir, 'config.json'), 'utf8')) as { + sites?: Record; + }; + expect(raw.sites?.myblog?.staffAccessToken).toBe(KEY); + expect(raw.sites?.myblog?.credentialRef).toBeUndefined(); + }); + + test('re-throws store set error without --insecure-storage when store reports available but set throws', async () => { + setCredentialStoreForTests(createBrokenCredentialStore()); + + await expect( + run([ + 'node', + 'ghst', + 'auth', + 'login', + '--non-interactive', + '--url', + 'https://myblog.ghost.io', + '--staff-token', + KEY, + '--site', + 'myblog', + ]), + ).resolves.toBe(ExitCode.GENERAL_ERROR); + }); + test('migrates plaintext staff tokens into secure store on read', async () => { const store = createMemoryCredentialStore(); setCredentialStoreForTests(store); diff --git a/tests/helpers/mock-credentials.ts b/tests/helpers/mock-credentials.ts index 7be0870..fe7939f 100644 --- a/tests/helpers/mock-credentials.ts +++ b/tests/helpers/mock-credentials.ts @@ -27,3 +27,14 @@ export function createUnavailableCredentialStore(): CredentialStore { delete: async () => undefined, }; } + +export function createBrokenCredentialStore(): CredentialStore { + return { + isAvailable: async () => true, + set: async () => { + throw new Error('Failed to store credential in Secret Service: secret-tool: The name is not activatable'); + }, + get: async () => null, + delete: async () => undefined, + }; +} diff --git a/tests/lib-credentials.test.ts b/tests/lib-credentials.test.ts index dcf317e..ccef911 100644 --- a/tests/lib-credentials.test.ts +++ b/tests/lib-credentials.test.ts @@ -179,6 +179,31 @@ describe.sequential('credential store adapters', () => { await expect(store.isAvailable()).resolves.toBe(false); }); + test('linux adapter returns unavailable when probe exits code 1 with D-Bus stderr', async () => { + delete process.env.VITEST; + setPlatform('linux'); + resetCredentialStoreCacheForTests(); + + queueSpawnOutcomes([ + { code: 1, stderr: 'secret-tool: The name is not activatable\n' }, + ]); + + const store = getCredentialStore(); + await expect(store.isAvailable()).resolves.toBe(false); + }); + + test('linux adapter considers service available only when probe exits cleanly with no stderr', async () => { + delete process.env.VITEST; + setPlatform('linux'); + resetCredentialStoreCacheForTests(); + + // code 1 with no stderr = "not found" (service healthy) + queueSpawnOutcomes([{ code: 1, stderr: '' }]); + + const store = getCredentialStore(); + await expect(store.isAvailable()).resolves.toBe(true); + }); + test('linux adapter handles input piping and error branches', async () => { delete process.env.VITEST; setPlatform('linux');