源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { callOpenWorkCloudUploadAction } from "../../../../apps/server/src/extensions/cloud-uploads.ts";
|
||||
import { listExperimentalExtensionActions } from "../../../../apps/server/src/extensions/index.ts";
|
||||
|
||||
const input = JSON.parse(await Bun.stdin.text());
|
||||
|
||||
if (input.mode === "inspect") {
|
||||
process.stdout.write(JSON.stringify({ ok: true, result: listExperimentalExtensionActions("") }));
|
||||
} else {
|
||||
let networkCalls = 0;
|
||||
const config = {
|
||||
host: "127.0.0.1",
|
||||
port: 8787,
|
||||
token: "test-client-token",
|
||||
hostToken: "test-host-token",
|
||||
configPath: `${input.root}/server.json`,
|
||||
approval: { mode: "auto", timeoutMs: 30_000 },
|
||||
corsOrigins: ["*"],
|
||||
workspaces: [],
|
||||
authorizedRoots: [input.root],
|
||||
readOnly: false,
|
||||
startedAt: Date.now(),
|
||||
tokenSource: "generated",
|
||||
hostTokenSource: "generated",
|
||||
logFormat: "pretty",
|
||||
logRequests: false,
|
||||
};
|
||||
try {
|
||||
const result = await callOpenWorkCloudUploadAction(
|
||||
config,
|
||||
input.action,
|
||||
input.args,
|
||||
input.context,
|
||||
{
|
||||
readCloudMcp: async () => ({
|
||||
type: "remote",
|
||||
enabled: true,
|
||||
url: input.cloudUrl,
|
||||
headers: { Authorization: `Bearer ${input.mcpToken}` },
|
||||
oauth: false,
|
||||
}),
|
||||
fetchImpl: async (url, init) => {
|
||||
networkCalls += 1;
|
||||
return fetch(url, init);
|
||||
},
|
||||
},
|
||||
);
|
||||
process.stdout.write(JSON.stringify({ ok: true, result, networkCalls }));
|
||||
} catch (error) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: false,
|
||||
status: error && typeof error === "object" && "status" in error ? error.status : null,
|
||||
code: error && typeof error === "object" && "code" in error ? error.code : null,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
networkCalls,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@openwork/labs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./egress": {
|
||||
"types": "./src/egress.ts",
|
||||
"development": "./src/egress.ts",
|
||||
"default": "./src/egress.ts"
|
||||
},
|
||||
"./idp": {
|
||||
"types": "./src/idp.ts",
|
||||
"development": "./src/idp.ts",
|
||||
"default": "./src/idp.ts"
|
||||
},
|
||||
"./mock-mcp": {
|
||||
"types": "./src/mock-mcp.ts",
|
||||
"development": "./src/mock-mcp.ts",
|
||||
"default": "./src/mock-mcp.ts"
|
||||
},
|
||||
"./release-feed": {
|
||||
"types": "./src/release-feed.ts",
|
||||
"development": "./src/release-feed.ts",
|
||||
"default": "./src/release-feed.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,901 @@
|
||||
import { createPublicKey, createSign, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { trimTrailingSlashes } from "./strings.ts";
|
||||
|
||||
const DEFAULT_DOMAIN = "acme.test";
|
||||
const DEFAULT_CLIENT_ID = "openwork-eval-oidc-client";
|
||||
const DEFAULT_CLIENT_SECRET = "openwork-eval-oidc-secret";
|
||||
const DEFAULT_ISSUER = "http://127.0.0.1/mock-openwork-idp";
|
||||
const DEFAULT_EVAL_ISSUER = "http://127.0.0.1:19190";
|
||||
const DEFAULT_GROUPS = ["Engineering", "OpenWork Lab"];
|
||||
const DEFAULT_ROLE = "member";
|
||||
|
||||
export const MOCK_IDP_BLOCKED_USER_PHRASE = "administrator has configured the application to block users";
|
||||
|
||||
export type MockGroupClaims =
|
||||
| "present"
|
||||
| "absent"
|
||||
| "unexpected-shape"
|
||||
| readonly string[]
|
||||
| {
|
||||
claim: string;
|
||||
values: readonly string[];
|
||||
};
|
||||
|
||||
export interface MockIdpKnobs {
|
||||
certTrailingNewline?: boolean;
|
||||
wrongDomain?: boolean;
|
||||
blockedUser?: string;
|
||||
groupClaims?: MockGroupClaims;
|
||||
emailMismatch?: boolean | string;
|
||||
guestUser?: boolean;
|
||||
}
|
||||
|
||||
export interface MockIdpSubjectInput {
|
||||
sub?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface MockIdpSubject {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MockIdpConfig {
|
||||
issuer?: string;
|
||||
domain?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
defaultSubject?: MockIdpSubjectInput;
|
||||
knobs?: MockIdpKnobs;
|
||||
}
|
||||
|
||||
export interface NormalizedMockIdpKnobs {
|
||||
certTrailingNewline: boolean;
|
||||
wrongDomain: boolean;
|
||||
blockedUser: string | null;
|
||||
groupClaims: MockGroupClaims;
|
||||
emailMismatch: boolean | string;
|
||||
guestUser: boolean;
|
||||
}
|
||||
|
||||
export interface NormalizedMockIdpConfig {
|
||||
issuer: string;
|
||||
domain: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
defaultSubject: MockIdpSubject;
|
||||
knobs: NormalizedMockIdpKnobs;
|
||||
}
|
||||
|
||||
export interface MockOidcRegistration {
|
||||
kind: "oidc";
|
||||
issuer: string;
|
||||
domain: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string[];
|
||||
skipDiscovery: boolean;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
jwksEndpoint: string;
|
||||
userInfoEndpoint: string;
|
||||
tokenEndpointAuthentication: "client_secret_post";
|
||||
}
|
||||
|
||||
export interface MockSamlEndpoints {
|
||||
metadataUrl: string;
|
||||
ssoPostUrl: string;
|
||||
certificate: string;
|
||||
}
|
||||
|
||||
export interface StartedMockIdpLab {
|
||||
protocol: "oidc";
|
||||
config: NormalizedMockIdpConfig;
|
||||
issuer: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
jwksEndpoint: string;
|
||||
userInfoEndpoint: string;
|
||||
saml: MockSamlEndpoints;
|
||||
keyId: string;
|
||||
certificate: string;
|
||||
publicKeyPem: string;
|
||||
registration(overrides?: Partial<MockOidcRegistration>): MockOidcRegistration;
|
||||
claimsFor(subject?: MockIdpSubjectInput): Record<string, unknown>;
|
||||
stop(): Promise<void>;
|
||||
[Symbol.asyncDispose](): Promise<void>;
|
||||
}
|
||||
|
||||
interface KeyMaterial {
|
||||
keyId: string;
|
||||
privateKeyPem: string;
|
||||
publicKeyPem: string;
|
||||
jwk: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AuthorizationCodeRecord {
|
||||
clientId: string;
|
||||
nonce: string | null;
|
||||
redirectUri: string;
|
||||
subject: MockIdpSubject;
|
||||
}
|
||||
|
||||
interface MockIdpState {
|
||||
config: NormalizedMockIdpConfig;
|
||||
keys: KeyMaterial;
|
||||
codes: Map<string, AuthorizationCodeRecord>;
|
||||
accessTokens: Map<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type SsoConfigErrorCode = "sso_cert_trailing_newline" | "sso_domain_mismatch";
|
||||
|
||||
export interface NormalizedCertificateMaterial {
|
||||
value: string;
|
||||
hadTrailingNewline: boolean;
|
||||
}
|
||||
|
||||
export interface SsoConfigurationValidationInput {
|
||||
configuredDomain: string;
|
||||
cert?: string | null;
|
||||
subjectEmail?: string | null;
|
||||
}
|
||||
|
||||
export interface SsoConfigurationError {
|
||||
code: SsoConfigErrorCode;
|
||||
message: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface SsoConfigurationValidation {
|
||||
ok: boolean;
|
||||
errors: SsoConfigurationError[];
|
||||
normalizedDomain: string;
|
||||
normalizedCert?: string;
|
||||
}
|
||||
|
||||
export interface SsoExpectation {
|
||||
code: SsoConfigErrorCode;
|
||||
includes?: readonly string[];
|
||||
}
|
||||
|
||||
export interface SsoExpectationMatch {
|
||||
passed: boolean;
|
||||
expectedCode: SsoConfigErrorCode;
|
||||
actualCodes: string[];
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface Rs256SigningArgvInput {
|
||||
keyId: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface BlockedUserResponseShape {
|
||||
status: 403;
|
||||
error: "access_denied";
|
||||
errorDescription: string;
|
||||
message: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isAddressInfo(value: ReturnType<Server["address"]>): value is AddressInfo {
|
||||
return typeof value === "object"
|
||||
&& value !== null
|
||||
&& typeof value.address === "string"
|
||||
&& typeof value.family === "string"
|
||||
&& typeof value.port === "number";
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is readonly string[] {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function isNamedGroupClaim(value: MockGroupClaims): value is { claim: string; values: readonly string[] } {
|
||||
return isRecord(value) && typeof value.claim === "string" && isStringArray(value.values);
|
||||
}
|
||||
|
||||
function cleanIssuer(value: string | undefined): string {
|
||||
const trimmed = trimTrailingSlashes((value ?? DEFAULT_ISSUER).trim());
|
||||
return trimmed || DEFAULT_ISSUER;
|
||||
}
|
||||
|
||||
function requestedIssuer(value: string | undefined): URL | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = new URL(cleanIssuer(trimmed));
|
||||
return url.protocol === "http:" ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function configuredMockIdpIssuer(input: MockIdpConfig): URL | null {
|
||||
const evalIssuer = process.env.OPENWORK_EVAL_DEN_API_URL?.trim() ? DEFAULT_EVAL_ISSUER : undefined;
|
||||
return requestedIssuer(input.issuer) ?? requestedIssuer(process.env.OPENWORK_EVAL_MOCK_IDP_ISSUER) ?? requestedIssuer(evalIssuer);
|
||||
}
|
||||
|
||||
export function normalizeDomain(value: string | undefined): string {
|
||||
const input = (value ?? DEFAULT_DOMAIN).trim().toLowerCase();
|
||||
let start = 0;
|
||||
let end = input.length;
|
||||
while (start < end && input[start] === "@") start += 1;
|
||||
while (end > start && input[end - 1] === ".") end -= 1;
|
||||
const normalized = input.slice(start, end);
|
||||
return normalized || DEFAULT_DOMAIN;
|
||||
}
|
||||
|
||||
export function emailDomain(email: string | null | undefined): string | null {
|
||||
const value = email?.trim().toLowerCase() ?? "";
|
||||
const at = value.lastIndexOf("@");
|
||||
if (at < 0 || at === value.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return normalizeDomain(value.slice(at + 1));
|
||||
}
|
||||
|
||||
function localPart(email: string): string {
|
||||
const at = email.indexOf("@");
|
||||
return at > 0 ? email.slice(0, at) : "sso.user";
|
||||
}
|
||||
|
||||
export function buildWrongDomainEmail(email: string, configuredDomain: string): string {
|
||||
return `${localPart(email)}@wrong-${normalizeDomain(configuredDomain)}`;
|
||||
}
|
||||
|
||||
function defaultSubject(domain: string): MockIdpSubject {
|
||||
const email = `sso.user@${domain}`;
|
||||
return {
|
||||
sub: email,
|
||||
email,
|
||||
name: "OpenWork SSO User",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSubject(input: MockIdpSubjectInput | undefined, domain: string): MockIdpSubject {
|
||||
const fallback = defaultSubject(domain);
|
||||
const email = input?.email?.trim().toLowerCase() || fallback.email;
|
||||
return {
|
||||
sub: input?.sub?.trim() || email,
|
||||
email,
|
||||
name: input?.name?.trim() || fallback.name,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeKnobs(knobs: MockIdpKnobs | undefined): NormalizedMockIdpKnobs {
|
||||
return {
|
||||
certTrailingNewline: knobs?.certTrailingNewline === true,
|
||||
wrongDomain: knobs?.wrongDomain === true,
|
||||
blockedUser: knobs?.blockedUser?.trim() || null,
|
||||
groupClaims: knobs?.groupClaims ?? "present",
|
||||
emailMismatch: knobs?.emailMismatch ?? false,
|
||||
guestUser: knobs?.guestUser === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMockIdpConfig(input: MockIdpConfig = {}): NormalizedMockIdpConfig {
|
||||
const domain = normalizeDomain(input.domain);
|
||||
return {
|
||||
issuer: cleanIssuer(input.issuer),
|
||||
domain,
|
||||
clientId: input.clientId?.trim() || DEFAULT_CLIENT_ID,
|
||||
clientSecret: input.clientSecret?.trim() || DEFAULT_CLIENT_SECRET,
|
||||
defaultSubject: normalizeSubject(input.defaultSubject, domain),
|
||||
knobs: normalizeKnobs(input.knobs),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCertificateMaterial(value: string | null | undefined): NormalizedCertificateMaterial {
|
||||
const raw = value ?? "";
|
||||
let end = raw.length;
|
||||
while (end > 0 && raw[end - 1] === "\n") {
|
||||
end -= 1;
|
||||
if (end > 0 && raw[end - 1] === "\r") end -= 1;
|
||||
}
|
||||
return {
|
||||
value: raw.slice(0, end),
|
||||
hadTrailingNewline: end !== raw.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateSsoConfiguration(input: SsoConfigurationValidationInput): SsoConfigurationValidation {
|
||||
const normalizedDomain = normalizeDomain(input.configuredDomain);
|
||||
const errors: SsoConfigurationError[] = [];
|
||||
const cert = normalizeCertificateMaterial(input.cert);
|
||||
if (input.cert !== undefined && input.cert !== null && cert.hadTrailingNewline) {
|
||||
errors.push({
|
||||
code: "sso_cert_trailing_newline",
|
||||
message: "SSO certificate has trailing newline characters. Remove the blank line at the end of the pasted certificate and save again.",
|
||||
detail: "The field POC failure was a certificate paste with a trailing newline; this matcher names it instead of surfacing a generic SSO failure.",
|
||||
});
|
||||
}
|
||||
|
||||
const subjectDomain = emailDomain(input.subjectEmail);
|
||||
if (subjectDomain && subjectDomain !== normalizedDomain) {
|
||||
errors.push({
|
||||
code: "sso_domain_mismatch",
|
||||
message: `SSO domain mismatch: IdP email domain ${subjectDomain} does not match configured organization domain ${normalizedDomain}.`,
|
||||
detail: "The IdP subject email domain must match the organization SSO domain before users are sent through the provider.",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
normalizedDomain,
|
||||
normalizedCert: input.cert === undefined || input.cert === null ? undefined : cert.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function matchSsoExpectation(validation: SsoConfigurationValidation, expectation: SsoExpectation): SsoExpectationMatch {
|
||||
const match = validation.errors.find((error) => error.code === expectation.code) ?? null;
|
||||
const includes = expectation.includes ?? [];
|
||||
const haystack = match ? `${match.message}\n${match.detail}` : "";
|
||||
const missing = includes.filter((needle) => !haystack.toLowerCase().includes(needle.toLowerCase()));
|
||||
const passed = Boolean(match) && missing.length === 0;
|
||||
return {
|
||||
passed,
|
||||
expectedCode: expectation.code,
|
||||
actualCodes: validation.errors.map((error) => error.code),
|
||||
detail: passed
|
||||
? match?.message ?? expectation.code
|
||||
: `Expected ${expectation.code}${missing.length ? ` with text ${missing.join(", ")}` : ""}; got ${validation.errors.map((error) => error.code).join(", ") || "no errors"}.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRs256SigningArgv(input: Rs256SigningArgvInput): readonly string[] {
|
||||
return [
|
||||
"node:crypto",
|
||||
"createSign",
|
||||
"RSA-SHA256",
|
||||
"--kid",
|
||||
input.keyId,
|
||||
"--payload-bytes",
|
||||
String(Buffer.byteLength(input.payload, "utf8")),
|
||||
];
|
||||
}
|
||||
|
||||
export function subjectWithKnobs(config: NormalizedMockIdpConfig, input?: MockIdpSubjectInput): MockIdpSubject {
|
||||
const requested = normalizeSubject(input, config.domain);
|
||||
let email = requested.email;
|
||||
if (config.knobs.emailMismatch) {
|
||||
email = typeof config.knobs.emailMismatch === "string" && config.knobs.emailMismatch.trim()
|
||||
? config.knobs.emailMismatch.trim().toLowerCase()
|
||||
: `mismatch.${localPart(requested.email)}@${config.domain}`;
|
||||
}
|
||||
if (config.knobs.wrongDomain) {
|
||||
email = buildWrongDomainEmail(email, config.domain);
|
||||
}
|
||||
if (config.knobs.guestUser) {
|
||||
email = input?.email?.trim().toLowerCase() || `guest.user@external.${config.domain}`;
|
||||
}
|
||||
return {
|
||||
sub: input?.sub?.trim() || (config.knobs.guestUser ? `guest:${email}` : email),
|
||||
email,
|
||||
name: input?.name?.trim() || requested.name,
|
||||
};
|
||||
}
|
||||
|
||||
function secondsSinceEpoch(date: Date): number {
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
}
|
||||
|
||||
function applyGroupClaims(claims: Record<string, unknown>, groupClaims: MockGroupClaims): void {
|
||||
if (groupClaims === "absent") {
|
||||
return;
|
||||
}
|
||||
if (groupClaims === "unexpected-shape") {
|
||||
claims.groups = { primary: DEFAULT_GROUPS[0], all: DEFAULT_GROUPS };
|
||||
claims.roles = { default: DEFAULT_ROLE };
|
||||
return;
|
||||
}
|
||||
if (isStringArray(groupClaims)) {
|
||||
claims.groups = [...groupClaims];
|
||||
claims.roles = [DEFAULT_ROLE];
|
||||
return;
|
||||
}
|
||||
if (isNamedGroupClaim(groupClaims)) {
|
||||
claims[groupClaims.claim] = [...groupClaims.values];
|
||||
claims.roles = [DEFAULT_ROLE];
|
||||
return;
|
||||
}
|
||||
claims.groups = [...DEFAULT_GROUPS];
|
||||
claims.roles = [DEFAULT_ROLE];
|
||||
}
|
||||
|
||||
export function buildOidcClaims(input: {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
subject: MockIdpSubject;
|
||||
nonce?: string | null;
|
||||
now?: Date;
|
||||
expiresInSeconds?: number;
|
||||
knobs?: NormalizedMockIdpKnobs;
|
||||
}): Record<string, unknown> {
|
||||
const now = input.now ?? new Date();
|
||||
const issuedAt = secondsSinceEpoch(now);
|
||||
const knobs = input.knobs ?? normalizeKnobs(undefined);
|
||||
const claims: Record<string, unknown> = {
|
||||
iss: cleanIssuer(input.issuer),
|
||||
sub: input.subject.sub,
|
||||
aud: input.clientId,
|
||||
iat: issuedAt,
|
||||
exp: issuedAt + (input.expiresInSeconds ?? 300),
|
||||
email: input.subject.email,
|
||||
email_verified: true,
|
||||
name: input.subject.name,
|
||||
preferred_username: input.subject.email,
|
||||
picture: `https://avatar.openwork.test/${encodeURIComponent(input.subject.email)}`,
|
||||
department: "Enterprise Lab",
|
||||
};
|
||||
if (input.nonce) {
|
||||
claims.nonce = input.nonce;
|
||||
}
|
||||
if (knobs.guestUser) {
|
||||
claims.userType = "Guest";
|
||||
claims.tenant_hint = "external";
|
||||
}
|
||||
applyGroupClaims(claims, knobs.groupClaims);
|
||||
return claims;
|
||||
}
|
||||
|
||||
function base64UrlJson(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
export function signOidcJwt(input: {
|
||||
keyId: string;
|
||||
privateKeyPem: string;
|
||||
claims: Record<string, unknown>;
|
||||
}): string {
|
||||
const header = base64UrlJson({ alg: "RS256", typ: "JWT", kid: input.keyId });
|
||||
const payload = base64UrlJson(input.claims);
|
||||
const signingInput = `${header}.${payload}`;
|
||||
const signer = createSign("RSA-SHA256");
|
||||
signer.update(signingInput);
|
||||
signer.end();
|
||||
const signature = signer.sign(input.privateKeyPem).toString("base64url");
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
function publicJwk(publicKeyPem: string, keyId: string): Record<string, unknown> {
|
||||
const exported = createPublicKey(publicKeyPem).export({ format: "jwk" });
|
||||
const jwk: Record<string, unknown> = {};
|
||||
for (const [name, value] of Object.entries(exported)) {
|
||||
jwk[name] = value;
|
||||
}
|
||||
jwk.kid = keyId;
|
||||
jwk.use = "sig";
|
||||
jwk.alg = "RS256";
|
||||
return jwk;
|
||||
}
|
||||
|
||||
function createKeyMaterial(): KeyMaterial {
|
||||
const keyPair = generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
});
|
||||
const keyId = `mock-idp-${randomBytes(6).toString("hex")}`;
|
||||
return {
|
||||
keyId,
|
||||
privateKeyPem: keyPair.privateKey,
|
||||
publicKeyPem: keyPair.publicKey,
|
||||
jwk: publicJwk(keyPair.publicKey, keyId),
|
||||
};
|
||||
}
|
||||
|
||||
function certificateFromPublicKey(publicKeyPem: string, trailingNewline: boolean): string {
|
||||
const body = publicKeyPem
|
||||
.split(/\r?\n/g)
|
||||
.filter((line) => line && !line.startsWith("-----"))
|
||||
.join("\n");
|
||||
const cert = `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----`;
|
||||
return trailingNewline ? `${cert}\n` : cert;
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
// A single global character-class replace: linear (no backtracking) and the
|
||||
// shape static analysis recognises as HTML escaping, unlike a chain of
|
||||
// string-pattern replaceAll calls.
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => HTML_ENTITIES[character] ?? character);
|
||||
}
|
||||
|
||||
export function buildBlockedUserResponse(subject: MockIdpSubjectInput, policyName = "OpenWork Mock IdP policy"): BlockedUserResponseShape {
|
||||
const label = subject.email?.trim() || subject.sub?.trim() || "this user";
|
||||
const errorDescription = `The ${MOCK_IDP_BLOCKED_USER_PHRASE} unless they are assigned to the enterprise application.`;
|
||||
const message = `${policyName} blocked ${label}. ${errorDescription} Ask an IdP administrator to assign the user or update the app assignment policy.`;
|
||||
return {
|
||||
status: 403,
|
||||
error: "access_denied",
|
||||
errorDescription,
|
||||
message,
|
||||
html: `<!doctype html><html><head><title>OpenWork Mock IdP policy block</title></head><body><main style="font-family: sans-serif; max-width: 720px; margin: 48px auto;"><p>OpenWork Mock IdP policy</p><h1>Sign-in blocked by identity provider policy</h1><p>${escapeHtml(message)}</p><p>error=access_denied</p></main></body></html>`,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesBlockedUser(subject: MockIdpSubject, blockedUser: string | null): boolean {
|
||||
if (!blockedUser) {
|
||||
return false;
|
||||
}
|
||||
const blocked = blockedUser.trim().toLowerCase();
|
||||
return subject.email.toLowerCase() === blocked || subject.sub.toLowerCase() === blocked;
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
||||
const payload = JSON.stringify(body);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
interface HtmlDocument {
|
||||
title: string;
|
||||
heading: string;
|
||||
paragraphs: string[];
|
||||
}
|
||||
|
||||
function sendHtml(response: ServerResponse, status: number, document: HtmlDocument): void {
|
||||
// Escape at the write boundary so request-derived fields cannot reach the
|
||||
// HTML response as markup.
|
||||
const title = escapeHtml(document.title);
|
||||
const heading = escapeHtml(document.heading);
|
||||
const paragraphs = document.paragraphs.map((text) => `<p>${escapeHtml(text)}</p>`).join("");
|
||||
const body = `<!doctype html><html><head><title>${title}</title></head><body><main style="font-family: sans-serif; max-width: 720px; margin: 48px auto;"><h1>${heading}</h1>${paragraphs}</main></body></html>`;
|
||||
response.writeHead(status, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function sendRedirect(response: ServerResponse, location: string): void {
|
||||
response.writeHead(302, {
|
||||
location,
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end();
|
||||
}
|
||||
|
||||
function method(request: IncomingMessage): string {
|
||||
return (request.method ?? "GET").toUpperCase();
|
||||
}
|
||||
|
||||
function requestUrl(request: IncomingMessage, issuer: string): URL {
|
||||
return new URL(request.url ?? "/", issuer);
|
||||
}
|
||||
|
||||
function headerValue(request: IncomingMessage, name: string): string {
|
||||
const raw = request.headers[name.toLowerCase()];
|
||||
if (Array.isArray(raw)) {
|
||||
return raw[0] ?? "";
|
||||
}
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
async function requestBody(request: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
if (typeof chunk === "string") {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
} else if (Buffer.isBuffer(chunk)) {
|
||||
chunks.push(chunk);
|
||||
} else {
|
||||
chunks.push(Buffer.from(String(chunk)));
|
||||
}
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
async function formBody(request: IncomingMessage): Promise<URLSearchParams> {
|
||||
return new URLSearchParams(await requestBody(request));
|
||||
}
|
||||
|
||||
function basicClientCredentials(authorization: string): { clientId: string; clientSecret: string } | null {
|
||||
if (!authorization.startsWith("Basic ")) {
|
||||
return null;
|
||||
}
|
||||
const decoded = Buffer.from(authorization.slice("Basic ".length), "base64").toString("utf8");
|
||||
const separator = decoded.indexOf(":");
|
||||
if (separator < 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clientId: decodeURIComponent(decoded.slice(0, separator)),
|
||||
clientSecret: decodeURIComponent(decoded.slice(separator + 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function clientAuthenticated(params: URLSearchParams, authorization: string, config: NormalizedMockIdpConfig): boolean {
|
||||
const basic = basicClientCredentials(authorization);
|
||||
const clientId = basic?.clientId ?? params.get("client_id") ?? "";
|
||||
const clientSecret = basic?.clientSecret ?? params.get("client_secret") ?? "";
|
||||
return safeEqual(clientId, config.clientId) && safeEqual(clientSecret, config.clientSecret);
|
||||
}
|
||||
|
||||
function oidcRegistration(config: NormalizedMockIdpConfig, overrides: Partial<MockOidcRegistration> = {}): MockOidcRegistration {
|
||||
const issuer = overrides.issuer ?? config.issuer;
|
||||
return {
|
||||
kind: "oidc",
|
||||
issuer,
|
||||
domain: overrides.domain ?? config.domain,
|
||||
clientId: overrides.clientId ?? config.clientId,
|
||||
clientSecret: overrides.clientSecret ?? config.clientSecret,
|
||||
scopes: overrides.scopes ?? ["openid", "email", "profile"],
|
||||
skipDiscovery: overrides.skipDiscovery ?? false,
|
||||
authorizationEndpoint: overrides.authorizationEndpoint ?? `${issuer}/authorize`,
|
||||
tokenEndpoint: overrides.tokenEndpoint ?? `${issuer}/token`,
|
||||
jwksEndpoint: overrides.jwksEndpoint ?? `${issuer}/jwks`,
|
||||
userInfoEndpoint: overrides.userInfoEndpoint ?? `${issuer}/userinfo`,
|
||||
tokenEndpointAuthentication: "client_secret_post",
|
||||
};
|
||||
}
|
||||
|
||||
function discovery(config: NormalizedMockIdpConfig): Record<string, unknown> {
|
||||
return {
|
||||
issuer: config.issuer,
|
||||
authorization_endpoint: `${config.issuer}/authorize`,
|
||||
token_endpoint: `${config.issuer}/token`,
|
||||
jwks_uri: `${config.issuer}/jwks`,
|
||||
userinfo_endpoint: `${config.issuer}/userinfo`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||
scopes_supported: ["openid", "email", "profile"],
|
||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "roles", "department"],
|
||||
};
|
||||
}
|
||||
|
||||
function samlMetadata(config: NormalizedMockIdpConfig, certificate: string): string {
|
||||
const certBody = certificate
|
||||
.replace(/-----BEGIN CERTIFICATE-----/g, "")
|
||||
.replace(/-----END CERTIFICATE-----/g, "")
|
||||
.replace(/\s+/g, "");
|
||||
const entityId = escapeHtml(`${config.issuer}/saml/metadata`);
|
||||
const ssoUrl = escapeHtml(`${config.issuer}/saml/sso`);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<EntityDescriptor entityID="${entityId}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<KeyDescriptor use="signing"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>${certBody}</X509Certificate></X509Data></KeyInfo></KeyDescriptor>
|
||||
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${ssoUrl}"/>
|
||||
</IDPSSODescriptor>
|
||||
</EntityDescriptor>`;
|
||||
}
|
||||
|
||||
function samlPostBindingPage(config: NormalizedMockIdpConfig): HtmlDocument {
|
||||
return {
|
||||
title: "OpenWork Mock IdP SAML endpoint",
|
||||
heading: "SAML POST binding endpoint",
|
||||
paragraphs: [
|
||||
"OpenWork Mock IdP lab",
|
||||
"This fixture exposes SAML metadata and a POST binding endpoint, but the OpenWork eval lab drives OIDC because this checkout implements OIDC and SAML SSO and OIDC keeps the signed-token path dependency-free.",
|
||||
`Issuer: ${config.issuer}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function authorize(state: MockIdpState, url: URL, response: ServerResponse): void {
|
||||
const redirectUri = url.searchParams.get("redirect_uri") ?? "";
|
||||
const clientId = url.searchParams.get("client_id") ?? "";
|
||||
if (!redirectUri || !safeEqual(clientId, state.config.clientId)) {
|
||||
sendJson(response, 400, { error: "invalid_request", error_description: "redirect_uri and a registered client_id are required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const loginHint = url.searchParams.get("login_hint") ?? undefined;
|
||||
const requestedSubject = loginHint ? { email: loginHint } : undefined;
|
||||
const subject = subjectWithKnobs(state.config, requestedSubject);
|
||||
if (matchesBlockedUser(subject, state.config.knobs.blockedUser)) {
|
||||
const blocked = buildBlockedUserResponse(subject);
|
||||
sendHtml(response, blocked.status, {
|
||||
title: "OpenWork Mock IdP policy block",
|
||||
heading: "Sign-in blocked by identity provider policy",
|
||||
paragraphs: ["OpenWork Mock IdP policy", blocked.message, "error=access_denied"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const code = randomBytes(18).toString("base64url");
|
||||
state.codes.set(code, {
|
||||
clientId,
|
||||
nonce: url.searchParams.get("nonce"),
|
||||
redirectUri,
|
||||
subject,
|
||||
});
|
||||
|
||||
const callback = new URL(redirectUri);
|
||||
callback.searchParams.set("code", code);
|
||||
const relayState = url.searchParams.get("state");
|
||||
if (relayState) {
|
||||
callback.searchParams.set("state", relayState);
|
||||
}
|
||||
sendRedirect(response, callback.toString());
|
||||
}
|
||||
|
||||
async function token(state: MockIdpState, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
const params = await formBody(request);
|
||||
if (!clientAuthenticated(params, headerValue(request, "authorization"), state.config)) {
|
||||
sendJson(response, 401, { error: "invalid_client", error_description: "The mock IdP did not recognize the OIDC client credentials." });
|
||||
return;
|
||||
}
|
||||
if (params.get("grant_type") !== "authorization_code") {
|
||||
sendJson(response, 400, { error: "unsupported_grant_type" });
|
||||
return;
|
||||
}
|
||||
const code = params.get("code") ?? "";
|
||||
const record = state.codes.get(code) ?? null;
|
||||
if (!record) {
|
||||
sendJson(response, 400, { error: "invalid_grant", error_description: "The authorization code was not issued by this mock IdP." });
|
||||
return;
|
||||
}
|
||||
state.codes.delete(code);
|
||||
const claims = buildOidcClaims({
|
||||
issuer: state.config.issuer,
|
||||
clientId: record.clientId,
|
||||
subject: record.subject,
|
||||
nonce: record.nonce,
|
||||
knobs: state.config.knobs,
|
||||
});
|
||||
const accessToken = randomBytes(24).toString("base64url");
|
||||
state.accessTokens.set(accessToken, claims);
|
||||
sendJson(response, 200, {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 300,
|
||||
id_token: signOidcJwt({ keyId: state.keys.keyId, privateKeyPem: state.keys.privateKeyPem, claims }),
|
||||
});
|
||||
}
|
||||
|
||||
function userinfo(state: MockIdpState, request: IncomingMessage, response: ServerResponse): void {
|
||||
const authorization = headerValue(request, "authorization");
|
||||
const tokenValue = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
|
||||
const claims = state.accessTokens.get(tokenValue) ?? null;
|
||||
if (!claims) {
|
||||
sendJson(response, 401, { error: "invalid_token" });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 200, claims);
|
||||
}
|
||||
|
||||
async function handleRequest(state: MockIdpState, certificate: string, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
const url = requestUrl(request, state.config.issuer);
|
||||
if (method(request) === "GET" && url.pathname === "/.well-known/openid-configuration") {
|
||||
sendJson(response, 200, discovery(state.config));
|
||||
return;
|
||||
}
|
||||
if (method(request) === "GET" && url.pathname === "/jwks") {
|
||||
sendJson(response, 200, { keys: [state.keys.jwk] });
|
||||
return;
|
||||
}
|
||||
if (method(request) === "GET" && url.pathname === "/authorize") {
|
||||
authorize(state, url, response);
|
||||
return;
|
||||
}
|
||||
if (method(request) === "POST" && url.pathname === "/token") {
|
||||
await token(state, request, response);
|
||||
return;
|
||||
}
|
||||
if (method(request) === "GET" && url.pathname === "/userinfo") {
|
||||
userinfo(state, request, response);
|
||||
return;
|
||||
}
|
||||
if (method(request) === "GET" && url.pathname === "/saml/metadata") {
|
||||
response.writeHead(200, { "content-type": "application/samlmetadata+xml; charset=utf-8", "cache-control": "no-store" });
|
||||
response.end(samlMetadata(state.config, certificate));
|
||||
return;
|
||||
}
|
||||
if ((method(request) === "GET" || method(request) === "POST") && url.pathname === "/saml/sso") {
|
||||
sendHtml(response, 200, samlPostBindingPage(state.config));
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
function listen(server: Server, host: string, port: number): Promise<AddressInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
const address = server.address();
|
||||
if (isAddressInfo(address)) {
|
||||
resolve(address);
|
||||
return;
|
||||
}
|
||||
reject(new Error("Mock IdP server did not expose a TCP port."));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function startMockIdpLab(input: MockIdpConfig = {}): Promise<StartedMockIdpLab> {
|
||||
const keys = createKeyMaterial();
|
||||
const certificate = certificateFromPublicKey(keys.publicKeyPem, input.knobs?.certTrailingNewline === true);
|
||||
const fixedIssuer = configuredMockIdpIssuer(input);
|
||||
const state: MockIdpState = {
|
||||
config: normalizeMockIdpConfig(input),
|
||||
keys,
|
||||
codes: new Map(),
|
||||
accessTokens: new Map(),
|
||||
};
|
||||
const server = createServer((request, response) => {
|
||||
void handleRequest(state, certificate, request, response).catch((error) => {
|
||||
sendJson(response, 500, { error: "mock_idp_error", message: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
});
|
||||
const fixedPort = fixedIssuer?.port ? Number.parseInt(fixedIssuer.port, 10) : 0;
|
||||
const address = await listen(server, fixedIssuer?.hostname ?? "127.0.0.1", fixedPort);
|
||||
state.config = normalizeMockIdpConfig({
|
||||
...input,
|
||||
issuer: fixedIssuer ? fixedIssuer.origin : `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
|
||||
const stop = () => close(server);
|
||||
return {
|
||||
protocol: "oidc",
|
||||
config: state.config,
|
||||
issuer: state.config.issuer,
|
||||
authorizationEndpoint: `${state.config.issuer}/authorize`,
|
||||
tokenEndpoint: `${state.config.issuer}/token`,
|
||||
jwksEndpoint: `${state.config.issuer}/jwks`,
|
||||
userInfoEndpoint: `${state.config.issuer}/userinfo`,
|
||||
saml: {
|
||||
metadataUrl: `${state.config.issuer}/saml/metadata`,
|
||||
ssoPostUrl: `${state.config.issuer}/saml/sso`,
|
||||
certificate,
|
||||
},
|
||||
keyId: keys.keyId,
|
||||
certificate,
|
||||
publicKeyPem: keys.publicKeyPem,
|
||||
registration: (overrides = {}) => oidcRegistration(state.config, overrides),
|
||||
claimsFor: (subject) => buildOidcClaims({
|
||||
issuer: state.config.issuer,
|
||||
clientId: state.config.clientId,
|
||||
subject: subjectWithKnobs(state.config, subject),
|
||||
knobs: state.config.knobs,
|
||||
}),
|
||||
stop,
|
||||
[Symbol.asyncDispose]: stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./egress.ts";
|
||||
export * from "./idp.ts";
|
||||
export * from "./mock-google.ts";
|
||||
export * from "./mock-github.ts";
|
||||
export * from "./mock-mcp.ts";
|
||||
export * from "./not-implemented.ts";
|
||||
export * from "./release-feed.ts";
|
||||
@@ -0,0 +1,395 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export interface MockGithubRepository {
|
||||
id: number;
|
||||
fullName: string;
|
||||
defaultBranch: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export interface MockGithubRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
url: string;
|
||||
at: string;
|
||||
authorization: string | null;
|
||||
}
|
||||
|
||||
export interface MockGithubHandle {
|
||||
apiUrl: string;
|
||||
/** Replaces the repository's complete file map and returns its deterministic new head SHA. */
|
||||
advanceHead(fullName: string, files: Record<string, string>): { headSha: string };
|
||||
injectFaults(fault: MockGithubFault): void;
|
||||
renameRepository(oldFullName: string, newFullName: string): void;
|
||||
requests(): Promise<MockGithubRequest[]>;
|
||||
stop(): Promise<void>;
|
||||
[Symbol.asyncDispose](): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MockGithubFault {
|
||||
count: number;
|
||||
status: number;
|
||||
pathIncludes?: string;
|
||||
retryAfterSeconds?: number;
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
export interface StartMockGithubOptions {
|
||||
installationId: number;
|
||||
accountLogin: string;
|
||||
repositories: MockGithubRepository[];
|
||||
/** files by repo fullName -> { branch, files: Record<repoRelativePath, string> } */
|
||||
repoFiles: Record<string, { branch: string; files: Record<string, string> }>;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
interface RepositorySnapshot {
|
||||
headSha: string;
|
||||
treeSha: string;
|
||||
tree: Array<{
|
||||
path: string;
|
||||
type: "blob" | "tree";
|
||||
sha: string;
|
||||
size?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PendingFault extends MockGithubFault {
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
function isAddressInfo(value: ReturnType<Server["address"]>): value is AddressInfo {
|
||||
return typeof value === "object"
|
||||
&& value !== null
|
||||
&& typeof value.address === "string"
|
||||
&& typeof value.family === "string"
|
||||
&& typeof value.port === "number";
|
||||
}
|
||||
|
||||
function sendJson(
|
||||
response: ServerResponse,
|
||||
status: number,
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): void {
|
||||
response.writeHead(status, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...headers,
|
||||
});
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function requestMethod(request: IncomingMessage): string {
|
||||
return (request.method ?? "GET").toUpperCase();
|
||||
}
|
||||
|
||||
function authorizationHeader(request: IncomingMessage): string | null {
|
||||
const value = request.headers.authorization;
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value[0] ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function contentDigest(files: Record<string, string>): string {
|
||||
const entries = Object.entries(files).sort(([left], [right]) => left.localeCompare(right));
|
||||
return JSON.stringify(entries);
|
||||
}
|
||||
|
||||
function repositorySnapshot(files: Record<string, string>): RepositorySnapshot {
|
||||
const digest = contentDigest(files);
|
||||
const treeSha = createHash("sha256").update(`tree\0${digest}`).digest("hex");
|
||||
const headSha = createHash("sha1").update(`commit\0${treeSha}`).digest("hex");
|
||||
const directories = new Set<string>();
|
||||
for (const path of Object.keys(files)) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
for (let index = 1; index < segments.length; index += 1) {
|
||||
directories.add(segments.slice(0, index).join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
const tree: RepositorySnapshot["tree"] = [];
|
||||
for (const path of [...directories].sort()) {
|
||||
tree.push({
|
||||
path,
|
||||
type: "tree",
|
||||
sha: createHash("sha1").update(`directory\0${path}\0${digest}`).digest("hex"),
|
||||
});
|
||||
}
|
||||
for (const [path, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
tree.push({
|
||||
path,
|
||||
type: "blob",
|
||||
sha: createHash("sha1").update(content).digest("hex"),
|
||||
size: Buffer.byteLength(content),
|
||||
});
|
||||
}
|
||||
tree.sort((left, right) => left.path.localeCompare(right.path));
|
||||
return { headSha, treeSha, tree };
|
||||
}
|
||||
|
||||
function repositoryJson(repository: MockGithubRepository): Record<string, unknown> {
|
||||
return {
|
||||
id: repository.id,
|
||||
full_name: repository.fullName,
|
||||
default_branch: repository.defaultBranch,
|
||||
private: repository.private,
|
||||
};
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | null, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function listen(server: Server, port: number): Promise<AddressInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
const address = server.address();
|
||||
if (isAddressInfo(address)) {
|
||||
resolve(address);
|
||||
return;
|
||||
}
|
||||
reject(new Error("Mock GitHub server did not expose a TCP port."));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
server.closeIdleConnections();
|
||||
});
|
||||
}
|
||||
|
||||
export async function startMockGithub(options: StartMockGithubOptions): Promise<MockGithubHandle> {
|
||||
const requests: MockGithubRequest[] = [];
|
||||
const faults: PendingFault[] = [];
|
||||
const repositories = options.repositories.map((repository) => ({ ...repository }));
|
||||
const repositoriesByFullName = new Map<string, MockGithubRepository>();
|
||||
const fixturesByFullName = new Map<string, { branch: string; files: Record<string, string> }>();
|
||||
const snapshotsByFullName = new Map<string, RepositorySnapshot>();
|
||||
for (const repository of repositories) {
|
||||
repositoriesByFullName.set(repository.fullName, repository);
|
||||
const fixture = options.repoFiles[repository.fullName];
|
||||
if (fixture) {
|
||||
const files = { ...fixture.files };
|
||||
fixturesByFullName.set(repository.fullName, { branch: fixture.branch, files });
|
||||
snapshotsByFullName.set(repository.fullName, repositorySnapshot(files));
|
||||
}
|
||||
}
|
||||
|
||||
let apiUrl = "http://127.0.0.1";
|
||||
const handleRequest = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", apiUrl);
|
||||
const method = requestMethod(request);
|
||||
if (method === "GET" && url.pathname === "/health") {
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && url.pathname === "/requests") {
|
||||
sendJson(response, 200, { requests });
|
||||
return;
|
||||
}
|
||||
|
||||
requests.push({
|
||||
method,
|
||||
path: url.pathname,
|
||||
url: `${url.pathname}${url.search}`,
|
||||
at: new Date().toISOString(),
|
||||
authorization: authorizationHeader(request),
|
||||
});
|
||||
|
||||
const faultIndex = faults.findIndex((fault) => (
|
||||
fault.remaining > 0 && (!fault.pathIncludes || url.pathname.includes(fault.pathIncludes))
|
||||
));
|
||||
if (faultIndex >= 0) {
|
||||
const fault = faults[faultIndex];
|
||||
if (fault) {
|
||||
fault.remaining -= 1;
|
||||
if (fault.remaining === 0) faults.splice(faultIndex, 1);
|
||||
if (fault.delayMs !== undefined) {
|
||||
await sleep(fault.delayMs);
|
||||
} else {
|
||||
const headers: Record<string, string> = {};
|
||||
if (fault.retryAfterSeconds !== undefined) {
|
||||
headers["retry-after"] = String(fault.retryAfterSeconds);
|
||||
}
|
||||
sendJson(response, fault.status, { message: "mock fault" }, headers);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const segments = url.pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
|
||||
if (method === "POST"
|
||||
&& segments.length === 4
|
||||
&& segments[0] === "app"
|
||||
&& segments[1] === "installations"
|
||||
&& segments[2] === String(options.installationId)
|
||||
&& segments[3] === "access_tokens") {
|
||||
sendJson(response, 201, { token: `mock-installation-token-${options.installationId}` });
|
||||
return;
|
||||
}
|
||||
if (method === "GET"
|
||||
&& segments.length === 3
|
||||
&& segments[0] === "app"
|
||||
&& segments[1] === "installations"
|
||||
&& segments[2] === String(options.installationId)) {
|
||||
sendJson(response, 200, {
|
||||
id: options.installationId,
|
||||
account: { login: options.accountLogin, type: "Organization" },
|
||||
repository_selection: "all",
|
||||
html_url: `${apiUrl}/settings`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && url.pathname === "/installation/repositories") {
|
||||
const perPage = positiveInteger(url.searchParams.get("per_page"), 30);
|
||||
const page = positiveInteger(url.searchParams.get("page"), 1);
|
||||
const start = (page - 1) * perPage;
|
||||
sendJson(response, 200, {
|
||||
total_count: repositories.length,
|
||||
repositories: repositories.slice(start, start + perPage).map(repositoryJson),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (segments[0] === "repos" && segments.length >= 3) {
|
||||
const fullName = `${segments[1]}/${segments[2]}`;
|
||||
const repository = repositoriesByFullName.get(fullName);
|
||||
const fixture = fixturesByFullName.get(fullName);
|
||||
const snapshot = snapshotsByFullName.get(fullName);
|
||||
if (!repository) {
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && segments.length === 3) {
|
||||
sendJson(response, 200, repositoryJson(repository));
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && segments.length === 5 && segments[3] === "branches") {
|
||||
const branch = segments[4];
|
||||
if (branch === repository.defaultBranch && (!fixture || fixture.branch === branch)) {
|
||||
sendJson(response, 200, { name: branch });
|
||||
} else {
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && segments.length === 5 && segments[3] === "commits") {
|
||||
if (fixture && snapshot && segments[4] === fixture.branch) {
|
||||
sendJson(response, 200, {
|
||||
sha: snapshot.headSha,
|
||||
commit: { tree: { sha: snapshot.treeSha } },
|
||||
});
|
||||
} else {
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (method === "GET"
|
||||
&& segments.length === 6
|
||||
&& segments[3] === "git"
|
||||
&& segments[4] === "trees") {
|
||||
if (snapshot && segments[5] === snapshot.treeSha && url.searchParams.get("recursive") === "1") {
|
||||
sendJson(response, 200, { truncated: false, tree: snapshot.tree });
|
||||
} else {
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && segments.length >= 5 && segments[3] === "contents") {
|
||||
const path = segments.slice(4).join("/");
|
||||
const ref = url.searchParams.get("ref") ?? repository.defaultBranch;
|
||||
const content = fixture?.branch === ref ? fixture.files[path] : undefined;
|
||||
if (typeof content === "string") {
|
||||
sendJson(response, 200, { content: Buffer.from(content).toString("base64"), encoding: "base64" });
|
||||
} else {
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
} catch (error) {
|
||||
// Log the detail server-side; never echo error text into the HTTP body
|
||||
// (CodeQL js/stack-trace-exposure), even in a local test witness.
|
||||
console.error(`[mock-github] request handler failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
sendJson(response, 500, { message: "mock github internal error" });
|
||||
}
|
||||
};
|
||||
const server = createServer((request, response) => {
|
||||
void handleRequest(request, response);
|
||||
});
|
||||
|
||||
const address = await listen(server, options.port ?? 0);
|
||||
apiUrl = `http://127.0.0.1:${address.port}`;
|
||||
let stopped = false;
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
await close(server);
|
||||
};
|
||||
|
||||
return {
|
||||
apiUrl,
|
||||
advanceHead(fullName, files) {
|
||||
const repository = repositoriesByFullName.get(fullName);
|
||||
if (!repository) throw new Error(`Mock GitHub repository not found: ${fullName}`);
|
||||
const fixture = fixturesByFullName.get(fullName);
|
||||
const nextFiles = { ...files };
|
||||
const snapshot = repositorySnapshot(nextFiles);
|
||||
fixturesByFullName.set(fullName, {
|
||||
branch: fixture?.branch ?? repository.defaultBranch,
|
||||
files: nextFiles,
|
||||
});
|
||||
snapshotsByFullName.set(fullName, snapshot);
|
||||
return { headSha: snapshot.headSha };
|
||||
},
|
||||
injectFaults(fault) {
|
||||
if (fault.count <= 0) return;
|
||||
faults.push({ ...fault, remaining: fault.count });
|
||||
},
|
||||
renameRepository(oldFullName, newFullName) {
|
||||
const repository = repositoriesByFullName.get(oldFullName);
|
||||
if (!repository) throw new Error(`Mock GitHub repository not found: ${oldFullName}`);
|
||||
if (repositoriesByFullName.has(newFullName)) {
|
||||
throw new Error(`Mock GitHub repository already exists: ${newFullName}`);
|
||||
}
|
||||
const renamed = { ...repository, fullName: newFullName };
|
||||
const index = repositories.findIndex((candidate) => candidate.fullName === oldFullName);
|
||||
if (index >= 0) repositories[index] = renamed;
|
||||
repositoriesByFullName.delete(oldFullName);
|
||||
repositoriesByFullName.set(newFullName, renamed);
|
||||
|
||||
const fixture = fixturesByFullName.get(oldFullName);
|
||||
fixturesByFullName.delete(oldFullName);
|
||||
if (fixture) fixturesByFullName.set(newFullName, fixture);
|
||||
const snapshot = snapshotsByFullName.get(oldFullName);
|
||||
snapshotsByFullName.delete(oldFullName);
|
||||
if (snapshot) snapshotsByFullName.set(newFullName, snapshot);
|
||||
},
|
||||
async requests() {
|
||||
return requests.map((request) => ({ ...request }));
|
||||
},
|
||||
stop,
|
||||
[Symbol.asyncDispose]: stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { buildOidcClaims, signOidcJwt } from "./idp.ts";
|
||||
|
||||
interface MockGoogleServerOptions {
|
||||
accounts: string[];
|
||||
port: number;
|
||||
autoApprove: boolean;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
interface StartedMockGoogleServer {
|
||||
baseUrl: string;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RequestEntry {
|
||||
method: string;
|
||||
path: string;
|
||||
url: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
interface PendingAuthorization {
|
||||
id: string;
|
||||
params: URLSearchParams;
|
||||
}
|
||||
|
||||
interface AuthorizationCode {
|
||||
account: Account;
|
||||
clientId: string;
|
||||
codeChallenge: string | null;
|
||||
codeChallengeMethod: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
interface RefreshCredential {
|
||||
account: Account;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
interface RecordedDraft {
|
||||
to: string;
|
||||
body: string;
|
||||
threadId?: string;
|
||||
attachments?: RecordedAttachment[];
|
||||
tokenId: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
interface RecordedAttachment {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
dataBase64: string;
|
||||
}
|
||||
|
||||
interface RecordedDriveUpload extends RecordedAttachment {
|
||||
tokenId: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
interface SigningKeys {
|
||||
keyId: string;
|
||||
privateKeyPem: string;
|
||||
}
|
||||
|
||||
interface MockGoogleState {
|
||||
accounts: Account[];
|
||||
autoApprove: boolean;
|
||||
baseUrl: string;
|
||||
requests: RequestEntry[];
|
||||
pending: Map<string, PendingAuthorization>;
|
||||
codes: Map<string, AuthorizationCode>;
|
||||
accessTokens: Map<string, Account>;
|
||||
refreshTokens: Map<string, RefreshCredential>;
|
||||
drafts: Map<string, RecordedDraft[]>;
|
||||
driveUploads: Map<string, RecordedDriveUpload[]>;
|
||||
keys: SigningKeys;
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isAddressInfo(value: ReturnType<Server["address"]>): value is AddressInfo {
|
||||
return typeof value === "object"
|
||||
&& value !== null
|
||||
&& typeof value.address === "string"
|
||||
&& typeof value.family === "string"
|
||||
&& typeof value.port === "number";
|
||||
}
|
||||
|
||||
function accountName(email: string): string {
|
||||
const localPart = email.split("@")[0] ?? email;
|
||||
const words = localPart.split(/[._+-]+/).filter(Boolean);
|
||||
return words.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`).join(" ") || email;
|
||||
}
|
||||
|
||||
function normalizeAccounts(values: string[]): Account[] {
|
||||
const emails = Array.from(new Set(values.map((value) => value.trim().toLowerCase()).filter(Boolean)));
|
||||
if (emails.length === 0) {
|
||||
throw new Error("startMockGoogle requires at least one account.");
|
||||
}
|
||||
return emails.map((email) => ({ sub: email, email, name: accountName(email) }));
|
||||
}
|
||||
|
||||
function createSigningKeys(): SigningKeys {
|
||||
const pair = generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
});
|
||||
return {
|
||||
keyId: `mock-google-${randomUUID()}`,
|
||||
privateKeyPem: pair.privateKey,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => HTML_ENTITIES[character] ?? character);
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
||||
response.writeHead(status, {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers": "authorization, content-type",
|
||||
"access-control-allow-methods": "GET, POST, OPTIONS",
|
||||
"cache-control": "no-store",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
});
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function sendHtml(response: ServerResponse, status: number, body: string): void {
|
||||
response.writeHead(status, {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-store",
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function sendRedirect(response: ServerResponse, location: string): void {
|
||||
response.writeHead(302, { "cache-control": "no-store", location });
|
||||
response.end();
|
||||
}
|
||||
|
||||
function method(request: IncomingMessage): string {
|
||||
return (request.method ?? "GET").toUpperCase();
|
||||
}
|
||||
|
||||
function requestUrl(request: IncomingMessage, baseUrl: string): URL {
|
||||
return new URL(request.url ?? "/", baseUrl);
|
||||
}
|
||||
|
||||
async function requestBuffer(request: IncomingMessage): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
if (typeof chunk === "string") {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
} else if (Buffer.isBuffer(chunk)) {
|
||||
chunks.push(chunk);
|
||||
} else {
|
||||
chunks.push(Buffer.from(String(chunk)));
|
||||
}
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async function requestBody(request: IncomingMessage): Promise<string> {
|
||||
return (await requestBuffer(request)).toString("utf8");
|
||||
}
|
||||
|
||||
async function formBody(request: IncomingMessage): Promise<URLSearchParams> {
|
||||
return new URLSearchParams(await requestBody(request));
|
||||
}
|
||||
|
||||
async function jsonBody(request: IncomingMessage): Promise<unknown> {
|
||||
const body = await requestBody(request);
|
||||
return body.trim() ? JSON.parse(body) : null;
|
||||
}
|
||||
|
||||
function bearerToken(request: IncomingMessage): string | null {
|
||||
const raw = request.headers.authorization;
|
||||
const header = Array.isArray(raw) ? raw[0] ?? "" : raw ?? "";
|
||||
if (!/^bearer /i.test(header)) return null;
|
||||
return header.slice("bearer ".length).trim() || null;
|
||||
}
|
||||
|
||||
function tokenId(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function accountForRequest(state: MockGoogleState, request: IncomingMessage): Account | null {
|
||||
const token = bearerToken(request);
|
||||
return token ? state.accessTokens.get(token) ?? null : null;
|
||||
}
|
||||
|
||||
function recordRequest(state: MockGoogleState, request: IncomingMessage, url: URL): void {
|
||||
state.requests.push({
|
||||
method: method(request),
|
||||
path: url.pathname,
|
||||
url: `${url.pathname}${url.search}`,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function callbackForCode(params: URLSearchParams, code: string): string | null {
|
||||
const redirectUri = params.get("redirect_uri");
|
||||
if (!redirectUri) return null;
|
||||
const callback = new URL(redirectUri);
|
||||
callback.searchParams.set("code", code);
|
||||
const relayState = params.get("state");
|
||||
if (relayState) callback.searchParams.set("state", relayState);
|
||||
return callback.toString();
|
||||
}
|
||||
|
||||
function authorizeAccount(
|
||||
state: MockGoogleState,
|
||||
pending: PendingAuthorization,
|
||||
account: Account,
|
||||
response: ServerResponse,
|
||||
): void {
|
||||
const code = `mock-google-code-${randomUUID()}`;
|
||||
const callback = callbackForCode(pending.params, code);
|
||||
if (!callback) {
|
||||
sendJson(response, 400, { error: "invalid_request", error_description: "redirect_uri is required" });
|
||||
return;
|
||||
}
|
||||
state.codes.set(code, {
|
||||
account,
|
||||
clientId: pending.params.get("client_id") ?? "mock-google-client",
|
||||
codeChallenge: pending.params.get("code_challenge"),
|
||||
codeChallengeMethod: pending.params.get("code_challenge_method") ?? "plain",
|
||||
scope: pending.params.get("scope") ?? "openid email profile",
|
||||
});
|
||||
state.pending.delete(pending.id);
|
||||
sendRedirect(response, callback);
|
||||
}
|
||||
|
||||
function chooserPage(state: MockGoogleState, pending: PendingAuthorization): string {
|
||||
const accounts = state.accounts.map((account) => {
|
||||
const chooseUrl = new URL("/choose-account", state.baseUrl);
|
||||
chooseUrl.searchParams.set("request_id", pending.id);
|
||||
chooseUrl.searchParams.set("email", account.email);
|
||||
return `<li><a data-account-email="${escapeHtml(account.email)}" href="${escapeHtml(`${chooseUrl.pathname}${chooseUrl.search}`)}">${escapeHtml(account.name)} <span>${escapeHtml(account.email)}</span></a></li>`;
|
||||
}).join("");
|
||||
return `<!doctype html><html><head><title>Choose an account</title></head><body><main style="font-family: system-ui, sans-serif; max-width: 560px; margin: 48px auto;"><h1>Choose an account</h1><p>Continue to OpenWork</p><ul>${accounts}</ul></main></body></html>`;
|
||||
}
|
||||
|
||||
function consentPage(pending: PendingAuthorization): string {
|
||||
const approveUrl = new URL("/approve", "http://mock.invalid");
|
||||
approveUrl.searchParams.set("request_id", pending.id);
|
||||
return `<!doctype html><html><head><title>Mock Google OAuth</title></head><body><main style="font-family: system-ui, sans-serif; max-width: 560px; margin: 48px auto;"><h1>Mock Google OAuth</h1><p>This fake Google provider is for OpenWork end-to-end tests.</p><form method="post" action="${escapeHtml(`${approveUrl.pathname}${approveUrl.search}`)}"><button>Approve OpenWork</button></form></main></body></html>`;
|
||||
}
|
||||
|
||||
function authorize(state: MockGoogleState, url: URL, response: ServerResponse): void {
|
||||
if (!url.searchParams.get("redirect_uri")) {
|
||||
sendJson(response, 400, { error: "invalid_request", error_description: "redirect_uri is required" });
|
||||
return;
|
||||
}
|
||||
const pending: PendingAuthorization = {
|
||||
id: randomUUID(),
|
||||
params: new URLSearchParams(url.searchParams),
|
||||
};
|
||||
const selectAccount = (url.searchParams.get("prompt") ?? "").split(/\s+/).includes("select_account");
|
||||
if (selectAccount) {
|
||||
state.pending.set(pending.id, pending);
|
||||
sendHtml(response, 200, chooserPage(state, pending));
|
||||
return;
|
||||
}
|
||||
if (state.autoApprove) {
|
||||
authorizeAccount(state, pending, state.accounts[0], response);
|
||||
return;
|
||||
}
|
||||
state.pending.set(pending.id, pending);
|
||||
sendHtml(response, 200, consentPage(pending));
|
||||
}
|
||||
|
||||
function chooseAccount(state: MockGoogleState, url: URL, response: ServerResponse): void {
|
||||
const pending = state.pending.get(url.searchParams.get("request_id") ?? "") ?? null;
|
||||
const account = state.accounts.find((candidate) => candidate.email === (url.searchParams.get("email") ?? "").toLowerCase()) ?? null;
|
||||
if (!pending || !account) {
|
||||
sendJson(response, 404, { error: "authorization_not_found" });
|
||||
return;
|
||||
}
|
||||
authorizeAccount(state, pending, account, response);
|
||||
}
|
||||
|
||||
function approve(state: MockGoogleState, url: URL, response: ServerResponse): void {
|
||||
const pending = state.pending.get(url.searchParams.get("request_id") ?? "") ?? null;
|
||||
if (!pending) {
|
||||
sendJson(response, 404, { error: "authorization_not_found" });
|
||||
return;
|
||||
}
|
||||
authorizeAccount(state, pending, state.accounts[0], response);
|
||||
}
|
||||
|
||||
function claimsFor(state: MockGoogleState, credential: RefreshCredential): Record<string, unknown> {
|
||||
return buildOidcClaims({
|
||||
issuer: state.baseUrl,
|
||||
clientId: credential.clientId,
|
||||
subject: credential.account,
|
||||
});
|
||||
}
|
||||
|
||||
function issueCredential(state: MockGoogleState, credential: RefreshCredential): {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
idToken: string;
|
||||
} {
|
||||
const accessToken = `mock-google-access-${randomUUID()}`;
|
||||
const refreshToken = `mock-google-refresh-${randomUUID()}`;
|
||||
const claims = claimsFor(state, credential);
|
||||
state.accessTokens.set(accessToken, credential.account);
|
||||
state.refreshTokens.set(refreshToken, credential);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken: signOidcJwt({ keyId: state.keys.keyId, privateKeyPem: state.keys.privateKeyPem, claims }),
|
||||
};
|
||||
}
|
||||
|
||||
async function token(state: MockGoogleState, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
const params = await formBody(request);
|
||||
const grantType = params.get("grant_type") ?? "authorization_code";
|
||||
let credential: RefreshCredential;
|
||||
if (grantType === "authorization_code") {
|
||||
const codeValue = params.get("code") ?? "";
|
||||
const code = state.codes.get(codeValue) ?? null;
|
||||
if (!code) {
|
||||
sendJson(response, 400, { error: "invalid_grant" });
|
||||
return;
|
||||
}
|
||||
if (code.codeChallenge) {
|
||||
const verifier = params.get("code_verifier") ?? "";
|
||||
const expected = code.codeChallengeMethod === "S256"
|
||||
? createHash("sha256").update(verifier).digest("base64url")
|
||||
: verifier;
|
||||
if (expected !== code.codeChallenge) {
|
||||
sendJson(response, 400, { error: "invalid_grant", error_description: "PKCE verification failed" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
state.codes.delete(codeValue);
|
||||
credential = { account: code.account, clientId: code.clientId, scope: code.scope };
|
||||
} else if (grantType === "refresh_token") {
|
||||
const refresh = state.refreshTokens.get(params.get("refresh_token") ?? "") ?? null;
|
||||
if (!refresh) {
|
||||
sendJson(response, 400, { error: "invalid_grant" });
|
||||
return;
|
||||
}
|
||||
credential = refresh;
|
||||
} else {
|
||||
sendJson(response, 400, { error: "unsupported_grant_type" });
|
||||
return;
|
||||
}
|
||||
|
||||
const issued = issueCredential(state, credential);
|
||||
sendJson(response, 200, {
|
||||
access_token: issued.accessToken,
|
||||
refresh_token: issued.refreshToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
scope: credential.scope,
|
||||
id_token: issued.idToken,
|
||||
});
|
||||
}
|
||||
|
||||
function userinfo(state: MockGoogleState, request: IncomingMessage, response: ServerResponse): void {
|
||||
const account = accountForRequest(state, request);
|
||||
if (!account) {
|
||||
sendJson(response, 401, { error: "invalid_token" });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 200, { sub: account.sub, email: account.email, email_verified: true, name: account.name });
|
||||
}
|
||||
|
||||
function decodedRawMessage(raw: string): string {
|
||||
try {
|
||||
return Buffer.from(raw, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function splitMessage(message: string): { headers: string; body: string } {
|
||||
const separator = /\r?\n\r?\n/.exec(message);
|
||||
if (!separator || separator.index === undefined) return { headers: message, body: "" };
|
||||
return {
|
||||
headers: message.slice(0, separator.index),
|
||||
body: message.slice(separator.index + separator[0].length),
|
||||
};
|
||||
}
|
||||
|
||||
function headerValue(headers: string, name: string): string {
|
||||
const unfolded = headers.replace(/\r?\n[ \t]+/g, " ");
|
||||
const prefix = `${name.toLowerCase()}:`;
|
||||
const line = unfolded.split(/\r?\n/).find((candidate) => candidate.toLowerCase().startsWith(prefix));
|
||||
return line ? line.slice(line.indexOf(":") + 1).trim() : "";
|
||||
}
|
||||
|
||||
function decodeBase64Body(body: string): string {
|
||||
try {
|
||||
return Buffer.from(body.replace(/\s+/g, ""), "base64").toString("utf8");
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
function mimeTextBody(headers: string, body: string): string | null {
|
||||
const contentType = headerValue(headers, "content-type");
|
||||
const boundary = /boundary="?([^";]+)"?/i.exec(contentType)?.[1] ?? null;
|
||||
if (boundary) {
|
||||
for (const part of body.split(`--${boundary}`).slice(1)) {
|
||||
if (!part.trim() || part.trim().startsWith("--")) continue;
|
||||
const split = splitMessage(part.replace(/^\r?\n/, ""));
|
||||
const decoded = mimeTextBody(split.headers, split.body);
|
||||
if (decoded !== null) return decoded;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (contentType && !contentType.toLowerCase().startsWith("text/plain")) return null;
|
||||
return headerValue(headers, "content-transfer-encoding").toLowerCase() === "base64"
|
||||
? decodeBase64Body(body)
|
||||
: body.trimEnd();
|
||||
}
|
||||
|
||||
function draftBody(headers: string, body: string): string {
|
||||
return mimeTextBody(headers, body) ?? "";
|
||||
}
|
||||
|
||||
function unquoteMimeParameter(value: string): string {
|
||||
return value.replace(/\\([\\"])/g, "$1");
|
||||
}
|
||||
|
||||
function mimeParameter(value: string, name: string): string {
|
||||
const quoted = new RegExp(`${name}="((?:\\\\.|[^"\\\\])*)"`, "i").exec(value)?.[1];
|
||||
if (quoted !== undefined) return unquoteMimeParameter(quoted);
|
||||
return new RegExp(`${name}=([^;\\s]+)`, "i").exec(value)?.[1] ?? "";
|
||||
}
|
||||
|
||||
function mimeAttachments(headers: string, body: string): RecordedAttachment[] {
|
||||
const contentType = headerValue(headers, "content-type");
|
||||
const boundary = mimeParameter(contentType, "boundary");
|
||||
if (boundary) {
|
||||
return body.split(`--${boundary}`).slice(1).flatMap((part) => {
|
||||
if (!part.trim() || part.trim().startsWith("--")) return [];
|
||||
const split = splitMessage(part.replace(/^\r?\n/, ""));
|
||||
return mimeAttachments(split.headers, split.body);
|
||||
});
|
||||
}
|
||||
|
||||
const disposition = headerValue(headers, "content-disposition");
|
||||
if (!/^attachment(?:;|$)/i.test(disposition)) return [];
|
||||
const filename = mimeParameter(disposition, "filename") || mimeParameter(contentType, "name");
|
||||
const mimeType = contentType.split(";", 1)[0]?.trim() || "application/octet-stream";
|
||||
const content = headerValue(headers, "content-transfer-encoding").toLowerCase() === "base64"
|
||||
? Buffer.from(body.replace(/\s+/g, ""), "base64")
|
||||
: Buffer.from(body.replace(/\r?\n$/, ""), "utf8");
|
||||
return [{ filename, mimeType, size: content.byteLength, dataBase64: content.toString("base64") }];
|
||||
}
|
||||
|
||||
function draftInput(value: unknown): { raw: string; threadId?: string } {
|
||||
if (!isRecord(value) || !isRecord(value.message)) return { raw: "" };
|
||||
const raw = typeof value.message.raw === "string" ? value.message.raw : "";
|
||||
const threadId = typeof value.message.threadId === "string" ? value.message.threadId : undefined;
|
||||
return threadId ? { raw, threadId } : { raw };
|
||||
}
|
||||
|
||||
async function createDraft(state: MockGoogleState, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
const account = accountForRequest(state, request);
|
||||
const accessToken = bearerToken(request);
|
||||
if (!account || !accessToken) {
|
||||
sendJson(response, 401, { error: { code: 401, message: "Invalid Credentials" } });
|
||||
return;
|
||||
}
|
||||
const input = draftInput(await jsonBody(request));
|
||||
const message = decodedRawMessage(input.raw);
|
||||
const split = splitMessage(message);
|
||||
const draft: RecordedDraft = {
|
||||
to: headerValue(split.headers, "to"),
|
||||
body: draftBody(split.headers, split.body),
|
||||
tokenId: tokenId(accessToken),
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
if (input.threadId) draft.threadId = input.threadId;
|
||||
const attachments = mimeAttachments(split.headers, split.body);
|
||||
if (attachments.length > 0) draft.attachments = attachments;
|
||||
const mailbox = state.drafts.get(account.email) ?? [];
|
||||
mailbox.push(draft);
|
||||
state.drafts.set(account.email, mailbox);
|
||||
const threadId = input.threadId ?? `thread-${randomUUID()}`;
|
||||
sendJson(response, 200, {
|
||||
id: `draft-${randomUUID()}`,
|
||||
message: { id: `msg-${randomUUID()}`, threadId },
|
||||
});
|
||||
}
|
||||
|
||||
function requestHeader(request: IncomingMessage, name: string): string {
|
||||
const value = request.headers[name.toLowerCase()];
|
||||
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
||||
}
|
||||
|
||||
function parseDriveMultipart(body: Buffer, contentType: string): RecordedAttachment | null {
|
||||
const boundary = mimeParameter(contentType, "boundary");
|
||||
if (!boundary) return null;
|
||||
const partBoundary = Buffer.from(`\r\n--${boundary}\r\n`, "utf8");
|
||||
const firstHeaderEnd = body.indexOf(Buffer.from("\r\n\r\n", "utf8"));
|
||||
const secondPart = body.indexOf(partBoundary);
|
||||
if (firstHeaderEnd < 0 || secondPart < 0 || secondPart <= firstHeaderEnd) return null;
|
||||
|
||||
const metadataText = body.subarray(firstHeaderEnd + 4, secondPart).toString("utf8");
|
||||
let metadata: unknown;
|
||||
try {
|
||||
metadata = JSON.parse(metadataText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const filename = isRecord(metadata) && typeof metadata.name === "string" ? metadata.name : "";
|
||||
if (!filename) return null;
|
||||
|
||||
const secondHeaderStart = secondPart + partBoundary.byteLength;
|
||||
const secondHeaderEnd = body.indexOf(Buffer.from("\r\n\r\n", "utf8"), secondHeaderStart);
|
||||
const footer = Buffer.from(`\r\n--${boundary}--`, "utf8");
|
||||
const contentEnd = body.lastIndexOf(footer);
|
||||
if (secondHeaderEnd < 0 || contentEnd < 0 || contentEnd < secondHeaderEnd) return null;
|
||||
const headers = body.subarray(secondHeaderStart, secondHeaderEnd).toString("utf8");
|
||||
const mimeType = headerValue(headers, "content-type") || "application/octet-stream";
|
||||
const content = body.subarray(secondHeaderEnd + 4, contentEnd);
|
||||
return { filename, mimeType, size: content.byteLength, dataBase64: content.toString("base64") };
|
||||
}
|
||||
|
||||
async function createDriveUpload(state: MockGoogleState, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
const account = accountForRequest(state, request);
|
||||
const accessToken = bearerToken(request);
|
||||
if (!account || !accessToken) {
|
||||
sendJson(response, 401, { error: { code: 401, message: "Invalid Credentials" } });
|
||||
return;
|
||||
}
|
||||
const upload = parseDriveMultipart(await requestBuffer(request), requestHeader(request, "content-type"));
|
||||
if (!upload) {
|
||||
sendJson(response, 400, { error: { code: 400, message: "Invalid multipart upload" } });
|
||||
return;
|
||||
}
|
||||
const recorded: RecordedDriveUpload = {
|
||||
...upload,
|
||||
tokenId: tokenId(accessToken),
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
const uploads = state.driveUploads.get(account.email) ?? [];
|
||||
uploads.push(recorded);
|
||||
state.driveUploads.set(account.email, uploads);
|
||||
const id = `drive-${randomUUID()}`;
|
||||
sendJson(response, 200, {
|
||||
id,
|
||||
name: upload.filename,
|
||||
mimeType: upload.mimeType,
|
||||
modifiedTime: recorded.at,
|
||||
webViewLink: `https://drive.google.test/file/d/${id}/view`,
|
||||
size: String(upload.size),
|
||||
});
|
||||
}
|
||||
|
||||
function pendingAuthorizations(state: MockGoogleState): unknown {
|
||||
return {
|
||||
pending: Array.from(state.pending.values()).map((entry) => ({
|
||||
id: entry.id,
|
||||
accounts: state.accounts.map((account) => {
|
||||
const url = new URL("/choose-account", state.baseUrl);
|
||||
url.searchParams.set("request_id", entry.id);
|
||||
url.searchParams.set("email", account.email);
|
||||
return { email: account.email, chooseUrl: url.toString() };
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleRequest(state: MockGoogleState, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
const url = requestUrl(request, state.baseUrl);
|
||||
const requestMethod = method(request);
|
||||
recordRequest(state, request, url);
|
||||
if (requestMethod === "OPTIONS") {
|
||||
sendJson(response, 204, {});
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "GET" && url.pathname === "/health") {
|
||||
sendJson(response, 200, { ok: true, accounts: state.accounts.map((account) => account.email), autoApprove: state.autoApprove });
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "GET" && url.pathname === "/requests") {
|
||||
sendJson(response, 200, { requests: state.requests });
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "GET" && url.pathname === "/__mock-google/pending-authorizations") {
|
||||
sendJson(response, 200, pendingAuthorizations(state));
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "GET" && url.pathname === "/__mock-google/drafts") {
|
||||
sendJson(response, 200, { drafts: state.drafts.get((url.searchParams.get("email") ?? "").toLowerCase()) ?? [] });
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "GET" && url.pathname === "/__mock-google/drive-uploads") {
|
||||
sendJson(response, 200, { uploads: state.driveUploads.get((url.searchParams.get("email") ?? "").toLowerCase()) ?? [] });
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "GET" && url.pathname === "/authorize") {
|
||||
authorize(state, url, response);
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "GET" && url.pathname === "/choose-account") {
|
||||
chooseAccount(state, url, response);
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "POST" && url.pathname === "/approve") {
|
||||
approve(state, url, response);
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "POST" && url.pathname === "/token") {
|
||||
await token(state, request, response);
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "GET" && url.pathname === "/userinfo") {
|
||||
userinfo(state, request, response);
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "GET" && url.pathname === "/gmail/v1/users/me/profile") {
|
||||
const account = accountForRequest(state, request);
|
||||
if (!account) {
|
||||
sendJson(response, 401, { error: { code: 401, message: "Invalid Credentials" } });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 200, { emailAddress: account.email, messagesTotal: 0, threadsTotal: 0, historyId: "1" });
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "GET" && url.pathname === "/gmail/v1/users/me/messages") {
|
||||
if (!accountForRequest(state, request)) {
|
||||
sendJson(response, 401, { error: { code: 401, message: "Invalid Credentials" } });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 200, { messages: [], resultSizeEstimate: 0 });
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "POST" && url.pathname === "/gmail/v1/users/me/drafts") {
|
||||
await createDraft(state, request, response);
|
||||
return;
|
||||
}
|
||||
if (requestMethod === "POST" && url.pathname === "/upload/drive/v3/files") {
|
||||
await createDriveUpload(state, request, response);
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
function listen(server: Server, port: number): Promise<AddressInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
const address = server.address();
|
||||
if (isAddressInfo(address)) {
|
||||
resolve(address);
|
||||
return;
|
||||
}
|
||||
reject(new Error("Mock Google server did not expose a TCP port."));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
server.closeIdleConnections();
|
||||
});
|
||||
}
|
||||
|
||||
export async function startMockGoogleServer(options: MockGoogleServerOptions): Promise<StartedMockGoogleServer> {
|
||||
const state: MockGoogleState = {
|
||||
accounts: normalizeAccounts(options.accounts),
|
||||
autoApprove: options.autoApprove,
|
||||
baseUrl: options.baseUrl ?? "http://127.0.0.1",
|
||||
requests: [],
|
||||
pending: new Map(),
|
||||
codes: new Map(),
|
||||
accessTokens: new Map(),
|
||||
refreshTokens: new Map(),
|
||||
drafts: new Map(),
|
||||
driveUploads: new Map(),
|
||||
keys: createSigningKeys(),
|
||||
};
|
||||
const server = createServer((request, response) => {
|
||||
void handleRequest(state, request, response).catch((error) => {
|
||||
sendJson(response, 500, { error: "mock_google_error", message: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
});
|
||||
const address = await listen(server, options.port);
|
||||
state.baseUrl = options.baseUrl ?? `http://127.0.0.1:${address.port}`;
|
||||
let stopped = false;
|
||||
return {
|
||||
baseUrl: state.baseUrl,
|
||||
async stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
await close(server);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { startMockGoogleServer } from "./mock-google-server.ts";
|
||||
import { trimTrailingSlashes } from "./strings.ts";
|
||||
|
||||
export interface MockGoogleDraft {
|
||||
to: string;
|
||||
body: string;
|
||||
threadId?: string;
|
||||
attachments?: MockGoogleAttachment[];
|
||||
/** Which credential created it — the isolation witness. */
|
||||
tokenId: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface MockGoogleAttachment {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
content: Buffer;
|
||||
}
|
||||
|
||||
export interface MockGoogleDriveUpload extends MockGoogleAttachment {
|
||||
/** Which credential created it — the isolation witness. */
|
||||
tokenId: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface MockGoogleHandle {
|
||||
/** Base URL for DEN_GOOGLE_API_BASE_URL. */
|
||||
apiUrl: string;
|
||||
/** For DEN_GOOGLE_OAUTH_AUTHORIZE_URL / _TOKEN_URL / _USERINFO_URL. */
|
||||
authorizeUrl: string;
|
||||
tokenUrl: string;
|
||||
userinfoUrl: string;
|
||||
/** Drive the real chooser page shown when prompt=select_account. Resolves once the callback has been served. */
|
||||
chooseAccount(email: string, opts?: { timeoutMs?: number }): Promise<void>;
|
||||
/** Drafts attributed to ONE mailbox. Must be able to return [] as a real negative assertion. */
|
||||
draftsFor(email: string, opts?: { since?: string; timeoutMs?: number; atLeast?: number }): Promise<MockGoogleDraft[]>;
|
||||
/** Drive uploads attributed to ONE account, including provider-observed bytes. */
|
||||
driveUploadsFor(email: string, opts?: { since?: string; timeoutMs?: number; atLeast?: number }): Promise<MockGoogleDriveUpload[]>;
|
||||
authorizeRequestSince(iso: string, opts?: { timeoutMs?: number }): Promise<{ params: URLSearchParams }>;
|
||||
stop(): Promise<void>;
|
||||
[Symbol.asyncDispose](): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartMockGoogleOptions {
|
||||
accounts: string[];
|
||||
port?: number;
|
||||
publicUrl?: string;
|
||||
autoApprove?: boolean;
|
||||
}
|
||||
|
||||
export async function startMockGoogle(options: StartMockGoogleOptions): Promise<MockGoogleHandle> {
|
||||
const accounts = Array.from(new Set(options.accounts.map((email) => email.trim().toLowerCase()).filter(Boolean)));
|
||||
if (accounts.length === 0) throw new Error("startMockGoogle requires at least one account.");
|
||||
const externalUrl = options.publicUrl ? trimTrailingSlashes(options.publicUrl.trim()) || undefined : undefined;
|
||||
const local = externalUrl
|
||||
? null
|
||||
: await startMockGoogleServer({ accounts, port: options.port ?? 3980, autoApprove: options.autoApprove ?? true });
|
||||
const url = externalUrl ?? local?.baseUrl;
|
||||
if (!url) throw new Error("Mock Google did not expose a URL.");
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] | null {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : null;
|
||||
}
|
||||
|
||||
async function json(urlValue: string, timeoutMs = 30_000): Promise<unknown> {
|
||||
const response = await fetch(urlValue, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(`Mock Google request failed: HTTP ${response.status} ${JSON.stringify(body).slice(0, 500)}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
const healthDeadline = Date.now() + 30_000;
|
||||
let healthy = false;
|
||||
let healthDetail = "unreachable";
|
||||
while (Date.now() < healthDeadline) {
|
||||
try {
|
||||
const body = await json(`${url}/health`, 2_000);
|
||||
if (isRecord(body) && body.ok === true) {
|
||||
const servedAccounts = stringArray(body.accounts);
|
||||
if (servedAccounts && (servedAccounts.length !== accounts.length || servedAccounts.some((email, index) => email !== accounts[index]))) {
|
||||
throw new Error(`Mock Google account mismatch: expected ${accounts.join(", ")}; got ${servedAccounts.join(", ")}.`);
|
||||
}
|
||||
if (typeof body.autoApprove === "boolean" && body.autoApprove !== (options.autoApprove ?? true)) {
|
||||
throw new Error(`Mock Google autoApprove mismatch: expected ${options.autoApprove ?? true}; got ${body.autoApprove}.`);
|
||||
}
|
||||
healthy = true;
|
||||
break;
|
||||
}
|
||||
healthDetail = JSON.stringify(body);
|
||||
} catch (error) {
|
||||
healthDetail = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
if (!healthy) {
|
||||
await local?.stop();
|
||||
throw new Error(`Mock Google not reachable at ${url}. Last: ${healthDetail}`);
|
||||
}
|
||||
|
||||
async function requests(): Promise<Record<string, unknown>[]> {
|
||||
const body = await json(`${url}/requests`);
|
||||
return isRecord(body) && Array.isArray(body.requests) ? body.requests.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function parseDraft(value: unknown): MockGoogleDraft | null {
|
||||
if (!isRecord(value)
|
||||
|| typeof value.to !== "string"
|
||||
|| typeof value.body !== "string"
|
||||
|| typeof value.tokenId !== "string"
|
||||
|| typeof value.at !== "string") return null;
|
||||
const draft: MockGoogleDraft = {
|
||||
to: value.to,
|
||||
body: value.body,
|
||||
tokenId: value.tokenId,
|
||||
at: value.at,
|
||||
};
|
||||
if (typeof value.threadId === "string") draft.threadId = value.threadId;
|
||||
if (Array.isArray(value.attachments)) {
|
||||
const attachments = value.attachments.flatMap((entry) => {
|
||||
const attachment = parseAttachment(entry);
|
||||
return attachment ? [attachment] : [];
|
||||
});
|
||||
if (attachments.length > 0) draft.attachments = attachments;
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function parseAttachment(value: unknown): MockGoogleAttachment | null {
|
||||
if (!isRecord(value)
|
||||
|| typeof value.filename !== "string"
|
||||
|| typeof value.mimeType !== "string"
|
||||
|| typeof value.size !== "number"
|
||||
|| typeof value.dataBase64 !== "string") return null;
|
||||
const content = Buffer.from(value.dataBase64, "base64");
|
||||
if (content.byteLength !== value.size) return null;
|
||||
return {
|
||||
filename: value.filename,
|
||||
mimeType: value.mimeType,
|
||||
size: value.size,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDriveUpload(value: unknown): MockGoogleDriveUpload | null {
|
||||
const attachment = parseAttachment(value);
|
||||
if (!attachment || !isRecord(value) || typeof value.tokenId !== "string" || typeof value.at !== "string") return null;
|
||||
return { ...attachment, tokenId: value.tokenId, at: value.at };
|
||||
}
|
||||
|
||||
async function readDrafts(email: string, since: string | undefined, timeoutMs: number): Promise<MockGoogleDraft[]> {
|
||||
const endpoint = new URL("/__mock-google/drafts", url);
|
||||
endpoint.searchParams.set("email", email.trim().toLowerCase());
|
||||
const body = await json(endpoint.toString(), timeoutMs);
|
||||
if (!isRecord(body) || !Array.isArray(body.drafts)) return [];
|
||||
return body.drafts.flatMap((value) => {
|
||||
const draft = parseDraft(value);
|
||||
return draft && (!since || draft.at >= since) ? [draft] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function readDriveUploads(email: string, since: string | undefined, timeoutMs: number): Promise<MockGoogleDriveUpload[]> {
|
||||
const endpoint = new URL("/__mock-google/drive-uploads", url);
|
||||
endpoint.searchParams.set("email", email.trim().toLowerCase());
|
||||
const body = await json(endpoint.toString(), timeoutMs);
|
||||
if (!isRecord(body) || !Array.isArray(body.uploads)) return [];
|
||||
return body.uploads.flatMap((value) => {
|
||||
const upload = parseDriveUpload(value);
|
||||
return upload && (!since || upload.at >= since) ? [upload] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function pollForAtLeast<T>(
|
||||
read: (timeoutMs: number) => Promise<T[]>,
|
||||
atLeast: number | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<T[]> {
|
||||
if (atLeast === undefined || atLeast <= 0) return read(timeoutMs);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let values = await read(Math.max(1, deadline - Date.now()));
|
||||
while (values.length < atLeast && Date.now() < deadline) {
|
||||
await sleep(250);
|
||||
values = await read(Math.max(1, deadline - Date.now()));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
await local?.stop();
|
||||
};
|
||||
|
||||
return {
|
||||
apiUrl: url,
|
||||
authorizeUrl: `${url}/authorize`,
|
||||
tokenUrl: `${url}/token`,
|
||||
userinfoUrl: `${url}/userinfo`,
|
||||
async chooseAccount(email, opts = {}) {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (!accounts.includes(normalizedEmail)) throw new Error(`Unknown mock Google account: ${email}`);
|
||||
const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
|
||||
let chooseUrl: string | null = null;
|
||||
while (!chooseUrl && Date.now() < deadline) {
|
||||
const body = await json(`${url}/__mock-google/pending-authorizations`, Math.max(1, deadline - Date.now()));
|
||||
const pending = isRecord(body) && Array.isArray(body.pending) ? body.pending.filter(isRecord) : [];
|
||||
for (const authorization of [...pending].reverse()) {
|
||||
if (!Array.isArray(authorization.accounts)) continue;
|
||||
const account = authorization.accounts.find((value) => isRecord(value) && value.email === normalizedEmail);
|
||||
if (isRecord(account) && typeof account.chooseUrl === "string") {
|
||||
chooseUrl = account.chooseUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!chooseUrl) await sleep(100);
|
||||
}
|
||||
if (!chooseUrl) throw new Error(`No pending mock Google account chooser offered ${normalizedEmail}.`);
|
||||
const remaining = Math.max(1, deadline - Date.now());
|
||||
const choice = await fetch(chooseUrl, { redirect: "manual", signal: AbortSignal.timeout(remaining) });
|
||||
const callbackUrl = choice.headers.get("location");
|
||||
if (choice.status !== 302 || !callbackUrl) {
|
||||
throw new Error(`Mock Google chooser failed: HTTP ${choice.status}`);
|
||||
}
|
||||
await fetch(callbackUrl, {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())),
|
||||
});
|
||||
},
|
||||
async draftsFor(email, opts = {}) {
|
||||
const timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
return pollForAtLeast(
|
||||
(remainingMs) => readDrafts(email, opts.since, remainingMs),
|
||||
opts.atLeast,
|
||||
timeoutMs,
|
||||
);
|
||||
},
|
||||
async driveUploadsFor(email, opts = {}) {
|
||||
const timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
return pollForAtLeast(
|
||||
(remainingMs) => readDriveUploads(email, opts.since, remainingMs),
|
||||
opts.atLeast,
|
||||
timeoutMs,
|
||||
);
|
||||
},
|
||||
async authorizeRequestSince(iso, opts = {}) {
|
||||
const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
|
||||
while (Date.now() < deadline) {
|
||||
const request = (await requests()).find((entry) => entry.method === "GET"
|
||||
&& entry.path === "/authorize"
|
||||
&& typeof entry.at === "string"
|
||||
&& entry.at >= iso);
|
||||
if (request && typeof request.url === "string") {
|
||||
return { params: new URL(request.url, url).searchParams };
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(`No GET /authorize reached the mock Google provider after ${iso}.`);
|
||||
},
|
||||
stop,
|
||||
[Symbol.asyncDispose]: stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import { trimTrailingSlashes } from "./strings.ts";
|
||||
|
||||
export interface MockAuthorizeRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
url: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** A tool invocation the connector actually served, and which credential served it. */
|
||||
export interface MockToolCall {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
/** sha256 prefix of the caller's bearer token — distinct per member credential. */
|
||||
tokenId: string | null;
|
||||
/** When the connector served it (the mock's clock). */
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface MockAgentToolStep {
|
||||
tool: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MockAgentWorkload {
|
||||
promptMarker: string;
|
||||
finalReply: string;
|
||||
steps: MockAgentToolStep[];
|
||||
}
|
||||
|
||||
export interface MockAgentRequest {
|
||||
model: string;
|
||||
promptMarker: string | null;
|
||||
matchedMarkers: string[];
|
||||
completedTools: number;
|
||||
kind: "utility" | "tool" | "final" | "error";
|
||||
toolName: string | null;
|
||||
arguments: Record<string, unknown>;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface MockMcpHandle {
|
||||
url: string;
|
||||
mcpUrl: string;
|
||||
authorizeRequestSince(iso: string, opts?: { timeoutMs?: number }): Promise<MockAuthorizeRequest & { params: URLSearchParams }>;
|
||||
requests(): Promise<MockAuthorizeRequest[]>;
|
||||
/**
|
||||
* Tool calls the connector served. This is the AUTHORITY for "did a person
|
||||
* really use it" — the app's own UI can look connected while nothing was
|
||||
* invoked, and per-member isolation is only provable by distinct tokenIds.
|
||||
*
|
||||
* Pass sinceIso when the mock is long-lived (publicUrl): its request log
|
||||
* spans runs, so an unfiltered atLeast is satisfied by a PREVIOUS run's
|
||||
* calls and returns before this run's calls ever arrive.
|
||||
*/
|
||||
toolCalls(opts?: { name?: string; timeoutMs?: number; atLeast?: number; sinceIso?: string }): Promise<MockToolCall[]>;
|
||||
agentRequests(opts?: { promptMarker?: string; timeoutMs?: number; atLeast?: number; sinceIso?: string }): Promise<MockAgentRequest[]>;
|
||||
handshakes(opts?: { timeoutMs?: number; atLeast?: number; sinceIso?: string }): Promise<MockAuthorizeRequest[]>;
|
||||
configureOAuthRedirectUris(redirectUris: readonly string[]): Promise<void>;
|
||||
resetOAuth(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
[Symbol.asyncDispose](): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartMockMcpOptions {
|
||||
port?: number;
|
||||
scriptPath?: string;
|
||||
publicUrl?: string;
|
||||
/** Advertised OAuth/resource origin when the mock sits behind a proxy; defaults to the mock's own URL. */
|
||||
issuer?: string;
|
||||
profileId?: EnterpriseMcpProfileId;
|
||||
fault?: string;
|
||||
oauthClientSecret?: string;
|
||||
allowUnauthenticatedMcp?: boolean;
|
||||
/** Serve this many additional synthetic mock_tool_<i> tools for scale specs. */
|
||||
extraToolCount?: number;
|
||||
/** Script deterministic OpenAI-compatible agent turns through this mock. */
|
||||
agentWorkloads?: MockAgentWorkload[];
|
||||
}
|
||||
|
||||
export type EnterpriseMcpProfileId =
|
||||
| "synthetic-enterprise-oauth-mcp"
|
||||
| "servicenow-inbound-quickstart"
|
||||
| "microsoft-work-iq"
|
||||
| "microsoft-enterprise"
|
||||
| "agent-365-mail-v1-2026-07"
|
||||
| "slack-user-mcp";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseRequest(value: unknown): MockAuthorizeRequest | null {
|
||||
if (!isRecord(value)) return null;
|
||||
if (
|
||||
typeof value.method !== "string"
|
||||
|| typeof value.path !== "string"
|
||||
|| typeof value.url !== "string"
|
||||
|| typeof value.at !== "string"
|
||||
) return null;
|
||||
return { method: value.method, path: value.path, url: value.url, at: value.at };
|
||||
}
|
||||
|
||||
function parseRequests(value: unknown): MockAuthorizeRequest[] {
|
||||
if (!isRecord(value) || !Array.isArray(value.requests)) return [];
|
||||
return value.requests.flatMap((entry) => {
|
||||
const request = parseRequest(entry);
|
||||
return request ? [request] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string, output: () => string, child: ChildProcess | null): Promise<void> {
|
||||
let last = "unreachable";
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
// With an externally-managed mock there is no child at all; only a spawned
|
||||
// child that has ALREADY exited (exitCode set) means startup failed.
|
||||
if (child && child.exitCode !== null) {
|
||||
throw new Error(`Mock OAuth+MCP server exited before becoming healthy. Output: ${output().slice(-1_000)}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(10_000) });
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (response.ok && isRecord(body) && body.ok === true) {
|
||||
if (Object.hasOwn(body, "autoApprove") && body.autoApprove === false) {
|
||||
throw new Error("Mock OAuth+MCP server must report autoApprove=true.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
last = response.ok ? JSON.stringify(body) : `HTTP ${response.status}`;
|
||||
} catch (error) {
|
||||
last = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`Mock OAuth+MCP server not reachable at ${url}. Last: ${last}. Output: ${output().slice(-1_000)}`);
|
||||
}
|
||||
|
||||
async function waitForEnterpriseHealth(url: string, output: () => string, child: ChildProcess): Promise<string> {
|
||||
let last = "unreachable";
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Enterprise MCP mock exited before becoming healthy. Output: ${output().slice(-1_000)}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(10_000) });
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (response.ok && isRecord(body) && body.status === "ok" && typeof body.mcpUrl === "string") {
|
||||
return body.mcpUrl;
|
||||
}
|
||||
last = response.ok ? JSON.stringify(body) : `HTTP ${response.status}`;
|
||||
} catch (error) {
|
||||
last = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`Enterprise MCP mock not reachable at ${url}. Last: ${last}. Output: ${output().slice(-1_000)}`);
|
||||
}
|
||||
|
||||
async function stopChild(child: ChildProcess | null): Promise<void> {
|
||||
if (!child || child.exitCode !== null) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 5_000);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
}
|
||||
|
||||
async function stopProcessGroup(child: ChildProcess | null): Promise<void> {
|
||||
if (!child || child.exitCode !== null || !child.pid) return;
|
||||
const pid = child.pid;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch {
|
||||
// The process group already exited.
|
||||
}
|
||||
resolve();
|
||||
}, 5_000);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM");
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function startEnterpriseProfileMock(options: StartMockMcpOptions): Promise<MockMcpHandle> {
|
||||
if (options.publicUrl) throw new Error("Enterprise MCP provider profiles must be started in the local eval process.");
|
||||
if (!options.profileId) throw new Error("An enterprise MCP provider profile id is required.");
|
||||
const profileId = options.profileId;
|
||||
const port = options.port ?? 3979;
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
const runner = join(REPO_ROOT, "packages", "enterprise-mcp-mock-server", "src", "cli.ts");
|
||||
let child: ChildProcess | null = null;
|
||||
let output = "";
|
||||
let redirectUris: readonly string[] = [];
|
||||
let mcpUrl = `${url}/mcp`;
|
||||
const boot = async (): Promise<void> => {
|
||||
output = "";
|
||||
const active = spawn("pnpm", ["--filter", "@openwork/enterprise-mcp-mock-server", "exec", "tsx", runner], {
|
||||
cwd: REPO_ROOT,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
PROFILE_ID: profileId,
|
||||
...(options.fault !== undefined ? { ACTIVE_FAULT_ID: options.fault } : {}),
|
||||
OAUTH_CLIENT_SECRET: options.oauthClientSecret ?? "enterprise-mcp-eval-client-secret",
|
||||
OAUTH_REDIRECT_URIS: redirectUris.join(","),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child = active;
|
||||
active.stdout?.on("data", (chunk: Buffer) => {
|
||||
output += String(chunk);
|
||||
});
|
||||
active.stderr?.on("data", (chunk: Buffer) => {
|
||||
output += String(chunk);
|
||||
});
|
||||
mcpUrl = await waitForEnterpriseHealth(url, () => output, active);
|
||||
};
|
||||
await boot();
|
||||
const stop = async (): Promise<void> => {
|
||||
const active = child;
|
||||
child = null;
|
||||
await stopProcessGroup(active);
|
||||
};
|
||||
return {
|
||||
url,
|
||||
mcpUrl,
|
||||
async requests() {
|
||||
return [];
|
||||
},
|
||||
async toolCalls(opts = {}) {
|
||||
if ((opts.atLeast ?? 0) > 0) await sleep(opts.timeoutMs ?? 120_000);
|
||||
return [];
|
||||
},
|
||||
async agentRequests(opts = {}) {
|
||||
if ((opts.atLeast ?? 0) > 0) await sleep(opts.timeoutMs ?? 120_000);
|
||||
return [];
|
||||
},
|
||||
async handshakes(opts = {}) {
|
||||
if ((opts.atLeast ?? 0) > 0) await sleep(opts.timeoutMs ?? 120_000);
|
||||
return [];
|
||||
},
|
||||
async authorizeRequestSince(iso) {
|
||||
throw new Error(`Enterprise profile request logs do not retain OAuth query parameters after ${iso}.`);
|
||||
},
|
||||
async configureOAuthRedirectUris(nextRedirectUris) {
|
||||
await stop();
|
||||
redirectUris = [...nextRedirectUris];
|
||||
await boot();
|
||||
},
|
||||
async resetOAuth() {
|
||||
await stop();
|
||||
await boot();
|
||||
},
|
||||
stop,
|
||||
[Symbol.asyncDispose]: stop,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startMockMcp(options: StartMockMcpOptions = {}): Promise<MockMcpHandle> {
|
||||
if (options.profileId) return startEnterpriseProfileMock(options);
|
||||
const port = options.port ?? 3979;
|
||||
const externalUrl = options.publicUrl ? trimTrailingSlashes(options.publicUrl.trim()) : undefined;
|
||||
const localUrl = `http://127.0.0.1:${port}`;
|
||||
const url = externalUrl || localUrl;
|
||||
let child: ChildProcess | null = null;
|
||||
let output = "";
|
||||
|
||||
if (!externalUrl) {
|
||||
child = spawn(process.execPath, [options.scriptPath ?? join(REPO_ROOT, "scripts", "mock-oauth-mcp-server.mjs")], {
|
||||
cwd: REPO_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
HOST: "0.0.0.0",
|
||||
PORT: String(port),
|
||||
ISSUER: options.issuer ?? url,
|
||||
AUTO_APPROVE: "1",
|
||||
...(options.allowUnauthenticatedMcp ? { MOCK_ALLOW_UNAUTHENTICATED_MCP: "1" } : {}),
|
||||
...(options.extraToolCount ? { MOCK_EXTRA_TOOL_COUNT: String(options.extraToolCount) } : {}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
output += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
output += String(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
await waitForHealth(url, () => output, child);
|
||||
|
||||
if (options.agentWorkloads) {
|
||||
const response = await fetch(`${url}/admin/agent-workloads`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ workloads: options.agentWorkloads }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Mock agent workload configuration failed: HTTP ${response.status} ${(await response.text()).slice(0, 500)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const requests = async (): Promise<MockAuthorizeRequest[]> => {
|
||||
const response = await fetch(`${url}/requests`, { signal: AbortSignal.timeout(15_000) });
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(`Mock request log failed: HTTP ${response.status} ${JSON.stringify(body).slice(0, 500)}`);
|
||||
return parseRequests(body);
|
||||
};
|
||||
const stop = async (): Promise<void> => {
|
||||
const active = child;
|
||||
child = null;
|
||||
await stopChild(active);
|
||||
};
|
||||
|
||||
const rawEntries = async (): Promise<Record<string, unknown>[]> => {
|
||||
const response = await fetch(`${url}/requests`, { signal: AbortSignal.timeout(15_000) });
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(`Mock request log failed: HTTP ${response.status}`);
|
||||
return isRecord(body) && Array.isArray(body.requests) ? body.requests.filter(isRecord) : [];
|
||||
};
|
||||
|
||||
const readToolCalls = async (name?: string, sinceIso?: string): Promise<MockToolCall[]> => {
|
||||
const calls: MockToolCall[] = [];
|
||||
for (const entry of await rawEntries()) {
|
||||
if (!Array.isArray(entry.toolCalls)) continue;
|
||||
const at = typeof entry.at === "string" ? entry.at : "";
|
||||
if (sinceIso && at < sinceIso) continue;
|
||||
for (const call of entry.toolCalls) {
|
||||
if (!isRecord(call) || typeof call.name !== "string") continue;
|
||||
if (name && call.name !== name) continue;
|
||||
calls.push({
|
||||
name: call.name,
|
||||
args: isRecord(call.args) ? call.args : {},
|
||||
tokenId: typeof call.tokenId === "string" ? call.tokenId : null,
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
};
|
||||
|
||||
const readAgentRequests = async (promptMarker?: string, sinceIso?: string): Promise<MockAgentRequest[]> => {
|
||||
const completions: MockAgentRequest[] = [];
|
||||
for (const entry of await rawEntries()) {
|
||||
if (!isRecord(entry.agentCompletion)) continue;
|
||||
const completion = entry.agentCompletion;
|
||||
const at = typeof entry.at === "string" ? entry.at : "";
|
||||
if (sinceIso && at < sinceIso) continue;
|
||||
const kind = completion.kind;
|
||||
if (kind !== "utility" && kind !== "tool" && kind !== "final" && kind !== "error") continue;
|
||||
const marker = typeof completion.promptMarker === "string" ? completion.promptMarker : null;
|
||||
if (promptMarker && marker !== promptMarker) continue;
|
||||
if (typeof completion.model !== "string"
|
||||
|| !Array.isArray(completion.matchedMarkers)
|
||||
|| typeof completion.completedTools !== "number") continue;
|
||||
completions.push({
|
||||
model: completion.model,
|
||||
promptMarker: marker,
|
||||
matchedMarkers: completion.matchedMarkers.filter((value): value is string => typeof value === "string"),
|
||||
completedTools: completion.completedTools,
|
||||
kind,
|
||||
toolName: typeof completion.toolName === "string" ? completion.toolName : null,
|
||||
arguments: isRecord(completion.arguments) ? completion.arguments : {},
|
||||
at,
|
||||
});
|
||||
}
|
||||
return completions;
|
||||
};
|
||||
|
||||
const readHandshakes = async (sinceIso?: string): Promise<MockAuthorizeRequest[]> => {
|
||||
const handshakes: MockAuthorizeRequest[] = [];
|
||||
for (const entry of await rawEntries()) {
|
||||
if (!Array.isArray(entry.rpcMethods) || !entry.rpcMethods.includes("initialize")) continue;
|
||||
const request = parseRequest(entry);
|
||||
if (!request || (sinceIso && request.at < sinceIso)) continue;
|
||||
handshakes.push(request);
|
||||
}
|
||||
return handshakes;
|
||||
};
|
||||
|
||||
return {
|
||||
url,
|
||||
mcpUrl: `${url}/mcp`,
|
||||
requests,
|
||||
async toolCalls(opts = {}) {
|
||||
const wanted = opts.atLeast ?? 0;
|
||||
if (wanted <= 0) return readToolCalls(opts.name, opts.sinceIso);
|
||||
// The engine invokes tools asynchronously after the model decides to, so
|
||||
// poll rather than read once.
|
||||
const deadline = Date.now() + (opts.timeoutMs ?? 120_000);
|
||||
let calls = await readToolCalls(opts.name, opts.sinceIso);
|
||||
while (calls.length < wanted && Date.now() < deadline) {
|
||||
await sleep(1_000);
|
||||
// A slow or aborted log read is a retryable poll attempt, not the
|
||||
// verdict; only the deadline decides.
|
||||
calls = await readToolCalls(opts.name, opts.sinceIso).catch(() => calls);
|
||||
}
|
||||
return calls;
|
||||
},
|
||||
async agentRequests(opts = {}) {
|
||||
const wanted = opts.atLeast ?? 0;
|
||||
if (wanted <= 0) return readAgentRequests(opts.promptMarker, opts.sinceIso);
|
||||
const deadline = Date.now() + (opts.timeoutMs ?? 120_000);
|
||||
let completions = await readAgentRequests(opts.promptMarker, opts.sinceIso);
|
||||
while (completions.length < wanted && Date.now() < deadline) {
|
||||
await sleep(500);
|
||||
completions = await readAgentRequests(opts.promptMarker, opts.sinceIso).catch(() => completions);
|
||||
}
|
||||
return completions;
|
||||
},
|
||||
async handshakes(opts = {}) {
|
||||
const wanted = opts.atLeast ?? 0;
|
||||
if (wanted <= 0) return readHandshakes(opts.sinceIso);
|
||||
const deadline = Date.now() + (opts.timeoutMs ?? 120_000);
|
||||
let handshakes: MockAuthorizeRequest[] = [];
|
||||
while (handshakes.length < wanted && Date.now() < deadline) {
|
||||
try {
|
||||
handshakes = await readHandshakes(opts.sinceIso);
|
||||
} catch {
|
||||
// A bounded request-log read can fail transiently while a remote mock recovers.
|
||||
}
|
||||
if (handshakes.length < wanted) await sleep(1_000);
|
||||
}
|
||||
return handshakes;
|
||||
},
|
||||
async authorizeRequestSince(iso, opts = {}) {
|
||||
const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
|
||||
while (Date.now() < deadline) {
|
||||
const request = (await requests()).find((entry) => entry.method === "GET" && entry.path === "/authorize" && entry.at >= iso);
|
||||
if (request) return { ...request, params: new URL(request.url, url).searchParams };
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`No GET /authorize reached the mock IdP after ${iso}. Output: ${output.slice(-1_000)}`);
|
||||
},
|
||||
async configureOAuthRedirectUris() {
|
||||
throw new Error("The legacy mock must receive preregistered redirect URIs before startup.");
|
||||
},
|
||||
async resetOAuth() {
|
||||
const response = await fetch(`${url}/admin/expire-oauth-tokens`, { method: "POST" });
|
||||
if (!response.ok) throw new Error(`Mock OAuth reset failed: HTTP ${response.status}`);
|
||||
},
|
||||
stop,
|
||||
[Symbol.asyncDispose]: stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function notImplemented(name: string, how: string): never {
|
||||
throw new Error(`${name} not implemented: ${how}`);
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
import { createServer } from "node:http";
|
||||
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
import { trimTrailingSlashes } from "./strings.ts";
|
||||
|
||||
export type ReleasePlatform = "mac-arm64" | "mac-x64" | "win-x64" | "linux-x64" | "linux-arm64";
|
||||
export type ReleaseDistribution = "public" | "enterprise" | "cloud";
|
||||
|
||||
export type ReleaseLabErrorCode =
|
||||
| "release_asset_missing"
|
||||
| "release_asset_not_installer_capable"
|
||||
| "release_asset_propagation_lag"
|
||||
| "release_asset_unavailable"
|
||||
| "release_feed_cache_stale"
|
||||
| "release_host_denied"
|
||||
| "release_probe_unexpected_success";
|
||||
|
||||
export type ReleaseAssetFault =
|
||||
| { kind: "status"; status: number; message?: string }
|
||||
| { kind: "slow-chunked"; chunkSize: number; delayMs: number };
|
||||
|
||||
export interface ReleaseAssetInput {
|
||||
platform: ReleasePlatform;
|
||||
distribution?: ReleaseDistribution;
|
||||
fileName?: string;
|
||||
body?: string | Uint8Array;
|
||||
contentType?: string;
|
||||
installerCapable?: boolean;
|
||||
rewrittenFileName?: string;
|
||||
fault?: ReleaseAssetFault;
|
||||
}
|
||||
|
||||
export interface ReleaseVersionInput {
|
||||
version: string;
|
||||
publishedAt?: string;
|
||||
prerelease?: boolean;
|
||||
installerCapable?: boolean;
|
||||
assets?: ReleaseAssetInput[];
|
||||
}
|
||||
|
||||
export interface ReleaseAsset {
|
||||
platform: ReleasePlatform;
|
||||
distribution: ReleaseDistribution;
|
||||
fileName: string;
|
||||
body: Uint8Array;
|
||||
size: number;
|
||||
contentType: string;
|
||||
installerCapable: boolean;
|
||||
rewrittenFileName: string | null;
|
||||
fault: ReleaseAssetFault | null;
|
||||
}
|
||||
|
||||
export interface ReleaseVersion {
|
||||
version: string;
|
||||
tagName: string;
|
||||
publishedAt: string;
|
||||
prerelease: boolean;
|
||||
installerCapable: boolean;
|
||||
assets: ReleaseAsset[];
|
||||
}
|
||||
|
||||
export interface DenAppVersionMetadataFixture {
|
||||
minAppVersion: string;
|
||||
latestAppVersion: string;
|
||||
publishedDesktopVersions: string[];
|
||||
}
|
||||
|
||||
export interface CatalogOptions {
|
||||
platforms?: ReleasePlatform[];
|
||||
distribution?: ReleaseDistribution;
|
||||
preInstallerVersions?: string[];
|
||||
}
|
||||
|
||||
export interface ResolveAllowedUpdateInput {
|
||||
catalog: ReleaseVersion[];
|
||||
currentVersion: string;
|
||||
allowedVersions?: string[] | null;
|
||||
}
|
||||
|
||||
export type ReleaseFeedProbe =
|
||||
| { kind: "asset"; version: string; platform: ReleasePlatform; allowedHosts?: string[] }
|
||||
| { kind: "cache"; expectedVersion: string }
|
||||
| { kind: "host"; url?: string; version?: string; platform?: ReleasePlatform; allowedHosts: string[] };
|
||||
|
||||
export interface ReleaseFeedLabConfig {
|
||||
catalog: ReleaseVersionInput[];
|
||||
initialCatalog?: ReleaseVersionInput[];
|
||||
allowedVersions?: string[] | null;
|
||||
cacheTtlMs?: number;
|
||||
staleUntil?: number | Date;
|
||||
repo?: string;
|
||||
actionableProbe?: ReleaseFeedProbe;
|
||||
}
|
||||
|
||||
export interface ReleaseSnapshot {
|
||||
catalog: ReleaseVersion[];
|
||||
metadata: DenAppVersionMetadataFixture;
|
||||
stale: boolean;
|
||||
cachedUntil: number | null;
|
||||
}
|
||||
|
||||
export interface ReleaseAssetUrlInput {
|
||||
baseUrl: string;
|
||||
version: string;
|
||||
fileName: string;
|
||||
repo?: string;
|
||||
}
|
||||
|
||||
type ComparableVersion = {
|
||||
release: number[];
|
||||
prerelease: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_REPO = "different-ai/openwork";
|
||||
const DEFAULT_PLATFORMS: ReleasePlatform[] = ["mac-arm64", "mac-x64", "win-x64", "linux-x64", "linux-arm64"];
|
||||
const DEFAULT_DISTRIBUTION: ReleaseDistribution = "enterprise";
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export class ReleaseLabError extends Error {
|
||||
code: ReleaseLabErrorCode;
|
||||
action: string;
|
||||
status: number | null;
|
||||
|
||||
constructor(code: ReleaseLabErrorCode, message: string, action: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = code;
|
||||
this.code = code;
|
||||
this.action = action;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function isReleaseLabError(value: unknown): value is ReleaseLabError {
|
||||
return value instanceof ReleaseLabError;
|
||||
}
|
||||
|
||||
function parseComparableVersion(value: string): ComparableVersion | null {
|
||||
const normalized = value.trim().replace(/^v/i, "");
|
||||
if (!normalized) return null;
|
||||
|
||||
const [withoutBuild] = normalized.split("+", 1);
|
||||
if (!withoutBuild) return null;
|
||||
|
||||
const [releasePart, prereleasePart = ""] = withoutBuild.split("-", 2);
|
||||
const release = releasePart.split(".").map((part) => Number(part));
|
||||
if (release.length !== 3 || release.some((part) => !Number.isInteger(part) || part < 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prerelease = prereleasePart
|
||||
.split(".")
|
||||
.flatMap((part) => {
|
||||
const trimmed = part.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
});
|
||||
return { release, prerelease };
|
||||
}
|
||||
|
||||
function comparePrerelease(left: string[], right: string[]): number {
|
||||
if (!left.length && !right.length) return 0;
|
||||
if (!left.length) return 1;
|
||||
if (!right.length) return -1;
|
||||
|
||||
const count = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const leftPart = left[index];
|
||||
const rightPart = right[index];
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
|
||||
const leftNumeric = /^\d+$/.test(leftPart) ? Number(leftPart) : null;
|
||||
const rightNumeric = /^\d+$/.test(rightPart) ? Number(rightPart) : null;
|
||||
if (leftNumeric !== null && rightNumeric !== null) {
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric < rightNumeric ? -1 : 1;
|
||||
continue;
|
||||
}
|
||||
if (leftNumeric !== null) return -1;
|
||||
if (rightNumeric !== null) return 1;
|
||||
|
||||
const comparison = leftPart.localeCompare(rightPart);
|
||||
if (comparison !== 0) return comparison < 0 ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function normalizeReleaseVersion(value: string): string | null {
|
||||
const normalized = value.trim().replace(/^v/i, "");
|
||||
return /^\d+\.\d+\.\d+$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
export function compareReleaseVersions(left: string, right: string): number | null {
|
||||
const parsedLeft = parseComparableVersion(left);
|
||||
const parsedRight = parseComparableVersion(right);
|
||||
if (!parsedLeft || !parsedRight) return null;
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const leftPart = parsedLeft.release[index];
|
||||
const rightPart = parsedRight.release[index];
|
||||
if (leftPart === undefined || rightPart === undefined) return null;
|
||||
if (leftPart !== rightPart) return leftPart < rightPart ? -1 : 1;
|
||||
}
|
||||
return comparePrerelease(parsedLeft.prerelease, parsedRight.prerelease);
|
||||
}
|
||||
|
||||
function requireVersion(value: string): string {
|
||||
const normalized = normalizeReleaseVersion(value);
|
||||
if (!normalized) {
|
||||
throw new ReleaseLabError(
|
||||
"release_asset_missing",
|
||||
`Release version ${JSON.stringify(value)} is not a stable x.y.z version.`,
|
||||
"Use a stable desktop version tag like 0.17.40 or v0.17.40.",
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function versionTag(value: string): string {
|
||||
return `v${requireVersion(value)}`;
|
||||
}
|
||||
|
||||
function platformFilePart(platform: ReleasePlatform): string {
|
||||
if (platform === "linux-x64") return "linux-x86_64";
|
||||
return platform;
|
||||
}
|
||||
|
||||
export function desktopReleaseAssetFileName(
|
||||
version: string,
|
||||
platform: ReleasePlatform,
|
||||
distribution: ReleaseDistribution = DEFAULT_DISTRIBUTION,
|
||||
): string {
|
||||
const normalized = requireVersion(version);
|
||||
const prefix = distribution === "public" ? "openwork" : `openwork-${distribution}`;
|
||||
if (platform === "win-x64") return `${prefix}-win-x64-${normalized}.exe`;
|
||||
if (platform === "mac-arm64" || platform === "mac-x64") return `${prefix}-${platform}-${normalized}.dmg`;
|
||||
return `${prefix}-${platformFilePart(platform)}-${normalized}.AppImage`;
|
||||
}
|
||||
|
||||
function contentTypeForPlatform(platform: ReleasePlatform): string {
|
||||
if (platform.startsWith("mac-")) return "application/x-apple-diskimage";
|
||||
if (platform === "win-x64") return "application/vnd.microsoft.portable-executable";
|
||||
return "application/vnd.appimage";
|
||||
}
|
||||
|
||||
function assetBody(input: ReleaseAssetInput, version: string): Uint8Array {
|
||||
if (typeof input.body === "string") return textEncoder.encode(input.body);
|
||||
if (input.body) return input.body;
|
||||
return textEncoder.encode(`openwork release ${version} ${input.platform}\n`);
|
||||
}
|
||||
|
||||
function normalizeAsset(input: ReleaseAssetInput, version: string, installerCapable: boolean): ReleaseAsset {
|
||||
const distribution = input.distribution ?? DEFAULT_DISTRIBUTION;
|
||||
const fileName = input.fileName ?? desktopReleaseAssetFileName(version, input.platform, distribution);
|
||||
const body = assetBody(input, version);
|
||||
return {
|
||||
platform: input.platform,
|
||||
distribution,
|
||||
fileName,
|
||||
body,
|
||||
size: body.byteLength,
|
||||
contentType: input.contentType ?? contentTypeForPlatform(input.platform),
|
||||
installerCapable: input.installerCapable ?? installerCapable,
|
||||
rewrittenFileName: input.rewrittenFileName ?? null,
|
||||
fault: input.fault ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeReleaseCatalog(input: ReleaseVersionInput[]): ReleaseVersion[] {
|
||||
const catalog = input.map((entry, index) => {
|
||||
const version = requireVersion(entry.version);
|
||||
const installerCapable = entry.installerCapable ?? true;
|
||||
const assetsInput = entry.assets ?? DEFAULT_PLATFORMS.map((platform) => ({ platform }));
|
||||
return {
|
||||
version,
|
||||
tagName: versionTag(version),
|
||||
publishedAt: entry.publishedAt ?? new Date(Date.UTC(2026, 0, 1, 12, 0, index)).toISOString(),
|
||||
prerelease: entry.prerelease ?? false,
|
||||
installerCapable,
|
||||
assets: assetsInput.map((asset) => normalizeAsset(asset, version, installerCapable)),
|
||||
};
|
||||
});
|
||||
return sortReleaseCatalog(catalog);
|
||||
}
|
||||
|
||||
export function createReleaseCatalog(versions: string[], options: CatalogOptions = {}): ReleaseVersionInput[] {
|
||||
const preInstallerVersions = options.preInstallerVersions ?? [];
|
||||
const platforms = options.platforms ?? DEFAULT_PLATFORMS;
|
||||
const distribution = options.distribution ?? DEFAULT_DISTRIBUTION;
|
||||
return versions.map((version) => {
|
||||
const normalized = requireVersion(version);
|
||||
const installerCapable = !preInstallerVersions.some((entry) => compareReleaseVersions(entry, normalized) === 0);
|
||||
return {
|
||||
version: normalized,
|
||||
installerCapable,
|
||||
assets: platforms.map((platform) => ({ platform, distribution, installerCapable })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function sortReleaseCatalog(catalog: ReleaseVersion[]): ReleaseVersion[] {
|
||||
return [...catalog].sort((left, right) => compareReleaseVersions(left.version, right.version) ?? 0);
|
||||
}
|
||||
|
||||
export function releaseCatalogMetadata(catalog: ReleaseVersion[]): DenAppVersionMetadataFixture {
|
||||
const sorted = sortReleaseCatalog(catalog);
|
||||
const versions = sorted.map((entry) => entry.version);
|
||||
const minAppVersion = versions[0] ?? "0.0.0";
|
||||
const latestAppVersion = versions.at(-1) ?? "0.0.0";
|
||||
return { minAppVersion, latestAppVersion, publishedDesktopVersions: versions };
|
||||
}
|
||||
|
||||
export function allowedReleaseCatalog(catalog: ReleaseVersion[], allowedVersions?: string[] | null): ReleaseVersion[] {
|
||||
const sorted = sortReleaseCatalog(catalog);
|
||||
if (!allowedVersions || allowedVersions.length === 0) return sorted;
|
||||
return sorted.filter((release) => allowedVersions.some((allowed) => compareReleaseVersions(release.version, allowed) === 0));
|
||||
}
|
||||
|
||||
export function highestAllowedVersion(catalog: ReleaseVersion[], allowedVersions?: string[] | null): string | null {
|
||||
return allowedReleaseCatalog(catalog, allowedVersions).at(-1)?.version ?? null;
|
||||
}
|
||||
|
||||
export function resolveAllowedUpdate(input: ResolveAllowedUpdateInput): string | null {
|
||||
const currentVersion = requireVersion(input.currentVersion);
|
||||
return allowedReleaseCatalog(input.catalog, input.allowedVersions)
|
||||
.filter((release) => compareReleaseVersions(release.version, currentVersion) === 1)
|
||||
.at(-1)?.version ?? null;
|
||||
}
|
||||
|
||||
function cleanBaseUrl(value: string): string {
|
||||
return trimTrailingSlashes(value.trim());
|
||||
}
|
||||
|
||||
export function buildReleaseAssetUrl(input: ReleaseAssetUrlInput): string {
|
||||
const repo = input.repo ?? DEFAULT_REPO;
|
||||
return `${cleanBaseUrl(input.baseUrl)}/${repo}/releases/download/${versionTag(input.version)}/${encodeURIComponent(input.fileName)}`;
|
||||
}
|
||||
|
||||
export function simulateBrowserRenamedFileName(fileName: string, suffix = " (1)"): string {
|
||||
const extensionIndex = fileName.lastIndexOf(".");
|
||||
if (extensionIndex <= 0) return `${fileName}${suffix}`;
|
||||
return `${fileName.slice(0, extensionIndex)}${suffix}${fileName.slice(extensionIndex)}`;
|
||||
}
|
||||
|
||||
function stripBrowserRenameSuffix(fileName: string): string {
|
||||
return fileName.replace(/ \(\d+\)(?=\.[^.]+$)/, "");
|
||||
}
|
||||
|
||||
export function clientFileNameMatchesAsset(asset: ReleaseAsset, clientFileName: string): boolean {
|
||||
const normalized = clientFileName.trim();
|
||||
return normalized === asset.fileName
|
||||
|| normalized === asset.rewrittenFileName
|
||||
|| stripBrowserRenameSuffix(normalized) === asset.fileName;
|
||||
}
|
||||
|
||||
function hostFromUrl(value: string): string {
|
||||
try {
|
||||
return new URL(value).host;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function assertReleaseHostAllowed(url: string, allowedHosts: string[]): void {
|
||||
const host = hostFromUrl(url);
|
||||
if (allowedHosts.includes(host)) return;
|
||||
throw new ReleaseLabError(
|
||||
"release_host_denied",
|
||||
`Release download host ${host || "<invalid>"} is not in the allowed outbound host list.`,
|
||||
"Add the release host to enterprise outbound allow rules or configure an internal artifact mirror.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAssetOrThrow(url: string, allowedHosts?: string[]): Promise<Uint8Array> {
|
||||
if (allowedHosts) assertReleaseHostAllowed(url, allowedHosts);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw actionableHttpError(response.status, url);
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
function actionableHttpError(status: number, url: string): ReleaseLabError {
|
||||
if (status === 504) {
|
||||
return new ReleaseLabError(
|
||||
"release_asset_propagation_lag",
|
||||
`Release asset ${url} returned HTTP 504 while the release was still propagating.`,
|
||||
"Retry against the mirrored artifact host or wait for release propagation before offering the update.",
|
||||
status,
|
||||
);
|
||||
}
|
||||
return new ReleaseLabError(
|
||||
"release_asset_unavailable",
|
||||
`Release asset ${url} returned HTTP ${status}.`,
|
||||
"Surface the failed artifact URL and status to the user instead of silently reporting no update.",
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
function cacheStaleError(expectedVersion: string): ReleaseLabError {
|
||||
return new ReleaseLabError(
|
||||
"release_feed_cache_stale",
|
||||
`The release feed cache has not exposed ${versionTag(expectedVersion)} yet.`,
|
||||
"Bypass or expire the release metadata cache before forcing an update during an enterprise rollout.",
|
||||
);
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(`${JSON.stringify(body, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function sendText(res: ServerResponse, status: number, body: string, headers: Record<string, string> = {}): void {
|
||||
res.writeHead(status, { "content-type": "text/plain; charset=utf-8", ...headers });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function serverBaseUrl(server: Server): string {
|
||||
const address = server.address();
|
||||
if (typeof address === "object" && address !== null && typeof address.port === "number") {
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
throw new ReleaseLabError(
|
||||
"release_asset_unavailable",
|
||||
"Release lab server did not expose a TCP port.",
|
||||
"Restart the hermetic release lab fixture before running update assertions.",
|
||||
);
|
||||
}
|
||||
|
||||
function decodePathSegments(url: URL): string[] {
|
||||
return url.pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
|
||||
}
|
||||
|
||||
function repoFromSegments(segments: string[]): string | null {
|
||||
const releaseIndex = segments.indexOf("releases");
|
||||
if (releaseIndex >= 2) return `${segments[releaseIndex - 2]}/${segments[releaseIndex - 1]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileNameFromSegments(segments: string[]): string | null {
|
||||
const downloadIndex = segments.indexOf("download");
|
||||
if (downloadIndex < 0) return null;
|
||||
const fileName = segments.slice(downloadIndex + 2).join("/");
|
||||
return fileName || null;
|
||||
}
|
||||
|
||||
function tagFromSegments(segments: string[]): string | null {
|
||||
const downloadIndex = segments.indexOf("download");
|
||||
if (downloadIndex < 0) return null;
|
||||
const tag = segments[downloadIndex + 1];
|
||||
return tag || null;
|
||||
}
|
||||
|
||||
function latestDownloadFileNameFromSegments(segments: string[]): string | null {
|
||||
const releasesIndex = segments.indexOf("releases");
|
||||
if (releasesIndex < 0) return null;
|
||||
if (segments[releasesIndex + 1] !== "latest" || segments[releasesIndex + 2] !== "download") return null;
|
||||
const fileName = segments.slice(releasesIndex + 3).join("/");
|
||||
return fileName || null;
|
||||
}
|
||||
|
||||
function isGitHubReleasesApi(segments: string[]): boolean {
|
||||
if (segments.length === 4 && segments[0] === "repos" && segments[3] === "releases") return true;
|
||||
return segments.length === 5 && segments[0] === "api" && segments[1] === "repos" && segments[4] === "releases";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export class ReleaseFeedLab {
|
||||
private catalog: ReleaseVersion[];
|
||||
private initialCatalog: ReleaseVersion[];
|
||||
private cachedSnapshot: ReleaseSnapshot | null;
|
||||
private server: Server | null;
|
||||
private baseUrlValue: string | null;
|
||||
private readonly allowedVersions: string[] | null;
|
||||
private readonly cacheTtlMs: number;
|
||||
private readonly staleUntilMs: number | null;
|
||||
private readonly repo: string;
|
||||
private readonly actionableProbe: ReleaseFeedProbe | null;
|
||||
|
||||
constructor(config: ReleaseFeedLabConfig) {
|
||||
this.catalog = normalizeReleaseCatalog(config.catalog);
|
||||
this.initialCatalog = normalizeReleaseCatalog(config.initialCatalog ?? config.catalog);
|
||||
this.cachedSnapshot = null;
|
||||
this.server = null;
|
||||
this.baseUrlValue = null;
|
||||
this.allowedVersions = config.allowedVersions ? [...config.allowedVersions] : null;
|
||||
this.cacheTtlMs = Math.max(0, config.cacheTtlMs ?? 0);
|
||||
this.staleUntilMs = config.staleUntil instanceof Date ? config.staleUntil.getTime() : config.staleUntil ?? null;
|
||||
this.repo = config.repo ?? DEFAULT_REPO;
|
||||
this.actionableProbe = config.actionableProbe ?? null;
|
||||
}
|
||||
|
||||
get baseUrl(): string {
|
||||
if (!this.baseUrlValue) {
|
||||
throw new ReleaseLabError(
|
||||
"release_asset_unavailable",
|
||||
"Release lab server has not been started.",
|
||||
"Call feed.start() before constructing hermetic artifact URLs.",
|
||||
);
|
||||
}
|
||||
return this.baseUrlValue;
|
||||
}
|
||||
|
||||
updateCatalog(catalog: ReleaseVersionInput[]): void {
|
||||
this.catalog = normalizeReleaseCatalog(catalog);
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cachedSnapshot = null;
|
||||
}
|
||||
|
||||
snapshot(now = Date.now()): ReleaseSnapshot {
|
||||
const staleByKnob = this.staleUntilMs !== null && now < this.staleUntilMs;
|
||||
if (staleByKnob) return this.makeSnapshot(this.initialCatalog, true, this.staleUntilMs);
|
||||
|
||||
if (this.cachedSnapshot && this.cachedSnapshot.cachedUntil !== null && now < this.cachedSnapshot.cachedUntil) {
|
||||
return this.cachedSnapshot;
|
||||
}
|
||||
|
||||
const cachedUntil = this.cacheTtlMs > 0 ? now + this.cacheTtlMs : null;
|
||||
const snapshot = this.makeSnapshot(this.catalog, false, cachedUntil);
|
||||
if (cachedUntil !== null) this.cachedSnapshot = snapshot;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
metadata(now = Date.now()): DenAppVersionMetadataFixture {
|
||||
return this.snapshot(now).metadata;
|
||||
}
|
||||
|
||||
highestAllowedVersion(now = Date.now()): string | null {
|
||||
return highestAllowedVersion(this.snapshot(now).catalog, this.allowedVersions);
|
||||
}
|
||||
|
||||
resolveAllowedUpdate(currentVersion: string, now = Date.now()): string | null {
|
||||
return resolveAllowedUpdate({ catalog: this.snapshot(now).catalog, currentVersion, allowedVersions: this.allowedVersions });
|
||||
}
|
||||
|
||||
assetUrl(version: string, platform: ReleasePlatform, distribution: ReleaseDistribution = DEFAULT_DISTRIBUTION): string {
|
||||
const asset = this.resolveAsset(version, platform, distribution);
|
||||
return buildReleaseAssetUrl({ baseUrl: this.baseUrl, repo: this.repo, version, fileName: asset.fileName });
|
||||
}
|
||||
|
||||
githubAssetUrl(version: string, platform: ReleasePlatform, distribution: ReleaseDistribution = DEFAULT_DISTRIBUTION): string {
|
||||
const asset = this.resolveAsset(version, platform, distribution);
|
||||
return buildReleaseAssetUrl({ baseUrl: "https://github.com", repo: this.repo, version, fileName: asset.fileName });
|
||||
}
|
||||
|
||||
localizeGitHubUrl(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
return `${this.baseUrl}${parsed.pathname}`;
|
||||
}
|
||||
|
||||
resolveAsset(version: string, platform: ReleasePlatform, distribution: ReleaseDistribution = DEFAULT_DISTRIBUTION): ReleaseAsset {
|
||||
const normalized = requireVersion(version);
|
||||
const release = this.snapshot().catalog.find((entry) => compareReleaseVersions(entry.version, normalized) === 0);
|
||||
if (!release) {
|
||||
throw new ReleaseLabError(
|
||||
"release_asset_missing",
|
||||
`Release ${versionTag(normalized)} is not present in the hermetic catalog.`,
|
||||
"Publish the requested version in the release feed before allowing clients to target it.",
|
||||
);
|
||||
}
|
||||
const asset = release.assets.find((entry) => entry.platform === platform && entry.distribution === distribution)
|
||||
?? release.assets.find((entry) => entry.platform === platform);
|
||||
if (!asset) {
|
||||
throw new ReleaseLabError(
|
||||
"release_asset_missing",
|
||||
`Release ${versionTag(normalized)} has no ${platform} artifact.`,
|
||||
"Keep the update hidden for this platform until the matching installer asset exists.",
|
||||
);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
|
||||
assertInstallableAsset(version: string, platform: ReleasePlatform, distribution: ReleaseDistribution = DEFAULT_DISTRIBUTION): ReleaseAsset {
|
||||
const asset = this.resolveAsset(version, platform, distribution);
|
||||
if (asset.installerCapable) return asset;
|
||||
throw new ReleaseLabError(
|
||||
"release_asset_not_installer_capable",
|
||||
`Release ${versionTag(version)} has ${platform} assets that predate installer-capable desktop releases.`,
|
||||
"Approve a newer desktop version with signed installer assets, or mount a compatible artifact before exposing the install path.",
|
||||
);
|
||||
}
|
||||
|
||||
resolveClientDownloadedAsset(version: string, platform: ReleasePlatform, clientFileName: string): ReleaseAsset {
|
||||
const release = this.snapshot().catalog.find((entry) => compareReleaseVersions(entry.version, version) === 0);
|
||||
const asset = release?.assets.find((entry) => entry.platform === platform && clientFileNameMatchesAsset(entry, clientFileName));
|
||||
if (!asset) {
|
||||
throw new ReleaseLabError(
|
||||
"release_asset_missing",
|
||||
`Downloaded filename ${JSON.stringify(clientFileName)} did not resolve to a ${platform} release asset.`,
|
||||
"Resolve install metadata from the URL or bundle manifest, not solely from a browser-controlled filename.",
|
||||
);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
|
||||
async start(): Promise<this> {
|
||||
if (this.server) return this;
|
||||
const server = createServer((req, res) => {
|
||||
void this.handleRequest(req, res);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once("error", onError);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.server = server;
|
||||
this.baseUrlValue = serverBaseUrl(server);
|
||||
return this;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const server = this.server;
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
this.server = null;
|
||||
this.baseUrlValue = null;
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async runActionableProbe(): Promise<never> {
|
||||
const probe = this.actionableProbe;
|
||||
if (!probe) {
|
||||
throw new ReleaseLabError(
|
||||
"release_probe_unexpected_success",
|
||||
"No degraded release-feed probe was configured.",
|
||||
"Configure the lab with a 504 asset, stale cache, or denied-host probe before asserting actionable errors.",
|
||||
);
|
||||
}
|
||||
|
||||
if (probe.kind === "cache") {
|
||||
const metadata = this.metadata();
|
||||
if (!metadata.publishedDesktopVersions.some((version) => compareReleaseVersions(version, probe.expectedVersion) === 0)) {
|
||||
throw cacheStaleError(probe.expectedVersion);
|
||||
}
|
||||
throw new ReleaseLabError(
|
||||
"release_probe_unexpected_success",
|
||||
`Expected ${versionTag(probe.expectedVersion)} to be hidden by cache lag, but it was visible.`,
|
||||
"Tighten the cache-lag fixture before asserting stale-feed behavior.",
|
||||
);
|
||||
}
|
||||
|
||||
if (probe.kind === "host") {
|
||||
const url = probe.url
|
||||
?? this.githubAssetUrl(probe.version ?? this.metadata().latestAppVersion, probe.platform ?? "win-x64");
|
||||
assertReleaseHostAllowed(url, probe.allowedHosts);
|
||||
throw new ReleaseLabError(
|
||||
"release_probe_unexpected_success",
|
||||
`Expected ${url} to be denied by host policy, but it was allowed.`,
|
||||
"Use an allowed-host list that excludes the release host for this degraded-flow frame.",
|
||||
);
|
||||
}
|
||||
|
||||
const url = this.assetUrl(probe.version, probe.platform);
|
||||
await fetchAssetOrThrow(url, probe.allowedHosts);
|
||||
throw new ReleaseLabError(
|
||||
"release_probe_unexpected_success",
|
||||
`Expected ${url} to fail, but it downloaded successfully.`,
|
||||
"Attach a fault knob to the release asset before asserting propagation failures.",
|
||||
);
|
||||
}
|
||||
|
||||
private makeSnapshot(catalog: ReleaseVersion[], stale: boolean, cachedUntil: number | null): ReleaseSnapshot {
|
||||
const allowedCatalog = allowedReleaseCatalog(catalog, this.allowedVersions);
|
||||
return {
|
||||
catalog: allowedCatalog,
|
||||
metadata: releaseCatalogMetadata(allowedCatalog.length > 0 ? allowedCatalog : catalog),
|
||||
stale,
|
||||
cachedUntil,
|
||||
};
|
||||
}
|
||||
|
||||
private githubReleasesJson(): unknown[] {
|
||||
return this.snapshot().catalog.slice().reverse().map((release) => ({
|
||||
url: `${this.baseUrl}/repos/${this.repo}/releases/tags/${release.tagName}`,
|
||||
html_url: `${this.baseUrl}/${this.repo}/releases/tag/${release.tagName}`,
|
||||
tag_name: release.tagName,
|
||||
name: release.tagName,
|
||||
draft: false,
|
||||
prerelease: release.prerelease,
|
||||
published_at: release.publishedAt,
|
||||
assets: release.assets.map((asset, index) => ({
|
||||
id: `${release.version}-${index}`,
|
||||
name: asset.fileName,
|
||||
label: asset.fileName,
|
||||
state: "uploaded",
|
||||
size: asset.size,
|
||||
content_type: asset.contentType,
|
||||
browser_download_url: buildReleaseAssetUrl({ baseUrl: this.baseUrl, repo: this.repo, version: release.version, fileName: asset.fileName }),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
|
||||
const segments = decodePathSegments(requestUrl);
|
||||
|
||||
if (requestUrl.pathname === "/v1/app-version") {
|
||||
sendJson(res, 200, this.metadata());
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGitHubReleasesApi(segments)) {
|
||||
sendJson(res, 200, this.githubReleasesJson());
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname.endsWith(".yml")) {
|
||||
this.sendElectronUpdaterManifest(res, segments);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestFileName = latestDownloadFileNameFromSegments(segments);
|
||||
if (latestFileName) {
|
||||
const latestVersion = this.metadata().latestAppVersion;
|
||||
await this.sendAsset(res, latestVersion, latestFileName);
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = tagFromSegments(segments);
|
||||
const fileName = fileNameFromSegments(segments);
|
||||
if (tag && fileName) {
|
||||
await this.sendAsset(res, tag, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: "not_found", path: requestUrl.pathname, repo: repoFromSegments(segments) });
|
||||
} catch (error) {
|
||||
const body = isReleaseLabError(error)
|
||||
? { error: error.code, message: error.message, action: error.action }
|
||||
: { error: "release_lab_internal_error", message: error instanceof Error ? error.message : String(error) };
|
||||
sendJson(res, 500, body);
|
||||
}
|
||||
}
|
||||
|
||||
private sendElectronUpdaterManifest(res: ServerResponse, segments: string[]): void {
|
||||
const tag = tagFromSegments(segments) ?? this.metadata().latestAppVersion;
|
||||
const version = requireVersion(tag);
|
||||
const asset = this.resolveAsset(version, "mac-arm64", "enterprise");
|
||||
const url = buildReleaseAssetUrl({ baseUrl: this.baseUrl, repo: this.repo, version, fileName: asset.fileName });
|
||||
sendText(res, 200, `version: ${version}\nfiles:\n - url: ${asset.fileName}\n sha512: release-lab-fixture\n size: ${asset.size}\npath: ${asset.fileName}\nsha512: release-lab-fixture\nreleaseDate: ${this.snapshot().catalog.find((entry) => entry.version === version)?.publishedAt ?? new Date(0).toISOString()}\n`, {
|
||||
"x-release-lab-asset-url": url,
|
||||
});
|
||||
}
|
||||
|
||||
private async sendAsset(res: ServerResponse, version: string, fileName: string): Promise<void> {
|
||||
const normalizedVersion = requireVersion(version);
|
||||
const release = this.snapshot().catalog.find((entry) => compareReleaseVersions(entry.version, normalizedVersion) === 0);
|
||||
const asset = release?.assets.find((entry) => entry.fileName === fileName);
|
||||
if (!asset) {
|
||||
sendJson(res, 404, { error: "release_asset_missing", version: normalizedVersion, fileName });
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.fault?.kind === "status") {
|
||||
sendJson(res, asset.fault.status, { error: "release_asset_fault", message: asset.fault.message ?? `HTTP ${asset.fault.status}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"content-type": asset.contentType,
|
||||
"content-length": String(asset.size),
|
||||
"connection": "close",
|
||||
"content-disposition": `attachment; filename="${(asset.rewrittenFileName ?? asset.fileName).replace(/["\\]/g, "-")}"`,
|
||||
"cache-control": "private, max-age=300",
|
||||
"x-release-lab-version": normalizedVersion,
|
||||
"x-release-lab-installer-capable": String(asset.installerCapable),
|
||||
};
|
||||
res.writeHead(200, headers);
|
||||
|
||||
if (asset.fault?.kind === "slow-chunked") {
|
||||
for (let index = 0; index < asset.body.byteLength; index += asset.fault.chunkSize) {
|
||||
res.write(asset.body.slice(index, index + asset.fault.chunkSize));
|
||||
await sleep(asset.fault.delayMs);
|
||||
}
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.end(asset.body);
|
||||
}
|
||||
}
|
||||
|
||||
export function denMetadataFromUnknown(value: unknown): DenAppVersionMetadataFixture | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const minAppVersion = typeof value.minAppVersion === "string" ? value.minAppVersion : "";
|
||||
const latestAppVersion = typeof value.latestAppVersion === "string" ? value.latestAppVersion : "";
|
||||
const publishedDesktopVersions = Array.isArray(value.publishedDesktopVersions)
|
||||
? value.publishedDesktopVersions.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
if (!minAppVersion || !latestAppVersion) return null;
|
||||
return { minAppVersion, latestAppVersion, publishedDesktopVersions };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function trimTrailingSlashes(value: string): string {
|
||||
let end = value.length;
|
||||
while (end > 0 && value[end - 1] === "/") end -= 1;
|
||||
return value.slice(0, end);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
createBlipSchedule,
|
||||
denyHostsFromOutboundManifest,
|
||||
opensslCertificateCommands,
|
||||
opensslFlavor,
|
||||
outboundManifestFromUnknown,
|
||||
parseClientHelloVersions,
|
||||
resolveEgressProfileConfig,
|
||||
startEgressLab,
|
||||
} from "../src/egress.ts";
|
||||
import { productDiagnosticsPrecondition } from "@openwork/behaviors";
|
||||
import { matchVerdictExpectations } from "@openwork/matchers";
|
||||
|
||||
function hi(value: number): number {
|
||||
return (value >> 8) & 0xff;
|
||||
}
|
||||
|
||||
function lo(value: number): number {
|
||||
return value & 0xff;
|
||||
}
|
||||
|
||||
function clientHelloFixture(versions: number[]): Buffer {
|
||||
const supportedVersions = versions.flatMap((version) => [hi(version), lo(version)]);
|
||||
const extensions = supportedVersions.length > 0
|
||||
? [0x00, 0x2b, 0x00, supportedVersions.length + 1, supportedVersions.length, ...supportedVersions]
|
||||
: [];
|
||||
const body = [
|
||||
0x03,
|
||||
0x03,
|
||||
...Array.from({ length: 32 }, () => 0),
|
||||
0x00,
|
||||
0x00,
|
||||
0x02,
|
||||
0x00,
|
||||
0x2f,
|
||||
0x01,
|
||||
0x00,
|
||||
hi(extensions.length),
|
||||
lo(extensions.length),
|
||||
...extensions,
|
||||
];
|
||||
const handshakeLength = body.length;
|
||||
const recordLength = handshakeLength + 4;
|
||||
return Buffer.from([
|
||||
0x16,
|
||||
0x03,
|
||||
0x01,
|
||||
hi(recordLength),
|
||||
lo(recordLength),
|
||||
0x01,
|
||||
0x00,
|
||||
hi(handshakeLength),
|
||||
lo(handshakeLength),
|
||||
...body,
|
||||
]);
|
||||
}
|
||||
|
||||
test("ClientHello parser distinguishes TLS 1.2-only from TLS 1.3-capable clients", () => {
|
||||
const tls12 = parseClientHelloVersions(clientHelloFixture([0x0303]));
|
||||
if (tls12.kind !== "parsed") throw new Error(`unexpected parse result: ${tls12.kind}`);
|
||||
assert.equal(tls12.offersTls13, false);
|
||||
assert.deepEqual(tls12.supportedVersionLabels, ["TLSv1.2"]);
|
||||
|
||||
const tls13 = parseClientHelloVersions(clientHelloFixture([0x0304, 0x0303]));
|
||||
if (tls13.kind !== "parsed") throw new Error(`unexpected parse result: ${tls13.kind}`);
|
||||
assert.equal(tls13.offersTls13, true);
|
||||
assert.deepEqual(tls13.supportedVersionLabels, ["TLSv1.3", "TLSv1.2"]);
|
||||
});
|
||||
|
||||
test("profile config resolution normalizes defaults", () => {
|
||||
const config = resolveEgressProfileConfig({
|
||||
profile: "deny",
|
||||
port: 0,
|
||||
denyHosts: ["GitHub.com", "github.com.", ""],
|
||||
denyMode: "blackhole",
|
||||
slow: { totalBytes: 10, chunkBytes: 4, delayMs: 0, latencyMs: 1 },
|
||||
blip: { route: "api/den", count: 2, fault: "reset" },
|
||||
});
|
||||
|
||||
assert.equal(config.port, null);
|
||||
assert.deepEqual(config.denyHosts, ["github.com"]);
|
||||
assert.equal(config.denyMode, "blackhole");
|
||||
assert.equal(config.slow.totalBytes, 10);
|
||||
assert.equal(config.blip.route, "/api/den");
|
||||
assert.equal(config.blip.count, 2);
|
||||
assert.equal(config.blip.fault, "reset");
|
||||
});
|
||||
|
||||
test("openssl argv construction includes CA, AIA, and fullchain prerequisites", () => {
|
||||
const commands = opensslCertificateCommands({
|
||||
dir: "/tmp/openwork-egress-test",
|
||||
hostname: "localhost",
|
||||
aiaUrl: "http://127.0.0.1:9000/__egress-lab/intermediate.der",
|
||||
corporateIssuer: true,
|
||||
});
|
||||
const labels = commands.map((command) => command.label);
|
||||
|
||||
assert.deepEqual(labels, [
|
||||
"root-key",
|
||||
"root-csr",
|
||||
"root-cert",
|
||||
"intermediate-key",
|
||||
"intermediate-csr",
|
||||
"intermediate-cert",
|
||||
"intermediate-der",
|
||||
"leaf-key",
|
||||
"leaf-csr",
|
||||
"leaf-cert",
|
||||
]);
|
||||
assert.ok(commands.some((command) => command.args.includes("/CN=OpenWork Egress Lab Corporate Interception CA")));
|
||||
assert.ok(commands.some((command) => command.args.includes("-extfile")));
|
||||
const rootCert = commands.find((command) => command.label === "root-cert");
|
||||
assert.ok(rootCert, "root-cert command must exist");
|
||||
assert.ok(
|
||||
rootCert.args.includes("-extfile"),
|
||||
"root CA extensions must come from -extfile; openssl 1.1.1 ignores `req -x509 -addext`",
|
||||
);
|
||||
assert.ok(!rootCert.args.includes("-addext"), "root-cert must not depend on -addext");
|
||||
});
|
||||
|
||||
test("openssl flavor probe returns a known value", async () => {
|
||||
const flavor = await opensslFlavor();
|
||||
assert.ok(["openssl", "libressl", "unknown"].includes(flavor));
|
||||
});
|
||||
|
||||
test("deny-list seeding reads installer-critical hosts from the outbound manifest", () => {
|
||||
const manifest = outboundManifestFromUnknown({
|
||||
hosts: [
|
||||
{ host: "github.com", kind: "fetched", components: ["installer"], requirement: "required-in-practice", blockedEffect: "installer dead" },
|
||||
{ host: "release-assets.githubusercontent.com", kind: "redirect-target", components: ["installer"], requirement: "required-in-practice", blockedEffect: "download fails" },
|
||||
{ host: "cdn.simpleicons.org", kind: "fetched", components: ["renderer"], requirement: "optional", blockedEffect: "icons missing" },
|
||||
],
|
||||
});
|
||||
if (!manifest) throw new Error("manifest fixture did not parse");
|
||||
|
||||
assert.deepEqual(denyHostsFromOutboundManifest(manifest), ["github.com", "release-assets.githubusercontent.com"]);
|
||||
});
|
||||
|
||||
test("blip schedule fires once, then stops", () => {
|
||||
const schedule = createBlipSchedule({ route: "/v1/me", count: 1, fault: "401", status: 401, body: "proxy" });
|
||||
const first = schedule.next("/v1/me");
|
||||
const second = schedule.next("/v1/me");
|
||||
|
||||
assert.deepEqual(first, { kind: "status", status: 401, body: "proxy" });
|
||||
assert.deepEqual(second, { kind: "pass" });
|
||||
assert.equal(schedule.snapshot()[0]?.remaining, 0);
|
||||
});
|
||||
|
||||
test("verdict matcher names injected faults and rejects vague output", () => {
|
||||
assert.equal(matchVerdictExpectations("LIKELY TLS VERSION/HANDSHAKE FAULT: TLS 1.3 timed out while TLS 1.2 completed", "tls12-only").ok, true);
|
||||
assert.equal(matchVerdictExpectations("endpoint_tls_handshake_timeout_tls12_comparison_failed: TLS 1.3 timed out and the runtime TLS 1.2 comparison also timed out", "tls12-only").ok, true);
|
||||
assert.equal(matchVerdictExpectations("LIKELY MISSING INTERMEDIATE OR UNTRUSTED ROOT", "broken-chain").ok, true);
|
||||
assert.equal(matchVerdictExpectations("LIKELY TLS INTERCEPTION: endpoint leaf is re-signed by Corporate CA", "intercept").ok, true);
|
||||
assert.equal(matchVerdictExpectations("BLOCKED HOST / PROXY DENY: github.com is blocked", "deny").ok, true);
|
||||
assert.deepEqual(matchVerdictExpectations("TypeError: fetch failed", ["tls12-only", "broken-chain"]), {
|
||||
ok: false,
|
||||
missing: ["tls12-only", "broken-chain"],
|
||||
});
|
||||
});
|
||||
|
||||
test("product diagnostics precondition skips clearly when Bun is unavailable", () => {
|
||||
const reason = productDiagnosticsPrecondition({ PATH: "/definitely-no-bun-here" });
|
||||
|
||||
if (!reason) throw new Error("Expected a missing-Bun skip reason");
|
||||
assert.ok(reason.includes("Bun is required"));
|
||||
assert.ok(reason.includes("product-verdict egress proofs"));
|
||||
});
|
||||
|
||||
test("generated egress lab CA certificates carry basicConstraints on every openssl build", async () => {
|
||||
await using lab = await startEgressLab({ profile: "tls12-only" });
|
||||
assert.ok(lab.rootPem, "lab must expose its root PEM");
|
||||
const root = new X509Certificate(lab.rootPem);
|
||||
// openssl 1.1.1 silently produced a non-CA root, which broke chain building
|
||||
// only on runners shipping 1.1.1 (macos-14).
|
||||
assert.equal(root.ca, true, `root must be a CA (subject: ${root.subject})`);
|
||||
assert.ok(lab.intermediatePemPath, "lab must expose its intermediate path");
|
||||
const intermediate = new X509Certificate(readFileSync(lab.intermediatePemPath, "utf8"));
|
||||
assert.equal(intermediate.ca, true, `intermediate must be a CA (subject: ${intermediate.subject})`);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { access, chmod, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import tls from "node:tls";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { stageTrustedEnterpriseTlsRoot, startEnterpriseTlsReverseEdge } from "../src/egress.ts";
|
||||
|
||||
function listen(server: http.Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") return reject(new Error("server did not bind"));
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
|
||||
function request(url: string, options: { ca?: string; method?: string; path?: string; body?: string } = {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = new URL(url);
|
||||
const outgoing = https.request({
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
ca: options.ca,
|
||||
method: options.method,
|
||||
path: options.path ?? `${target.pathname}${target.search}`,
|
||||
}, (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
});
|
||||
outgoing.on("error", reject);
|
||||
outgoing.end(options.body);
|
||||
});
|
||||
}
|
||||
|
||||
function peerCertificatePem(url: string, ca: string): Promise<string> {
|
||||
const target = new URL(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = tls.connect({ host: target.hostname, port: Number(target.port), ca, servername: "localhost" }, () => {
|
||||
const raw = socket.getPeerCertificate().raw;
|
||||
socket.end();
|
||||
if (!raw) reject(new Error("peer did not provide a certificate"));
|
||||
else resolve(new X509Certificate(raw).toString());
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function manifest(rootPemPath: string): string {
|
||||
return `${JSON.stringify({
|
||||
pid: process.pid,
|
||||
candidateUrl: "https://localhost:8443",
|
||||
negativeUrl: "https://localhost:9443",
|
||||
adminUrl: "http://127.0.0.1:8445",
|
||||
rootPemPath,
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
test("enterprise TLS privileged trust material is validated and staged", async (t) => {
|
||||
const edge = await startEnterpriseTlsReverseEdge({ upstream: "http://127.0.0.1:1" });
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "openwork-enterprise-tls-validation-test-"));
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
||||
try {
|
||||
await t.test("accepts an owned regular manifest and CA PEM", async () => {
|
||||
const rootPemPath = path.join(dir, "happy-root.pem");
|
||||
const manifestPath = path.join(dir, "happy-manifest.json");
|
||||
await writeFile(rootPemPath, edge.rootPem, { mode: 0o600 });
|
||||
await writeFile(manifestPath, manifest(rootPemPath), { mode: 0o600 });
|
||||
const staged = await stageTrustedEnterpriseTlsRoot(manifestPath, [uid]);
|
||||
try {
|
||||
assert.deepEqual(staged.rootPem, Buffer.from(edge.rootPem));
|
||||
assert.deepEqual(await readFile(staged.stagedRootPemPath), staged.rootPem);
|
||||
assert.notEqual(staged.stagedRootPemPath, rootPemPath);
|
||||
assert.equal((await stat(staged.stagedRootPemPath)).mode & 0o777, 0o600);
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
await t.test("rejects a symlinked manifest", async () => {
|
||||
const rootPemPath = path.join(dir, "manifest-link-root.pem");
|
||||
const targetPath = path.join(dir, "manifest-target.json");
|
||||
const manifestPath = path.join(dir, "manifest-link.json");
|
||||
await writeFile(rootPemPath, edge.rootPem, { mode: 0o600 });
|
||||
await writeFile(targetPath, manifest(rootPemPath), { mode: 0o600 });
|
||||
await symlink(targetPath, manifestPath);
|
||||
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), { name: "ENTERPRISE_TLS_MANIFEST_UNTRUSTED" });
|
||||
});
|
||||
|
||||
await t.test("rejects a symlinked PEM", async () => {
|
||||
const targetPath = path.join(dir, "pem-target.pem");
|
||||
const rootPemPath = path.join(dir, "pem-link.pem");
|
||||
const manifestPath = path.join(dir, "pem-link-manifest.json");
|
||||
await writeFile(targetPath, edge.rootPem, { mode: 0o600 });
|
||||
await symlink(targetPath, rootPemPath);
|
||||
await writeFile(manifestPath, manifest(rootPemPath), { mode: 0o600 });
|
||||
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), { name: "ENTERPRISE_TLS_ROOT_PEM_UNTRUSTED" });
|
||||
});
|
||||
|
||||
await t.test("rejects a world-writable PEM", async () => {
|
||||
const rootPemPath = path.join(dir, "writable-root.pem");
|
||||
const manifestPath = path.join(dir, "writable-manifest.json");
|
||||
await writeFile(rootPemPath, edge.rootPem, { mode: 0o600 });
|
||||
await chmod(rootPemPath, 0o666);
|
||||
await writeFile(manifestPath, manifest(rootPemPath), { mode: 0o600 });
|
||||
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), { name: "ENTERPRISE_TLS_ROOT_PEM_UNTRUSTED" });
|
||||
});
|
||||
|
||||
await t.test("rejects non-CA and unparseable PEM files", async () => {
|
||||
const manifestPath = path.join(dir, "invalid-manifest.json");
|
||||
const rootPemPath = path.join(dir, "invalid-root.pem");
|
||||
await writeFile(manifestPath, manifest(rootPemPath), { mode: 0o600 });
|
||||
await writeFile(rootPemPath, await peerCertificatePem(edge.candidateUrl, edge.rootPem), { mode: 0o600 });
|
||||
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), /certificate is not a CA/u);
|
||||
await writeFile(rootPemPath, "not a certificate\n", { mode: 0o600 });
|
||||
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), { name: "ENTERPRISE_TLS_ROOT_PEM_UNTRUSTED" });
|
||||
});
|
||||
|
||||
await t.test("rejects a relative rootPemPath", async () => {
|
||||
const manifestPath = path.join(dir, "relative-manifest.json");
|
||||
await writeFile(manifestPath, manifest("relative-root.pem"), { mode: 0o600 });
|
||||
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid]), /rootPemPath must be absolute/u);
|
||||
});
|
||||
|
||||
await t.test("rejects an owner outside the explicit allowed uid set", async () => {
|
||||
const rootPemPath = path.join(dir, "owner-root.pem");
|
||||
const manifestPath = path.join(dir, "owner-manifest.json");
|
||||
await writeFile(rootPemPath, edge.rootPem, { mode: 0o600 });
|
||||
await writeFile(manifestPath, manifest(rootPemPath), { mode: 0o600 });
|
||||
await assert.rejects(stageTrustedEnterpriseTlsRoot(manifestPath, [uid + 1]), /owner uid .* is not allowed/u);
|
||||
});
|
||||
} finally {
|
||||
await edge.stop();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("enterprise TLS edge pins its upstream and exposes selective trust", async () => {
|
||||
const upstreamRequests: { method: string; url: string; body: string }[] = [];
|
||||
const upstream = http.createServer(async (incoming, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of incoming) chunks.push(Buffer.from(chunk));
|
||||
upstreamRequests.push({ method: incoming.method ?? "GET", url: incoming.url ?? "/", body: Buffer.concat(chunks).toString("utf8") });
|
||||
response.end("from-pinned-upstream");
|
||||
});
|
||||
const attackerHits: string[] = [];
|
||||
const attacker = http.createServer((incoming, response) => {
|
||||
attackerHits.push(incoming.url ?? "/");
|
||||
response.end("attacker");
|
||||
});
|
||||
const upstreamPort = await listen(upstream);
|
||||
const attackerPort = await listen(attacker);
|
||||
const edge = await startEnterpriseTlsReverseEdge({ upstream: `http://127.0.0.1:${upstreamPort}` });
|
||||
const rootPath = edge.rootPemPath;
|
||||
try {
|
||||
await assert.rejects(request(`${edge.candidateUrl}/default-trust`));
|
||||
assert.equal(await request(`${edge.candidateUrl}/v1/me?full=1`, { ca: edge.rootPem, method: "POST", body: "payload" }), "from-pinned-upstream");
|
||||
await assert.rejects(request(`${edge.negativeUrl}/negative`, { ca: edge.rootPem }));
|
||||
|
||||
const absoluteTarget = `http://127.0.0.1:${attackerPort}/stolen?token=yes`;
|
||||
assert.equal(await request(edge.candidateUrl, { ca: edge.rootPem, path: absoluteTarget }), "from-pinned-upstream");
|
||||
assert.deepEqual(attackerHits, []);
|
||||
assert.deepEqual(upstreamRequests, [
|
||||
{ method: "POST", url: "/v1/me?full=1", body: "payload" },
|
||||
{ method: "GET", url: "/stolen?token=yes", body: "" },
|
||||
]);
|
||||
assert.deepEqual(edge.requests.map(({ endpoint, method, path, body }) => ({ endpoint, method, path, body })), [
|
||||
{ endpoint: "trusted-candidate", method: "POST", path: "/v1/me?full=1", body: "payload" },
|
||||
{ endpoint: "trusted-candidate", method: "GET", path: "/stolen?token=yes", body: "" },
|
||||
]);
|
||||
assert.equal(edge.linuxTrust.restartApplication, true);
|
||||
assert.equal(edge.linuxTrust.install()[1]?.file, "/usr/sbin/update-ca-certificates");
|
||||
assert.equal(edge.linuxTrust.prerequisiteFailures.root, "ENTERPRISE_TLS_LINUX_ROOT_REQUIRED");
|
||||
const prerequisites = await edge.linuxTrust.checkPrerequisites();
|
||||
if (!prerequisites.ok) {
|
||||
assert.ok([
|
||||
"ENTERPRISE_TLS_LINUX_ROOT_REQUIRED",
|
||||
"ENTERPRISE_TLS_UPDATE_CA_CERTIFICATES_REQUIRED",
|
||||
].includes(prerequisites.failure));
|
||||
}
|
||||
} finally {
|
||||
await edge.stop();
|
||||
await Promise.all([close(upstream), close(attacker)]);
|
||||
}
|
||||
await assert.rejects(access(rootPath));
|
||||
await assert.rejects(request(edge.candidateUrl, { ca: edge.rootPem }));
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildBlockedUserResponse,
|
||||
buildOidcClaims,
|
||||
buildRs256SigningArgv,
|
||||
buildWrongDomainEmail,
|
||||
matchSsoExpectation,
|
||||
normalizeCertificateMaterial,
|
||||
normalizeDomain,
|
||||
normalizeMockIdpConfig,
|
||||
startMockIdpLab,
|
||||
subjectWithKnobs,
|
||||
validateSsoConfiguration,
|
||||
} from "../src/idp.ts";
|
||||
|
||||
test("mock IdP config normalization trims issuer, domain, and credentials", () => {
|
||||
const config = normalizeMockIdpConfig({
|
||||
issuer: " https://idp.example.test/ ",
|
||||
domain: " @Acme.TEST. ",
|
||||
clientId: " client-one ",
|
||||
clientSecret: " secret-one ",
|
||||
});
|
||||
|
||||
assert.equal(config.issuer, "https://idp.example.test");
|
||||
assert.equal(config.domain, "acme.test");
|
||||
assert.equal(config.clientId, "client-one");
|
||||
assert.equal(config.clientSecret, "secret-one");
|
||||
assert.equal(config.defaultSubject.email, "sso.user@acme.test");
|
||||
});
|
||||
|
||||
test("certificate normalizer names the trailing-newline paste bug", () => {
|
||||
const cert = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n";
|
||||
const normalized = normalizeCertificateMaterial(cert);
|
||||
const validation = validateSsoConfiguration({ configuredDomain: "acme.test", cert, subjectEmail: "maya@acme.test" });
|
||||
const match = matchSsoExpectation(validation, { code: "sso_cert_trailing_newline", includes: ["trailing newline", "certificate"] });
|
||||
|
||||
assert.equal(normalized.hadTrailingNewline, true);
|
||||
assert.equal(normalized.value.endsWith("\n"), false);
|
||||
assert.equal(validation.ok, false);
|
||||
assert.equal(match.passed, true);
|
||||
});
|
||||
|
||||
test("domain mismatch normalizer names IdP email domains that do not match the org", () => {
|
||||
const wrongEmail = buildWrongDomainEmail("maya@acme.test", "acme.test");
|
||||
const validation = validateSsoConfiguration({ configuredDomain: "ACME.test", subjectEmail: wrongEmail });
|
||||
const match = matchSsoExpectation(validation, { code: "sso_domain_mismatch", includes: ["wrong-acme.test", "acme.test"] });
|
||||
|
||||
assert.equal(wrongEmail, "maya@wrong-acme.test");
|
||||
assert.equal(normalizeDomain(" @ACME.test. "), "acme.test");
|
||||
assert.equal(validation.normalizedDomain, "acme.test");
|
||||
assert.equal(match.passed, true);
|
||||
});
|
||||
|
||||
test("claim builder supports present, absent, and unexpected group claim shapes", () => {
|
||||
const baseConfig = normalizeMockIdpConfig({ domain: "acme.test" });
|
||||
const subject = subjectWithKnobs(baseConfig, { email: "dev@acme.test", name: "Dev User" });
|
||||
const present = buildOidcClaims({ issuer: baseConfig.issuer, clientId: baseConfig.clientId, subject, knobs: baseConfig.knobs });
|
||||
const absentConfig = normalizeMockIdpConfig({ knobs: { groupClaims: "absent" } });
|
||||
const absent = buildOidcClaims({ issuer: absentConfig.issuer, clientId: absentConfig.clientId, subject, knobs: absentConfig.knobs });
|
||||
const oddConfig = normalizeMockIdpConfig({ knobs: { groupClaims: "unexpected-shape" } });
|
||||
const odd = buildOidcClaims({ issuer: oddConfig.issuer, clientId: oddConfig.clientId, subject, knobs: oddConfig.knobs });
|
||||
|
||||
assert.deepEqual(present.groups, ["Engineering", "OpenWork Lab"]);
|
||||
assert.equal("groups" in absent, false);
|
||||
assert.deepEqual(odd.groups, { primary: "Engineering", all: ["Engineering", "OpenWork Lab"] });
|
||||
});
|
||||
|
||||
test("email mismatch and guest-user knobs shape SSO subjects", () => {
|
||||
const mismatchConfig = normalizeMockIdpConfig({ knobs: { emailMismatch: "other@acme.test" } });
|
||||
const guestConfig = normalizeMockIdpConfig({ knobs: { guestUser: true } });
|
||||
|
||||
assert.equal(subjectWithKnobs(mismatchConfig, { email: "invited@acme.test" }).email, "other@acme.test");
|
||||
assert.equal(subjectWithKnobs(guestConfig, { email: "guest@external.test" }).sub, "guest:guest@external.test");
|
||||
});
|
||||
|
||||
test("RS256 signing argv documents the dependency-free node:crypto path", () => {
|
||||
assert.deepEqual(buildRs256SigningArgv({ keyId: "kid-one", payload: "abc" }), [
|
||||
"node:crypto",
|
||||
"createSign",
|
||||
"RSA-SHA256",
|
||||
"--kid",
|
||||
"kid-one",
|
||||
"--payload-bytes",
|
||||
"3",
|
||||
]);
|
||||
});
|
||||
|
||||
test("blocked-user response uses the Entra-style policy wording", () => {
|
||||
const response = buildBlockedUserResponse({ email: "blocked@acme.test" });
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(response.error, "access_denied");
|
||||
assert.match(response.errorDescription, /administrator has configured the application to block users/);
|
||||
assert.match(response.message, /IdP administrator/);
|
||||
assert.match(response.html, /OpenWork Mock IdP policy/);
|
||||
});
|
||||
|
||||
test("mock IdP escapes request-derived blocked-user HTML", async () => {
|
||||
const loginHint = "blocked@acme.test<script>alert(1)</script>";
|
||||
await using lab = await startMockIdpLab({ knobs: { blockedUser: loginHint } });
|
||||
const url = new URL(lab.authorizationEndpoint);
|
||||
url.searchParams.set("client_id", lab.config.clientId);
|
||||
url.searchParams.set("redirect_uri", "http://127.0.0.1/callback");
|
||||
url.searchParams.set("login_hint", loginHint);
|
||||
|
||||
const response = await fetch(url);
|
||||
const body = await response.text();
|
||||
assert.equal(response.status, 403);
|
||||
// Plain substring checks: stricter than a tag regex (`<script` also catches
|
||||
// `<script src=...`) and they cannot be mistaken for a tag-filtering sanitizer.
|
||||
assert.ok(!body.includes("<script"), `escaped body must not contain raw markup: ${body}`);
|
||||
assert.ok(body.includes("<script>"), `escaped body must contain the escaped payload: ${body}`);
|
||||
});
|
||||
|
||||
test("expectation matcher fails with actionable detail when the named error is missing", () => {
|
||||
const validation = validateSsoConfiguration({ configuredDomain: "acme.test", subjectEmail: "maya@acme.test" });
|
||||
const match = matchSsoExpectation(validation, { code: "sso_domain_mismatch" });
|
||||
|
||||
assert.equal(match.passed, false);
|
||||
assert.deepEqual(match.actualCodes, []);
|
||||
assert.match(match.detail, /Expected sso_domain_mismatch/);
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import type { Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import test from "node:test";
|
||||
|
||||
import { startMockGoogle } from "../src/mock-google.ts";
|
||||
import type { MockGoogleHandle } from "../src/mock-google.ts";
|
||||
|
||||
interface CallbackLab {
|
||||
url: string;
|
||||
callbacks: URL[];
|
||||
stop(): Promise<void>;
|
||||
[Symbol.asyncDispose](): Promise<void>;
|
||||
}
|
||||
|
||||
interface OAuthTokens {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isAddressInfo(value: ReturnType<Server["address"]>): value is AddressInfo {
|
||||
return typeof value === "object"
|
||||
&& value !== null
|
||||
&& typeof value.address === "string"
|
||||
&& typeof value.family === "string"
|
||||
&& typeof value.port === "number";
|
||||
}
|
||||
|
||||
function close(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
server.closeIdleConnections();
|
||||
});
|
||||
}
|
||||
|
||||
async function startCallbackLab(): Promise<CallbackLab> {
|
||||
const callbacks: URL[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
callbacks.push(new URL(request.url ?? "/", "http://127.0.0.1"));
|
||||
response.writeHead(200, { "content-type": "text/plain" });
|
||||
response.end("OAuth callback served");
|
||||
});
|
||||
const address = await new Promise<AddressInfo>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
const value = server.address();
|
||||
if (isAddressInfo(value)) {
|
||||
resolve(value);
|
||||
return;
|
||||
}
|
||||
reject(new Error("Callback lab did not expose a TCP port."));
|
||||
});
|
||||
});
|
||||
let stopped = false;
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
await close(server);
|
||||
};
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/callback`,
|
||||
callbacks,
|
||||
stop,
|
||||
[Symbol.asyncDispose]: stop,
|
||||
};
|
||||
}
|
||||
|
||||
function tokenFingerprint(accessToken: string): string {
|
||||
return createHash("sha256").update(accessToken).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
async function authorize(
|
||||
google: MockGoogleHandle,
|
||||
callback: CallbackLab,
|
||||
email: string,
|
||||
clientId: string,
|
||||
): Promise<OAuthTokens> {
|
||||
const verifier = `verifier-${clientId}`;
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
const authorizeUrl = new URL(google.authorizeUrl);
|
||||
authorizeUrl.searchParams.set("client_id", clientId);
|
||||
authorizeUrl.searchParams.set("redirect_uri", callback.url);
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("scope", "openid email profile https://www.googleapis.com/auth/gmail.compose");
|
||||
authorizeUrl.searchParams.set("state", `state-${clientId}`);
|
||||
authorizeUrl.searchParams.set("prompt", "consent select_account");
|
||||
authorizeUrl.searchParams.set("code_challenge", challenge);
|
||||
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
||||
const since = new Date().toISOString();
|
||||
const callbackCount = callback.callbacks.length;
|
||||
const chooser = await fetch(authorizeUrl, { redirect: "manual" });
|
||||
const chooserHtml = await chooser.text();
|
||||
|
||||
assert.equal(chooser.status, 200);
|
||||
assert.match(chooserHtml, /Choose an account/);
|
||||
assert.match(chooserHtml, new RegExp(`data-account-email="${email.replaceAll(".", "\\.")}"`));
|
||||
const observed = await google.authorizeRequestSince(since, { timeoutMs: 2_000 });
|
||||
assert.equal(observed.params.get("prompt"), "consent select_account");
|
||||
await google.chooseAccount(email, { timeoutMs: 2_000 });
|
||||
assert.equal(callback.callbacks.length, callbackCount + 1, "chooseAccount must wait until the callback server receives the request");
|
||||
const callbackUrl = callback.callbacks.at(-1);
|
||||
const code = callbackUrl?.searchParams.get("code");
|
||||
assert.ok(code, "callback must carry an authorization code");
|
||||
|
||||
const response = await fetch(google.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
client_id: clientId,
|
||||
client_secret: `secret-${clientId}`,
|
||||
redirect_uri: callback.url,
|
||||
}),
|
||||
});
|
||||
const body: unknown = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(isRecord(body));
|
||||
if (typeof body.access_token !== "string" || typeof body.id_token !== "string") {
|
||||
throw new Error(`Token response omitted access_token or id_token: ${JSON.stringify(body)}`);
|
||||
}
|
||||
return { accessToken: body.access_token, idToken: body.id_token };
|
||||
}
|
||||
|
||||
function idTokenPayload(idToken: string): Record<string, unknown> {
|
||||
const payload = idToken.split(".")[1];
|
||||
assert.ok(payload, "id_token must have a JWT payload");
|
||||
const parsed: unknown = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||
assert.ok(isRecord(parsed));
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function userinfo(google: MockGoogleHandle, accessToken: string): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(google.userinfoUrl, { headers: { authorization: `Bearer ${accessToken}` } });
|
||||
const body: unknown = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(isRecord(body));
|
||||
return body;
|
||||
}
|
||||
|
||||
async function submitDraft(
|
||||
google: MockGoogleHandle,
|
||||
accessToken: string,
|
||||
message: string,
|
||||
threadId?: string,
|
||||
): Promise<void> {
|
||||
const requestMessage: { raw: string; threadId?: string } = {
|
||||
raw: Buffer.from(message, "utf8").toString("base64url"),
|
||||
};
|
||||
if (threadId) requestMessage.threadId = threadId;
|
||||
const response = await fetch(`${google.apiUrl}/gmail/v1/users/me/drafts`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ message: requestMessage }),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
}
|
||||
|
||||
async function createDraft(
|
||||
google: MockGoogleHandle,
|
||||
accessToken: string,
|
||||
to: string,
|
||||
body: string,
|
||||
threadId?: string,
|
||||
): Promise<void> {
|
||||
const message = [
|
||||
`To: ${to}`,
|
||||
"Subject: Fixture witness",
|
||||
"MIME-Version: 1.0",
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
Buffer.from(body, "utf8").toString("base64"),
|
||||
].join("\r\n");
|
||||
await submitDraft(google, accessToken, message, threadId);
|
||||
}
|
||||
|
||||
test("mock Google issues independent account identities and credential fingerprints", async () => {
|
||||
const accountA = "jordan@acme.test";
|
||||
const accountB = "jordan@acmelabs.test";
|
||||
await using google = await startMockGoogle({ accounts: [accountA, accountB], port: 0, autoApprove: false });
|
||||
await using callback = await startCallbackLab();
|
||||
|
||||
const tokensA = await authorize(google, callback, accountA, "client-a");
|
||||
const tokensB = await authorize(google, callback, accountB, "client-b");
|
||||
assert.notEqual(tokenFingerprint(tokensA.accessToken), tokenFingerprint(tokensB.accessToken));
|
||||
assert.equal(idTokenPayload(tokensA.idToken).email, accountA);
|
||||
assert.equal(idTokenPayload(tokensB.idToken).email, accountB);
|
||||
assert.equal((await userinfo(google, tokensA.accessToken)).email, accountA);
|
||||
assert.equal((await userinfo(google, tokensB.accessToken)).email, accountB);
|
||||
|
||||
await createDraft(google, tokensA.accessToken, "archive@acme.test", "Account A", "thread-a");
|
||||
await createDraft(google, tokensB.accessToken, "archive@acmelabs.test", "Account B", "thread-b");
|
||||
const draftsA = await google.draftsFor(accountA);
|
||||
const draftsB = await google.draftsFor(accountB);
|
||||
assert.equal(draftsA[0]?.tokenId, tokenFingerprint(tokensA.accessToken));
|
||||
assert.equal(draftsB[0]?.tokenId, tokenFingerprint(tokensB.accessToken));
|
||||
assert.notEqual(draftsA[0]?.tokenId, draftsB[0]?.tokenId);
|
||||
});
|
||||
|
||||
test("mock Google attributes drafts to one mailbox and returns an empty mailbox promptly", async () => {
|
||||
const accountA = "jordan@acme.test";
|
||||
const accountB = "jordan@acmelabs.test";
|
||||
await using google = await startMockGoogle({ accounts: [accountA, accountB], port: 0, autoApprove: false });
|
||||
await using callback = await startCallbackLab();
|
||||
const tokensA = await authorize(google, callback, accountA, "client-a");
|
||||
const since = new Date().toISOString();
|
||||
await createDraft(google, tokensA.accessToken, "supplier@parts.test", "Mailbox isolation witness", "thread-one");
|
||||
|
||||
const draftsA = await google.draftsFor(accountA, { since, atLeast: 1, timeoutMs: 2_000 });
|
||||
const startedAt = Date.now();
|
||||
const draftsB = await google.draftsFor(accountB, { since, timeoutMs: 5_000 });
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
assert.deepEqual(draftsB, []);
|
||||
assert.ok(elapsedMs < 1_000, `empty mailbox read blocked for ${elapsedMs}ms`);
|
||||
assert.equal(draftsA.length, 1);
|
||||
assert.deepEqual(draftsA[0], {
|
||||
to: "supplier@parts.test",
|
||||
body: "Mailbox isolation witness",
|
||||
threadId: "thread-one",
|
||||
tokenId: tokenFingerprint(tokensA.accessToken),
|
||||
at: draftsA[0]?.at,
|
||||
});
|
||||
});
|
||||
|
||||
test("mock Google decodes text/plain nested inside mixed and alternative MIME", async () => {
|
||||
const account = "jordan@acme.test";
|
||||
await using google = await startMockGoogle({ accounts: [account], port: 0, autoApprove: false });
|
||||
await using callback = await startCallbackLab();
|
||||
const tokens = await authorize(google, callback, account, "client-nested-mime");
|
||||
const plainBody = "Nested multipart body marker";
|
||||
const mixedBoundary = "fixture-mixed";
|
||||
const alternativeBoundary = "fixture-alternative";
|
||||
const message = [
|
||||
"To: archive@acme.test",
|
||||
"Subject: Nested MIME witness",
|
||||
"MIME-Version: 1.0",
|
||||
`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`,
|
||||
"",
|
||||
`--${mixedBoundary}`,
|
||||
`Content-Type: multipart/alternative; boundary="${alternativeBoundary}"`,
|
||||
"",
|
||||
`--${alternativeBoundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
Buffer.from(plainBody, "utf8").toString("base64"),
|
||||
`--${alternativeBoundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
Buffer.from(`<div>${plainBody}</div>`, "utf8").toString("base64"),
|
||||
`--${alternativeBoundary}--`,
|
||||
`--${mixedBoundary}`,
|
||||
'Content-Type: application/octet-stream; name="witness.bin"',
|
||||
'Content-Disposition: attachment; filename="witness.bin"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
Buffer.from("attachment bytes", "utf8").toString("base64"),
|
||||
`--${mixedBoundary}--`,
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
await submitDraft(google, tokens.accessToken, message);
|
||||
|
||||
const drafts = await google.draftsFor(account);
|
||||
assert.equal(drafts[0]?.body, plainBody);
|
||||
});
|
||||
|
||||
test("mock Google witnesses exact Gmail attachment and Drive upload bytes", async () => {
|
||||
const account = "jordan@acme.test";
|
||||
const fixture = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0x80, 0x13, 0x0a]);
|
||||
await using google = await startMockGoogle({ accounts: [account], port: 0, autoApprove: false });
|
||||
await using callback = await startCallbackLab();
|
||||
const tokens = await authorize(google, callback, account, "client-binary-witness");
|
||||
|
||||
const mixedBoundary = "fixture-mixed-binary";
|
||||
const message = [
|
||||
"To: archive@acme.test",
|
||||
"Subject: Binary witness",
|
||||
"MIME-Version: 1.0",
|
||||
`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`,
|
||||
"",
|
||||
`--${mixedBoundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
Buffer.from("Binary body", "utf8").toString("base64"),
|
||||
`--${mixedBoundary}`,
|
||||
'Content-Type: image/png; name="witness.png"',
|
||||
'Content-Disposition: attachment; filename="witness.png"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
fixture.toString("base64"),
|
||||
`--${mixedBoundary}--`,
|
||||
"",
|
||||
].join("\r\n");
|
||||
await submitDraft(google, tokens.accessToken, message);
|
||||
const drafts = await google.draftsFor(account, { atLeast: 1, timeoutMs: 2_000 });
|
||||
assert.equal(drafts[0]?.attachments?.[0]?.filename, "witness.png");
|
||||
assert.equal(drafts[0]?.attachments?.[0]?.mimeType, "image/png");
|
||||
assert.deepEqual(drafts[0]?.attachments?.[0]?.content, fixture);
|
||||
|
||||
const driveBoundary = "fixture-drive-binary";
|
||||
const metadata = Buffer.from(`--${driveBoundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{"name":"witness.png"}\r\n`, "utf8");
|
||||
const contentHeader = Buffer.from(`--${driveBoundary}\r\nContent-Type: image/png\r\n\r\n`, "utf8");
|
||||
const footer = Buffer.from(`\r\n--${driveBoundary}--\r\n`, "utf8");
|
||||
const uploadBody = Buffer.concat([metadata, contentHeader, fixture, footer]);
|
||||
const uploadBytes = new Uint8Array(uploadBody.byteLength);
|
||||
uploadBytes.set(uploadBody);
|
||||
const response = await fetch(`${google.apiUrl}/upload/drive/v3/files?uploadType=multipart`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${tokens.accessToken}`,
|
||||
"content-type": `multipart/related; boundary=${driveBoundary}`,
|
||||
},
|
||||
body: uploadBytes,
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const uploads = await google.driveUploadsFor(account, { atLeast: 1, timeoutMs: 2_000 });
|
||||
assert.equal(uploads[0]?.filename, "witness.png");
|
||||
assert.equal(uploads[0]?.mimeType, "image/png");
|
||||
assert.deepEqual(uploads[0]?.content, fixture);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { allocateFreePort } from "@openwork/cdp";
|
||||
import { startMockMcp } from "../src/mock-mcp.ts";
|
||||
|
||||
test("records an unauthenticated initialize attempt as a handshake", async () => {
|
||||
await using mock = await startMockMcp({ port: await allocateFreePort() });
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
const response = await fetch(mock.mcpUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "mock-handshake-test", version: "1.0.0" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
const handshakes = await mock.handshakes({ sinceIso: startedAt });
|
||||
assert.equal(handshakes.length, 1);
|
||||
assert.equal(handshakes[0]?.method, "POST");
|
||||
assert.equal(handshakes[0]?.path, "/mcp");
|
||||
});
|
||||
|
||||
function completionBody(marker: string, completedTools: number): Record<string, unknown> {
|
||||
return {
|
||||
model: "mock-agent-workload-model",
|
||||
stream: true,
|
||||
tools: [
|
||||
{ type: "function", function: { name: "write", parameters: { type: "object" } } },
|
||||
{ type: "function", function: { name: "read", parameters: { type: "object" } } },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: `run ${marker}` },
|
||||
...Array.from({ length: completedTools }, (_, index) => ({
|
||||
role: "tool",
|
||||
tool_call_id: `call-${index}`,
|
||||
content: `tool result ${index}`,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("scripts and records deterministic OpenAI-compatible agent tool rounds", async () => {
|
||||
const marker = "agent-workload-unit-marker";
|
||||
await using mock = await startMockMcp({
|
||||
port: await allocateFreePort(),
|
||||
agentWorkloads: [{
|
||||
promptMarker: marker,
|
||||
finalReply: "unit workload complete",
|
||||
steps: [
|
||||
{ tool: "write", arguments: { filePath: "/tmp/unit.txt", content: marker } },
|
||||
{ tool: "read", arguments: { filePath: "/tmp/unit.txt" } },
|
||||
],
|
||||
}],
|
||||
});
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
const first = await fetch(`${mock.url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(completionBody(marker, 0)),
|
||||
});
|
||||
const firstText = await first.text();
|
||||
assert.equal(first.status, 200);
|
||||
assert.match(firstText, /"name":"write"/);
|
||||
assert.match(firstText, /unit\.txt/);
|
||||
|
||||
const second = await fetch(`${mock.url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(completionBody(marker, 1)),
|
||||
});
|
||||
assert.match(await second.text(), /"name":"read"/);
|
||||
|
||||
const final = await fetch(`${mock.url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(completionBody(marker, 2)),
|
||||
});
|
||||
assert.match(await final.text(), /unit workload complete/);
|
||||
|
||||
const requests = await mock.agentRequests({ promptMarker: marker, sinceIso: startedAt, atLeast: 3, timeoutMs: 5_000 });
|
||||
assert.deepEqual(requests.map((request) => request.kind), ["tool", "tool", "final"]);
|
||||
assert.deepEqual(requests.map((request) => request.completedTools), [0, 1, 2]);
|
||||
assert.deepEqual(requests.map((request) => request.matchedMarkers), [[marker], [marker], [marker]]);
|
||||
});
|
||||
Reference in New Issue
Block a user