Files
stack/packages/mosaic/src/credentials/validate.spec.ts
T

346 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { CredentialProviderEvidenceError } from './gitea-provider.js';
import {
evaluateGiteaReadValidation,
evaluateGiteaWriteValidation,
type CredentialResolver,
type CredentialValidationDependencies,
type GiteaCredentialProvider,
type ProviderIdentityEvidence,
type ReceivePackEvidence,
type RepositoryPermissionEvidence,
type ResolvedCredential,
} from './validate.js';
interface FixtureOptions {
readonly subjectProviderIdentity?: string;
readonly subjectPermission?: 'read' | 'write' | 'admin';
readonly subjectTransportState?: 'advertised' | 'refused';
readonly subjectTransportPrincipal?: string;
readonly subjectTransportResolutionId?: string;
readonly controlProviderIdentity?: string;
readonly controlPermission?: 'read' | 'write' | 'admin';
readonly controlTransportState?: 'advertised' | 'refused';
readonly controlTransportPrincipal?: string;
readonly unauthenticatedTransportState?: 'advertised' | 'refused';
readonly omitControl?: boolean;
}
interface Fixture {
readonly dependencies: CredentialValidationDependencies;
readonly resolverCalls: string[];
readonly identityHandles: ResolvedCredential[];
readonly permissionHandles: ResolvedCredential[];
readonly receivePackHandles: Array<ResolvedCredential | undefined>;
}
const SUBJECT = 'seat-name';
const CONTROL = 'read-only-control';
const ESTATE = 'homelab';
const HOST = 'git.example.invalid';
const REPO = 'owner/repo';
function credential(identity: string, resolutionId: string): ResolvedCredential {
return Object.freeze({
identity,
estate: ESTATE,
host: HOST,
resolutionId,
secret: new Uint8Array([99, 97, 110, 97, 114, 121]),
});
}
function fixture(options: FixtureOptions = {}): Fixture {
const subjectCredential = credential(SUBJECT, 'subject-resolution');
const controlCredential = credential(CONTROL, 'control-resolution');
const resolverCalls: string[] = [];
const identityHandles: ResolvedCredential[] = [];
const permissionHandles: ResolvedCredential[] = [];
const receivePackHandles: Array<ResolvedCredential | undefined> = [];
const resolver: CredentialResolver = {
async resolve(identity: string): Promise<ResolvedCredential | undefined> {
resolverCalls.push(identity);
if (identity === SUBJECT) return subjectCredential;
if (identity === CONTROL && options.omitControl !== true) return controlCredential;
return undefined;
},
};
const provider: GiteaCredentialProvider = {
async readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidence> {
identityHandles.push(resolved);
const login =
resolved.identity === SUBJECT
? (options.subjectProviderIdentity ?? SUBJECT)
: (options.controlProviderIdentity ?? CONTROL);
return {
login,
endpoint: 'GET /api/v1/user',
contentType: 'application/json',
};
},
async readRepositoryPermission(
resolved: ResolvedCredential,
): Promise<RepositoryPermissionEvidence> {
permissionHandles.push(resolved);
const effective =
resolved.identity === SUBJECT
? (options.subjectPermission ?? 'write')
: (options.controlPermission ?? 'read');
return {
effective,
endpoint: `GET /api/v1/repos/${REPO}`,
contentType: 'application/json',
};
},
async probeReceivePack(resolved: ResolvedCredential | undefined): Promise<ReceivePackEvidence> {
receivePackHandles.push(resolved);
if (resolved === undefined) {
return {
state: options.unauthenticatedTransportState ?? 'refused',
principal: null,
resolutionId: null,
contentType: 'text/plain',
};
}
if (resolved.identity === SUBJECT) {
return {
state: options.subjectTransportState ?? 'advertised',
principal: options.subjectTransportPrincipal ?? SUBJECT,
resolutionId: options.subjectTransportResolutionId ?? resolved.resolutionId,
contentType: 'application/x-git-receive-pack-advertisement',
};
}
return {
state: options.controlTransportState ?? 'refused',
principal: options.controlTransportPrincipal ?? CONTROL,
resolutionId: resolved.resolutionId,
contentType: 'text/plain',
};
},
};
return {
dependencies: {
resolver,
provider,
estateRegistry: {
matches(estate: string, host: string): boolean {
return estate === ESTATE && host === HOST;
},
},
},
resolverCalls,
identityHandles,
permissionHandles,
receivePackHandles,
};
}
async function validate(options: FixtureOptions = {}): Promise<{
readonly result: Awaited<ReturnType<typeof evaluateGiteaWriteValidation>>;
readonly observed: Fixture;
}> {
const observed = fixture(options);
const result = await evaluateGiteaWriteValidation(
{
identity: SUBJECT,
estate: ESTATE,
host: HOST,
repo: REPO,
readOnlyControlIdentity: CONTROL,
},
observed.dependencies,
);
return { result, observed };
}
describe('Gitea read validation', (): void => {
it('reads the explicit provider identity and repository permission without a write control', async (): Promise<void> => {
const observed = fixture({ subjectPermission: 'read' });
const result = await evaluateGiteaReadValidation(
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
observed.dependencies,
);
expect(result.outcome).toBe('ok');
expect(result.evidence.providerIdentity?.login).toBe(SUBJECT);
expect(result.evidence.repositoryPermission?.effective).toBe('read');
expect(result.evidence.writeDifferential).toBeNull();
expect(observed.resolverCalls).toEqual([SUBJECT]);
});
it('classifies the provider rejecting the subject credential as an authoritative refusal', async (): Promise<void> => {
const observed = fixture({ subjectPermission: 'read' });
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
throw new CredentialProviderEvidenceError(
'credential-rejected',
'provider rejected the supplied credential',
);
};
const result = await evaluateGiteaReadValidation(
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
observed.dependencies,
);
expect(result.outcome).toBe('refused');
expect(result.exitCode).toBe(10);
expect(result.reason.code).toBe('credential-rejected');
});
it('confirms in-scope capability while reporting identity as not measured', async (): Promise<void> => {
const observed = fixture({ subjectPermission: 'write' });
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
throw new CredentialProviderEvidenceError(
'identity-read-forbidden',
'identity endpoint requires a scope this token does not hold',
);
};
const result = await evaluateGiteaReadValidation(
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
observed.dependencies,
);
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('identity-not-measured');
expect(result.evidence.providerIdentity).toBeNull();
expect(result.evidence.repositoryPermission?.effective).toBe('write');
});
it('refuses a shared fallback rather than reporting a different principal as the subject', async (): Promise<void> => {
const observed = fixture({
subjectProviderIdentity: 'shared-owner',
subjectPermission: 'read',
});
const result = await evaluateGiteaReadValidation(
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
observed.dependencies,
);
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('provider-identity-mismatch');
});
});
describe('principal-bound Gitea write validation contract v1.1', (): void => {
it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise<void> => {
const { result, observed } = await validate();
expect(result.outcome).toBe('ok');
expect(observed.resolverCalls).toEqual([SUBJECT, CONTROL]);
expect(observed.identityHandles[0]).toBe(observed.permissionHandles[0]);
expect(observed.identityHandles[0]).toBe(observed.receivePackHandles[0]);
});
it('refuses a subject credential whose provider identity is a shared fallback', async (): Promise<void> => {
const { result } = await validate({ subjectProviderIdentity: 'shared-owner' });
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('provider-identity-mismatch');
expect(result.mutation).toBe('none');
});
it('routes a transport principal mismatch to indeterminate, not refused', async (): Promise<void> => {
const { result } = await validate({ subjectTransportPrincipal: 'shared-owner' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('transport-principal-mismatch');
});
it('routes a transport credential-handle mismatch to indeterminate', async (): Promise<void> => {
const { result } = await validate({ subjectTransportResolutionId: 'fallback-resolution' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('transport-principal-mismatch');
});
it('refuses when the provider repository object authoritatively denies write', async (): Promise<void> => {
const { result } = await validate({ subjectPermission: 'read' });
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('permission-denied');
});
it('is indeterminate when repo permission says write but receive-pack refuses', async (): Promise<void> => {
const { result } = await validate({ subjectTransportState: 'refused' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('permission-evidence-disagrees');
});
it('makes a write-capable read-only control invalidate the entire result', async (): Promise<void> => {
const { result } = await validate({ controlPermission: 'write' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('read-only-control-invalid');
});
it('makes an identity-mismatched read-only control invalidate the entire result', async (): Promise<void> => {
const { result } = await validate({ controlProviderIdentity: 'other-control' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('read-only-control-invalid');
});
it('makes a read-only control that receives write transport invalidate the result', async (): Promise<void> => {
const { result } = await validate({ controlTransportState: 'advertised' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('read-only-control-invalid');
});
it('is indeterminate when the configured read-only control credential is absent', async (): Promise<void> => {
const { result } = await validate({ omitControl: true });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('read-only-control-invalid');
});
it('keeps the unauthenticated arm and rejects an advertisement there', async (): Promise<void> => {
const { result } = await validate({ unauthenticatedTransportState: 'advertised' });
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('permission-evidence-disagrees');
});
it('refuses an estate-host mismatch before resolving any credential', async (): Promise<void> => {
const observed = fixture();
const result = await evaluateGiteaWriteValidation(
{
identity: SUBJECT,
estate: 'usc',
host: HOST,
repo: REPO,
readOnlyControlIdentity: CONTROL,
},
observed.dependencies,
);
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('estate-host-mismatch');
expect(observed.resolverCalls).toEqual([]);
});
it('returns structured proof bounds only after every principal-bound arm passes', async (): Promise<void> => {
const { result } = await validate();
expect(result.outcome).toBe('ok');
expect(result.evidence.writeDifferential).toMatchObject({
state: 'can-write',
credentialBinding: 'same-resolution',
transportPrincipal: SUBJECT,
authenticatedReceivePack: 'advertised',
readOnlyControl: {
identity: CONTROL,
providerPermission: 'read',
receivePack: 'refused',
},
unauthenticatedReceivePack: 'refused',
artifactCreated: false,
});
expect(result.evidence.writeDifferential?.proves).toContain('declared subject credential');
expect(result.evidence.writeDifferential?.doesNotProve).toContain('branch protection');
});
});