796 lines
29 KiB
TypeScript
796 lines
29 KiB
TypeScript
import {
|
|
chmod,
|
|
mkdtemp,
|
|
mkdir,
|
|
open,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
symlink,
|
|
unlink,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import { writeSync } from 'node:fs';
|
|
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 { credentialLifecycleLocksDirectory } from '../credentials/lifecycle.js';
|
|
import { TeaLoginStore } from '../credentials/tea-login-store.js';
|
|
import { executeCredentialGet, 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.each(['partialWrite', 'shortWrite', 'recordMutation', 'seal'] as const)(
|
|
'handles %s without overstating credential disclosure',
|
|
async (method): 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: 'active-generation',
|
|
scopes: ['write:repository'],
|
|
createdAt: '2026-08-05T00:00:00.000Z',
|
|
},
|
|
new TextEncoder().encode('seat-token-canary'),
|
|
);
|
|
const authorityPath = join(cleanup!, 'authority.json');
|
|
const outputPath = join(cleanup!, 'credential.out');
|
|
await writeFile(
|
|
authorityPath,
|
|
JSON.stringify({
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
secret: 'seat-token-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' },
|
|
}),
|
|
);
|
|
if (method === 'recordMutation') {
|
|
const original = CredentialAuditJournal.prototype.recordMutation;
|
|
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
|
|
async function (this: CredentialAuditJournal, decision): Promise<void> {
|
|
if (decision === 'credential-issued') {
|
|
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
|
|
}
|
|
await original.call(this, decision);
|
|
},
|
|
);
|
|
} else if (method === 'seal') {
|
|
const original = CredentialAuditJournal.prototype.seal;
|
|
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
|
this: CredentialAuditJournal,
|
|
outcome,
|
|
reason,
|
|
): Promise<string> {
|
|
if (outcome === 'ok') {
|
|
throw new CredentialJournalError('journal-unavailable', 'injected seal failure');
|
|
}
|
|
return original.call(this, outcome, reason);
|
|
});
|
|
}
|
|
let writes = 0;
|
|
const authority = await open(authorityPath, 'r');
|
|
const output = await open(outputPath, 'w+', 0o600);
|
|
try {
|
|
const result = await executeCredentialGet('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: authority.fd.toString(),
|
|
outputFd: output.fd.toString(),
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
credentialWrite:
|
|
method === 'partialWrite'
|
|
? (fd, data): number => {
|
|
writes += 1;
|
|
if (writes === 2) throw new Error('injected partial write failure');
|
|
return writeSync(fd, data);
|
|
}
|
|
: method === 'shortWrite'
|
|
? (fd, data): number =>
|
|
writeSync(fd, data.subarray(0, Math.max(1, Math.floor(data.byteLength / 2))))
|
|
: undefined,
|
|
});
|
|
if (method === 'shortWrite') {
|
|
expect(result).toMatchObject({
|
|
outcome: 'ok',
|
|
mutation: 'none',
|
|
reason: { code: 'get-verified' },
|
|
});
|
|
} else {
|
|
expect(result).toMatchObject({
|
|
outcome: 'indeterminate',
|
|
mutation: method === 'partialWrite' ? 'unknown' : 'applied',
|
|
reason: { code: 'credential-issuance-indeterminate' },
|
|
});
|
|
}
|
|
const emitted = await readFile(outputPath, 'utf8');
|
|
if (method === 'partialWrite') {
|
|
expect(emitted).toContain('username=seat-name');
|
|
expect(emitted).not.toContain('seat-token-canary');
|
|
} else {
|
|
expect(emitted).toContain('password=seat-token-canary');
|
|
}
|
|
} finally {
|
|
await output.close();
|
|
await authority.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(['absent', 'divergent'] as const)(
|
|
'restores an independently %s Tea pre-state after rotation journal failure',
|
|
async (teaState): 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'),
|
|
);
|
|
const teaConfig = join(cleanup!, 'tea', 'config.yml');
|
|
const teaStore = new TeaLoginStore(teaConfig);
|
|
if (teaState === 'divergent') {
|
|
await teaStore.put(
|
|
'seat-name',
|
|
'git.example.invalid',
|
|
new TextEncoder().encode('divergent-tea-token'),
|
|
);
|
|
}
|
|
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 },
|
|
);
|
|
let replacementRevoked = false;
|
|
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
|
const method = init?.method ?? 'GET';
|
|
if (url.pathname === '/api/v1/user') {
|
|
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url.pathname.endsWith('/tokens') && method === 'POST') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
name: 'new-generation',
|
|
sha1: 'replacement-token',
|
|
scopes: ['write:repository'],
|
|
}),
|
|
{ status: 201, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
if (url.pathname.endsWith('/tokens') && method === 'GET') {
|
|
return new Response(
|
|
JSON.stringify(
|
|
replacementRevoked ? [] : [{ name: 'new-generation', scopes: ['write:repository'] }],
|
|
),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
if (url.pathname.endsWith('/tokens/new-generation') && method === 'DELETE') {
|
|
replacementRevoked = true;
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
throw new Error(`unexpected provider request: ${method} ${url.pathname}`);
|
|
});
|
|
let mintRecords = 0;
|
|
const recordMutation = CredentialAuditJournal.prototype.recordMutation;
|
|
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
|
|
async function (this: CredentialAuditJournal, decision): Promise<void> {
|
|
if (decision === 'token-mint-applied') {
|
|
mintRecords += 1;
|
|
if (mintRecords === 2) {
|
|
throw new CredentialJournalError('journal-unavailable', 'injected rotation failure');
|
|
}
|
|
}
|
|
await recordMutation.call(this, decision);
|
|
},
|
|
);
|
|
const authority = await open(authorityPath, 'r');
|
|
try {
|
|
const result = await executeCredentialRotate('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: authority.fd.toString(),
|
|
tokenName: 'new-generation',
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
teaConfig,
|
|
});
|
|
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
|
|
if (teaState === 'absent') {
|
|
expect(teaStore.snapshot('seat-name', 'git.example.invalid')).toBeUndefined();
|
|
} else {
|
|
expect(
|
|
teaStore.matchesSecret(
|
|
'seat-name',
|
|
'git.example.invalid',
|
|
new TextEncoder().encode('divergent-tea-token'),
|
|
),
|
|
).toBe(true);
|
|
}
|
|
} finally {
|
|
await authority.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it('preserves an open nested provision journal in the rotate result', async (): Promise<void> => {
|
|
const paths = await fixture();
|
|
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
|
await mkdir(paths.tokenDirectory, { recursive: true, 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'),
|
|
);
|
|
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 },
|
|
);
|
|
const snapshot = TeaLoginStore.prototype.snapshot;
|
|
let snapshotCalls = 0;
|
|
vi.spyOn(TeaLoginStore.prototype, 'snapshot').mockImplementation(function (
|
|
this: TeaLoginStore,
|
|
identity,
|
|
host,
|
|
) {
|
|
snapshotCalls += 1;
|
|
if (snapshotCalls === 2) throw new Error('injected nested snapshot failure');
|
|
return snapshot.call(this, identity, host);
|
|
});
|
|
const seal = CredentialAuditJournal.prototype.seal;
|
|
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
|
this: CredentialAuditJournal,
|
|
outcome,
|
|
reasonCode,
|
|
): Promise<string> {
|
|
if (reasonCode === 'credential-snapshot-unavailable') {
|
|
throw new CredentialJournalError('journal-unavailable', 'injected nested seal failure');
|
|
}
|
|
return seal.call(this, outcome, reasonCode);
|
|
});
|
|
const authority = await open(authorityPath, 'r');
|
|
try {
|
|
const result = await executeCredentialRotate('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: authority.fd.toString(),
|
|
tokenName: 'new-generation',
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
teaConfig: join(cleanup!, 'tea', 'config.yml'),
|
|
});
|
|
expect(result).toMatchObject({
|
|
operation: 'rotate',
|
|
outcome: 'error',
|
|
mutation: 'none',
|
|
audit: { state: 'open' },
|
|
});
|
|
} finally {
|
|
await authority.close();
|
|
}
|
|
});
|
|
|
|
it('preserves the journal failure diagnosis when rotate lock failure cannot be sealed', async (): Promise<void> => {
|
|
const paths = await fixture();
|
|
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
|
await mkdir(paths.tokenDirectory, { recursive: true, 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'),
|
|
);
|
|
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 },
|
|
);
|
|
const locksDirectory = credentialLifecycleLocksDirectory();
|
|
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
|
|
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--seat-name.lock');
|
|
await unlink(lockPath).catch((): void => undefined);
|
|
await symlink('/dev/null', lockPath);
|
|
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
|
|
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
|
|
);
|
|
const authority = await open(authorityPath, 'r');
|
|
try {
|
|
const result = await executeCredentialRotate('seat-name', {
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
actor: 'seat-name',
|
|
authorityFd: authority.fd.toString(),
|
|
tokenName: 'new-generation',
|
|
mosaicHome: paths.mosaicHome,
|
|
registry: paths.registryPath,
|
|
tokenDir: paths.tokenDirectory,
|
|
stateDir: paths.stateRoot,
|
|
teaConfig: join(cleanup!, 'tea', 'config.yml'),
|
|
});
|
|
expect(result).toMatchObject({
|
|
operation: 'rotate',
|
|
outcome: 'error',
|
|
mutation: 'none',
|
|
reason: { code: 'journal-recovery-required' },
|
|
audit: { state: 'open' },
|
|
});
|
|
} finally {
|
|
await authority.close();
|
|
await unlink(lockPath).catch((): void => undefined);
|
|
}
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|