scripts/smoke-transactional.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env tsx
/**
* `pnpm smoke:transactional` — end-to-end smoke test of the transactional API
* against a DEPLOYED environment, through the same HTTP surface an operator
* (session cookie, `/api/*`) and an integrator (Bearer key, `/v1/*`) use.
*
* pnpm operator login --env dev --host <host>
* pnpm smoke:transactional --env dev --host <host> --mailbox <mbx_...> \
* --sender <addr on a verified sending domain> --to <recipient> [--send] [--wait-delivery 60]
*
* Without --send it only checks the session and that the mailbox answers, then
* prints the plan. With --send it creates a throwaway template and live key,
* sends ONE real message to --to, and always revokes/archives them at the end.
* The plaintext key is held in memory only and never printed.
*
* Pure logic (args, bodies, assertions) is in ./lib/smoke-transactional-core.ts.
*/
import { randomBytes } from "node:crypto";
import {
type FetchLike,
getSessionInfo,
OperatorAuthError,
type OperatorSession,
operatorFetch,
requireSession,
resolveHost,
sessionPathFor,
} from "./lib/operator-session";
import {
assessArchive,
assessDelivery,
assessKeyCreate,
assessKeyListed,
assessPolicyRejection,
assessReplay,
assessRevoke,
assessRoutingDestination,
assessSend,
assessSenderName,
assessStatus,
buildIntegratorRequest,
buildKeyBody,
buildSendPayload,
buildTemplateBody,
describeResponse,
extractKeyList,
extractStatus,
formatStep,
type HttpResult,
hasFailure,
isTerminalDelivery,
mailboxApiPath,
makeRunIds,
manualCheckLine,
manualCleanupCommands,
POLICY_REJECT_ADDRESS,
parseRoutingDestinations,
parseSmokeArgs,
planLines,
type RoutingDestination,
type RoutingLookup,
redactSecrets,
type SendView,
SMOKE_USAGE,
SmokeInputError,
type StatusView,
type StepResult,
step,
summaryLine,
toHttpResult,
} from "./lib/smoke-transactional-core";
import { wranglerCapture } from "./lib/wrangler-cli";
const POLL_INTERVAL_MS = 5_000;
const fetchImpl: FetchLike = (input, init) => fetch(input, init);
const results: StepResult[] = [];
/** Every secret that could appear in an error message: the plaintext key and the cookie. */
const secrets: string[] = [];
function report(result: StepResult): StepResult {
results.push(result);
console.log(redactSecrets(formatStep(result), secrets));
return result;
}
async function operatorCall(
session: OperatorSession,
env: string | undefined,
path: string,
method: string,
body?: unknown,
): Promise<HttpResult> {
const response = await operatorFetch(
session,
path,
{ method, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) },
{ fetchImpl, env },
);
return toHttpResult(response.status, await response.text());
}
async function integratorCall(request: { url: string; init: RequestInit }): Promise<HttpResult> {
const response = await fetchImpl(request.url, request.init);
return toHttpResult(response.status, await response.text());
}
function errorText(error: unknown): string {
return redactSecrets(error instanceof Error ? error.message : String(error), secrets);
}
/** CLOUDFLARE_ACCOUNT_ID, else the single account `wrangler whoami --json` reports. */
function resolveAccountId(): { id: string } | { error: string } {
const fromEnv = process.env.CLOUDFLARE_ACCOUNT_ID?.trim();
if (fromEnv) return { id: fromEnv };
const who = wranglerCapture(["whoami", "--json"]);
if (!who.ok) return { error: "`wrangler whoami --json` failed; set CLOUDFLARE_ACCOUNT_ID" };
try {
const parsed = JSON.parse(who.out) as {
account?: { id?: string };
accounts?: Array<{ id?: string }>;
};
if (parsed.account?.id) return { id: parsed.account.id };
const ids = (parsed.accounts ?? []).map((a) => a.id).filter((id): id is string => !!id);
if (ids.length === 1 && ids[0]) return { id: ids[0] };
return {
error: `wrangler reports ${ids.length} accounts; set CLOUDFLARE_ACCOUNT_ID to choose one`,
};
} catch {
return { error: "could not parse `wrangler whoami --json`; set CLOUDFLARE_ACCOUNT_ID" };
}
}
/**
* The account's Email Routing destination addresses, through the same REST
* auth `setup:routing` uses for its Email Routing calls (CLOUDFLARE_API_TOKEN).
* Read-only. Any failure is returned as a reason, never thrown: this only
* guards the delivery wait.
*/
async function lookupRoutingDestinations(): Promise<RoutingLookup> {
const token = process.env.CLOUDFLARE_API_TOKEN?.trim();
if (!token) {
return {
ok: false,
reason: "CLOUDFLARE_API_TOKEN is not set (needs Account · Email Routing Addresses · Read)",
};
}
secrets.push(token);
const account = resolveAccountId();
if ("error" in account) return { ok: false, reason: account.error };
const destinations: RoutingDestination[] = [];
try {
for (let page = 1; page <= 20; page += 1) {
const response = await fetchImpl(
`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(account.id)}/email/routing/addresses?per_page=50&page=${page}`,
{ headers: { Authorization: `Bearer ${token}`, accept: "application/json" } },
);
const payload = (await response.json().catch(() => null)) as {
success?: boolean;
result?: unknown;
result_info?: { total_pages?: number };
errors?: Array<{ message?: string }>;
} | null;
if (!response.ok || payload?.success !== true) {
const message = payload?.errors?.map((e) => e.message).join("; ") || "no error detail";
return { ok: false, reason: `HTTP ${response.status}: ${message}` };
}
destinations.push(...parseRoutingDestinations(payload.result));
if (page >= (payload.result_info?.total_pages ?? 1)) break;
}
} catch (error) {
return { ok: false, reason: errorText(error) };
}
return { ok: true, destinations };
}
async function main(): Promise<number> {
const opts = parseSmokeArgs(process.argv.slice(2));
if (opts.help) {
console.log(SMOKE_USAGE);
return 0;
}
const host = resolveHost(opts.host, opts.env);
const nowMs = Date.now();
const ids = makeRunIds(nowMs, randomBytes(8).toString("hex"));
for (const line of planLines(opts, host, ids)) console.log(line);
console.log("");
// --- Read-only preflight (both modes) ---
const session = requireSession(host, opts.env);
secrets.push(session.cookie);
const info = await getSessionInfo(session, fetchImpl);
if (!info) {
report(
step("operator session", "FAIL", [
`stored session for ${host} is no longer valid; run: pnpm operator login${opts.env ? ` --env ${opts.env}` : ""} --host ${host}`,
]),
);
console.log(summaryLine(results));
return 1;
}
report(
step("operator session", "PASS", [
`signed in as ${info.email}${info.expiresAt ? ` (expires ${info.expiresAt})` : ""}`,
]),
);
const keysPath = mailboxApiPath(opts.mailboxId, "/api-keys");
const reach = await operatorCall(session, opts.env, keysPath, "GET");
const existing = extractKeyList(reach.body);
report(
reach.status === 200 && existing
? step("mailbox reachable (GET api-keys)", "PASS", [
`HTTP 200, ${existing.length} key(s), ${existing.filter((k) => k.status === "active").length} active`,
])
: step("mailbox reachable (GET api-keys)", "FAIL", [describeResponse(reach, secrets)]),
);
// A recipient that is a verified Email Routing destination address never
// produces a delivery event, so waiting for one could only time out.
let skipDeliveryWait = false;
if (opts.waitDeliverySeconds > 0) {
const routing = assessRoutingDestination(opts.to, await lookupRoutingDestinations());
skipDeliveryWait = routing.skipWait;
report(routing.step);
}
if (!opts.send) {
console.log("");
console.log("Plan only: nothing was created or sent. Re-run with --send to execute steps 1-8.");
console.log(summaryLine(results));
return hasFailure(results) ? 1 : 0;
}
if (hasFailure(results)) {
console.log("Preflight failed; not sending.");
console.log(summaryLine(results));
return 1;
}
console.log("");
let templateCreated = false;
let keyId: string | undefined;
let send: SendView | undefined;
try {
// 1. Template
const template = await operatorCall(
session,
opts.env,
mailboxApiPath(opts.mailboxId, "/templates"),
"POST",
buildTemplateBody(ids),
);
templateCreated = template.status === 201;
report(
step(`create template ${ids.templateId}`, templateCreated ? "PASS" : "FAIL", [
templateCreated ? "HTTP 201" : describeResponse(template, secrets),
]),
);
if (!templateCreated) return 1;
// 2. Live key
const created = await operatorCall(
session,
opts.env,
keysPath,
"POST",
buildKeyBody({ sender: opts.sender, to: opts.to, templateId: ids.templateId, nowMs }),
);
const assessed = assessKeyCreate(created);
keyId = assessed.keyId;
const plaintextKey = assessed.plaintextKey;
if (plaintextKey) secrets.push(plaintextKey);
report(assessed.step);
if (!keyId || !plaintextKey || assessed.step.outcome === "FAIL") return 1;
report(assessKeyListed(await operatorCall(session, opts.env, keysPath, "GET"), keyId));
// 3. Sender name
const patch = await operatorCall(
session,
opts.env,
mailboxApiPath(opts.mailboxId, `/api-keys/${encodeURIComponent(keyId)}`),
"PATCH",
{ senderName: opts.senderName },
);
const afterPatch = await operatorCall(session, opts.env, keysPath, "GET");
report(assessSenderName(patch, afterPatch, keyId, opts.senderName));
// 4. Real send
const payload = buildSendPayload(ids, opts.to);
const sendRequest = buildIntegratorRequest({
host,
mailboxId: opts.mailboxId,
apiKey: plaintextKey,
method: "POST",
idempotencyKey: ids.idempotencyKey,
body: payload,
});
const sent = assessSend(await integratorCall(sendRequest), secrets);
send = sent.send;
report(sent.step);
if (sent.step.outcome === "FAIL") return 1;
// 5. Replay: identical request, identical Idempotency-Key.
report(assessReplay(send, await integratorCall(sendRequest)));
// 6. Off-policy recipient, new Idempotency-Key: refused before anything is reserved.
report(
assessPolicyRejection(
await integratorCall(
buildIntegratorRequest({
host,
mailboxId: opts.mailboxId,
apiKey: plaintextKey,
method: "POST",
idempotencyKey: ids.rejectIdempotencyKey,
body: buildSendPayload(ids, POLICY_REJECT_ADDRESS),
}),
),
),
);
// 7. Status, and optionally wait for a delivery event.
const statusRequest = buildIntegratorRequest({
host,
mailboxId: opts.mailboxId,
apiKey: plaintextKey,
method: "GET",
requestId: send.requestId,
});
const firstStatus = await integratorCall(statusRequest);
report(assessStatus(firstStatus, send));
if (opts.waitDeliverySeconds > 0 && skipDeliveryWait) {
report(
step("delivery event", "SKIP", [
`not waited: ${opts.to} is a verified Email Routing destination address, which produces no Email Sending event (see the WARN above)`,
]),
);
} else if (opts.waitDeliverySeconds > 0 && firstStatus.status === 200) {
let view: StatusView = extractStatus(firstStatus.body);
const started = Date.now();
const deadline = started + opts.waitDeliverySeconds * 1000;
console.log(` waiting up to ${opts.waitDeliverySeconds}s for a delivery event...`);
while (!isTerminalDelivery(view) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, Math.min(POLL_INTERVAL_MS, deadline - Date.now())));
const polled = await integratorCall(statusRequest);
if (polled.status === 200) view = extractStatus(polled.body);
}
report(assessDelivery(view, Math.round((Date.now() - started) / 1000)));
} else if (opts.waitDeliverySeconds === 0) {
report(
step("delivery event", "SKIP", ["not waited (pass --wait-delivery <seconds> to poll)"]),
);
}
return hasFailure(results) ? 1 : 0;
} catch (error) {
report(step("unexpected error", "FAIL", [errorText(error)]));
return 1;
} finally {
// 8. Always clean up whatever was created.
const cleanup: StepResult[] = [];
if (keyId) {
try {
cleanup.push(
report(
assessRevoke(
await operatorCall(
session,
opts.env,
mailboxApiPath(opts.mailboxId, `/api-keys/${encodeURIComponent(keyId)}/revoke`),
"POST",
),
),
),
);
} catch (error) {
cleanup.push(report(step("cleanup: revoke key", "FAIL", [errorText(error)])));
}
}
if (templateCreated) {
try {
cleanup.push(
report(
assessArchive(
await operatorCall(
session,
opts.env,
mailboxApiPath(
opts.mailboxId,
`/templates/${encodeURIComponent(ids.templateId)}/archive`,
),
"POST",
),
ids.templateId,
),
),
);
} catch (error) {
cleanup.push(report(step("cleanup: archive template", "FAIL", [errorText(error)])));
}
}
if (hasFailure(cleanup)) {
const revokeFailed = cleanup.some((r) => r.name.includes("revoke") && r.outcome === "FAIL");
const archiveFailed = cleanup.some((r) => r.name.includes("archive") && r.outcome === "FAIL");
console.log("");
console.log(
`CLEANUP INCOMPLETE — key ${revokeFailed ? keyId : "(revoked)"}, template ${archiveFailed ? ids.templateId : "(archived)"}. ` +
"The key expires on its own within 1h and can only mail the --to address. Finish by hand:",
);
for (const line of manualCleanupCommands({
host,
env: opts.env,
mailboxId: opts.mailboxId,
keyId: revokeFailed ? keyId : undefined,
templateId: archiveFailed ? ids.templateId : undefined,
sessionPath: sessionPathFor(host),
})) {
console.log(` ${line}`);
}
}
console.log("");
if (send?.providerMessageId) {
console.log(`providerMessageId: ${send.providerMessageId}`);
console.log(manualCheckLine(opts, ids, send.providerMessageId));
}
console.log(summaryLine(results));
}
}
main().then(
(code) => {
process.exitCode = hasFailure(results) ? 1 : code;
},
(error: unknown) => {
const known = error instanceof SmokeInputError || error instanceof OperatorAuthError;
console.error(`smoke:transactional: ${errorText(error)}`);
if (error instanceof SmokeInputError) console.error(SMOKE_USAGE);
if (!known && error instanceof Error && error.name !== "OperatorInputError") {
console.error(redactSecrets(error.stack ?? "", secrets));
}
process.exitCode = 1;
},
);