Files
iot-dc3/dc3-cli/test/credential-encrypted.test.ts
pnokerandClaude Code 5deea71fa8 fix(cli): root-cause attachment protocol, config bootstrap, and core type errors
attachment upload now sends multipart form-data (the spring
@requestpart contract) instead of an octet-stream body with a custom
header, matching the device import grammar. the profile schema allows
an empty username during bootstrap so config set gateway works on a
fresh install without a chicken-and-egg ordering, and getActiveProfile
enforces the username at consumption time with a clear actionable
message. fixes two pre-existing type errors in atomic-fs (pid possibly
null) and credential-encrypted (record type narrowing). 303 tests.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-06 12:30:56 +08:00

199 lines
7.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createCipheriv, randomBytes, scryptSync } from 'node:crypto';
// Redirect os.homedir() to a per-test temp dir; the store bakes its paths at
// module load, hence resetModules + dynamic imports after the stub is set.
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()),
};
});
describe('EncryptedFileStore (random key file, no plaintext, quarantine)', () => {
let home: string;
let encPath: string;
let keyPath: string;
let stderr: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'dc3-cli-enc-'));
osStub.home = home;
encPath = join(home, '.dc3', 'credentials.enc');
keyPath = join(home, '.dc3', 'credentials.key');
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 });
});
async function freshStore() {
const { EncryptedFileStore } = await import('../src/core/credential-encrypted');
return new EncryptedFileStore();
}
it('never writes the password (or any entries copy) to disk (F002)', async () => {
const store = await freshStore();
await store.savePassword('admin@default', 'Plain#Pass9');
const raw = await readFile(encPath, 'utf8');
// Negative guards: the defect CANNOT recur — no plaintext, no entries echo.
expect(raw.includes('Plain#Pass9')).toBe(false);
expect(raw.includes('admin@default')).toBe(false);
const parsed = JSON.parse(raw) as Record<string, unknown>;
expect(parsed.entries).toBeUndefined();
expect(parsed.v).toBe(2);
// Round-trip still works.
expect(await store.getPassword('admin@default')).toBe('Plain#Pass9');
});
it('keeps entries of other profiles when one is overwritten', async () => {
const store = await freshStore();
await store.savePassword('a@t1', 'pw-a');
await store.savePassword('b@t2', 'pw-b');
await store.savePassword('a@t1', 'pw-a2');
expect(await store.getPassword('a@t1')).toBe('pw-a2');
expect(await store.getPassword('b@t2')).toBe('pw-b');
});
it('parallel saves keep every entry — concurrent logins lose nothing (F011)', async () => {
const store = await freshStore();
await Promise.all(
Array.from({ length: 6 }, (_, i) => store.savePassword(`agent${i}@tenant`, `pw-${i}`)),
);
for (let i = 0; i < 6; i++) {
expect(await store.getPassword(`agent${i}@tenant`)).toBe(`pw-${i}`);
}
});
it('without the key file the store is undecryptable: quarantine + loud warning (F002)', async () => {
const store = await freshStore();
await store.savePassword('admin@default', 'Plain#Pass9');
await rm(keyPath);
expect(await store.getPassword('admin@default')).toBeNull();
expect(stderr).toContain('credentials.key');
expect(stderr).toContain('credentials.enc');
const quarantined = (await readdir(join(home, '.dc3'))).filter((n) =>
n.startsWith('credentials.enc.corrupt-'),
);
// The previous bytes survive for recovery; a fresh write cannot destroy them.
expect(quarantined.length).toBe(1);
});
it('a corrupt store is quarantined and warned, never silently reset (F027)', async () => {
const store = await freshStore();
await store.savePassword('admin@default', 'Plain#Pass9');
await writeFile(encPath, 'GARBAGE-NOT-JSON', 'utf8');
expect(await store.getPassword('admin@default')).toBeNull();
expect(stderr).toContain('not valid JSON');
expect((await readdir(join(home, '.dc3'))).some((n) => n.startsWith('credentials.enc.corrupt-'))).toBe(true);
// Re-login after corruption starts fresh but preserves the quarantine copy.
await store.savePassword('new@default', 'pw-new');
expect(await store.getPassword('new@default')).toBe('pw-new');
expect((await readdir(join(home, '.dc3'))).filter((n) => n.startsWith('credentials.enc.corrupt-')).length).toBe(1);
});
it('legacy files with plaintext entries are scrubbed (re-encrypted) on first read (F002 migration)', async () => {
const os = await import('node:os');
const legacyKey = scryptSync(
`${os.hostname()}-${os.userInfo().username}-${os.arch()}`,
'dc3-cli-static-salt',
32,
);
const entries = { 'admin@default': 'Legacy#Pass1' };
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', legacyKey, iv);
const data = Buffer.concat([cipher.update(JSON.stringify(entries), 'utf8'), cipher.final()]);
await mkdir(join(home, '.dc3'), { recursive: true });
await writeFile(
encPath,
JSON.stringify({
iv: iv.toString('hex'),
tag: cipher.getAuthTag().toString('hex'),
data: data.toString('hex'),
entries, // the leaked plaintext copy
}),
'utf8',
);
const store = await freshStore();
expect(await store.getPassword('admin@default')).toBe('Legacy#Pass1');
expect(stderr).toContain('migrated');
const raw = await readFile(encPath, 'utf8');
expect(raw.includes('Legacy#Pass1')).toBe(false);
expect(raw.includes('admin@default')).toBe(false);
expect((JSON.parse(raw) as Record<string, unknown>).entries).toBeUndefined();
expect(existsSync(keyPath)).toBe(true);
});
it('legacy files that cannot be decrypted are quarantined with a warning', async () => {
await mkdir(join(home, '.dc3'), { recursive: true });
await writeFile(
encPath,
JSON.stringify({
iv: randomBytes(16).toString('hex'),
tag: randomBytes(16).toString('hex'),
data: randomBytes(64).toString('hex'),
entries: { 'admin@default': 'deadbeef' },
}),
'utf8',
);
const store = await freshStore();
expect(await store.getPassword('admin@default')).toBeNull();
expect(stderr).toContain('legacy');
expect((await readdir(join(home, '.dc3'))).some((n) => n.startsWith('credentials.enc.corrupt-'))).toBe(true);
});
it('deleting the last entry removes the file (logout hygiene)', async () => {
const store = await freshStore();
await store.savePassword('admin@default', 'pw');
await store.deletePassword('admin@default');
expect(existsSync(encPath)).toBe(false);
expect(await store.getPassword('admin@default')).toBeNull();
});
it('getAllIdentifiers lists stored entries (reset scrubbing)', async () => {
const store = await freshStore();
await store.savePassword('a@t1', 'pw-a');
await store.savePassword('b@t2', 'pw-b');
expect((await store.getAllIdentifiers()).sort()).toEqual(['a@t1', 'b@t2']);
});
it('credentials.enc and credentials.key are owner-only (F026)', async () => {
const store = await freshStore();
await store.savePassword('admin@default', 'pw');
const { stat } = await import('node:fs/promises');
if (process.platform !== 'win32') {
expect((await stat(encPath)).mode & 0o777).toBe(0o600);
expect((await stat(keyPath)).mode & 0o777).toBe(0o600);
return;
}
const { execFile } = await import('node:child_process');
const { promisify } = await import('node:util');
const icacls = promisify(execFile);
for (const path of [encPath, keyPath]) {
const { stdout } = await icacls('icacls', [path], { windowsHide: true });
expect(stdout).not.toMatch(/\(I\)/u);
}
});
});