161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
import { chmod, copyFile, mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { mkdtemp } from 'node:fs/promises';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { rm } from 'node:fs/promises';
|
|
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
|
import { FileCredentialResolver, FileCredentialStore } from './file-credential-store.js';
|
|
|
|
let cleanup: string | undefined;
|
|
|
|
async function fixtureRoot(): Promise<string> {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-store-'));
|
|
const root = join(cleanup, 'tokens');
|
|
await mkdir(root, { mode: 0o700 });
|
|
return root;
|
|
}
|
|
|
|
function registry(): ReturnType<typeof parseCredentialEstateRegistry> {
|
|
return parseCredentialEstateRegistry(
|
|
JSON.stringify({
|
|
version: 1,
|
|
estates: [
|
|
{
|
|
name: 'homelab',
|
|
hosts: [
|
|
{
|
|
host: 'git.example.invalid',
|
|
provider: 'gitea',
|
|
apiBaseUrl: 'https://git.example.invalid',
|
|
tokenPrefix: 'gitea-example',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
afterEach(async (): Promise<void> => {
|
|
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
cleanup = undefined;
|
|
});
|
|
|
|
describe('phase-1 governed file credential resolver', (): void => {
|
|
it('resolves only the exact estate/host/identity token at a test-overridable root', async (): Promise<void> => {
|
|
const root = await fixtureRoot();
|
|
await writeFile(join(root, 'gitea-example-seat.token'), 'canary-token', { mode: 0o600 });
|
|
const resolver = new FileCredentialResolver(root, registry());
|
|
|
|
const resolved = await resolver.resolve('seat', 'homelab', 'git.example.invalid');
|
|
const wrongEstate = await resolver.resolve('seat', 'usc', 'git.example.invalid');
|
|
|
|
expect(resolved?.identity).toBe('seat');
|
|
expect(Buffer.from(resolved?.secret ?? []).toString('utf8')).toBe('canary-token');
|
|
expect(wrongEstate).toBeUndefined();
|
|
});
|
|
|
|
it('rejects a group-writable token directory even when the token file is private', async (): Promise<void> => {
|
|
const root = await fixtureRoot();
|
|
await writeFile(join(root, 'gitea-example-seat-name.token'), 'private-token', {
|
|
mode: 0o600,
|
|
});
|
|
await chmod(root, 0o770);
|
|
const resolver = new FileCredentialResolver(root, registry());
|
|
|
|
await expect(resolver.resolve('seat-name', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
|
/insecure-token-owner/,
|
|
);
|
|
});
|
|
|
|
it('rejects a token file with group or other permissions', async (): Promise<void> => {
|
|
const root = await fixtureRoot();
|
|
const path = join(root, 'gitea-example-seat.token');
|
|
await writeFile(path, 'canary-token', { mode: 0o600 });
|
|
await chmod(path, 0o640);
|
|
const resolver = new FileCredentialResolver(root, registry());
|
|
|
|
await expect(resolver.resolve('seat', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
|
/insecure-token-mode/,
|
|
);
|
|
});
|
|
|
|
it('rejects a symlinked token instead of following it', async (): Promise<void> => {
|
|
const root = await fixtureRoot();
|
|
const target = join(cleanup ?? root, 'outside-token');
|
|
await writeFile(target, 'canary-token', { mode: 0o600 });
|
|
await symlink(target, join(root, 'gitea-example-seat.token'));
|
|
const resolver = new FileCredentialResolver(root, registry());
|
|
|
|
await expect(resolver.resolve('seat', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
|
/symbolic link|unavailable/,
|
|
);
|
|
});
|
|
|
|
it('rejects traversal-shaped identities before touching storage', async (): Promise<void> => {
|
|
const root = await fixtureRoot();
|
|
const resolver = new FileCredentialResolver(root, registry());
|
|
|
|
await expect(resolver.resolve('../other', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
|
/invalid-identity/,
|
|
);
|
|
});
|
|
|
|
it('atomically stores, lists, reads binding metadata, and removes a governed credential', async (): Promise<void> => {
|
|
const root = await fixtureRoot();
|
|
const store = new FileCredentialStore(root, registry());
|
|
await store.put(
|
|
{
|
|
identity: 'seat',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
providerLogin: 'seat',
|
|
tokenName: 'mosaic-seat-1',
|
|
scopes: ['write:repository'],
|
|
createdAt: '2026-08-05T00:00:00.000Z',
|
|
},
|
|
new TextEncoder().encode('new-private-token'),
|
|
);
|
|
|
|
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
|
|
await expect(
|
|
store.readBinding('seat', 'homelab', 'git.example.invalid'),
|
|
).resolves.toMatchObject({
|
|
providerLogin: 'seat',
|
|
tokenName: 'mosaic-seat-1',
|
|
});
|
|
await expect(
|
|
new FileCredentialResolver(root, registry()).resolve(
|
|
'seat',
|
|
'homelab',
|
|
'git.example.invalid',
|
|
),
|
|
).resolves.toMatchObject({ identity: 'seat' });
|
|
await copyFile(
|
|
join(root, 'gitea-example-seat.credential.json'),
|
|
join(root, 'gitea-example-other.credential.json'),
|
|
);
|
|
await expect(
|
|
new FileCredentialResolver(root, registry()).resolve(
|
|
'other',
|
|
'homelab',
|
|
'git.example.invalid',
|
|
),
|
|
).rejects.toThrow(/credential-binding-mismatch/);
|
|
await unlink(join(root, 'gitea-example-other.credential.json'));
|
|
await store.remove('seat', 'homelab', 'git.example.invalid');
|
|
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
|
});
|
|
|
|
it('returns undefined for an absent token without borrowing another identity', async (): Promise<void> => {
|
|
const root = await fixtureRoot();
|
|
await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 });
|
|
const resolver = new FileCredentialResolver(root, registry());
|
|
|
|
const resolved = await resolver.resolve('missing-seat', 'homelab', 'git.example.invalid');
|
|
|
|
expect(resolved).toBeUndefined();
|
|
});
|
|
});
|