374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
/**
|
||
* Red-first owner-authority resolver contract for #1051.
|
||
*
|
||
* Fixtures are operator-agnostic. The HOMELAB owner name belongs in the local
|
||
* estate policy, never in framework source. Anonymous lookup is intentional:
|
||
* the ruled owner class is PUBLIC and least-privilege seats may lack read:user.
|
||
*/
|
||
|
||
interface MigrationOwnerResolution {
|
||
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||
readonly reasonCode: string;
|
||
readonly principal: {
|
||
readonly name: string;
|
||
readonly kind: 'durable-human';
|
||
} | null;
|
||
readonly authority: {
|
||
readonly system: 'gitea';
|
||
readonly endpoint: string;
|
||
readonly contentType: 'application/json';
|
||
} | null;
|
||
}
|
||
|
||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||
|
||
interface OwnerResolverModule {
|
||
resolveProviderDurableOwner(
|
||
input: {
|
||
readonly estateRegistrySource: string;
|
||
readonly ownerPolicySource: string;
|
||
readonly host: string;
|
||
readonly requestedOwner: string;
|
||
},
|
||
dependencies: {
|
||
readonly fetch: FetchLike;
|
||
readonly absentControlName: () => string;
|
||
},
|
||
): Promise<MigrationOwnerResolution>;
|
||
}
|
||
|
||
const MODULE_PATH = './brain-owner-resolver.js';
|
||
|
||
async function loadResolver(requirement: string): Promise<OwnerResolverModule> {
|
||
try {
|
||
return (await import(MODULE_PATH)) as OwnerResolverModule;
|
||
} catch (error: unknown) {
|
||
const detail = error instanceof Error ? error.message : String(error);
|
||
throw new Error(`${requirement}: brain owner resolver is absent (${detail})`);
|
||
}
|
||
}
|
||
|
||
function estateRegistry(): string {
|
||
return JSON.stringify({
|
||
version: 1,
|
||
estates: [
|
||
{
|
||
name: 'homelab',
|
||
readOnlyControlIdentity: 'read-control',
|
||
hosts: [
|
||
{
|
||
host: 'git.example.invalid',
|
||
provider: 'gitea',
|
||
apiBaseUrl: 'https://git.example.invalid',
|
||
tokenPrefix: 'gitea-example',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
function ownerPolicy(): string {
|
||
return JSON.stringify({
|
||
version: 1,
|
||
estates: [
|
||
{
|
||
estate: 'homelab',
|
||
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||
controls: {
|
||
publicIdentity: 'public-control',
|
||
privateIdentity: 'private-control',
|
||
},
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
function jsonResponse(status: number, body: unknown): Response {
|
||
return new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||
});
|
||
}
|
||
|
||
function publicUser(login: string, active = false): Response {
|
||
return jsonResponse(200, {
|
||
id: 42,
|
||
login,
|
||
visibility: 'public',
|
||
active,
|
||
});
|
||
}
|
||
|
||
function identityFromUrl(input: string | URL | Request): string {
|
||
const value = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||
return decodeURIComponent(new URL(value).pathname.split('/').at(-1) ?? '');
|
||
}
|
||
|
||
function controlledFetch(
|
||
overrides: Readonly<Record<string, Response>> = {},
|
||
calls: Array<{ identity: string; authorization: string | null }> = [],
|
||
): FetchLike {
|
||
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||
const identity = identityFromUrl(input);
|
||
const headers = new Headers(init?.headers);
|
||
calls.push({ identity, authorization: headers.get('authorization') });
|
||
const override = overrides[identity];
|
||
if (override !== undefined) return override.clone();
|
||
if (identity === 'public-control') return publicUser('public-control');
|
||
if (identity === 'private-control' || identity === 'generated-absent-control') {
|
||
return jsonResponse(404, { message: 'not found' });
|
||
}
|
||
if (identity === 'durable-owner') return publicUser('durable-owner', false);
|
||
return jsonResponse(404, { message: 'not found' });
|
||
};
|
||
}
|
||
|
||
describe('provider-backed durable owner resolver', (): void => {
|
||
it('resolves an allowlisted PUBLIC owner by exact login with public/private/absent controls and ignores active=false', async (): Promise<void> => {
|
||
const resolver = await loadResolver('MB-REQ-09 provider owner resolution');
|
||
const calls: Array<{ identity: string; authorization: string | null }> = [];
|
||
|
||
const result = await resolver.resolveProviderDurableOwner(
|
||
{
|
||
estateRegistrySource: estateRegistry(),
|
||
ownerPolicySource: ownerPolicy(),
|
||
host: 'git.example.invalid',
|
||
requestedOwner: 'user:durable-owner',
|
||
},
|
||
{
|
||
fetch: controlledFetch({}, calls),
|
||
absentControlName: (): string => 'generated-absent-control',
|
||
},
|
||
);
|
||
|
||
expect(result).toEqual({
|
||
verdict: 'resolved',
|
||
reasonCode: 'owner-verified',
|
||
principal: { name: 'user:durable-owner', kind: 'durable-human' },
|
||
authority: {
|
||
system: 'gitea',
|
||
endpoint: 'GET /api/v1/users/durable-owner',
|
||
contentType: 'application/json',
|
||
},
|
||
});
|
||
expect(calls.map((call) => call.identity)).toEqual([
|
||
'public-control',
|
||
'private-control',
|
||
'generated-absent-control',
|
||
'durable-owner',
|
||
]);
|
||
expect(calls.every((call) => call.authorization === null)).toBe(true);
|
||
});
|
||
|
||
it('refuses provider redirects and configures a bounded no-redirect request', async (): Promise<void> => {
|
||
const resolver = await loadResolver('MB-REQ-09 owner lookup SSRF boundary');
|
||
const requests: RequestInit[] = [];
|
||
|
||
const result = await resolver.resolveProviderDurableOwner(
|
||
{
|
||
estateRegistrySource: estateRegistry(),
|
||
ownerPolicySource: ownerPolicy(),
|
||
host: 'git.example.invalid',
|
||
requestedOwner: 'user:durable-owner',
|
||
},
|
||
{
|
||
fetch: async (_input, init): Promise<Response> => {
|
||
requests.push(init ?? {});
|
||
return new Response(JSON.stringify({ message: 'redirect' }), {
|
||
status: 302,
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
location: 'http://127.0.0.1/internal',
|
||
},
|
||
});
|
||
},
|
||
absentControlName: (): string => 'generated-absent-control',
|
||
},
|
||
);
|
||
|
||
expect(result).toMatchObject({ verdict: 'not-measured', reasonCode: 'owner-control-invalid' });
|
||
expect(requests).toHaveLength(1);
|
||
expect(requests[0]?.redirect).toBe('manual');
|
||
expect(requests[0]?.signal).toBeInstanceOf(AbortSignal);
|
||
});
|
||
|
||
it('cancels a chunked provider body as soon as it exceeds the byte ceiling', async (): Promise<void> => {
|
||
const resolver = await loadResolver('MB-REQ-09 bounded owner response stream');
|
||
let cancelled = false;
|
||
const oversized = new ReadableStream<Uint8Array>({
|
||
start(controller): void {
|
||
controller.enqueue(new Uint8Array(200_000));
|
||
controller.enqueue(new Uint8Array(100_000));
|
||
},
|
||
cancel(): void {
|
||
cancelled = true;
|
||
},
|
||
});
|
||
|
||
const result = await resolver.resolveProviderDurableOwner(
|
||
{
|
||
estateRegistrySource: estateRegistry(),
|
||
ownerPolicySource: ownerPolicy(),
|
||
host: 'git.example.invalid',
|
||
requestedOwner: 'user:durable-owner',
|
||
},
|
||
{
|
||
fetch: async (): Promise<Response> =>
|
||
new Response(oversized, {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
}),
|
||
absentControlName: (): string => 'generated-absent-control',
|
||
},
|
||
);
|
||
|
||
expect(result).toMatchObject({
|
||
verdict: 'not-measured',
|
||
reasonCode: 'owner-unexpected-provider-shape',
|
||
});
|
||
expect(cancelled).toBe(true);
|
||
});
|
||
|
||
it('requires the GLPI standing remediation queue in the local estate policy', async (): Promise<void> => {
|
||
const resolver = await loadResolver('MB-REQ-09 standing process policy');
|
||
const raw = JSON.parse(ownerPolicy()) as { estates: Array<Record<string, unknown>> };
|
||
delete raw.estates[0]?.['standingProcess'];
|
||
let fetchCalls = 0;
|
||
|
||
const result = await resolver.resolveProviderDurableOwner(
|
||
{
|
||
estateRegistrySource: estateRegistry(),
|
||
ownerPolicySource: JSON.stringify(raw),
|
||
host: 'git.example.invalid',
|
||
requestedOwner: 'user:durable-owner',
|
||
},
|
||
{
|
||
fetch: async (): Promise<Response> => {
|
||
fetchCalls += 1;
|
||
return publicUser('durable-owner');
|
||
},
|
||
absentControlName: (): string => 'generated-absent-control',
|
||
},
|
||
);
|
||
|
||
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-policy-invalid' });
|
||
expect(fetchCalls).toBe(0);
|
||
});
|
||
|
||
it('rejects a provider-valid but unlisted principal before provider lookup', async (): Promise<void> => {
|
||
const resolver = await loadResolver('MB-REQ-09 provider-valid unlisted owner refusal');
|
||
const calls: Array<{ identity: string; authorization: string | null }> = [];
|
||
|
||
const result = await resolver.resolveProviderDurableOwner(
|
||
{
|
||
estateRegistrySource: estateRegistry(),
|
||
ownerPolicySource: ownerPolicy(),
|
||
host: 'git.example.invalid',
|
||
requestedOwner: 'user:other-public-user',
|
||
},
|
||
{
|
||
fetch: controlledFetch({ 'other-public-user': publicUser('other-public-user') }, calls),
|
||
absentControlName: (): string => 'generated-absent-control',
|
||
},
|
||
);
|
||
|
||
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-not-allowlisted' });
|
||
expect(calls).toHaveLength(0);
|
||
});
|
||
|
||
it.each([
|
||
['user:durable–owner', 'owner-name-invalid'],
|
||
[' user:durable-owner ', 'owner-name-invalid'],
|
||
['user:durable.owner', 'owner-not-allowlisted'],
|
||
['user:durable owner', 'owner-name-invalid'],
|
||
['user:durable-owner', 'owner-name-invalid'],
|
||
['user:be-coder-07@mission-seat', 'owner-name-invalid'],
|
||
] as const)(
|
||
'rejects non-canonical, unlisted, or transient-seat presentation %s before lookup',
|
||
async (name, reasonCode): Promise<void> => {
|
||
const resolver = await loadResolver('MB-REQ-09 owner allowlist grammar');
|
||
let fetchCalls = 0;
|
||
|
||
const result = await resolver.resolveProviderDurableOwner(
|
||
{
|
||
estateRegistrySource: estateRegistry(),
|
||
ownerPolicySource: ownerPolicy(),
|
||
host: 'git.example.invalid',
|
||
requestedOwner: name,
|
||
},
|
||
{
|
||
fetch: async (): Promise<Response> => {
|
||
fetchCalls += 1;
|
||
return publicUser('durable-owner');
|
||
},
|
||
absentControlName: (): string => 'generated-absent-control',
|
||
},
|
||
);
|
||
|
||
expect(result).toMatchObject({ verdict: 'refused', reasonCode });
|
||
expect(fetchCalls).toBe(0);
|
||
},
|
||
);
|
||
|
||
it('fails closed as not-resolvable rather than claiming a private-or-absent owner does not exist', async (): Promise<void> => {
|
||
const resolver = await loadResolver('MB-REQ-09 private/absent ambiguity');
|
||
|
||
const result = await resolver.resolveProviderDurableOwner(
|
||
{
|
||
estateRegistrySource: estateRegistry(),
|
||
ownerPolicySource: ownerPolicy(),
|
||
host: 'git.example.invalid',
|
||
requestedOwner: 'user:durable-owner',
|
||
},
|
||
{
|
||
fetch: controlledFetch({ 'durable-owner': jsonResponse(404, { message: 'hidden' }) }),
|
||
absentControlName: (): string => 'generated-absent-control',
|
||
},
|
||
);
|
||
|
||
expect(result).toMatchObject({
|
||
verdict: 'not-measured',
|
||
reasonCode: 'owner-not-resolvable',
|
||
principal: null,
|
||
});
|
||
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
|
||
});
|
||
|
||
it.each([
|
||
['public control hidden', { 'public-control': jsonResponse(404, {}) }],
|
||
['public control login mismatch', { 'public-control': publicUser('other') }],
|
||
['private control unexpectedly public', { 'private-control': publicUser('private-control') }],
|
||
[
|
||
'generated absent control unexpectedly resolves',
|
||
{ 'generated-absent-control': publicUser('generated-absent-control') },
|
||
],
|
||
] as const)(
|
||
'makes the whole result not-measured when %s',
|
||
async (_caseName, overrides): Promise<void> => {
|
||
const resolver = await loadResolver('MB-REQ-09 owner resolver controls');
|
||
|
||
const result = await resolver.resolveProviderDurableOwner(
|
||
{
|
||
estateRegistrySource: estateRegistry(),
|
||
ownerPolicySource: ownerPolicy(),
|
||
host: 'git.example.invalid',
|
||
requestedOwner: 'user:durable-owner',
|
||
},
|
||
{
|
||
fetch: controlledFetch(overrides),
|
||
absentControlName: (): string => 'generated-absent-control',
|
||
},
|
||
);
|
||
|
||
expect(result).toMatchObject({
|
||
verdict: 'not-measured',
|
||
reasonCode: 'owner-control-invalid',
|
||
});
|
||
},
|
||
);
|
||
});
|