Skip to content
Merged
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
58 changes: 0 additions & 58 deletions apps/desktop/src/main/__tests__/credential-store-contract.test.ts

This file was deleted.

80 changes: 80 additions & 0 deletions apps/desktop/src/main/__tests__/credential-store-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import { migrateLegacyCredentials, type SafeStorageLike } from '../credential-store.js';

// The migration ORCHESTRATION — shared lock, version gate, fail-closed aborts,
// atomic 0600 rewrite, idempotency, malformed/missing handling — is tested in
// @maka/storage against migrateLegacyCredentialFile. This file covers only the
// desktop GLUE: that safeStorage is wired through correctly (base64-decode then
// decryptString) and that its availability is propagated.

/** Fake safeStorage: "decrypt" strips an `enc:` prefix so every value is proven
* to round-trip through decryptString. */
function fakeSafeStorage(available: boolean): SafeStorageLike {
return {
isEncryptionAvailable: () => available,
decryptString: (buf) => buf.toString('utf8').replace(/^enc:/, ''),
};
}

/** The legacy on-disk encoding: base64(safeStorage.encryptString(value)). */
function encrypted(value: string): string {
return Buffer.from(`enc:${value}`).toString('base64');
}

async function withWorkspace<T>(fn: (root: string, path: string) => Promise<T>): Promise<T> {
const root = await mkdtemp(join(tmpdir(), 'maka-cred-mig-'));
try {
return await fn(root, join(root, 'credentials.json'));
} finally {
await rm(root, { recursive: true, force: true });
}
}

describe('migrateLegacyCredentials (desktop safeStorage glue)', () => {
it('base64-decodes and safeStorage-decrypts every kind to v1 plaintext-0600', async () => {
await withWorkspace(async (root, path) => {
// Full scope: API key + bot token + proxy password all migrate.
await writeFile(
path,
JSON.stringify({
values: {
'openai:apiKey': encrypted('sk-1'),
'settings:bot:telegram:botToken': encrypted('tok-2'),
'settings:network-proxy:proxyPassword': encrypted('pw-3'),
},
}),
'utf8',
);

await migrateLegacyCredentials(root, fakeSafeStorage(true));

const after = JSON.parse(await readFile(path, 'utf8')) as {
version: number;
values: Record<string, string>;
};
assert.equal(after.version, 1);
assert.deepEqual(after.values, {
'openai:apiKey': 'sk-1',
'settings:bot:telegram:botToken': 'tok-2',
'settings:network-proxy:proxyPassword': 'pw-3',
});
if (process.platform !== 'win32') {
assert.equal((await stat(path)).mode & 0o777, 0o600); // owner-only at rest
}
});
});

it('propagates safeStorage unavailability: aborts and leaves the file intact', async () => {
await withWorkspace(async (root, path) => {
const original = JSON.stringify({ values: { 'openai:apiKey': encrypted('sk-1') } });
await writeFile(path, original, 'utf8');

await assert.rejects(migrateLegacyCredentials(root, fakeSafeStorage(false)), /unavailable/);
assert.equal(await readFile(path, 'utf8'), original); // untouched — no data loss
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { describe, it } from 'node:test';
import type { BotProvider } from '@maka/core';
import type { CredentialKind, CredentialStore } from '../credential-store.js';

const repoRoot = process.cwd().endsWith(join('apps', 'desktop'))
Expand All @@ -13,6 +12,11 @@ async function readRepo(relativePath: string): Promise<string> {
return readFile(join(repoRoot, relativePath), 'utf8');
}

// Compile-time guard: the desktop entrypoint still consumes the shared
// CredentialStore contract (re-exported from @maka/storage). The store
// implementation and its kind/slug invariants — legacy stored-key names,
// no raw secret in key names, fail-closed reads — are now tested
// behaviourally in packages/storage/src/__tests__/credential-store.test.ts.
const credentialKinds: CredentialKind[] = [
'api_key',
'oauth_token',
Expand All @@ -22,113 +26,33 @@ const credentialKinds: CredentialKind[] = [
'gateway_token',
'tavily_api_key',
];

type Phase4CredentialStoreMethods = Pick<
CredentialStore,
| 'getBotToken'
| 'setBotToken'
| 'deleteBotToken'
| 'getBotAppSecret'
| 'setBotAppSecret'
| 'deleteBotAppSecret'
| 'getProxyPassword'
| 'setProxyPassword'
| 'deleteProxyPassword'
| 'getGatewayToken'
| 'setGatewayToken'
| 'deleteGatewayToken'
| 'getTavilyApiKey'
| 'setTavilyApiKey'
| 'deleteTavilyApiKey'
>;

type BotScopedSetter = (provider: BotProvider, secret: string) => Promise<void>;
const botScopedSetter: BotScopedSetter = null as unknown as Phase4CredentialStoreMethods['setBotToken'];

const secretReader = null as unknown as Pick<CredentialStore, 'getSecret'>;
void credentialKinds;
void botScopedSetter;

describe('credential-store secret kind expansion contract', () => {
it('exposes the Phase 4 credential kinds without changing legacy stored kind names', async () => {
const source = await readRepo('apps/desktop/src/main/credential-store.ts');

for (const kind of credentialKinds) {
assert.match(source, new RegExp(`'${kind}'`), `CredentialKind must include ${kind}`);
}

assert.match(source, /case 'api_key':\s*return 'apiKey';/, 'api_key must keep the legacy stored key suffix');
assert.match(source, /case 'oauth_token':\s*return 'oauthToken';/, 'oauth_token must keep the legacy stored key suffix');
assert.match(source, /getApiKey\(slug: string\)[\s\S]*?return this\.get\(slug, 'apiKey'\);/);
assert.match(source, /setApiKey\(slug: string, apiKey: string\)[\s\S]*?return this\.set\(slug, 'apiKey', apiKey\);/);
assert.match(source, /getOAuthToken\(slug: string\)[\s\S]*?return this\.get\(slug, 'oauthToken'\);/);
assert.match(source, /setOAuthToken\(slug: string, token: string\)[\s\S]*?return this\.set\(slug, 'oauthToken', token\);/);
assert.match(source, /private key\(slug: string, kind: StoredCredentialKind\): string \{\s*return `\$\{slug\}:\$\{kind\}`;\s*\}/);
});

it('scopes bot token and app-secret helpers by provider and kind', async () => {
const source = await readRepo('apps/desktop/src/main/credential-store.ts');
void secretReader;

assert.match(source, /const BOT_SECRET_SLUG_PREFIX = 'settings:bot';/);
assert.match(source, /function botSecretSlug\(provider: BotProvider\): string \{\s*return `\$\{BOT_SECRET_SLUG_PREFIX\}:\$\{provider\}`;\s*\}/);
assert.match(source, /getBotToken\(provider: BotProvider\)[\s\S]*return this\.get\(botSecretSlug\(provider\), 'botToken'\);/);
assert.match(source, /setBotToken\(provider: BotProvider, token: string\)[\s\S]*return this\.set\(botSecretSlug\(provider\), 'botToken', token\);/);
assert.match(source, /deleteBotToken\(provider: BotProvider\)[\s\S]*return this\.deleteSecret\(botSecretSlug\(provider\), 'bot_token'\);/);
assert.match(source, /getBotAppSecret\(provider: BotProvider\)[\s\S]*return this\.get\(botSecretSlug\(provider\), 'botAppSecret'\);/);
assert.match(source, /setBotAppSecret\(provider: BotProvider, secret: string\)[\s\S]*return this\.set\(botSecretSlug\(provider\), 'botAppSecret', secret\);/);
assert.match(source, /deleteBotAppSecret\(provider: BotProvider\)[\s\S]*return this\.deleteSecret\(botSecretSlug\(provider\), 'app_secret'\);/);
assert.doesNotMatch(source, /botSecretSlug\([^)]*(token|secret|value|apiKey|password|key)[^)]*\)/);
});

it('uses isolated singleton slugs for global secret helpers', async () => {
const source = await readRepo('apps/desktop/src/main/credential-store.ts');

assert.match(source, /const GLOBAL_PROXY_SECRET_SLUG = 'settings:network-proxy';/);
assert.match(source, /const GLOBAL_GATEWAY_SECRET_SLUG = 'settings:open-gateway';/);
assert.match(source, /const GLOBAL_TAVILY_SECRET_SLUG = 'settings:web-search:tavily';/);
assert.match(source, /getProxyPassword\(\)[\s\S]*return this\.get\(GLOBAL_PROXY_SECRET_SLUG, 'proxyPassword'\);/);
assert.match(source, /setProxyPassword\(password: string\)[\s\S]*return this\.set\(GLOBAL_PROXY_SECRET_SLUG, 'proxyPassword', password\);/);
assert.match(source, /deleteProxyPassword\(\)[\s\S]*return this\.deleteSecret\(GLOBAL_PROXY_SECRET_SLUG, 'proxy_password'\);/);
assert.match(source, /getGatewayToken\(\)[\s\S]*return this\.get\(GLOBAL_GATEWAY_SECRET_SLUG, 'gatewayToken'\);/);
assert.match(source, /setGatewayToken\(token: string\)[\s\S]*return this\.set\(GLOBAL_GATEWAY_SECRET_SLUG, 'gatewayToken', token\);/);
assert.match(source, /deleteGatewayToken\(\)[\s\S]*return this\.deleteSecret\(GLOBAL_GATEWAY_SECRET_SLUG, 'gateway_token'\);/);
assert.match(source, /getTavilyApiKey\(\)[\s\S]*return this\.get\(GLOBAL_TAVILY_SECRET_SLUG, 'tavilyApiKey'\);/);
assert.match(source, /setTavilyApiKey\(key: string\)[\s\S]*return this\.set\(GLOBAL_TAVILY_SECRET_SLUG, 'tavilyApiKey', key\);/);
assert.match(source, /deleteTavilyApiKey\(\)[\s\S]*return this\.deleteSecret\(GLOBAL_TAVILY_SECRET_SLUG, 'tavily_api_key'\);/);
});

it('keeps credential writes encrypted, fail-closed, and in the existing file shape', async () => {
describe('credential store migration off safeStorage (#32)', () => {
it('uses the pure-Node @maka/storage backend as the live store, not a safeStorage store', async () => {
const source = await readRepo('apps/desktop/src/main/credential-store.ts');
const setBlock = source.match(/private async set\([^)]*\): Promise<void> \{[\s\S]*?\n \}/);
const getBlock = source.match(/private async get\([^)]*\): Promise<string \| null> \{[\s\S]*?\n \}/);
const readBlock = source.match(/private async readUnlocked\(\): Promise<CredentialFile> \{[\s\S]*?\n \}/);
// Contract + implementation are re-exported from the shared package.
assert.match(source, /from '@maka\/storage'/);
assert.match(source, /createFileCredentialStore/);
// No live get/set store class remains in the desktop file — safeStorage
// is used ONLY by the one-time importer below.
assert.doesNotMatch(source, /class \w*CredentialStore/);

assert.ok(setBlock, 'set helper must exist');
assert.ok(getBlock, 'get helper must exist');
assert.ok(readBlock, 'readUnlocked helper must exist');
assert.match(setBlock![0], /if \(!safeStorage\.isEncryptionAvailable\(\)\) \{[\s\S]*throw new Error/);
assert.match(setBlock![0], /file\.values\[this\.key\(slug, kind\)\] = safeStorage\.encryptString\(value\)\.toString\('base64'\);/);
assert.match(getBlock![0], /if \(!encrypted\) return null;/);
assert.match(getBlock![0], /safeStorage\.decryptString\(Buffer\.from\(encrypted, 'base64'\)\)/);
assert.match(readBlock![0], /ENOENT'[\s\S]*return \{ values: \{\} \};/);
assert.match(source, /interface CredentialFile \{\s*values: Record<string, string>;\s*\}/);
assert.match(source, /JSON\.stringify\(file, null, 2\) \+ '\\n'/);
assert.doesNotMatch(source, /writeFile\([^)]*value/);
const main = await readRepo('apps/desktop/src/main/main.ts');
assert.match(main, /createFileCredentialStore\(workspaceRoot\)/);
assert.doesNotMatch(main, /createSafeStorageCredentialStore/);
});

it('keeps delete helpers idempotent and targeted', async () => {
const source = await readRepo('apps/desktop/src/main/credential-store.ts');
const deleteSecretBlock = source.match(/async deleteSecret\([^)]*\): Promise<void> \{[\s\S]*?\n \}/);
const deleteBlock = source.match(/async delete\(slug: string\): Promise<void> \{[\s\S]*?\n \}/);

assert.ok(deleteSecretBlock, 'deleteSecret helper must exist');
assert.ok(deleteBlock, 'delete helper must exist');
assert.match(deleteSecretBlock![0], /delete file\.values\[this\.key\(slug, toStoredKind\(kind\)\)\];/);
assert.doesNotMatch(deleteSecretBlock![0], /if \([^)]*values\[this\.key/, 'targeted delete should remain idempotent when missing');
assert.match(deleteBlock![0], /for \(const kind of STORED_CREDENTIAL_KINDS\)/);
assert.match(source, /'apiKey'[\s\S]*'oauthToken'[\s\S]*'botToken'[\s\S]*'botAppSecret'[\s\S]*'proxyPassword'[\s\S]*'gatewayToken'[\s\S]*'tavilyApiKey'/);
it('runs the migration before any credential use, non-fatally', async () => {
const main = await readRepo('apps/desktop/src/main/main.ts');
// Inside the whenReady handler, in a try/catch so a migration failure
// doesn't crash startup (later reads fail closed with guidance).
assert.match(main, /try \{\s*await migrateLegacyCredentials\(workspaceRoot, safeStorage\);\s*\} catch/);
});

it('does not start settings migration or change renderer-facing settings masking in this phase', async () => {
it('keeps renderer-facing settings masked and does not read bot/global secrets in main', async () => {
const main = await readRepo('apps/desktop/src/main/main.ts');
const helpers = await readRepo('apps/desktop/src/main/settings-ipc-helpers.ts');

Expand Down
Loading