@supacloud/cli 0.108.1 → 0.108.2
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/index.js +126 -93
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -27016,125 +27016,152 @@ import { ${className}Fixture } from "./${name}.fixture";
|
|
|
27016
27016
|
const baseUrl = process.env["SUPACLOUD_FEATURE_E2E_URL"];
|
|
27017
27017
|
const authorization = process.env["SUPACLOUD_FEATURE_E2E_AUTHORIZATION"];
|
|
27018
27018
|
const otherAuthorization = process.env["SUPACLOUD_FEATURE_E2E_OTHER_AUTHORIZATION"];
|
|
27019
|
-
const fixtureId = process.env["SUPACLOUD_FEATURE_E2E_ID"]
|
|
27019
|
+
const fixtureId = process.env["SUPACLOUD_FEATURE_E2E_ID"];
|
|
27020
|
+
const raceId = process.env["SUPACLOUD_FEATURE_E2E_RACE_ID"];
|
|
27020
27021
|
const idempotencyKey = process.env["SUPACLOUD_FEATURE_E2E_IDEMPOTENCY_KEY"];
|
|
27021
|
-
const
|
|
27022
|
-
const
|
|
27023
|
-
|
|
27024
|
-
|
|
27025
|
-
|
|
27026
|
-
|
|
27027
|
-
|
|
27028
|
-
|
|
27029
|
-
|
|
27022
|
+
const required = process.env["SUPACLOUD_FEATURE_E2E_REQUIRED"] === "1";
|
|
27023
|
+
const enabled = required || [baseUrl, authorization, otherAuthorization, fixtureId, raceId, idempotencyKey]
|
|
27024
|
+
.some(value => value !== undefined);
|
|
27025
|
+
|
|
27026
|
+
function requireConfig() {
|
|
27027
|
+
if (![baseUrl, authorization, otherAuthorization, fixtureId, raceId, idempotencyKey]
|
|
27028
|
+
.every(value => typeof value === "string" && value.trim().length > 0)) {
|
|
27029
|
+
throw new Error("Feature acceptance requires URL, AUTHORIZATION, OTHER_AUTHORIZATION, ID, RACE_ID and IDEMPOTENCY_KEY (SUPACLOUD_FEATURE_E2E_*).");
|
|
27030
|
+
}
|
|
27031
|
+
if (fixtureId === raceId) throw new Error("Feature acceptance requires two distinct draft fixtures.");
|
|
27032
|
+
const url = new URL(baseUrl!);
|
|
27033
|
+
if (url.username || url.password || url.search || url.hash
|
|
27034
|
+
|| (url.protocol !== "https:" && !(url.protocol === "http:"
|
|
27035
|
+
&& ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))) {
|
|
27036
|
+
throw new Error("Feature acceptance requires HTTPS or a loopback HTTP endpoint without embedded credentials.");
|
|
27030
27037
|
}
|
|
27031
|
-
return { baseUrl, authorization
|
|
27038
|
+
return { baseUrl: url, authorization: authorization!, otherAuthorization: otherAuthorization!,
|
|
27039
|
+
fixtureId: fixtureId!, raceId: raceId!, idempotencyKey: idempotencyKey! };
|
|
27032
27040
|
}
|
|
27033
27041
|
|
|
27034
|
-
|
|
27035
|
-
|
|
27036
|
-
|
|
27037
|
-
|
|
27038
|
-
return JSON.parse(text) as unknown;
|
|
27039
|
-
} catch {
|
|
27040
|
-
return text;
|
|
27041
|
-
}
|
|
27042
|
+
function request(config: ReturnType<typeof requireConfig>, path: string, init: RequestInit = {}) {
|
|
27043
|
+
return fetch(new URL(path, config.baseUrl), {
|
|
27044
|
+
...init, redirect: "error", signal: AbortSignal.timeout(10_000),
|
|
27045
|
+
});
|
|
27042
27046
|
}
|
|
27043
27047
|
|
|
27044
|
-
function
|
|
27045
|
-
|
|
27048
|
+
async function object(response: Response): Promise<Record<string, unknown>> {
|
|
27049
|
+
const value: unknown = await response.json();
|
|
27050
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
27051
|
+
throw new Error("Feature acceptance expected a JSON object.");
|
|
27052
|
+
}
|
|
27053
|
+
return value as Record<string, unknown>;
|
|
27046
27054
|
}
|
|
27047
27055
|
|
|
27048
|
-
test.skipIf(!enabled)("${name} authenticated
|
|
27056
|
+
test.skipIf(!enabled)("${name} authenticated approval, replay, recovery and concurrency acceptance", async () => {
|
|
27049
27057
|
const config = requireConfig();
|
|
27050
27058
|
const headers = { authorization: config.authorization, "content-type": "application/json" };
|
|
27051
|
-
const
|
|
27059
|
+
const path = (id: string) => "/${name}/" + encodeURIComponent(id);
|
|
27060
|
+
const list = await request(config, "/${name}", { headers });
|
|
27052
27061
|
expect(list.ok).toBe(true);
|
|
27053
|
-
const
|
|
27054
|
-
|
|
27055
|
-
|
|
27056
|
-
);
|
|
27062
|
+
const listed: unknown = await list.json();
|
|
27063
|
+
expect(Array.isArray(listed)).toBe(true);
|
|
27064
|
+
expect(listed).toContainEqual(expect.objectContaining({ id: config.fixtureId }));
|
|
27065
|
+
const detail = await request(config, path(config.fixtureId), { headers });
|
|
27057
27066
|
expect(detail.ok).toBe(true);
|
|
27058
|
-
const
|
|
27059
|
-
|
|
27067
|
+
const before = await object(detail);
|
|
27068
|
+
expect(before).toMatchObject({ id: config.fixtureId, state: "draft" });
|
|
27069
|
+
expect(Number.isSafeInteger(before["version"])).toBe(true);
|
|
27070
|
+
expect(Number(before["version"])).toBeGreaterThanOrEqual(1);
|
|
27071
|
+
const body = { ...${className}Fixture.approve, expectedVersion: Number(before["version"]) };
|
|
27072
|
+
const approve = (id: string, key: string, input = body) => request(
|
|
27073
|
+
config, path(id) + "/approve",
|
|
27060
27074
|
{
|
|
27061
|
-
method: "POST",
|
|
27062
|
-
|
|
27063
|
-
body: JSON.stringify(body),
|
|
27075
|
+
method: "POST", headers: { ...headers, "idempotency-key": key },
|
|
27076
|
+
body: JSON.stringify(input),
|
|
27064
27077
|
},
|
|
27065
27078
|
);
|
|
27066
|
-
const first = await approve(config.idempotencyKey);
|
|
27067
|
-
|
|
27068
|
-
|
|
27069
|
-
|
|
27070
|
-
|
|
27071
|
-
|
|
27072
|
-
|
|
27073
|
-
|
|
27074
|
-
|
|
27075
|
-
);
|
|
27079
|
+
const first = await approve(config.fixtureId, config.idempotencyKey);
|
|
27080
|
+
expect(first.status).toBe(200);
|
|
27081
|
+
const firstBody = await object(first);
|
|
27082
|
+
expect(firstBody).toMatchObject({ id: config.fixtureId, state: "approved" });
|
|
27083
|
+
expect(Number.isSafeInteger(firstBody["version"])).toBe(true);
|
|
27084
|
+
expect(Number(firstBody["version"])).toBeGreaterThan(Number(before["version"]));
|
|
27085
|
+
|
|
27086
|
+
// Read-only reconciliation is exercised here; this is not a network-loss or worker-crash test.
|
|
27087
|
+
const operationPath = path(config.fixtureId) + "/operations/" + encodeURIComponent(config.idempotencyKey);
|
|
27088
|
+
const recovered = await request(config, operationPath, { headers });
|
|
27076
27089
|
expect(recovered.ok).toBe(true);
|
|
27077
|
-
|
|
27078
|
-
|
|
27079
|
-
|
|
27080
|
-
|
|
27081
|
-
|
|
27082
|
-
);
|
|
27090
|
+
expect(await object(recovered)).toMatchObject({
|
|
27091
|
+
id: config.fixtureId, operationId: config.idempotencyKey,
|
|
27092
|
+
status: "confirmed", state: firstBody["state"], version: firstBody["version"],
|
|
27093
|
+
});
|
|
27094
|
+
const jobStatus = await request(config, operationPath + "/status", { headers });
|
|
27083
27095
|
expect(jobStatus.ok).toBe(true);
|
|
27084
|
-
|
|
27096
|
+
const job = await object(jobStatus);
|
|
27097
|
+
expect(job).toMatchObject({ id: config.fixtureId, operationId: config.idempotencyKey });
|
|
27098
|
+
expect(typeof job["status"]).toBe("string");
|
|
27099
|
+
expect(["queued", "running", "completed"].some(status => status === job["status"])).toBe(true);
|
|
27085
27100
|
|
|
27086
|
-
|
|
27087
|
-
// domain conflict, but it must never create a second successful transition.
|
|
27088
|
-
const replay = await approve(config.idempotencyKey);
|
|
27089
|
-
const replayBody = await json(replay);
|
|
27101
|
+
const replay = await approve(config.fixtureId, config.idempotencyKey);
|
|
27090
27102
|
expect(replay.status).toBe(first.status);
|
|
27091
|
-
expect(
|
|
27103
|
+
expect(await object(replay)).toEqual(firstBody);
|
|
27092
27104
|
|
|
27093
|
-
//
|
|
27094
|
-
// boundary instead of being treated as a new approval.
|
|
27105
|
+
// Conflicting replay and stale version must fail without changing the authoritative row.
|
|
27095
27106
|
const conflicting = await approve(
|
|
27096
|
-
config.idempotencyKey,
|
|
27097
|
-
{ ...${className}Fixture.approve, reason: "conflicting replay" },
|
|
27107
|
+
config.fixtureId, config.idempotencyKey, { ...body, reason: "conflicting replay" },
|
|
27098
27108
|
);
|
|
27099
27109
|
expect([400, 409, 422]).toContain(conflicting.status);
|
|
27100
|
-
|
|
27101
|
-
|
|
27102
|
-
|
|
27110
|
+
const stale = await approve(config.fixtureId, config.idempotencyKey + "-stale");
|
|
27111
|
+
expect([409, 422]).toContain(stale.status);
|
|
27112
|
+
const after = await request(config, path(config.fixtureId), { headers });
|
|
27113
|
+
expect(after.ok).toBe(true);
|
|
27114
|
+
expect(await object(after)).toMatchObject(firstBody);
|
|
27115
|
+
|
|
27116
|
+
// Race on a separate fresh draft; racing the already-approved row proves nothing.
|
|
27117
|
+
const raceDetail = await request(config, path(config.raceId), { headers });
|
|
27118
|
+
expect(raceDetail.ok).toBe(true);
|
|
27119
|
+
const raceBefore = await object(raceDetail);
|
|
27120
|
+
expect(raceBefore).toMatchObject({ id: config.raceId, state: "draft" });
|
|
27121
|
+
expect(Number.isSafeInteger(raceBefore["version"])).toBe(true);
|
|
27122
|
+
expect(Number(raceBefore["version"])).toBeGreaterThanOrEqual(1);
|
|
27123
|
+
const raceBody = { ...body, expectedVersion: Number(raceBefore["version"]) };
|
|
27103
27124
|
const [raceA, raceB] = await Promise.all([
|
|
27104
|
-
approve(config.idempotencyKey + "-race-a"),
|
|
27105
|
-
approve(config.idempotencyKey + "-race-b"),
|
|
27125
|
+
approve(config.raceId, config.idempotencyKey + "-race-a", raceBody),
|
|
27126
|
+
approve(config.raceId, config.idempotencyKey + "-race-b", raceBody),
|
|
27106
27127
|
]);
|
|
27107
|
-
|
|
27108
|
-
|
|
27109
|
-
|
|
27110
|
-
|
|
27111
|
-
|
|
27128
|
+
const statuses = [raceA.status, raceB.status];
|
|
27129
|
+
expect(statuses.filter(status => status === 200)).toHaveLength(1);
|
|
27130
|
+
expect(statuses.filter(status => status === 409 || status === 422)).toHaveLength(1);
|
|
27131
|
+
const winner = await object(raceA.status === 200 ? raceA : raceB);
|
|
27132
|
+
expect(winner).toMatchObject({ id: config.raceId, state: "approved" });
|
|
27133
|
+
expect(Number.isSafeInteger(winner["version"])).toBe(true);
|
|
27134
|
+
expect(Number(winner["version"])).toBeGreaterThan(Number(raceBefore["version"]));
|
|
27135
|
+
const raceAfter = await request(config, path(config.raceId), { headers });
|
|
27136
|
+
expect(raceAfter.ok).toBe(true);
|
|
27137
|
+
expect(await object(raceAfter)).toMatchObject(winner);
|
|
27138
|
+
}, { timeout: 120_000 });
|
|
27139
|
+
|
|
27140
|
+
test.skipIf(!enabled)("${name} rejects cross-tenant reads, writes and receipt disclosure", async () => {
|
|
27112
27141
|
const config = requireConfig();
|
|
27113
|
-
if (!otherAuthorization) throw new Error("Missing SUPACLOUD_FEATURE_E2E_OTHER_AUTHORIZATION");
|
|
27114
27142
|
const headers = {
|
|
27115
|
-
authorization: otherAuthorization,
|
|
27143
|
+
authorization: config.otherAuthorization,
|
|
27116
27144
|
"content-type": "application/json",
|
|
27117
27145
|
};
|
|
27118
|
-
const list = await
|
|
27146
|
+
const list = await request(config, "/${name}", { headers });
|
|
27119
27147
|
expect(list.ok).toBe(true);
|
|
27120
|
-
|
|
27121
|
-
|
|
27122
|
-
|
|
27123
|
-
|
|
27124
|
-
|
|
27125
|
-
);
|
|
27126
|
-
|
|
27127
|
-
|
|
27128
|
-
|
|
27129
|
-
|
|
27130
|
-
|
|
27131
|
-
|
|
27132
|
-
|
|
27133
|
-
|
|
27134
|
-
|
|
27135
|
-
);
|
|
27148
|
+
const listed: unknown = await list.json();
|
|
27149
|
+
expect(Array.isArray(listed)).toBe(true);
|
|
27150
|
+
expect(listed).not.toContainEqual(expect.objectContaining({ id: config.fixtureId }));
|
|
27151
|
+
expect(listed).not.toContainEqual(expect.objectContaining({ id: config.raceId }));
|
|
27152
|
+
const path = "/${name}/" + encodeURIComponent(config.fixtureId);
|
|
27153
|
+
const operationPath = path + "/operations/" + encodeURIComponent(config.idempotencyKey);
|
|
27154
|
+
for (const target of [path, operationPath, operationPath + "/status"]) {
|
|
27155
|
+
const response = await request(config, target, { headers });
|
|
27156
|
+
expect([403, 404]).toContain(response.status);
|
|
27157
|
+
}
|
|
27158
|
+
// Reuse the successful key: authorization must run before a cached success is disclosed.
|
|
27159
|
+
const approval = await request(config, path + "/approve", {
|
|
27160
|
+
method: "POST", headers: { ...headers, "idempotency-key": config.idempotencyKey },
|
|
27161
|
+
body: JSON.stringify(${className}Fixture.approve),
|
|
27162
|
+
});
|
|
27136
27163
|
expect([403, 404]).toContain(approval.status);
|
|
27137
|
-
});
|
|
27164
|
+
}, { timeout: 120_000 });
|
|
27138
27165
|
`,
|
|
27139
27166
|
[`${name}.deployment.md`]: `# ${className} feature delivery checklist
|
|
27140
27167
|
|
|
@@ -27162,10 +27189,16 @@ Add request fields in the contract, not a second handwritten Writer interface.
|
|
|
27162
27189
|
Typecheck application adapters after changing a field type; the derived Port
|
|
27163
27190
|
must expose incompatible assumptions in business code.
|
|
27164
27191
|
|
|
27165
|
-
For
|
|
27166
|
-
|
|
27167
|
-
|
|
27168
|
-
|
|
27192
|
+
For release acceptance, set \`SUPACLOUD_FEATURE_E2E_REQUIRED=1\` and supply URL,
|
|
27193
|
+
AUTHORIZATION, OTHER_AUTHORIZATION, ID, RACE_ID and IDEMPOTENCY_KEY under the
|
|
27194
|
+
\`SUPACLOUD_FEATURE_E2E_\` prefix. Use two distinct fresh draft fixtures and a fresh
|
|
27195
|
+
key on every run. Missing or partial configuration fails; only an entirely
|
|
27196
|
+
unconfigured developer run skips. Use HTTPS, or loopback HTTP for local tests.
|
|
27197
|
+
The first approval must succeed, its receipt must match, replay must preserve the
|
|
27198
|
+
result, and exactly one of two concurrent writes on the second draft must succeed.
|
|
27199
|
+
Cross-tenant detail, receipt, job status and replay must be denied. This HTTP test
|
|
27200
|
+
does not prove audit/outbox counts, worker completion, crashes, browser interaction,
|
|
27201
|
+
revoked membership or deployment; verify those separately against durable sources.
|
|
27169
27202
|
|
|
27170
27203
|
The named RPC adapter must invoke the continuation in the same database transaction
|
|
27171
27204
|
used by the request-scoped Writer, or own the complete command instead; never execute
|
|
@@ -33105,7 +33138,7 @@ async function runProjectDeployment(http, args) {
|
|
|
33105
33138
|
// package.json
|
|
33106
33139
|
var package_default = {
|
|
33107
33140
|
name: "@supacloud/cli",
|
|
33108
|
-
version: "0.108.
|
|
33141
|
+
version: "0.108.2",
|
|
33109
33142
|
description: "Project-scoped CLI for SupaCloud users",
|
|
33110
33143
|
type: "module",
|
|
33111
33144
|
main: "./dist/index.js",
|