420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
import {
|
|
chmod,
|
|
mkdtemp,
|
|
mkdir,
|
|
open,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
CredentialAuditJournal,
|
|
CredentialJournalError,
|
|
listCredentialJournals,
|
|
} from '../credentials/audit-journal.js';
|
|
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
|
import { FileCredentialStore } from '../credentials/file-credential-store.js';
|
|
import { executeCredentialRotate, executeCredentialWire } from './cred.js';
|
|
|
|
let cleanup: string | undefined;
|
|
afterEach(async (): Promise<void> => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
cleanup = undefined;
|
|
});
|
|
|
|
async function fixture(): Promise<{
|
|
readonly mosaicHome: string;
|
|
readonly registryPath: string;
|
|
readonly tokenDirectory: string;
|
|
readonly stateRoot: string;
|
|
}> {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-command-'));
|
|
await chmod(cleanup, 0o700);
|
|
const mosaicHome = join(cleanup, 'mosaic');
|
|
const credentialDirectory = join(mosaicHome, 'cred');
|
|
await mkdir(credentialDirectory, { recursive: true, mode: 0o700 });
|
|
const registryPath = join(credentialDirectory, 'estates.json');
|
|
await writeFile(
|
|
registryPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
estates: [
|
|
{
|
|
name: 'homelab',
|
|
hosts: [
|
|
{
|
|
host: 'git.example.invalid',
|
|
provider: 'gitea',
|
|
apiBaseUrl: 'https://git.example.invalid',
|
|
tokenPrefix: 'gitea-example',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
return {
|
|
mosaicHome,
|
|
registryPath,
|
|
tokenDirectory: join(mosaicHome, 'secrets', 'gitea-tokens'),
|
|
stateRoot: join(cleanup, 'state'),
|
|
};
|
|
}
|
|
|
|
describe('credential lifecycle command controls', (): void => {
|
|
it('returns the visible open rotation journal when protected authority resolution fails', async (): Promise<void> => {
|
|
const paths = await fixture();
|
|
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
|
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
|
|
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
|
await store.put(
|
|
{
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
providerLogin: 'seat-name',
|
|
tokenName: 'old-generation',
|
|
scopes: ['write:repository'],
|
|
createdAt: '2026-08-05T00:00:00.000Z',
|
|
},
|
|
new TextEncoder().encode('old-token-canary'),
|
|
);
|
|
|
|
const result = await executeCredentialRotate('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: '999',
|
|
tokenName: 'new-generation',
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
});
|
|
|
|
expect(result.outcome).toBe('error');
|
|
expect(result.mutation).toBe('none');
|
|
expect(result.audit.state).toBe('open');
|
|
expect(result.audit.journalId).not.toBeNull();
|
|
await expect(listCredentialJournals(paths.stateRoot)).resolves.toContainEqual(
|
|
expect.objectContaining({ id: result.audit.journalId, state: 'open' }),
|
|
);
|
|
});
|
|
|
|
it('refuses an unauthenticated actor before rewriting another seat environment', async (): Promise<void> => {
|
|
const paths = await fixture();
|
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
const before = 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n';
|
|
await writeFile(seatEnvironment, before, { mode: 0o600 });
|
|
|
|
const result = await executeCredentialWire('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'intruder-seat',
|
|
seatEnv: seatEnvironment,
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
});
|
|
|
|
expect(result.outcome).toBe('refused');
|
|
expect(result.mutation).toBe('none');
|
|
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
|
|
});
|
|
|
|
it('authenticates the exact seat and rewrites its roster-derived projection idempotently', async (): Promise<void> => {
|
|
const paths = await fixture();
|
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n', {
|
|
mode: 0o600,
|
|
});
|
|
const authorityPath = join(cleanup!, 'authority.json');
|
|
await writeFile(
|
|
authorityPath,
|
|
JSON.stringify({
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
secret: 'authority-canary',
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
async (): Promise<Response> =>
|
|
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
|
|
const invoke = async () => {
|
|
const authority = await open(authorityPath, 'r');
|
|
try {
|
|
return await executeCredentialWire('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: authority.fd.toString(),
|
|
seatEnv: seatEnvironment,
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
});
|
|
} finally {
|
|
await authority.close();
|
|
}
|
|
};
|
|
|
|
const first = await invoke();
|
|
const afterFirst = await readFile(seatEnvironment, 'utf8');
|
|
const second = await invoke();
|
|
const afterSecond = await readFile(seatEnvironment, 'utf8');
|
|
|
|
expect(first.outcome).toBe('ok');
|
|
expect(second.outcome).toBe('ok');
|
|
expect(afterSecond).toBe(afterFirst);
|
|
expect(afterSecond).toContain('MOSAIC_GIT_IDENTITY=seat-name\n');
|
|
expect(afterSecond).toContain('MOSAIC_CREDENTIAL_ESTATE=homelab\n');
|
|
expect(afterSecond).toContain('GITEA_LOGIN=seat-name--git.example.invalid\n');
|
|
});
|
|
|
|
it.each(['recordMutation', 'seal'] as const)(
|
|
'reports an applied wire as indeterminate when audit %s fails after rename',
|
|
async (method): Promise<void> => {
|
|
const paths = await fixture();
|
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
|
const authorityPath = join(cleanup!, 'authority.json');
|
|
await writeFile(
|
|
authorityPath,
|
|
JSON.stringify({
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
secret: 'authority-canary',
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
async (): Promise<Response> =>
|
|
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
vi.spyOn(CredentialAuditJournal.prototype, method).mockRejectedValueOnce(
|
|
new CredentialJournalError('journal-unavailable', 'injected audit failure'),
|
|
);
|
|
const authority = await open(authorityPath, 'r');
|
|
try {
|
|
const result = await executeCredentialWire('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: authority.fd.toString(),
|
|
seatEnv: seatEnvironment,
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
});
|
|
expect(result.outcome).toBe('indeterminate');
|
|
expect(result.mutation).toBe('applied');
|
|
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
|
|
} finally {
|
|
await authority.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it('refuses to overwrite a roster projection replaced after validation', async (): Promise<void> => {
|
|
const paths = await fixture();
|
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
|
const authorityPath = join(cleanup!, 'authority.json');
|
|
await writeFile(
|
|
authorityPath,
|
|
JSON.stringify({
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
secret: 'authority-canary',
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
async (): Promise<Response> =>
|
|
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
const authority = await open(authorityPath, 'r');
|
|
try {
|
|
const result = await executeCredentialWire('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: authority.fd.toString(),
|
|
seatEnv: seatEnvironment,
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
wireBeforeRename: async (): Promise<void> => {
|
|
const replacement = join(agents, 'replacement');
|
|
await writeFile(replacement, 'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n', { mode: 0o600 });
|
|
await rename(replacement, seatEnvironment);
|
|
},
|
|
});
|
|
expect(result.outcome).toBe('error');
|
|
expect(result.mutation).toBe('none');
|
|
expect(await readFile(seatEnvironment, 'utf8')).toBe(
|
|
'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n',
|
|
);
|
|
} finally {
|
|
await authority.close();
|
|
}
|
|
});
|
|
|
|
it('reports directory-sync failure after rename as applied and indeterminate', async (): Promise<void> => {
|
|
const paths = await fixture();
|
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
|
const authorityPath = join(cleanup!, 'authority.json');
|
|
await writeFile(
|
|
authorityPath,
|
|
JSON.stringify({
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
secret: 'authority-canary',
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
async (): Promise<Response> =>
|
|
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
const authority = await open(authorityPath, 'r');
|
|
try {
|
|
const result = await executeCredentialWire('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: authority.fd.toString(),
|
|
seatEnv: seatEnvironment,
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
wireDirectorySync: async (): Promise<void> => {
|
|
throw new Error('injected directory sync failure');
|
|
},
|
|
});
|
|
expect(result.outcome).toBe('indeterminate');
|
|
expect(result.mutation).toBe('applied');
|
|
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
|
|
} finally {
|
|
await authority.close();
|
|
}
|
|
});
|
|
|
|
it('removes a temporary projection when directory revalidation fails before rename', async (): Promise<void> => {
|
|
const paths = await fixture();
|
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
|
const authorityPath = join(cleanup!, 'authority.json');
|
|
await writeFile(
|
|
authorityPath,
|
|
JSON.stringify({
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
secret: 'authority-canary',
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
vi.stubGlobal('fetch', async (): Promise<Response> => {
|
|
await chmod(agents, 0o777);
|
|
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
const authority = await open(authorityPath, 'r');
|
|
try {
|
|
const result = await executeCredentialWire('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: authority.fd.toString(),
|
|
seatEnv: seatEnvironment,
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
});
|
|
expect(result.outcome).toBe('error');
|
|
expect(await readdir(agents)).toEqual(['seat-name.env.generated']);
|
|
} finally {
|
|
await authority.close();
|
|
await chmod(agents, 0o700);
|
|
}
|
|
});
|
|
|
|
it('refuses a caller-selected seat filename that is not bound to the requested identity', async (): Promise<void> => {
|
|
const paths = await fixture();
|
|
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
const seatEnvironment = join(agents, 'other-seat.env.generated');
|
|
const before = 'MOSAIC_AGENT_NAME=other-seat\nMOSAIC_AGENT_CLASS=coder\n';
|
|
await writeFile(seatEnvironment, before, { mode: 0o600 });
|
|
|
|
const result = await executeCredentialWire('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: '999',
|
|
seatEnv: seatEnvironment,
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
});
|
|
|
|
expect(result.outcome).toBe('refused');
|
|
expect(result.reason.code).toBe('credential-binding-mismatch');
|
|
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
|
|
});
|
|
});
|