346 lines
13 KiB
TypeScript
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');
|
|
});
|
|
});
|