Files
iot-dc3/dc3-cli/test/credential-store.test.ts
pnokerandClaude Code b588204110 feat(cli): adopt the remaining guardian tests and auth store fix
adopts the remaining uncommitted work from the parallel session:
five new guardian test files (client-errors, device-import-template,
entity-id-guard, jwt-guard, option-hygiene) that lock in the error
taxonomy, id encoding, and cli option hygiene; an attachment file
cleaner in the agentic service; attachment controller and service
tests; a salt-type fix in the auth store; ci workflow typecheck step;
and the cli test report. 818 tests total across 37 files.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-06 21:26:15 +08:00

370 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Redirect os.homedir() to a per-test temp dir; stores and the config manager
// bake their paths at module load, hence resetModules + dynamic imports.
const osStub = vi.hoisted(() => ({ home: '' }));
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
homedir: () => (osStub.home ? osStub.home : actual.homedir()),
};
});
// Controllable keychain double: availability and contents are per-test state.
const keychainMock = vi.hoisted(() => ({
available: false,
password: null as string | null,
failGet: false,
saved: [] as Array<{ identifier: string; password: string }>,
deleted: [] as string[],
}));
vi.mock('../src/core/credential-keychain.js', () => ({
KeychainStore: class {
readonly name = 'keychain';
async isAvailable(): Promise<boolean> {
return keychainMock.available;
}
async getPassword(_identifier: string): Promise<string | null> {
if (keychainMock.failGet) {
throw new Error('keychain transport error');
}
return keychainMock.password;
}
async savePassword(identifier: string, password: string): Promise<void> {
keychainMock.saved.push({ identifier, password });
}
async deletePassword(identifier: string): Promise<void> {
keychainMock.deleted.push(identifier);
}
},
}));
describe('credential store chain (F033) and save contract (F005)', () => {
let home: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'dc3-cli-chain-'));
osStub.home = home;
keychainMock.available = false;
keychainMock.password = null;
keychainMock.failGet = false;
keychainMock.saved = [];
keychainMock.deleted = [];
delete process.env.DC3_PASSWORD;
vi.resetModules();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
osStub.home = '';
await rm(home, { recursive: true, force: true });
});
async function writeConfigFile(credentialStore: string): Promise<void> {
await mkdir(join(home, '.dc3'), { recursive: true });
await writeFile(
join(home, '.dc3', 'config.json'),
JSON.stringify({
version: 1,
current_profile: 'default',
settings: {},
profiles: {
default: {
gateway: 'http://localhost:8000',
tenant: 'default',
username: 'admin',
credential_store: credentialStore,
},
},
}),
'utf8',
);
}
it('the configured store is consulted first', async () => {
await writeConfigFile('encrypted');
const store = await import('../src/core/credential-encrypted');
const encrypted = new store.EncryptedFileStore();
await encrypted.savePassword('admin@default', 'from-encrypted');
vi.stubEnv('DC3_PASSWORD', 'from-env');
const { resolvePassword } = await import('../src/core/credential-store');
expect(await resolvePassword('admin@default')).toBe('from-encrypted');
});
it('an unavailable configured store falls through keychain → encrypted → env', async () => {
await writeConfigFile('keychain'); // unavailable in this fixture
vi.stubEnv('DC3_PASSWORD', 'from-env');
const { resolvePassword } = await import('../src/core/credential-store');
expect(await resolvePassword('admin@default')).toBe('from-env');
});
it('the encrypted store is tried before env when it holds the entry', async () => {
await writeConfigFile('prompt'); // inert configured store
const store = await import('../src/core/credential-encrypted');
await new store.EncryptedFileStore().savePassword('admin@default', 'from-encrypted');
vi.stubEnv('DC3_PASSWORD', 'from-env');
const { resolvePassword } = await import('../src/core/credential-store');
expect(await resolvePassword('admin@default')).toBe('from-encrypted');
});
it('env is only consulted while DC3_PASSWORD is set', async () => {
await writeConfigFile('keychain');
const { resolvePassword } = await import('../src/core/credential-store');
expect(await resolvePassword('admin@default')).toBeNull();
});
it('a store that throws is skipped without failing the chain', async () => {
await writeConfigFile('keychain');
keychainMock.available = true;
keychainMock.failGet = true;
vi.stubEnv('DC3_PASSWORD', 'from-env');
const { resolvePassword } = await import('../src/core/credential-store');
expect(await resolvePassword('admin@default')).toBe('from-env');
});
it('keychain entries win over encrypted and env (priority order)', async () => {
await writeConfigFile('encrypted'); // configured encrypted holds nothing here
keychainMock.available = true;
keychainMock.password = 'from-keychain';
vi.stubEnv('DC3_PASSWORD', 'from-env');
const { resolvePassword } = await import('../src/core/credential-store');
expect(await resolvePassword('admin@default')).toBe('from-keychain');
});
it('savePasswordToStore reports persisted:false for an unavailable store — never silent (F005)', async () => {
await writeConfigFile('keychain'); // unavailable
const { savePasswordToStore } = await import('../src/core/credential-store');
const result = await savePasswordToStore('admin@default', 'pw');
expect(result).toEqual({ persisted: false, store: 'keychain' });
});
it('savePasswordToStore never reports the env store as persisted (password_saved semantics)', async () => {
// The env store is "available" while DC3_PASSWORD is set, but it is
// process-scoped: nothing reaches persistent storage and the result must
// say so — even with the variable present.
await writeConfigFile('env');
vi.stubEnv('DC3_PASSWORD', 'from-env');
const { savePasswordToStore } = await import('../src/core/credential-store');
const result = await savePasswordToStore('admin@default', 'pw');
expect(result).toEqual({ persisted: false, store: 'env' });
// Without the variable the answer must not change either.
vi.unstubAllEnvs();
expect(await savePasswordToStore('admin@default', 'pw')).toEqual({
persisted: false,
store: 'env',
});
});
it('savePasswordToStore persists and reports the target store', async () => {
await writeConfigFile('encrypted');
const { savePasswordToStore, resolvePassword } = await import('../src/core/credential-store');
const result = await savePasswordToStore('admin@default', 'pw');
expect(result).toEqual({ persisted: true, store: 'encrypted' });
expect(await resolvePassword('admin@default')).toBe('pw');
});
it('savePasswordToStore honors an explicit profile name', async () => {
await mkdir(join(home, '.dc3'), { recursive: true });
await writeFile(
join(home, '.dc3', 'config.json'),
JSON.stringify({
version: 1,
current_profile: 'default',
settings: {},
profiles: {
default: { username: 'admin', credential_store: 'keychain' },
other: { username: 'bob', credential_store: 'encrypted' },
},
}),
'utf8',
);
const { savePasswordToStore } = await import('../src/core/credential-store');
const result = await savePasswordToStore('bob@default', 'pw', 'other');
expect(result).toEqual({ persisted: true, store: 'encrypted' });
});
it('deletePasswordFromStore is best-effort and never throws', async () => {
await writeConfigFile('keychain');
keychainMock.available = true;
const { deletePasswordFromStore } = await import('../src/core/credential-store');
await expect(deletePasswordFromStore('admin@default')).resolves.toBeUndefined();
expect(keychainMock.deleted).toEqual(['admin@default']);
});
it('deleting the last stored password removes the AES key with the ciphertext (logout hygiene)', async () => {
await writeConfigFile('encrypted');
const { EncryptedFileStore } = await import('../src/core/credential-encrypted');
await new EncryptedFileStore().savePassword('admin@default', 'pw');
expect(existsSync(join(home, '.dc3', 'credentials.enc'))).toBe(true);
expect(existsSync(join(home, '.dc3', 'credentials.key'))).toBe(true);
const { deletePasswordFromStore } = await import('../src/core/credential-store');
await deletePasswordFromStore('admin@default');
// No other profile holds an entry: logout must leave no decryptable slice
// — neither the ciphertext nor the key that could decrypt future captures.
expect(existsSync(join(home, '.dc3', 'credentials.enc'))).toBe(false);
expect(existsSync(join(home, '.dc3', 'credentials.key'))).toBe(false);
});
it('the AES key stays on disk while other entries still need it', async () => {
await writeConfigFile('encrypted');
const { EncryptedFileStore } = await import('../src/core/credential-encrypted');
await new EncryptedFileStore().savePassword('a@t1', 'pw-a');
await new EncryptedFileStore().savePassword('b@t2', 'pw-b');
const { deletePasswordFromStore } = await import('../src/core/credential-store');
await deletePasswordFromStore('a@t1');
expect(existsSync(join(home, '.dc3', 'credentials.enc'))).toBe(true);
expect(existsSync(join(home, '.dc3', 'credentials.key'))).toBe(true);
expect(await new EncryptedFileStore().getPassword('b@t2')).toBe('pw-b');
});
});
describe('config reset clears every local auth store (F016)', () => {
let home: string;
let stderr: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'dc3-cli-reset-'));
osStub.home = home;
keychainMock.available = false;
keychainMock.saved = [];
keychainMock.deleted = [];
vi.resetModules();
stderr = '';
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
stderr += args.join(' ');
});
});
afterEach(async () => {
vi.restoreAllMocks();
osStub.home = '';
await rm(home, { recursive: true, force: true });
});
function fakeTokenState() {
return {
token: 'tok-reset-test',
salt: 'salt',
tenant: 'tenantA',
username: 'admin',
issuedAt: 1,
expiresAt: 2_000_000_000,
};
}
it('resetAllLocalState wipes config, tokens, and stored passwords in one call', async () => {
await mkdir(join(home, '.dc3'), { recursive: true });
await writeFile(
join(home, '.dc3', 'config.json'),
JSON.stringify({
version: 1,
current_profile: 'default',
settings: {},
profiles: {
default: { username: 'admin', gateway: 'http://gw', tenant: 'tenantA' },
},
}),
'utf8',
);
await writeFile(join(home, '.dc3', 'tokens.json'), JSON.stringify({ default: fakeTokenState() }), 'utf8');
const encrypted = new (await import('../src/core/credential-encrypted')).EncryptedFileStore();
await encrypted.savePassword('admin@tenantA', 'pw');
expect(existsSync(join(home, '.dc3', 'credentials.enc'))).toBe(true);
expect(existsSync(join(home, '.dc3', 'credentials.key'))).toBe(true);
const { resetAllLocalState } = await import('../src/core/credential-store');
await resetAllLocalState();
expect(existsSync(join(home, '.dc3', 'tokens.json'))).toBe(false);
expect(existsSync(join(home, '.dc3', 'credentials.enc'))).toBe(false);
// The AES key goes with the ciphertext: a reset that kept it would leave
// a decryptable slice of local auth state behind (F016 residue).
expect(existsSync(join(home, '.dc3', 'credentials.key'))).toBe(false);
const config = JSON.parse(await readFile(join(home, '.dc3', 'config.json'), 'utf8'));
expect(config.profiles).toEqual({});
expect(config.current_profile).toBe('default');
const { tokenManager } = await import('../src/core/token-manager');
expect(await tokenManager.getAllStates()).toEqual({});
});
it('the encrypted store wipe removes key entries the identifier sources never knew about', async () => {
const encrypted = new (await import('../src/core/credential-encrypted')).EncryptedFileStore();
await encrypted.savePassword('ghost@tenantB', 'pw');
await encrypted.savePassword('lurker@tenantC', 'pw');
expect(existsSync(join(home, '.dc3', 'credentials.key'))).toBe(true);
const { clearAllStoredCredentials } = await import('../src/core/credential-store');
// No identifiers from token states at all: the wholesale store wipe must
// still remove every entry AND the AES key.
await clearAllStoredCredentials([]);
expect(await encrypted.getPassword('ghost@tenantB')).toBeNull();
expect(await encrypted.getPassword('lurker@tenantC')).toBeNull();
expect(existsSync(join(home, '.dc3', 'credentials.enc'))).toBe(false);
expect(existsSync(join(home, '.dc3', 'credentials.key'))).toBe(false);
});
it('reset also scrubs identifiers whose token is already gone (encrypted enumeration)', async () => {
const encrypted = new (await import('../src/core/credential-encrypted')).EncryptedFileStore();
await encrypted.savePassword('ghost@tenantB', 'pw');
const { resetAllLocalState } = await import('../src/core/credential-store');
await resetAllLocalState();
expect(await encrypted.getPassword('ghost@tenantB')).toBeNull();
});
it('a failing clearer warns loudly naming what survived; the config reset still lands', async () => {
await mkdir(join(home, '.dc3'), { recursive: true });
const { configManager } = await import('../src/core/config-manager');
await configManager.setProfile('default', { username: 'admin' });
await configManager.reset({
clearTokens: () => {
throw new Error('tokens locked');
},
});
expect(stderr).toContain('token state');
expect(stderr).toContain('tokens locked');
const config = JSON.parse(await readFile(join(home, '.dc3', 'config.json'), 'utf8'));
expect(config.profiles).toEqual({});
});
it('keychain entries are deleted through the reset path when the keychain is available', async () => {
keychainMock.available = true;
await mkdir(join(home, '.dc3'), { recursive: true });
await writeFile(
join(home, '.dc3', 'tokens.json'),
JSON.stringify({ default: fakeTokenState() }),
'utf8',
);
const { resetAllLocalState } = await import('../src/core/credential-store');
await resetAllLocalState();
expect(keychainMock.deleted).toContain('admin@tenantA');
});
});