Files
my-pi/pi-permission-system/test/helpers/forwarding-fixtures.ts
T

262 lines
8.8 KiB
TypeScript

/**
* Shared fixtures for the forwarding subsystem's test files.
*
* Collapses the temp forwarding-directory scaffolding, the forwarded-request
* writer, and the `ParentAuthorizerDeps` / `ForwardedRequestServerDeps` /
* `ForwarderContext` / UI-decision builders that the split-out per-class test
* files repeated per test.
*
* Consumed by test/authority/approval-escalator.test.ts (the escalation-up
* role, ParentAuthorizer since #555) and test/authority/forwarded-request-server.test.ts
* (the serving-down role) — both extracted from `PermissionForwarder` by Phase 8
* Step 6 (#530).
* The `{ emit, on }` events mock is not duplicated here — reuse `makeEvents`
* from `#test/helpers/handler-fixtures`.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { vi } from "vitest";
import type { ParentAuthorizerDeps } from "#src/authority/approval-escalator";
import type { ForwardedRequestServerDeps } from "#src/authority/forwarded-request-server";
import type { ForwarderContext } from "#src/authority/forwarder-context";
import {
ForwardingLivenessJudge,
ServingHeartbeatStore,
type TargetServingLookup,
} from "#src/authority/forwarding-liveness";
import {
createPermissionForwardingLocation,
type ForwardedAccessIntent,
type ForwardedPermissionRequest,
PERMISSION_FORWARDING_TIMEOUT_MS,
type PermissionForwardingLocation,
} from "#src/authority/permission-forwarding";
import {
type ServingLookup,
ServingSessionRegistry,
} from "#src/authority/serving-registry";
import {
type SubagentSessionInfo,
SubagentSessionRegistry,
} from "#src/authority/subagent-registry";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";
/** Handle over a temp forwarding directory; register `cleanup` in `afterEach`. */
export interface ForwardingTempDir {
/** Absolute path passed as `forwardingDir` to `ParentAuthorizerDeps` / `ForwardedRequestServerDeps`. */
forwardingDir: string;
/** The session's request/response location under `forwardingDir`. */
location: PermissionForwardingLocation;
/** Writes a `ForwardedPermissionRequest` JSON into `location.requestsDir`. */
writeRequest(
overrides?: Partial<ForwardedPermissionRequest>,
): ForwardedPermissionRequest;
/** `rmSync(root, { recursive, force })`. */
cleanup(): void;
}
/**
* Creates a temp forwarding directory for `sessionId`.
*
* Always creates `requests/`; pass `{ createResponsesDir: false }` to omit
* `responses/` (the missing-`responses/` race test relies on this).
*/
export function createForwardingTempDir(
sessionId: string,
options: { createResponsesDir?: boolean } = {},
): ForwardingTempDir {
const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
const forwardingDir = join(root, "forwarding");
const location = createPermissionForwardingLocation(forwardingDir, sessionId);
mkdirSync(location.requestsDir, { recursive: true });
if (options.createResponsesDir ?? true) {
mkdirSync(location.responsesDir, { recursive: true });
}
return {
forwardingDir,
location,
writeRequest(overrides = {}) {
const request: ForwardedPermissionRequest = {
id: "req-forwarded",
createdAt: Date.now(),
requesterSessionId: "child-session",
targetSessionId: sessionId,
requesterAgentName: "Explore",
payload: makePromptPayload(),
...overrides,
};
writeFileSync(
join(location.requestsDir, `${request.id}.json`),
JSON.stringify(request),
"utf-8",
);
return request;
},
cleanup() {
rmSync(root, { recursive: true, force: true });
},
};
}
/**
* Builds `ForwardedRequestServerDeps` with a policy that defers to escalation
* (`ask`) and an approving escalator.
*
* Override `policy` / `escalator` with captured `vi.fn()` mocks to assert the
* resolve-then-escalate flow (e.g. `policy: { resolve }` returning
* `makeCheckResult({ state: "allow" })`, `escalator: { escalate }`).
*/
export function makeServerDeps(
overrides: Partial<ForwardedRequestServerDeps> = {},
): ForwardedRequestServerDeps {
return {
forwardingDir: "/tmp/forwarding",
logger: { review: vi.fn(), debug: vi.fn() },
policy: { resolve: vi.fn(() => makeCheckResult({ state: "ask" })) },
escalator: {
escalate: vi
.fn()
.mockResolvedValue({ approved: true, state: "approved" }),
},
recorder: { recordSessionApproval: vi.fn() },
...overrides,
};
}
/**
* Builds `ParentAuthorizerDeps` with a silent logger.
*
* `forwardingDir` and `registry` are the two a test almost always supplies
* (from `createForwardingTempDir` and `makeSubagentRegistry`); everything else
* defaults so a new dep lands here once rather than at every construction site.
*
* `serving` defaults to a lookup that reports every target as serving, so a
* test exercising the ordinary round trip is not accidentally fast-failed; a
* test targeting the unserved path passes {@link makeLivenessJudge}.
* `getTimeoutMs` defaults to the production value — override it with a small
* number to exercise the timeout without waiting it out.
*/
export function makeParentAuthorizerDeps(
overrides: Partial<ParentAuthorizerDeps> = {},
): ParentAuthorizerDeps {
return {
forwardingDir: "/tmp/forwarding",
logger: { review: vi.fn(), debug: vi.fn() },
serving: alwaysServing,
getTimeoutMs: () => PERMISSION_FORWARDING_TIMEOUT_MS,
...overrides,
};
}
/** A `TargetServingLookup` answering "yes" for any target (the non-fast-fail default). */
const alwaysServing: TargetServingLookup = {
isServing: () => true,
describe: () => ({ channel: "none", state: null, servingIds: [] }),
};
/**
* Builds the real judge over an in-process registry and the heartbeat records
* under `forwardingDir`.
*
* The production collaborator rather than a fake, because what these tests are
* about is which channel answers for which target — a hand-written double would
* be free to disagree with the routing under test.
*/
export function makeLivenessJudge(options: {
forwardingDir: string;
registry?: ServingLookup;
isProcessAlive?: (pid: number) => boolean;
}): ForwardingLivenessJudge {
return new ForwardingLivenessJudge({
registry: options.registry ?? new ServingSessionRegistry(),
heartbeats: new ServingHeartbeatStore({
forwardingDir: options.forwardingDir,
logger: { review: vi.fn(), debug: vi.fn() },
...(options.isProcessAlive
? { isProcessAlive: options.isProcessAlive }
: {}),
}),
});
}
/** Publishes a serving heartbeat for `sessionId`, as a live parent would. */
export function publishServingHeartbeat(
forwardingDir: string,
sessionId: string,
pid?: number,
): void {
new ServingHeartbeatStore({
forwardingDir,
logger: { review: vi.fn(), debug: vi.fn() },
...(pid === undefined ? {} : { pid }),
}).markServing(sessionId);
}
/**
* Builds a well-formed `ForwardedAccessIntent` (ADR 0008 §2) for request /
* policy fixtures. Defaults model a child in a worktree: a cwd-relative alias
* alongside the absolute one, so a relative parent rule stays relevant across
* cwds.
*/
export function makeForwardedAccessIntent(
overrides: Partial<ForwardedAccessIntent> = {},
): ForwardedAccessIntent {
return {
surface: "bash",
matchValues: ["git status"],
boundaryValue: null,
requesterCwd: "/worktree/issue-42",
principal: { sessionId: "child-session", agentName: "Explore" },
...overrides,
};
}
/**
* Builds a `ForwarderContext`.
*
* The `sessionId` shortcut populates `getSessionId`; an explicit
* `sessionManager` override merges last for tests stubbing other readers.
*/
export function makeForwarderContext(
overrides: {
hasUI?: boolean;
ui?: ForwarderContext["ui"];
cwd?: string;
sessionId?: string;
sessionManager?: Partial<ForwarderContext["sessionManager"]>;
} = {},
): ForwarderContext {
return {
hasUI: overrides.hasUI ?? false,
ui: overrides.ui ?? { select: vi.fn(), input: vi.fn() },
cwd: overrides.cwd ?? "/repo",
sessionManager: {
getSessionId: vi.fn(() => overrides.sessionId ?? ""),
getSessionDir: vi.fn(() => ""),
getEntries: vi.fn(() => []),
...overrides.sessionManager,
},
};
}
/**
* Builds a `SubagentSessionRegistry`, optionally pre-registering `childSessionId`.
*
* Omit `entry` for an empty registry (the "session not in registry" case);
* pass `{}` to register `childSessionId` with no `parentSessionId`.
*/
export function makeSubagentRegistry(
childSessionId: string,
entry?: SubagentSessionInfo,
): SubagentSessionRegistry {
const registry = new SubagentSessionRegistry();
if (entry) {
registry.register(childSessionId, entry);
}
return registry;
}