@pithy-sh/cloudflare 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/aiManager.d.ts +101 -0
- package/dist/ai/aiManager.d.ts.map +1 -0
- package/dist/ai/aiManager.js +150 -0
- package/dist/ai/vectorizeManager.d.ts +69 -0
- package/dist/ai/vectorizeManager.d.ts.map +1 -0
- package/dist/ai/vectorizeManager.js +103 -0
- package/dist/ai/vectorizeProvisioner.d.ts +125 -0
- package/dist/ai/vectorizeProvisioner.d.ts.map +1 -0
- package/dist/ai/vectorizeProvisioner.js +197 -0
- package/dist/client/accounts.d.ts +50 -0
- package/dist/client/accounts.d.ts.map +1 -0
- package/dist/client/accounts.js +57 -0
- package/dist/client/clients.d.ts +124 -0
- package/dist/client/clients.d.ts.map +1 -0
- package/dist/client/clients.js +218 -0
- package/dist/client/errors.d.ts +75 -0
- package/dist/client/errors.d.ts.map +1 -0
- package/dist/client/errors.js +100 -0
- package/dist/client/manager.d.ts +47 -0
- package/dist/client/manager.d.ts.map +1 -0
- package/dist/client/manager.js +53 -0
- package/dist/d1/d1Manager.d.ts +76 -0
- package/dist/d1/d1Manager.d.ts.map +1 -0
- package/dist/d1/d1Manager.js +130 -0
- package/dist/d1/d1PreparedStatement.d.ts +26 -0
- package/dist/d1/d1PreparedStatement.d.ts.map +1 -0
- package/dist/d1/d1PreparedStatement.js +93 -0
- package/dist/d1/d1Provisioner.d.ts +30 -0
- package/dist/d1/d1Provisioner.d.ts.map +1 -0
- package/dist/d1/d1Provisioner.js +69 -0
- package/dist/email/emailRoutingManager.d.ts +38 -0
- package/dist/email/emailRoutingManager.d.ts.map +1 -0
- package/dist/email/emailRoutingManager.js +126 -0
- package/dist/email/emailSendManager.d.ts +47 -0
- package/dist/email/emailSendManager.d.ts.map +1 -0
- package/dist/email/emailSendManager.js +54 -0
- package/dist/env/devVars.d.ts +67 -0
- package/dist/env/devVars.d.ts.map +1 -0
- package/dist/env/devVars.js +86 -0
- package/dist/hostnames/customHostnamesManager.d.ts +53 -0
- package/dist/hostnames/customHostnamesManager.d.ts.map +1 -0
- package/dist/hostnames/customHostnamesManager.js +86 -0
- package/dist/kv/kvManager.d.ts +78 -0
- package/dist/kv/kvManager.d.ts.map +1 -0
- package/dist/kv/kvManager.js +137 -0
- package/dist/kv/kvProvisioner.d.ts +30 -0
- package/dist/kv/kvProvisioner.d.ts.map +1 -0
- package/dist/kv/kvProvisioner.js +73 -0
- package/dist/media/assetSeeder.d.ts +33 -0
- package/dist/media/assetSeeder.d.ts.map +1 -0
- package/dist/media/assetSeeder.js +72 -0
- package/dist/media/imageManager.d.ts +36 -0
- package/dist/media/imageManager.d.ts.map +1 -0
- package/dist/media/imageManager.js +105 -0
- package/dist/media/ownership.d.ts +42 -0
- package/dist/media/ownership.d.ts.map +1 -0
- package/dist/media/ownership.js +48 -0
- package/dist/media/streamManager.d.ts +85 -0
- package/dist/media/streamManager.d.ts.map +1 -0
- package/dist/media/streamManager.js +147 -0
- package/dist/queue/queueManager.d.ts +86 -0
- package/dist/queue/queueManager.d.ts.map +1 -0
- package/dist/queue/queueManager.js +116 -0
- package/dist/r2/r2Credentials.d.ts +14 -0
- package/dist/r2/r2Credentials.d.ts.map +1 -0
- package/dist/r2/r2Credentials.js +15 -0
- package/dist/r2/r2Manager.d.ts +228 -0
- package/dist/r2/r2Manager.d.ts.map +1 -0
- package/dist/r2/r2Manager.js +407 -0
- package/dist/r2/r2Provisioner.d.ts +41 -0
- package/dist/r2/r2Provisioner.d.ts.map +1 -0
- package/dist/r2/r2Provisioner.js +88 -0
- package/dist/secrets/secretsStoreManager.d.ts +84 -0
- package/dist/secrets/secretsStoreManager.d.ts.map +1 -0
- package/dist/secrets/secretsStoreManager.js +149 -0
- package/dist/secrets/secretsStores.d.ts +49 -0
- package/dist/secrets/secretsStores.d.ts.map +1 -0
- package/dist/secrets/secretsStores.js +67 -0
- package/dist/test-utils/emailRoutingRules.d.ts +41 -0
- package/dist/test-utils/emailRoutingRules.d.ts.map +1 -0
- package/dist/test-utils/emailRoutingRules.js +91 -0
- package/dist/test-utils/fixtureReportSetup.d.ts +27 -0
- package/dist/test-utils/fixtureReportSetup.d.ts.map +1 -0
- package/dist/test-utils/fixtureReportSetup.js +32 -0
- package/dist/test-utils/fixtures.d.ts +226 -0
- package/dist/test-utils/fixtures.d.ts.map +1 -0
- package/dist/test-utils/fixtures.js +279 -0
- package/dist/test-utils/harness.d.ts +194 -0
- package/dist/test-utils/harness.d.ts.map +1 -0
- package/dist/test-utils/harness.js +353 -0
- package/dist/test-utils/inboundRecorder.d.ts +93 -0
- package/dist/test-utils/inboundRecorder.d.ts.map +1 -0
- package/dist/test-utils/inboundRecorder.js +143 -0
- package/dist/test-utils/integrationSetup.d.ts +24 -0
- package/dist/test-utils/integrationSetup.d.ts.map +1 -0
- package/dist/test-utils/integrationSetup.js +39 -0
- package/dist/test-utils/reap.d.ts +114 -0
- package/dist/test-utils/reap.d.ts.map +1 -0
- package/dist/test-utils/reap.js +187 -0
- package/dist/tokens/accountTokensManager.d.ts +159 -0
- package/dist/tokens/accountTokensManager.d.ts.map +1 -0
- package/dist/tokens/accountTokensManager.js +282 -0
- package/dist/tokens/permissions.d.ts +38 -0
- package/dist/tokens/permissions.d.ts.map +1 -0
- package/dist/tokens/permissions.js +52 -0
- package/dist/tokens/profiles.d.ts +104 -0
- package/dist/tokens/profiles.d.ts.map +1 -0
- package/dist/tokens/profiles.js +139 -0
- package/dist/turnstile/turnstileManager.d.ts +77 -0
- package/dist/turnstile/turnstileManager.d.ts.map +1 -0
- package/dist/turnstile/turnstileManager.js +140 -0
- package/dist/user/userManager.d.ts +38 -0
- package/dist/user/userManager.d.ts.map +1 -0
- package/dist/user/userManager.js +60 -0
- package/dist/workers/buildsManager.d.ts +118 -0
- package/dist/workers/buildsManager.d.ts.map +1 -0
- package/dist/workers/buildsManager.js +239 -0
- package/dist/workers/buildsTypes.d.ts +104 -0
- package/dist/workers/buildsTypes.d.ts.map +1 -0
- package/dist/workers/buildsTypes.js +89 -0
- package/dist/workers/workersBuildEvent.d.ts +66 -0
- package/dist/workers/workersBuildEvent.d.ts.map +1 -0
- package/dist/workers/workersBuildEvent.js +49 -0
- package/dist/workers/workersManager.d.ts +138 -0
- package/dist/workers/workersManager.d.ts.map +1 -0
- package/dist/workers/workersManager.js +314 -0
- package/dist/workers/workersProvisioner.d.ts +82 -0
- package/dist/workers/workersProvisioner.d.ts.map +1 -0
- package/dist/workers/workersProvisioner.js +108 -0
- package/dist/workflows/stepFailure.d.ts +182 -0
- package/dist/workflows/stepFailure.d.ts.map +1 -0
- package/dist/workflows/stepFailure.js +229 -0
- package/dist/workflows/workflowsClient.d.ts +89 -0
- package/dist/workflows/workflowsClient.d.ts.map +1 -0
- package/dist/workflows/workflowsClient.js +158 -0
- package/dist/zones/zonesManager.d.ts +52 -0
- package/dist/zones/zonesManager.d.ts.map +1 -0
- package/dist/zones/zonesManager.js +76 -0
- package/package.json +18 -9
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { CloudflareInvalidResponseError, CloudflareRequestError } from "../client/errors.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/test-utils/inboundRecorder.ts
|
|
6
|
+
/**
|
|
7
|
+
* A throwaway Worker that records what its `email()` handler was handed, and the reader that fetches
|
|
8
|
+
* the record back out.
|
|
9
|
+
*
|
|
10
|
+
* ## Why a Worker has to exist at all
|
|
11
|
+
*
|
|
12
|
+
* An Email Routing rule cannot point at a Worker that is not there. Cloudflare rejects the rule with
|
|
13
|
+
* `2016 Workers Script Info not found` at creation time, so a live test of `ensureWorkerRoute` has to
|
|
14
|
+
* deploy a real script before it can create a real rule. That fact alone forces this file; everything
|
|
15
|
+
* else here is what the script may as well do once it is running.
|
|
16
|
+
*
|
|
17
|
+
* ## Why the record goes through KV
|
|
18
|
+
*
|
|
19
|
+
* There is no inbox. The fixture zone's catch-all drops and it has zero verified destination
|
|
20
|
+
* addresses, so nothing this suite sends can be read from a mailbox — and a test that waits for one
|
|
21
|
+
* waits forever. The Worker is the destination, so the Worker has to be the witness: it writes what it
|
|
22
|
+
* received to a KV namespace the test created, and the test reads it back over the REST API.
|
|
23
|
+
*
|
|
24
|
+
* ## What it deliberately does not do
|
|
25
|
+
*
|
|
26
|
+
* It does not classify, suppress, or parse MIME. Everything `@pithy-sh/email` actually does with an
|
|
27
|
+
* inbound message is unit-tested against real D1 in the Workers pool, where a hostile message can be
|
|
28
|
+
* constructed exactly rather than mailed and hoped for. What only a live delivery can answer is what
|
|
29
|
+
* Cloudflare *hands* the handler — which headers survive, which Cloudflare adds, and whether a
|
|
30
|
+
* sender's own headers arrive intact. So the recorder records, and asserts nothing.
|
|
31
|
+
*/
|
|
32
|
+
/** The KV binding the recorder writes through. */
|
|
33
|
+
const INBOUND_RECORDER_BINDING = "OBSERVED";
|
|
34
|
+
/** The header a sender stamps to address its record. One send, one key, so parallel runs cannot collide. */
|
|
35
|
+
const INBOUND_NONCE_HEADER = "X-Pithy-Probe";
|
|
36
|
+
/**
|
|
37
|
+
* The recorder's source, as a module string.
|
|
38
|
+
*
|
|
39
|
+
* Kept verbatim here rather than built from a template because it is uploaded as-is and read by whoever
|
|
40
|
+
* is debugging a delivery that did not arrive. It writes **once**, under the nonce it finds in the raw
|
|
41
|
+
* message, and it must never throw: a throw inside `email()` is a delivery failure Cloudflare retries,
|
|
42
|
+
* which turns one test message into several.
|
|
43
|
+
*/
|
|
44
|
+
const INBOUND_RECORDER_MODULE = `
|
|
45
|
+
const NONCE = /^${INBOUND_NONCE_HEADER}:[ \\t]*(\\S+)[ \\t]*$/im;
|
|
46
|
+
const AUTH_RESULTS = /^Authentication-Results:[ \\t]*([\\s\\S]*?)(?=\\r?\\n[^ \\t])/gim;
|
|
47
|
+
|
|
48
|
+
/** Header names in the raw block, in order, lowercased. Duplicates kept — two DKIM signatures are two facts. */
|
|
49
|
+
function rawHeaderNames(raw) {
|
|
50
|
+
const block = raw.split(/\\r?\\n\\r?\\n/)[0] ?? "";
|
|
51
|
+
return block
|
|
52
|
+
.split(/\\r?\\n/)
|
|
53
|
+
.filter((line) => /^[A-Za-z0-9-]+:/.test(line))
|
|
54
|
+
.map((line) => line.slice(0, line.indexOf(":")).toLowerCase());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default {
|
|
58
|
+
async email(message, env) {
|
|
59
|
+
let record;
|
|
60
|
+
let nonce = "unkeyed";
|
|
61
|
+
try {
|
|
62
|
+
const raw = await new Response(message.raw).text();
|
|
63
|
+
nonce = NONCE.exec(raw)?.[1] ?? "unkeyed";
|
|
64
|
+
record = {
|
|
65
|
+
envelopeFrom: message.from,
|
|
66
|
+
envelopeTo: message.to,
|
|
67
|
+
rawSize: message.rawSize,
|
|
68
|
+
seen: [...message.headers.keys()].map((name) => name.toLowerCase()).sort(),
|
|
69
|
+
rawHeaderNames: rawHeaderNames(raw),
|
|
70
|
+
authenticationResults: [...raw.matchAll(AUTH_RESULTS)].map((match) => match[1].trim()),
|
|
71
|
+
raw: raw.slice(0, 16384),
|
|
72
|
+
};
|
|
73
|
+
} catch (error) {
|
|
74
|
+
record = { failure: String(error) };
|
|
75
|
+
}
|
|
76
|
+
await env.${INBOUND_RECORDER_BINDING}.put(nonce, JSON.stringify(record), { expirationTtl: 3600 });
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
`;
|
|
80
|
+
/**
|
|
81
|
+
* What the recorder saw — validated on the way back in, because a KV read is a boundary like any other.
|
|
82
|
+
*
|
|
83
|
+
* The Worker that wrote it is ours, which is exactly the argument that has let every unvalidated
|
|
84
|
+
* internal read through. A truncated write or a stale record from an older recorder deserves a Zod
|
|
85
|
+
* failure naming the field, not a `TypeError` three assertions later.
|
|
86
|
+
*/
|
|
87
|
+
const ObservedInbound = z.object({
|
|
88
|
+
envelopeFrom: z.string().describe("The SMTP envelope sender, as `message.from` reported it."),
|
|
89
|
+
envelopeTo: z.string().describe("The SMTP envelope recipient, as `message.to` reported it."),
|
|
90
|
+
rawSize: z.number().describe("The message size in bytes, as `message.rawSize` reported it."),
|
|
91
|
+
seen: z.array(z.string()).describe("Header names on `message.headers`, lowercased and sorted — the surface workerd#6740 is about."),
|
|
92
|
+
rawHeaderNames: z.array(z.string()).describe("Header names in the raw MIME block, lowercased, in wire order, duplicates kept."),
|
|
93
|
+
authenticationResults: z.array(z.string()).describe("Every `Authentication-Results` value in the raw block, in wire order: the topmost is the receiver's."),
|
|
94
|
+
raw: z.string().describe("The delivered message, truncated to the first 16 KiB.")
|
|
95
|
+
}).describe("One inbound message as the Worker's `email()` handler received it.");
|
|
96
|
+
/** A trivial module Worker: enough for an Email Routing rule to be allowed to point at it. */
|
|
97
|
+
const PLACEHOLDER_INBOUND_MODULE = "export default { async email() {} };\n";
|
|
98
|
+
/** The module filename every throwaway Worker here is uploaded under. */
|
|
99
|
+
const INBOUND_MODULE_NAME = "worker.mjs";
|
|
100
|
+
/**
|
|
101
|
+
* Deploy the recorder at `scriptName`, bound to the KV namespace records are written to.
|
|
102
|
+
*
|
|
103
|
+
* Uploaded through `CloudflareWorkersManager.createWorker` like anything else. It briefly was not:
|
|
104
|
+
* #373 was a manager that could not upload a module to the live API at all, and this file carried a
|
|
105
|
+
* hand-rolled `fetch` around it. The manager is the path again — a helper that exists because the
|
|
106
|
+
* product is broken is the thing people copy.
|
|
107
|
+
*/
|
|
108
|
+
async function deployInboundRecorder(options) {
|
|
109
|
+
const { workers, scriptName, namespaceId, compatibilityDate } = options;
|
|
110
|
+
await workers.createWorker(scriptName, compatibilityDate, { bindings: [{
|
|
111
|
+
type: "kv_namespace",
|
|
112
|
+
name: INBOUND_RECORDER_BINDING,
|
|
113
|
+
namespace_id: namespaceId
|
|
114
|
+
}] }, {
|
|
115
|
+
name: INBOUND_MODULE_NAME,
|
|
116
|
+
body: INBOUND_RECORDER_MODULE
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The record for one nonce, or null while nothing has arrived.
|
|
121
|
+
*
|
|
122
|
+
* Null rather than a throw for the missing case, because "not yet" is the normal state of a mail
|
|
123
|
+
* delivery: the caller polls. A 404 from KV is that state; anything else is a real failure and throws.
|
|
124
|
+
*/
|
|
125
|
+
async function readObservedInbound(options) {
|
|
126
|
+
const { creds, namespaceId, nonce } = options;
|
|
127
|
+
const url = `https://api.cloudflare.com/client/v4/accounts/${creds.accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(nonce)}`;
|
|
128
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${creds.apiToken}` } });
|
|
129
|
+
if (response.status === 404) return null;
|
|
130
|
+
if (!response.ok) throw new CloudflareRequestError({
|
|
131
|
+
message: "Could not read the recorded inbound message.",
|
|
132
|
+
action: "Check the token carries Workers KV Storage: Edit on this account.",
|
|
133
|
+
detail: `KV read for the inbound record returned ${response.status}.`
|
|
134
|
+
});
|
|
135
|
+
const body = JSON.parse(await response.text());
|
|
136
|
+
if (typeof body === "object" && body !== null && "failure" in body) throw new CloudflareInvalidResponseError({
|
|
137
|
+
message: "The inbound recorder could not read the message it was handed.",
|
|
138
|
+
detail: `The recorder Worker recorded a failure: ${String(body.failure)}`
|
|
139
|
+
});
|
|
140
|
+
return ObservedInbound.parse(body);
|
|
141
|
+
}
|
|
142
|
+
//#endregion
|
|
143
|
+
export { INBOUND_MODULE_NAME, INBOUND_NONCE_HEADER, INBOUND_RECORDER_BINDING, INBOUND_RECORDER_MODULE, ObservedInbound, PLACEHOLDER_INBOUND_MODULE, deployInboundRecorder, readObservedInbound };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* The Vitest `globalSetup` every `vitest.integration.config.ts` points at — one fixture report and one
|
|
5
|
+
* debris sweep per integration run, before a single suite is collected.
|
|
6
|
+
*
|
|
7
|
+
* **A `globalSetup` is the only place either can correctly live.** Reaping used to happen in a suite's
|
|
8
|
+
* `beforeAll`, and Vitest runs no hooks inside a `describe.skipIf(true)` — so each reaper was gated on
|
|
9
|
+
* exactly the credential whose absence lets debris pile up, and a package with no live suite of its own
|
|
10
|
+
* reaped nothing however much it created. `globalSetup` runs before collection, so no suite's gate can
|
|
11
|
+
* switch it off, and it runs once per project rather than once per file.
|
|
12
|
+
*
|
|
13
|
+
* The fixture report is the same argument about a different thing. A suite that skips for want of a
|
|
14
|
+
* Turnstile widget prints "skipped" and nothing else, and the one place that can say *which* fixture and
|
|
15
|
+
* *where to make it* is a place no suite's gate reaches. So it runs first, and it runs **before the
|
|
16
|
+
* credentials check** — a contributor with no account is exactly who needs to be told why the run went
|
|
17
|
+
* quiet, and gating the explanation on the thing being explained is how it goes silent for them.
|
|
18
|
+
*
|
|
19
|
+
* Never throws. Housekeeping that fails the run it was meant to help is worse than the debris: a missing
|
|
20
|
+
* or unprivileged token must skip the sweep and let the suites make their own skip decision, exactly as
|
|
21
|
+
* they did before.
|
|
22
|
+
*/
|
|
23
|
+
export default function setup(): Promise<void>;
|
|
24
|
+
//# sourceMappingURL=integrationSetup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"integrationSetup.d.ts","sourceRoot":"","sources":["../../src/test-utils/integrationSetup.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAA8B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAkBnD"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { loadIntegrationCreds } from "./harness.js";
|
|
4
|
+
import { fixtureValue, reportFixtureEstate, resolveFixture } from "./fixtures.js";
|
|
5
|
+
import { reapAllStaleTestResources } from "./reap.js";
|
|
6
|
+
//#region src/test-utils/integrationSetup.ts
|
|
7
|
+
/**
|
|
8
|
+
* The Vitest `globalSetup` every `vitest.integration.config.ts` points at — one fixture report and one
|
|
9
|
+
* debris sweep per integration run, before a single suite is collected.
|
|
10
|
+
*
|
|
11
|
+
* **A `globalSetup` is the only place either can correctly live.** Reaping used to happen in a suite's
|
|
12
|
+
* `beforeAll`, and Vitest runs no hooks inside a `describe.skipIf(true)` — so each reaper was gated on
|
|
13
|
+
* exactly the credential whose absence lets debris pile up, and a package with no live suite of its own
|
|
14
|
+
* reaped nothing however much it created. `globalSetup` runs before collection, so no suite's gate can
|
|
15
|
+
* switch it off, and it runs once per project rather than once per file.
|
|
16
|
+
*
|
|
17
|
+
* The fixture report is the same argument about a different thing. A suite that skips for want of a
|
|
18
|
+
* Turnstile widget prints "skipped" and nothing else, and the one place that can say *which* fixture and
|
|
19
|
+
* *where to make it* is a place no suite's gate reaches. So it runs first, and it runs **before the
|
|
20
|
+
* credentials check** — a contributor with no account is exactly who needs to be told why the run went
|
|
21
|
+
* quiet, and gating the explanation on the thing being explained is how it goes silent for them.
|
|
22
|
+
*
|
|
23
|
+
* Never throws. Housekeeping that fails the run it was meant to help is worse than the debris: a missing
|
|
24
|
+
* or unprivileged token must skip the sweep and let the suites make their own skip decision, exactly as
|
|
25
|
+
* they did before.
|
|
26
|
+
*/
|
|
27
|
+
async function setup() {
|
|
28
|
+
reportFixtureEstate();
|
|
29
|
+
const creds = loadIntegrationCreds();
|
|
30
|
+
if (!creds.hasCreds) return;
|
|
31
|
+
const emailRoutingZoneId = resolveFixture("email-routing").ready ? fixtureValue("email-routing", "EMAIL_ROUTING_ZONE_ID") : void 0;
|
|
32
|
+
try {
|
|
33
|
+
await reapAllStaleTestResources(creds, { emailRoutingZoneId });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.warn(`stale test resources could not be swept: ${error instanceof Error ? error.message : String(error)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { setup as default };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { type IntegrationCreds, type ReapableKind, testResourceAge } from "./harness";
|
|
4
|
+
/**
|
|
5
|
+
* The reap registry: every throwaway resource kind a live suite can mint, in one place, swept once per
|
|
6
|
+
* integration run.
|
|
7
|
+
*
|
|
8
|
+
* ## Why this exists, and why per-suite reaping was never enough
|
|
9
|
+
*
|
|
10
|
+
* {@link ReapableKind} and `reapStaleTestResources` are generic — they know how to reap *a* kind, and
|
|
11
|
+
* nothing about which kinds exist. So a kind was reaped only where some suite happened to hand the
|
|
12
|
+
* reaper a `list`/`remove` pair, and five such call sites covered the whole repo. Secrets Store
|
|
13
|
+
* entries, Queues, and API tokens had none at all, which is why eight `pithy-int-secret-…` entries sat
|
|
14
|
+
* on a real account with nothing in the repo able to reclaim them, ever.
|
|
15
|
+
*
|
|
16
|
+
* Two failures compound, and the second is the one that makes per-suite reaping structurally wrong.
|
|
17
|
+
*
|
|
18
|
+
* **A reaper registered in a suite's `beforeAll` does not run when that suite skips.** Every live suite
|
|
19
|
+
* is a `describe.skipIf(...)`, and Vitest runs no hooks inside a skipped suite. So the reaper is gated
|
|
20
|
+
* on exactly the credential whose absence lets debris accumulate: without `R2_CREDENTIALS` the R2
|
|
21
|
+
* suite skips, and because D1 reaping lived inside `@pithy-sh/storage`'s bundle, the **D1** reaper went
|
|
22
|
+
* offline with it. A reaper must never be gated on the same condition as the suite that dirties the
|
|
23
|
+
* account.
|
|
24
|
+
*
|
|
25
|
+
* **And reaping was per-suite, not per-run.** `test:integration --filter @pithy-sh/vector` mints
|
|
26
|
+
* Vectorize indexes and reaps nothing, because the only index reaper lived in `@pithy-sh/cloudflare`.
|
|
27
|
+
* Cross-package coverage was accidental.
|
|
28
|
+
*
|
|
29
|
+
* So the sweep moved to a Vitest `globalSetup` (see `integrationSetup.ts`), which runs once per project
|
|
30
|
+
* before any suite is collected and cannot be skipped by a suite's gate. A new live suite inherits
|
|
31
|
+
* cleanup instead of remembering to arrange it.
|
|
32
|
+
*
|
|
33
|
+
* ## What is deliberately still not reaped
|
|
34
|
+
*
|
|
35
|
+
* Resources composed under {@link RESERVED_TEST_PROJECT} — `pithy provision --feature`'s ephemeral D1 and
|
|
36
|
+
* KV, which a live test provisions through the *product's* namer because the names are the thing under
|
|
37
|
+
* test. `testResourceAge` cannot read an age out of `pithy-int-test-dev-73-slug-db`, and returns null
|
|
38
|
+
* rather than guessing. That conservatism is correct and stays: failing to clean up costs pennies, and
|
|
39
|
+
* deleting a resource a running suite still owns turns a green run red for reasons nobody can see. Those
|
|
40
|
+
* suites tear down in an unconditional `afterAll`; an interrupted run leaves debris that must be removed
|
|
41
|
+
* by hand. `CONTRIBUTING.md` says so out loud rather than implying the sweep covers everything.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* What this run is allowed to sweep beyond the account itself.
|
|
45
|
+
*
|
|
46
|
+
* Every other kind is account-scoped: the credentials name the account, and the account holds the
|
|
47
|
+
* debris. An Email Routing rule is not — it lives on a **zone**, and a sweep that guessed which zone
|
|
48
|
+
* would be editing mail delivery on a domain nobody pointed it at. So the zone is passed in, from the
|
|
49
|
+
* fixture that declared it, and a run that was told nothing sweeps nothing.
|
|
50
|
+
*/
|
|
51
|
+
export interface ReapScope {
|
|
52
|
+
/** The zone whose `pithy-int-` routing rules may be reclaimed. Absent means the kind reports itself skipped. */
|
|
53
|
+
emailRoutingZoneId?: string;
|
|
54
|
+
}
|
|
55
|
+
/** A kind that cannot be reaped this run, and the credential it is waiting on. */
|
|
56
|
+
export interface SkippedReapKind {
|
|
57
|
+
/** The human label, matching the kind it stands in for. */
|
|
58
|
+
label: string;
|
|
59
|
+
/** Why it was skipped — names the missing variable, so the report is actionable. */
|
|
60
|
+
skipped: string;
|
|
61
|
+
}
|
|
62
|
+
/** One entry in a reap plan: a kind that can be swept, or a named reason it cannot. */
|
|
63
|
+
export type ReapPlanEntry = (ReapableKind & {
|
|
64
|
+
label: string;
|
|
65
|
+
}) | SkippedReapKind;
|
|
66
|
+
/** What one kind's sweep did. `skipped` is non-null only when the kind never ran. */
|
|
67
|
+
export interface ReapKindResult {
|
|
68
|
+
/** The kind's label, as it appears in the plan. */
|
|
69
|
+
label: string;
|
|
70
|
+
/** Names successfully removed. */
|
|
71
|
+
reaped: string[];
|
|
72
|
+
/** Names that matched but could not be removed — reported, never swallowed. */
|
|
73
|
+
failed: string[];
|
|
74
|
+
/** The reason this kind did not run, or null when it did. */
|
|
75
|
+
skipped: string | null;
|
|
76
|
+
}
|
|
77
|
+
/** Every label in a plan, in plan order — what a test asserts against so a dropped kind is visible. */
|
|
78
|
+
export declare function reapPlanLabels(plan: readonly ReapPlanEntry[]): string[];
|
|
79
|
+
/**
|
|
80
|
+
* Sweep every kind in a plan and report what went.
|
|
81
|
+
*
|
|
82
|
+
* Never throws, and never lets one kind's failure end the sweep: a reaper exists to help a suite, and a
|
|
83
|
+
* housekeeping error that fails the run is worse than the debris it was cleaning. A listing that throws
|
|
84
|
+
* yields an empty result for that kind; a removal that throws lands in `failed`, where it is visible.
|
|
85
|
+
*/
|
|
86
|
+
export declare function reapKinds(plan: readonly ReapPlanEntry[], options?: {
|
|
87
|
+
now?: number;
|
|
88
|
+
staleAfterMs?: number;
|
|
89
|
+
}): Promise<ReapKindResult[]>;
|
|
90
|
+
/**
|
|
91
|
+
* Build the sweep plan for an account.
|
|
92
|
+
*
|
|
93
|
+
* Alphabetical by label, so the set is readable as a list rather than as an accident of construction
|
|
94
|
+
* order, and so a test can pin it. Every kind appears whether or not it can run — a kind that is missing
|
|
95
|
+
* a credential reports *why*, because "nothing to reap" and "unable to reap" look identical in a log and
|
|
96
|
+
* only one of them is fine.
|
|
97
|
+
*
|
|
98
|
+
* R2 is the one kind an API token cannot reclaim: Cloudflare refuses to delete a non-empty bucket, and
|
|
99
|
+
* emptying one is an S3-protocol operation. So it needs the key pair, and says so when it lacks it.
|
|
100
|
+
*/
|
|
101
|
+
export declare function testResourceReapPlan(creds: IntegrationCreds, options?: ReapScope): ReapPlanEntry[];
|
|
102
|
+
/**
|
|
103
|
+
* Reclaim every stale throwaway resource on the account, across every kind.
|
|
104
|
+
*
|
|
105
|
+
* The entry point a `globalSetup` calls. Reports one line per kind that did something or could not run;
|
|
106
|
+
* a kind with nothing to do stays quiet, because a clean account should produce a clean log.
|
|
107
|
+
*/
|
|
108
|
+
export declare function reapAllStaleTestResources(creds: IntegrationCreds, options?: ReapScope & {
|
|
109
|
+
now?: number;
|
|
110
|
+
staleAfterMs?: number;
|
|
111
|
+
}): Promise<ReapKindResult[]>;
|
|
112
|
+
/** Re-exported so a caller reasoning about one name does not need two imports. */
|
|
113
|
+
export { testResourceAge };
|
|
114
|
+
//# sourceMappingURL=reap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reap.d.ts","sourceRoot":"","sources":["../../src/test-utils/reap.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,YAAY,EAEjB,eAAe,EAChB,MAAM,WAAW,CAAC;AAEnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,gHAAgH;IAChH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,kFAAkF;AAClF,MAAM,WAAW,eAAe;IAC9B,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,uFAAuF;AACvF,MAAM,MAAM,aAAa,GAAG,CAAC,YAAY,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,eAAe,CAAC;AAEjF,qFAAqF;AACrF,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,kCAAkC;IAClC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,+EAA+E;IAC/E,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,6DAA6D;IAC7D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAOD,uGAAuG;AACvG,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,aAAa,EAAE,GAAG,MAAM,EAAE,CAEvE;AAED;;;;;;GAMG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,SAAS,aAAa,EAAE,EAC9B,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAO,GACpD,OAAO,CAAC,cAAc,EAAE,CAAC,CA+B3B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,GAAE,SAAc,GAAG,aAAa,EAAE,CAkHtG;AAED;;;;;GAKG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,gBAAgB,EACvB,OAAO,GAAE,SAAS,GAAG;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAO,GAChE,OAAO,CAAC,cAAc,EAAE,CAAC,CAc3B;AAED,kFAAkF;AAClF,OAAO,EAAE,eAAe,EAAE,CAAC"}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { CloudflareClients } from "../client/clients.js";
|
|
4
|
+
import { listEmailRoutingRules, namedRules } from "./emailRoutingRules.js";
|
|
5
|
+
import { emptyTestBucket, staleTestResourceNames, testResourceAge } from "./harness.js";
|
|
6
|
+
import { Cloudflare } from "cloudflare";
|
|
7
|
+
//#region src/test-utils/reap.ts
|
|
8
|
+
/** Whether a plan entry is a stand-in for a kind that cannot run. */
|
|
9
|
+
function isSkipped(entry) {
|
|
10
|
+
return "skipped" in entry;
|
|
11
|
+
}
|
|
12
|
+
/** Every label in a plan, in plan order — what a test asserts against so a dropped kind is visible. */
|
|
13
|
+
function reapPlanLabels(plan) {
|
|
14
|
+
return plan.map((entry) => entry.label);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Sweep every kind in a plan and report what went.
|
|
18
|
+
*
|
|
19
|
+
* Never throws, and never lets one kind's failure end the sweep: a reaper exists to help a suite, and a
|
|
20
|
+
* housekeeping error that fails the run is worse than the debris it was cleaning. A listing that throws
|
|
21
|
+
* yields an empty result for that kind; a removal that throws lands in `failed`, where it is visible.
|
|
22
|
+
*/
|
|
23
|
+
async function reapKinds(plan, options = {}) {
|
|
24
|
+
const results = [];
|
|
25
|
+
for (const entry of plan) {
|
|
26
|
+
if (isSkipped(entry)) {
|
|
27
|
+
results.push({
|
|
28
|
+
label: entry.label,
|
|
29
|
+
reaped: [],
|
|
30
|
+
failed: [],
|
|
31
|
+
skipped: entry.skipped
|
|
32
|
+
});
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const reaped = [];
|
|
36
|
+
const failed = [];
|
|
37
|
+
let names;
|
|
38
|
+
try {
|
|
39
|
+
names = await entry.list();
|
|
40
|
+
} catch {
|
|
41
|
+
results.push({
|
|
42
|
+
label: entry.label,
|
|
43
|
+
reaped,
|
|
44
|
+
failed,
|
|
45
|
+
skipped: null
|
|
46
|
+
});
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const name of staleTestResourceNames(names, options)) try {
|
|
50
|
+
await entry.remove(name);
|
|
51
|
+
reaped.push(name);
|
|
52
|
+
} catch {
|
|
53
|
+
failed.push(name);
|
|
54
|
+
}
|
|
55
|
+
results.push({
|
|
56
|
+
label: entry.label,
|
|
57
|
+
reaped,
|
|
58
|
+
failed,
|
|
59
|
+
skipped: null
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build the sweep plan for an account.
|
|
66
|
+
*
|
|
67
|
+
* Alphabetical by label, so the set is readable as a list rather than as an accident of construction
|
|
68
|
+
* order, and so a test can pin it. Every kind appears whether or not it can run — a kind that is missing
|
|
69
|
+
* a credential reports *why*, because "nothing to reap" and "unable to reap" look identical in a log and
|
|
70
|
+
* only one of them is fine.
|
|
71
|
+
*
|
|
72
|
+
* R2 is the one kind an API token cannot reclaim: Cloudflare refuses to delete a non-empty bucket, and
|
|
73
|
+
* emptying one is an S3-protocol operation. So it needs the key pair, and says so when it lacks it.
|
|
74
|
+
*/
|
|
75
|
+
function testResourceReapPlan(creds, options = {}) {
|
|
76
|
+
const clients = new CloudflareClients({
|
|
77
|
+
accountId: creds.accountId,
|
|
78
|
+
apiToken: creds.apiToken
|
|
79
|
+
});
|
|
80
|
+
const sdk = new Cloudflare({ apiToken: creds.apiToken });
|
|
81
|
+
const workers = clients.workers();
|
|
82
|
+
const tokens = clients.accountTokens();
|
|
83
|
+
const d1 = clients.d1Provisioner();
|
|
84
|
+
const kv = clients.kvProvisioner();
|
|
85
|
+
const vectorize = clients.vectorizeProvisioner();
|
|
86
|
+
const r2 = clients.r2Provisioner();
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
label: "API token",
|
|
90
|
+
list: async () => (await tokens.listTokens()).map((token) => token.name),
|
|
91
|
+
remove: async (name) => {
|
|
92
|
+
await tokens.deleteTokensByName(name);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: "D1 database",
|
|
97
|
+
list: async () => (await d1.listDatabases()).map((database) => database.name),
|
|
98
|
+
remove: async (name) => {
|
|
99
|
+
const found = (await d1.listDatabases()).find((database) => database.name === name);
|
|
100
|
+
if (found) await d1.deleteDatabase(found.uuid);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
options.emailRoutingZoneId ? {
|
|
104
|
+
label: "Email Routing rule",
|
|
105
|
+
list: async () => namedRules(await listEmailRoutingRules(creds, options.emailRoutingZoneId ?? "")).map((rule) => rule.name),
|
|
106
|
+
remove: async (name) => {
|
|
107
|
+
await clients.emailRouting().removeWorkerRoute({
|
|
108
|
+
zoneId: options.emailRoutingZoneId ?? "",
|
|
109
|
+
ruleName: name
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
} : {
|
|
113
|
+
label: "Email Routing rule",
|
|
114
|
+
skipped: "no EMAIL_ROUTING_ZONE_ID: the zone to sweep is unknown."
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
label: "KV namespace",
|
|
118
|
+
list: async () => (await kv.listNamespaces()).map((namespace) => namespace.title),
|
|
119
|
+
remove: async (title) => {
|
|
120
|
+
const found = (await kv.listNamespaces()).find((namespace) => namespace.title === title);
|
|
121
|
+
if (found) await kv.deleteNamespace(found.id);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
label: "Queue",
|
|
126
|
+
list: async () => {
|
|
127
|
+
const names = [];
|
|
128
|
+
for await (const queue of sdk.queues.list({ account_id: creds.accountId })) if (queue.queue_name) names.push(queue.queue_name);
|
|
129
|
+
return names;
|
|
130
|
+
},
|
|
131
|
+
remove: async (name) => {
|
|
132
|
+
for await (const queue of sdk.queues.list({ account_id: creds.accountId })) if (queue.queue_name === name && queue.queue_id) {
|
|
133
|
+
await sdk.queues.delete(queue.queue_id, { account_id: creds.accountId });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
creds.r2 ? {
|
|
139
|
+
label: "R2 bucket",
|
|
140
|
+
list: async () => (await r2.listBuckets()).map((bucket) => bucket.name),
|
|
141
|
+
remove: async (name) => {
|
|
142
|
+
await emptyTestBucket(creds, name);
|
|
143
|
+
await r2.deleteBucket(name);
|
|
144
|
+
}
|
|
145
|
+
} : {
|
|
146
|
+
label: "R2 bucket",
|
|
147
|
+
skipped: "no R2_CREDENTIALS: a bucket cannot be emptied, and R2 refuses to delete a non-empty one."
|
|
148
|
+
},
|
|
149
|
+
creds.secretsStoreId ? {
|
|
150
|
+
label: "Secrets Store entry",
|
|
151
|
+
list: async () => (await clients.secrets(creds.secretsStoreId).listSecrets()).map((entry) => entry.name),
|
|
152
|
+
remove: async (name) => {
|
|
153
|
+
await clients.secrets(creds.secretsStoreId).deleteSecretIfPresent(name);
|
|
154
|
+
}
|
|
155
|
+
} : {
|
|
156
|
+
label: "Secrets Store entry",
|
|
157
|
+
skipped: "no SECRETS_STORE_ID: the store to sweep is unknown."
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
label: "Vectorize index",
|
|
161
|
+
list: async () => (await vectorize.listIndexes()).map((index) => index.name),
|
|
162
|
+
remove: (name) => vectorize.deleteIndex(name)
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
label: "Worker script",
|
|
166
|
+
list: async () => (await workers.listWorkers()).map((script) => script.id ?? "").filter(Boolean),
|
|
167
|
+
remove: (name) => workers.deleteWorker(name)
|
|
168
|
+
}
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reclaim every stale throwaway resource on the account, across every kind.
|
|
173
|
+
*
|
|
174
|
+
* The entry point a `globalSetup` calls. Reports one line per kind that did something or could not run;
|
|
175
|
+
* a kind with nothing to do stays quiet, because a clean account should produce a clean log.
|
|
176
|
+
*/
|
|
177
|
+
async function reapAllStaleTestResources(creds, options = {}) {
|
|
178
|
+
const results = await reapKinds(testResourceReapPlan(creds, options), options);
|
|
179
|
+
for (const result of results) {
|
|
180
|
+
if (result.skipped) console.warn(`stale ${result.label}(s) were not swept: ${result.skipped}`);
|
|
181
|
+
if (result.reaped.length > 0) console.warn(`reaped ${result.reaped.length} stale ${result.label}(s): ${result.reaped.join(", ")}`);
|
|
182
|
+
if (result.failed.length > 0) console.warn(`could not reap ${result.failed.length} stale ${result.label}(s): ${result.failed.join(", ")}`);
|
|
183
|
+
}
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
export { reapAllStaleTestResources, reapKinds, reapPlanLabels, testResourceAge, testResourceReapPlan };
|