* add external bws Bitwarden secrets provider * add self-hosted Bitwarden server support
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
/** Focused tests for the external-bws Bitwarden Secrets Manager provider. */
|
|
|
|
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
const { bitwardenProvider } = await import('../src/lib/server/secretproviders/bitwarden.ts');
|
|
const { getProvider } = await import('../src/lib/server/secretproviders/index.ts');
|
|
const { UnsupportedOperationError } = await import('../src/lib/server/secretproviders/shared.ts');
|
|
|
|
const PROJECT_ID = '123e4567-e89b-42d3-a456-426614174000';
|
|
const TOKEN = 'test-machine-account-token';
|
|
const originalBwsPath = process.env.DOCKHAND_BWS_PATH;
|
|
let testDir = '';
|
|
let fakeCounter = 0;
|
|
|
|
beforeEach(async () => {
|
|
testDir = await mkdtemp(join(tmpdir(), 'dockhand-bitwarden-test-'));
|
|
delete process.env.DOCKHAND_BWS_PATH;
|
|
process.env.UNRELATED_DOCKHAND_SECRET = 'must-not-reach-child';
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (originalBwsPath === undefined) delete process.env.DOCKHAND_BWS_PATH;
|
|
else process.env.DOCKHAND_BWS_PATH = originalBwsPath;
|
|
delete process.env.UNRELATED_DOCKHAND_SECRET;
|
|
await rm(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function installFakeBws(source: string): Promise<string> {
|
|
const path = join(testDir, `bws-${fakeCounter++}`);
|
|
await writeFile(path, `#!${process.execPath}\n${source}\n`, { mode: 0o700 });
|
|
await chmod(path, 0o700);
|
|
process.env.DOCKHAND_BWS_PATH = path;
|
|
return path;
|
|
}
|
|
|
|
async function installSecretResponse(payloadSource: string): Promise<void> {
|
|
await installFakeBws(`
|
|
const args = process.argv.slice(2);
|
|
if (JSON.stringify(args) !== JSON.stringify(['secret', 'list', '${PROJECT_ID}', '--output', 'json', '--color', 'no'])) process.exit(41);
|
|
if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}') process.exit(42);
|
|
if (args.includes('${TOKEN}') || process.env.UNRELATED_DOCKHAND_SECRET) process.exit(43);
|
|
process.stdout.write(${payloadSource});
|
|
`);
|
|
}
|
|
|
|
describe('bitwardenProvider capabilities', () => {
|
|
test('is registered as a bulk-only provider', () => {
|
|
expect(getProvider('bitwarden')).toBe(bitwardenProvider);
|
|
expect(bitwardenProvider.type).toBe('bitwarden');
|
|
expect(bitwardenProvider.label).toBe('Bitwarden Secrets Manager');
|
|
expect(bitwardenProvider.supportsBulk).toBe(true);
|
|
expect(bitwardenProvider.supportsReferences).toBe(false);
|
|
expect(bitwardenProvider.isReference('bw://anything')).toBe(false);
|
|
});
|
|
|
|
test('rejects inline reference resolution', async () => {
|
|
await expect(bitwardenProvider.resolveSecretReferences({ token: TOKEN }, [])).rejects.toThrow(
|
|
UnsupportedOperationError
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('testConnection', () => {
|
|
test('checks the compatible version, then performs an authenticated project query', async () => {
|
|
const logPath = join(testDir, 'calls.jsonl');
|
|
await installFakeBws(`
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({
|
|
args,
|
|
hasToken: Boolean(process.env.BWS_ACCESS_TOKEN),
|
|
home: process.env.HOME,
|
|
sameHome: process.env.HOME === process.env.XDG_CONFIG_HOME,
|
|
leaked: Boolean(process.env.UNRELATED_DOCKHAND_SECRET)
|
|
}) + '\\n');
|
|
if (args.length === 1 && args[0] === '--version') {
|
|
if (process.env.BWS_ACCESS_TOKEN) process.exit(51);
|
|
console.log('bws 2.1.0');
|
|
} else if (JSON.stringify(args) === JSON.stringify(['project', 'list', '--output', 'json', '--color', 'no'])) {
|
|
if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}' || args.includes('${TOKEN}')) process.exit(52);
|
|
console.log('[]');
|
|
} else {
|
|
process.exit(53);
|
|
}
|
|
`);
|
|
|
|
expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({ ok: true });
|
|
const calls = (await readFile(logPath, 'utf8'))
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => JSON.parse(line));
|
|
expect(calls.map((call) => call.args)).toEqual([
|
|
['--version'],
|
|
['project', 'list', '--output', 'json', '--color', 'no']
|
|
]);
|
|
expect(calls[0].hasToken).toBe(false);
|
|
expect(calls[1].hasToken).toBe(true);
|
|
expect(calls.every((call) => call.sameHome && !call.leaked)).toBe(true);
|
|
expect(calls.every((call) => !existsSync(call.home))).toBe(true);
|
|
});
|
|
|
|
test('configures a safe self-hosted server in the isolated profile before authentication', async () => {
|
|
const logPath = join(testDir, 'self-hosted-calls.jsonl');
|
|
await installFakeBws(`
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({
|
|
args,
|
|
hasToken: Boolean(process.env.BWS_ACCESS_TOKEN),
|
|
home: process.env.HOME
|
|
}) + '\\n');
|
|
if (args.length === 1 && args[0] === '--version') {
|
|
console.log('bws 2.1.0');
|
|
} else if (JSON.stringify(args) === JSON.stringify(['config', 'server-base', 'https://bitwarden.example.com'])) {
|
|
if (process.env.BWS_ACCESS_TOKEN) process.exit(54);
|
|
} else if (JSON.stringify(args) === JSON.stringify(['project', 'list', '--output', 'json', '--color', 'no'])) {
|
|
if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}') process.exit(55);
|
|
console.log('[]');
|
|
} else {
|
|
process.exit(56);
|
|
}
|
|
`);
|
|
|
|
expect(
|
|
await bitwardenProvider.testConnection({
|
|
token: TOKEN,
|
|
serverUrl: ' https://bitwarden.example.com/ '
|
|
})
|
|
).toEqual({ ok: true });
|
|
const calls = (await readFile(logPath, 'utf8'))
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => JSON.parse(line));
|
|
expect(calls.map((call) => call.args)).toEqual([
|
|
['--version'],
|
|
['config', 'server-base', 'https://bitwarden.example.com'],
|
|
['project', 'list', '--output', 'json', '--color', 'no']
|
|
]);
|
|
expect(calls.map((call) => call.hasToken)).toEqual([false, false, true]);
|
|
expect(calls[1].home).toBe(calls[2].home);
|
|
expect(calls.every((call) => !existsSync(call.home))).toBe(true);
|
|
});
|
|
|
|
test('rejects an unsafe self-hosted server before executing bws', async () => {
|
|
process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws');
|
|
expect(
|
|
await bitwardenProvider.testConnection({ token: TOKEN, serverUrl: 'http://127.0.0.1' })
|
|
).toEqual({
|
|
ok: false,
|
|
error:
|
|
'Bitwarden Secrets Manager: host not allowed (loopback address (127.0.0.1) blocked)'
|
|
});
|
|
});
|
|
|
|
test('rejects missing, relative-path, and incompatible clients with sanitized errors', async () => {
|
|
process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws');
|
|
expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({
|
|
ok: false,
|
|
error: 'Bitwarden bws executable was not found'
|
|
});
|
|
|
|
process.env.DOCKHAND_BWS_PATH = 'relative/bws';
|
|
expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({
|
|
ok: false,
|
|
error: 'Bitwarden bws executable path must be absolute'
|
|
});
|
|
|
|
for (const version of ['2.0.9', '2.1.0-beta.1', '3.0.0']) {
|
|
await installFakeBws(`console.log('bws ${version}');`);
|
|
expect(await bitwardenProvider.testConnection({ token: TOKEN })).toEqual({
|
|
ok: false,
|
|
error: 'Bitwarden bws version must be >=2.1.0 and <3.0.0'
|
|
});
|
|
}
|
|
});
|
|
|
|
test('rejects an empty token before executing bws', async () => {
|
|
process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws');
|
|
expect(await bitwardenProvider.testConnection({ token: ' ' })).toEqual({
|
|
ok: false,
|
|
error: 'Bitwarden Machine Account access token is empty'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('resolveBulk', () => {
|
|
test('uses the exact command and returns a null-prototype string record', async () => {
|
|
const statePath = join(testDir, 'state-home');
|
|
await installFakeBws(`
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
if (JSON.stringify(args) !== JSON.stringify(['secret', 'list', '${PROJECT_ID}', '--output', 'json', '--color', 'no'])) process.exit(61);
|
|
if (process.env.BWS_ACCESS_TOKEN !== '${TOKEN}' || args.includes('${TOKEN}')) process.exit(62);
|
|
if (process.env.UNRELATED_DOCKHAND_SECRET || process.env.HOME !== process.env.XDG_CONFIG_HOME) process.exit(63);
|
|
fs.writeFileSync(${JSON.stringify(statePath)}, process.env.HOME);
|
|
console.log(JSON.stringify([{ key: 'DB_PASSWORD', value: 'hunter2' }, { key: '_PORT', value: '5432' }]));
|
|
`);
|
|
|
|
const result = await bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID);
|
|
expect(Object.getPrototypeOf(result)).toBeNull();
|
|
expect(Object.entries(result)).toEqual([
|
|
['DB_PASSWORD', 'hunter2'],
|
|
['_PORT', '5432']
|
|
]);
|
|
const stateHome = await readFile(statePath, 'utf8');
|
|
expect(existsSync(stateHome)).toBe(false);
|
|
});
|
|
|
|
test('validates the Project UUID before execution', async () => {
|
|
process.env.DOCKHAND_BWS_PATH = join(testDir, 'missing-bws');
|
|
await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, 'not-a-uuid')).rejects.toThrow(
|
|
'Bitwarden Project selector must be a valid UUID'
|
|
);
|
|
});
|
|
|
|
test('rejects duplicate, invalid, dangerous, and malformed secret fields', async () => {
|
|
const cases: Array<[unknown, string]> = [
|
|
[
|
|
[
|
|
{ key: 'DUPLICATE', value: 'one' },
|
|
{ key: 'DUPLICATE', value: 'two' }
|
|
],
|
|
'duplicate secret keys'
|
|
],
|
|
[[{ key: 'BAD-NAME', value: 'value' }], 'not a valid environment variable name'],
|
|
[[{ key: 'constructor', value: 'value' }], 'secret key is not allowed'],
|
|
[[{ key: 'PORT', value: 5432 }], 'invalid secret list'],
|
|
[[{ key: 'VALUE', value: 'contains\0nul' }], 'invalid secret list'],
|
|
[['not-an-object'], 'invalid secret list']
|
|
];
|
|
|
|
for (const [payload, expected] of cases) {
|
|
await installSecretResponse(JSON.stringify(JSON.stringify(payload)));
|
|
await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow(
|
|
expected
|
|
);
|
|
}
|
|
});
|
|
|
|
test('rejects malformed JSON and a non-array root', async () => {
|
|
await installSecretResponse(JSON.stringify('{broken'));
|
|
await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow(
|
|
'Bitwarden bws returned invalid JSON'
|
|
);
|
|
|
|
await installSecretResponse(JSON.stringify('{"key":"value"}'));
|
|
await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow(
|
|
'Bitwarden bws returned an invalid JSON response'
|
|
);
|
|
});
|
|
|
|
test('does not expose stderr, tokens, or secret output on process failure', async () => {
|
|
await installFakeBws(`
|
|
process.stdout.write('secret-output');
|
|
process.stderr.write('raw stderr ${TOKEN} another-secret');
|
|
process.exit(7);
|
|
`);
|
|
try {
|
|
await bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID);
|
|
throw new Error('expected resolveBulk to fail');
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
expect(message).toBe('Bitwarden bws command failed');
|
|
expect(message).not.toContain(TOKEN);
|
|
expect(message).not.toContain('secret-output');
|
|
expect(message).not.toContain('raw stderr');
|
|
}
|
|
});
|
|
|
|
test('bounds stdout and terminates an oversized command', async () => {
|
|
await installFakeBws(`
|
|
process.on('SIGTERM', () => process.exit(0));
|
|
process.stdout.write('x'.repeat(11 * 1024 * 1024));
|
|
setTimeout(() => {}, 60_000);
|
|
`);
|
|
await expect(bitwardenProvider.resolveBulk({ token: TOKEN }, PROJECT_ID)).rejects.toThrow(
|
|
'Bitwarden bws command exceeded the stdout limit'
|
|
);
|
|
});
|
|
});
|