源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user