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