@specific.dev/spectest 0.96.0 → 0.98.0
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/README.md +5 -28
- package/dist/components/aws.js +13 -3
- package/dist/components/emulate/entry.mjs +37 -2
- package/dist/components/emulate/service.js +1 -0
- package/dist/components/google.js +3 -1
- package/dist/components/index.d.ts +0 -1
- package/dist/components/index.js +0 -1
- package/package.json +1 -6
- package/src/components/aws.test.ts +49 -0
- package/src/components/aws.ts +14 -3
- package/src/components/emulate/entry.mjs +37 -2
- package/src/components/emulate/service.ts +1 -0
- package/src/components/google.ts +3 -1
- package/src/components/index.ts +0 -2
- package/dist/components/gcp.d.ts +0 -2
- package/dist/components/gcp.js +0 -2
- package/dist/components/pubsub.d.ts +0 -77
- package/dist/components/pubsub.js +0 -139
- package/src/components/gcp.ts +0 -11
- package/src/components/pubsub.test.ts +0 -88
- package/src/components/pubsub.ts +0 -182
package/README.md
CHANGED
|
@@ -15,33 +15,10 @@ Install the [Spectest CLI](https://github.com/specific-dev/spectest/releases/lat
|
|
|
15
15
|
and run `spectest docs /installation` for project setup and authentication.
|
|
16
16
|
`spectest docs` contains the complete offline authoring guide.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
`pubsub()` runs Google's local emulator and creates topics/subscriptions before
|
|
24
|
-
dependent services start:
|
|
25
|
-
|
|
26
|
-
```ts
|
|
27
|
-
import { pubsub } from "@specific.dev/spectest/components/gcp";
|
|
28
|
-
|
|
29
|
-
const services = {
|
|
30
|
-
messages: pubsub({
|
|
31
|
-
projectId: "local-orders",
|
|
32
|
-
subscriptions: { "orders-worker": { topic: "orders" } },
|
|
33
|
-
}),
|
|
34
|
-
};
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
The default endpoint is `https://pubsub.googleapis.com`, with trusted TLS and
|
|
38
|
-
gRPC support. Keep your application's normal endpoint configuration, supply
|
|
39
|
-
local test credentials and project `local-orders`, and use
|
|
40
|
-
`dependsOn: ["messages"]`. Tests can call
|
|
41
|
-
`ctx.svc.messages.publish()`, `pull()` and `acknowledge()`. VM snapshots preserve
|
|
42
|
-
the emulator's live state. See `examples/google-pubsub` and
|
|
43
|
-
`spectest docs /components/pubsub` for test credential setup and options.
|
|
44
|
-
Set `tls: false` to use explicit plaintext emulator mode instead.
|
|
18
|
+
Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
|
|
19
|
+
`pubsub`/`pubsubClient` exports and `/components/gcp` entry point are removed.
|
|
20
|
+
Use a local service definition or `serviceGroup()` wrapper; the repository’s
|
|
21
|
+
`examples/google-pubsub` demonstrates this using public SDK APIs.
|
|
45
22
|
|
|
46
23
|
## HTTP assertions
|
|
47
24
|
|
|
@@ -150,4 +127,4 @@ Clients use trusted TLS on port 443. The ingress selects HTTP/2 with ALPN,
|
|
|
150
127
|
routes each stream by `:authority`, and preserves streaming bodies, metadata,
|
|
151
128
|
trailers, and cancellation. HTTP/1.1 requests still use the existing HTTP proxy.
|
|
152
129
|
Runtime services support the same option. HTTP/2 streams currently bypass
|
|
153
|
-
`ctx.intercept` and HTTP request recording.
|
|
130
|
+
`ctx.intercept` and HTTP request recording.
|
package/dist/components/aws.js
CHANGED
|
@@ -94,13 +94,23 @@ const AWS_REGIONS = [
|
|
|
94
94
|
const AWS_CN_REGIONS = ["cn-north-1", "cn-northwest-1"];
|
|
95
95
|
/** Every hostname pattern the emulator answers to: the two partitions' global
|
|
96
96
|
* endpoints (`sts.amazonaws.com`, `iam.amazonaws.com`) plus one per region
|
|
97
|
-
* for the regional ones (`states.us-east-1.amazonaws.com`).
|
|
97
|
+
* for the regional ones (`states.us-east-1.amazonaws.com`). ECR adds
|
|
98
|
+
* labels for its API and account registries, each needing its own SAN. */
|
|
98
99
|
const AWS_HOST_PATTERNS = [
|
|
99
100
|
"*.amazonaws.com",
|
|
100
|
-
...AWS_REGIONS.
|
|
101
|
+
...AWS_REGIONS.flatMap((r) => regionalHostPatterns(r, "amazonaws.com")),
|
|
101
102
|
"*.amazonaws.com.cn",
|
|
102
|
-
...AWS_CN_REGIONS.
|
|
103
|
+
...AWS_CN_REGIONS.flatMap((r) => regionalHostPatterns(r, "amazonaws.com.cn")),
|
|
103
104
|
];
|
|
105
|
+
function regionalHostPatterns(region, suffix) {
|
|
106
|
+
return [
|
|
107
|
+
`*.${region}.${suffix}`,
|
|
108
|
+
`*.ecr.${region}.${suffix}`,
|
|
109
|
+
`*.dkr.ecr.${region}.${suffix}`,
|
|
110
|
+
`*.ecr-fips.${region}.${suffix}`,
|
|
111
|
+
`*.dkr.ecr-fips.${region}.${suffix}`,
|
|
112
|
+
];
|
|
113
|
+
}
|
|
104
114
|
/** The VM's own docker socket, bind-mounted so the emulator can start the
|
|
105
115
|
* official AWS runtime containers. This is the VM's dockerd, inside the
|
|
106
116
|
* hermetic environment — the same daemon that runs the services. */
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// the whole OAuth exchange is readable on the case page with no helper API.
|
|
31
31
|
|
|
32
32
|
import { createServer } from "@emulators/core";
|
|
33
|
-
import { SignJWT, decodeJwt, exportJWK } from "jose";
|
|
33
|
+
import { SignJWT, decodeJwt, exportJWK, exportPKCS8 } from "jose";
|
|
34
34
|
|
|
35
35
|
const CONFIG = JSON.parse(process.env.SPECTEST_AUTH_CONFIG ?? "{}");
|
|
36
36
|
const PORT = Number(CONFIG.port ?? 4000);
|
|
@@ -60,6 +60,37 @@ const JWKS = {
|
|
|
60
60
|
keys: [{ ...(await exportJWK(KEY_PAIR.publicKey)), kid: KID, use: "sig", alg: "RS256" }],
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
// Google's Node/Python clients fetch PEM certificates from /oauth2/v1/certs,
|
|
64
|
+
// while browser clients use the JWK endpoint. Both must identify the same key.
|
|
65
|
+
let GOOGLE_CERT;
|
|
66
|
+
if ((CONFIG.providers ?? []).some(provider => provider.name === "google")) {
|
|
67
|
+
const child = Bun.spawn(["openssl", "req", "-new", "-x509", "-key", "/dev/stdin", "-days", "3650", "-subj", "/CN=spectest-google-token-signing"], {
|
|
68
|
+
stdin: new Blob([await exportPKCS8(KEY_PAIR.privateKey)]), stdout: "pipe", stderr: "pipe",
|
|
69
|
+
});
|
|
70
|
+
const [cert, error, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
71
|
+
if (code !== 0) throw new Error(`google(): signing certificate setup failed: ${error}`);
|
|
72
|
+
GOOGLE_CERT = cert;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function googleIdToken(req, path) {
|
|
76
|
+
const match = /^\/v1\/projects\/-\/serviceAccounts\/([^/]+):generateIdToken$/.exec(path);
|
|
77
|
+
if (!match || req.method !== "POST") return json({ error: "Unsupported IAM operation" }, 404);
|
|
78
|
+
let options, email;
|
|
79
|
+
try {
|
|
80
|
+
options = await req.json();
|
|
81
|
+
email = decodeURIComponent(match[1]);
|
|
82
|
+
} catch { return json({ error: "Invalid token request" }, 400); }
|
|
83
|
+
if (!options || !/^[^@\s]+@[^@\s]+$/.test(email) || typeof options.audience !== "string" || !options.audience) return json({ error: "serviceAccountEmail and audience are required" }, 400);
|
|
84
|
+
// Stable service-account subject; isolated test identities require no cloud credentials.
|
|
85
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(email)));
|
|
86
|
+
const subject = BigInt('0x' + Buffer.from(digest.slice(0, 8)).toString('hex')).toString();
|
|
87
|
+
const token = await new SignJWT(options.includeEmail ? { email, email_verified: true } : {})
|
|
88
|
+
.setProtectedHeader({ alg: "RS256", kid: KID, typ: "JWT" })
|
|
89
|
+
.setIssuer("https://accounts.google.com").setSubject(subject).setAudience(options.audience)
|
|
90
|
+
.setIssuedAt().setExpirationTime("1h").sign(KEY_PAIR.privateKey);
|
|
91
|
+
return json({ token }, 200);
|
|
92
|
+
}
|
|
93
|
+
|
|
63
94
|
// ──────────────────────────────────────────────────────────────────────────
|
|
64
95
|
// Providers
|
|
65
96
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -205,7 +236,11 @@ Bun.serve({
|
|
|
205
236
|
}
|
|
206
237
|
|
|
207
238
|
let res;
|
|
208
|
-
if (provider.
|
|
239
|
+
if (provider.name === "google" && host === "iamcredentials.googleapis.com") {
|
|
240
|
+
res = await googleIdToken(req, path);
|
|
241
|
+
} else if (provider.name === "google" && path === "/oauth2/v1/certs") {
|
|
242
|
+
res = json({ [KID]: GOOGLE_CERT }, 200);
|
|
243
|
+
} else if (provider.oidc && provider.jwksPath?.test(path)) {
|
|
209
244
|
// Our key, never the emulator's — we re-sign every id_token.
|
|
210
245
|
res = json(JWKS, 200);
|
|
211
246
|
} else {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// `google()` — Google, emulated inside the environment, answering at its
|
|
2
2
|
// real endpoints.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// Supports service-account ID tokens (IAM generateIdToken) and Sign in
|
|
5
|
+
// with Google: OpenID Connect discovery, the account
|
|
5
6
|
// picker, PKCE, the token exchange, refresh, and userinfo. The component is
|
|
6
7
|
// named for the provider rather than for the feature, because the backing
|
|
7
8
|
// emulator also carries Gmail, Calendar and Drive — claiming
|
|
@@ -16,6 +17,7 @@ function spec(users, opts) {
|
|
|
16
17
|
baseUrl: "https://accounts.google.com",
|
|
17
18
|
hosts: [
|
|
18
19
|
"accounts.google.com",
|
|
20
|
+
"iamcredentials.googleapis.com",
|
|
19
21
|
"oauth2.googleapis.com",
|
|
20
22
|
"www.googleapis.com",
|
|
21
23
|
"openidconnect.googleapis.com",
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
export { pubsub, pubsubClient, type PubSubOptions, type PubSubClient, type PubSubHelpers, type PubSubSubscriptionOptions, type PubSubMessage, type PubSubReceivedMessage } from "./pubsub.js";
|
|
2
1
|
export { temporal, temporalClient, type TemporalOptions, type TemporalClient, type TemporalNamespace, type TemporalWorkerOptions, type TemporalPoller, type TemporalSearchAttributeType, type TemporalTaskType } from "./temporal.js";
|
|
3
2
|
export { postgres, type PostgresOptions, type PostgresHelpers, } from "./postgres.js";
|
|
4
3
|
export { s3, type S3Options, type S3Helpers, } from "./s3.js";
|
package/dist/components/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specific.dev/spectest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.98.0",
|
|
4
4
|
"description": "Spectest SDK for defining test environments in TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,11 +16,6 @@
|
|
|
16
16
|
"bun": "./src/components/index.ts",
|
|
17
17
|
"default": "./dist/components/index.js"
|
|
18
18
|
},
|
|
19
|
-
"./components/gcp": {
|
|
20
|
-
"types": "./dist/components/gcp.d.ts",
|
|
21
|
-
"bun": "./src/components/gcp.ts",
|
|
22
|
-
"default": "./dist/components/gcp.js"
|
|
23
|
-
},
|
|
24
19
|
"./coverage": {
|
|
25
20
|
"types": "./dist/coverage.d.ts",
|
|
26
21
|
"bun": "./src/coverage.ts",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { X509Certificate } from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { defineEnvironment } from "../index.js";
|
|
7
|
+
import { lowerIngress } from "../ingress.js";
|
|
8
|
+
import { generateHostCert } from "../harness/certificate.js";
|
|
9
|
+
import { aws } from "./aws.js";
|
|
10
|
+
|
|
11
|
+
test("AWS certificate verifies nested ECR endpoints in every partition", async () => {
|
|
12
|
+
const env = defineEnvironment({ name: "test", services: { cloud: aws() } });
|
|
13
|
+
const ingress = lowerIngress(env.project([]));
|
|
14
|
+
expect(ingress.certificates).toHaveLength(1);
|
|
15
|
+
const names = ingress.certificates[0].hostnames;
|
|
16
|
+
const dir = await mkdtemp(join(tmpdir(), "spectest-aws-tls-"));
|
|
17
|
+
const ca = join(dir, "ca.crt");
|
|
18
|
+
const key = join(dir, "ca.key");
|
|
19
|
+
try {
|
|
20
|
+
const root = Bun.spawnSync(["openssl", "req", "-x509", "-newkey", "rsa:2048",
|
|
21
|
+
"-nodes", "-days", "1", "-subj", "/CN=test-ca", "-keyout", key, "-out", ca]);
|
|
22
|
+
expect(root.exitCode, root.stderr.toString()).toBe(0);
|
|
23
|
+
const { cert } = await generateHostCert("aws", names, ca, key);
|
|
24
|
+
const leaf = new X509Certificate(cert);
|
|
25
|
+
for (const host of [
|
|
26
|
+
"api.ecr.eu-central-1.amazonaws.com",
|
|
27
|
+
"123456789012.dkr.ecr.eu-central-1.amazonaws.com",
|
|
28
|
+
"api.ecr.us-gov-west-1.amazonaws.com",
|
|
29
|
+
"api.ecr-fips.us-gov-west-1.amazonaws.com",
|
|
30
|
+
"123456789012.dkr.ecr-fips.us-gov-west-1.amazonaws.com",
|
|
31
|
+
"api.ecr.cn-north-1.amazonaws.com.cn",
|
|
32
|
+
"123456789012.dkr.ecr.cn-northwest-1.amazonaws.com.cn",
|
|
33
|
+
"states.us-east-1.amazonaws.com",
|
|
34
|
+
"sts.amazonaws.com",
|
|
35
|
+
]) {
|
|
36
|
+
const pattern = leaf.checkHost(host);
|
|
37
|
+
expect(pattern, host).toBeDefined();
|
|
38
|
+
expect(ingress.proxies).toContainEqual({ hostname: pattern, service: "cloud", port: 4566 });
|
|
39
|
+
}
|
|
40
|
+
expect(leaf.checkHost("api.ecr.eu-central-1.amazonaws.com.attacker.test")).toBeUndefined();
|
|
41
|
+
const file = join(dir, "leaf.crt");
|
|
42
|
+
await writeFile(file, cert);
|
|
43
|
+
const verified = Bun.spawnSync(["openssl", "verify", "-CAfile", ca,
|
|
44
|
+
"-verify_hostname", "api.ecr.eu-central-1.amazonaws.com", file]);
|
|
45
|
+
expect(verified.exitCode, verified.stderr.toString()).toBe(0);
|
|
46
|
+
} finally {
|
|
47
|
+
await rm(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
}, 10_000);
|
package/src/components/aws.ts
CHANGED
|
@@ -101,14 +101,25 @@ const AWS_CN_REGIONS = ["cn-north-1", "cn-northwest-1"];
|
|
|
101
101
|
|
|
102
102
|
/** Every hostname pattern the emulator answers to: the two partitions' global
|
|
103
103
|
* endpoints (`sts.amazonaws.com`, `iam.amazonaws.com`) plus one per region
|
|
104
|
-
* for the regional ones (`states.us-east-1.amazonaws.com`).
|
|
104
|
+
* for the regional ones (`states.us-east-1.amazonaws.com`). ECR adds
|
|
105
|
+
* labels for its API and account registries, each needing its own SAN. */
|
|
105
106
|
const AWS_HOST_PATTERNS = [
|
|
106
107
|
"*.amazonaws.com",
|
|
107
|
-
...AWS_REGIONS.
|
|
108
|
+
...AWS_REGIONS.flatMap((r) => regionalHostPatterns(r, "amazonaws.com")),
|
|
108
109
|
"*.amazonaws.com.cn",
|
|
109
|
-
...AWS_CN_REGIONS.
|
|
110
|
+
...AWS_CN_REGIONS.flatMap((r) => regionalHostPatterns(r, "amazonaws.com.cn")),
|
|
110
111
|
];
|
|
111
112
|
|
|
113
|
+
function regionalHostPatterns(region: string, suffix: string): string[] {
|
|
114
|
+
return [
|
|
115
|
+
`*.${region}.${suffix}`,
|
|
116
|
+
`*.ecr.${region}.${suffix}`,
|
|
117
|
+
`*.dkr.ecr.${region}.${suffix}`,
|
|
118
|
+
`*.ecr-fips.${region}.${suffix}`,
|
|
119
|
+
`*.dkr.ecr-fips.${region}.${suffix}`,
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
/** The VM's own docker socket, bind-mounted so the emulator can start the
|
|
113
124
|
* official AWS runtime containers. This is the VM's dockerd, inside the
|
|
114
125
|
* hermetic environment — the same daemon that runs the services. */
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// the whole OAuth exchange is readable on the case page with no helper API.
|
|
31
31
|
|
|
32
32
|
import { createServer } from "@emulators/core";
|
|
33
|
-
import { SignJWT, decodeJwt, exportJWK } from "jose";
|
|
33
|
+
import { SignJWT, decodeJwt, exportJWK, exportPKCS8 } from "jose";
|
|
34
34
|
|
|
35
35
|
const CONFIG = JSON.parse(process.env.SPECTEST_AUTH_CONFIG ?? "{}");
|
|
36
36
|
const PORT = Number(CONFIG.port ?? 4000);
|
|
@@ -60,6 +60,37 @@ const JWKS = {
|
|
|
60
60
|
keys: [{ ...(await exportJWK(KEY_PAIR.publicKey)), kid: KID, use: "sig", alg: "RS256" }],
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
// Google's Node/Python clients fetch PEM certificates from /oauth2/v1/certs,
|
|
64
|
+
// while browser clients use the JWK endpoint. Both must identify the same key.
|
|
65
|
+
let GOOGLE_CERT;
|
|
66
|
+
if ((CONFIG.providers ?? []).some(provider => provider.name === "google")) {
|
|
67
|
+
const child = Bun.spawn(["openssl", "req", "-new", "-x509", "-key", "/dev/stdin", "-days", "3650", "-subj", "/CN=spectest-google-token-signing"], {
|
|
68
|
+
stdin: new Blob([await exportPKCS8(KEY_PAIR.privateKey)]), stdout: "pipe", stderr: "pipe",
|
|
69
|
+
});
|
|
70
|
+
const [cert, error, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
71
|
+
if (code !== 0) throw new Error(`google(): signing certificate setup failed: ${error}`);
|
|
72
|
+
GOOGLE_CERT = cert;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function googleIdToken(req, path) {
|
|
76
|
+
const match = /^\/v1\/projects\/-\/serviceAccounts\/([^/]+):generateIdToken$/.exec(path);
|
|
77
|
+
if (!match || req.method !== "POST") return json({ error: "Unsupported IAM operation" }, 404);
|
|
78
|
+
let options, email;
|
|
79
|
+
try {
|
|
80
|
+
options = await req.json();
|
|
81
|
+
email = decodeURIComponent(match[1]);
|
|
82
|
+
} catch { return json({ error: "Invalid token request" }, 400); }
|
|
83
|
+
if (!options || !/^[^@\s]+@[^@\s]+$/.test(email) || typeof options.audience !== "string" || !options.audience) return json({ error: "serviceAccountEmail and audience are required" }, 400);
|
|
84
|
+
// Stable service-account subject; isolated test identities require no cloud credentials.
|
|
85
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(email)));
|
|
86
|
+
const subject = BigInt('0x' + Buffer.from(digest.slice(0, 8)).toString('hex')).toString();
|
|
87
|
+
const token = await new SignJWT(options.includeEmail ? { email, email_verified: true } : {})
|
|
88
|
+
.setProtectedHeader({ alg: "RS256", kid: KID, typ: "JWT" })
|
|
89
|
+
.setIssuer("https://accounts.google.com").setSubject(subject).setAudience(options.audience)
|
|
90
|
+
.setIssuedAt().setExpirationTime("1h").sign(KEY_PAIR.privateKey);
|
|
91
|
+
return json({ token }, 200);
|
|
92
|
+
}
|
|
93
|
+
|
|
63
94
|
// ──────────────────────────────────────────────────────────────────────────
|
|
64
95
|
// Providers
|
|
65
96
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -205,7 +236,11 @@ Bun.serve({
|
|
|
205
236
|
}
|
|
206
237
|
|
|
207
238
|
let res;
|
|
208
|
-
if (provider.
|
|
239
|
+
if (provider.name === "google" && host === "iamcredentials.googleapis.com") {
|
|
240
|
+
res = await googleIdToken(req, path);
|
|
241
|
+
} else if (provider.name === "google" && path === "/oauth2/v1/certs") {
|
|
242
|
+
res = json({ [KID]: GOOGLE_CERT }, 200);
|
|
243
|
+
} else if (provider.oidc && provider.jwksPath?.test(path)) {
|
|
209
244
|
// Our key, never the emulator's — we re-sign every id_token.
|
|
210
245
|
res = json(JWKS, 200);
|
|
211
246
|
} else {
|
|
@@ -155,6 +155,7 @@ const ENTRY_SCRIPT: string = (() => {
|
|
|
155
155
|
const DOCKERFILE = `
|
|
156
156
|
FROM ${IMAGE_BASE}
|
|
157
157
|
WORKDIR /srv
|
|
158
|
+
RUN apk add --no-cache openssl
|
|
158
159
|
RUN echo '{"name":"spectest-provider","private":true}' > package.json \\
|
|
159
160
|
&& bun add \\
|
|
160
161
|
@emulators/core@${EMULATE_VERSION} \\
|
package/src/components/google.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// `google()` — Google, emulated inside the environment, answering at its
|
|
2
2
|
// real endpoints.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// Supports service-account ID tokens (IAM generateIdToken) and Sign in
|
|
5
|
+
// with Google: OpenID Connect discovery, the account
|
|
5
6
|
// picker, PKCE, the token exchange, refresh, and userinfo. The component is
|
|
6
7
|
// named for the provider rather than for the feature, because the backing
|
|
7
8
|
// emulator also carries Gmail, Calendar and Drive — claiming
|
|
@@ -27,6 +28,7 @@ function spec(users: ProviderUser[], opts: GoogleOptions): ProviderSpec {
|
|
|
27
28
|
baseUrl: "https://accounts.google.com",
|
|
28
29
|
hosts: [
|
|
29
30
|
"accounts.google.com",
|
|
31
|
+
"iamcredentials.googleapis.com",
|
|
30
32
|
"oauth2.googleapis.com",
|
|
31
33
|
"www.googleapis.com",
|
|
32
34
|
"openidconnect.googleapis.com",
|
package/src/components/index.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
// Reusable, pre-built service definitions for common dependencies.
|
|
2
|
-
export { pubsub, pubsubClient, type PubSubOptions, type PubSubClient, type PubSubHelpers,
|
|
3
|
-
type PubSubSubscriptionOptions, type PubSubMessage, type PubSubReceivedMessage } from "./pubsub.js";
|
|
4
2
|
export { temporal, temporalClient, type TemporalOptions, type TemporalClient,
|
|
5
3
|
type TemporalNamespace, type TemporalWorkerOptions, type TemporalPoller,
|
|
6
4
|
type TemporalSearchAttributeType, type TemporalTaskType } from "./temporal.js";
|
package/dist/components/gcp.d.ts
DELETED
package/dist/components/gcp.js
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import type { ServiceSetupContext, ServicesMap } from "../index.js";
|
|
2
|
-
export interface PubSubSubscriptionOptions {
|
|
3
|
-
/** Topic ID in this component's project. */
|
|
4
|
-
topic: string;
|
|
5
|
-
ackDeadlineSeconds?: number;
|
|
6
|
-
/** HTTP is supported by the emulator, including other service names. */
|
|
7
|
-
pushEndpoint?: string;
|
|
8
|
-
}
|
|
9
|
-
export interface PubSubOptions {
|
|
10
|
-
/** Local project ID; no Google Cloud account is needed. Default "spectest". */
|
|
11
|
-
projectId?: string;
|
|
12
|
-
/** Official Cloud CLI image with the Pub/Sub emulator installed. */
|
|
13
|
-
image?: string;
|
|
14
|
-
/** Emulator HTTP and gRPC port. Default 8085. */
|
|
15
|
-
port?: number;
|
|
16
|
-
/** TLS on pubsub.googleapis.com by default. Set false for direct emulator
|
|
17
|
-
* access, or choose a different hostname for a second isolated instance. */
|
|
18
|
-
tls?: false | {
|
|
19
|
-
hostname: string;
|
|
20
|
-
};
|
|
21
|
-
topics?: string[];
|
|
22
|
-
/** Subscription ID → options. Referenced topics are created automatically. */
|
|
23
|
-
subscriptions?: Record<string, PubSubSubscriptionOptions>;
|
|
24
|
-
/** Runs after the declared topics and subscriptions have been created. */
|
|
25
|
-
setup?: (ctx: ServiceSetupContext<PubSubHelpers>) => void | Promise<void>;
|
|
26
|
-
}
|
|
27
|
-
export interface PubSubMessage {
|
|
28
|
-
/** Base64 encoded data, as returned by the Pub/Sub REST API. */
|
|
29
|
-
data?: string;
|
|
30
|
-
attributes?: Record<string, string>;
|
|
31
|
-
messageId: string;
|
|
32
|
-
publishTime: string;
|
|
33
|
-
orderingKey?: string;
|
|
34
|
-
}
|
|
35
|
-
export interface PubSubReceivedMessage {
|
|
36
|
-
ackId: string;
|
|
37
|
-
message: PubSubMessage;
|
|
38
|
-
deliveryAttempt?: number;
|
|
39
|
-
}
|
|
40
|
-
/** Stateless emulator REST client. No credentials or gRPC channel to preserve across forks.
|
|
41
|
-
* Resource arguments are short IDs in projectId; publish accepts UTF-8 text or bytes.
|
|
42
|
-
*/
|
|
43
|
-
export declare function pubsubClient(endpoint: string, projectId?: string, fetcher?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>): {
|
|
44
|
-
projectId: string;
|
|
45
|
-
endpoint: string;
|
|
46
|
-
topicName: (id: string) => string;
|
|
47
|
-
subscriptionName: (id: string) => string;
|
|
48
|
-
createTopic(id: string): Promise<{
|
|
49
|
-
name: string;
|
|
50
|
-
}>;
|
|
51
|
-
createSubscription(id: string, options: PubSubSubscriptionOptions): Promise<{
|
|
52
|
-
name: string;
|
|
53
|
-
topic: string;
|
|
54
|
-
ackDeadlineSeconds: number;
|
|
55
|
-
}>;
|
|
56
|
-
publish(topic: string, data: string | Uint8Array, attributes?: Record<string, string>): Promise<string>;
|
|
57
|
-
/** A pull leases messages; call acknowledge() to remove them. Empty is valid.
|
|
58
|
-
* Uses the emulator's immediate pull mode for bounded test polling.
|
|
59
|
-
*/
|
|
60
|
-
pull(subscription: string, options?: {
|
|
61
|
-
maxMessages?: number;
|
|
62
|
-
timeoutMs?: number;
|
|
63
|
-
}): Promise<PubSubReceivedMessage[]>;
|
|
64
|
-
acknowledge(subscription: string, ackIds: string[]): Promise<void>;
|
|
65
|
-
};
|
|
66
|
-
export type PubSubClient = ReturnType<typeof pubsubClient>;
|
|
67
|
-
export interface PubSubHelpers extends PubSubClient {
|
|
68
|
-
/** Direct plaintext address for clients explicitly using emulator mode. */
|
|
69
|
-
emulatorHost: string;
|
|
70
|
-
}
|
|
71
|
-
/** Google Cloud's real local Pub/Sub emulator, ready for Google client libraries.
|
|
72
|
-
* Mount directly in services: { pubsub: pubsub({ topics: ["orders"] }) }.
|
|
73
|
-
* Applications use their default pubsub.googleapis.com:443 endpoint with
|
|
74
|
-
* verified TLS and local test credentials. No emulator endpoint override.
|
|
75
|
-
* State survives VM snapshots/forks, but not an emulator process restart.
|
|
76
|
-
*/
|
|
77
|
-
export declare function pubsub(opts?: PubSubOptions): import("../index.js").ServiceGroup<ServicesMap, PubSubHelpers, "emulator">;
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { serviceGroup, certificate } from "../index.js";
|
|
2
|
-
import { rawFetch } from "../harness/raw-fetch.js";
|
|
3
|
-
import { readRaw } from "../inspect.js";
|
|
4
|
-
function resourceId(id) {
|
|
5
|
-
if (!/^[A-Za-z][A-Za-z0-9._~+%-]{2,254}$/.test(id) || id.startsWith("goog")) {
|
|
6
|
-
throw new Error(`pubsub(): invalid topic or subscription ID ${JSON.stringify(id)}; use a 3–255 character ID starting with a letter, not "goog"`);
|
|
7
|
-
}
|
|
8
|
-
return id;
|
|
9
|
-
}
|
|
10
|
-
function projectName(id) {
|
|
11
|
-
if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(id))
|
|
12
|
-
throw new Error("pubsub(): projectId must be a 6–30 character Google Cloud project ID");
|
|
13
|
-
return id;
|
|
14
|
-
}
|
|
15
|
-
function subscriptionOptions(options) {
|
|
16
|
-
resourceId(options.topic);
|
|
17
|
-
const seconds = options.ackDeadlineSeconds ?? 10;
|
|
18
|
-
if (!Number.isInteger(seconds) || seconds < 10 || seconds > 600)
|
|
19
|
-
throw new Error("pubsub(): ackDeadlineSeconds must be an integer between 10 and 600");
|
|
20
|
-
if (options.pushEndpoint && !/^https?:$/.test(new URL(options.pushEndpoint).protocol))
|
|
21
|
-
throw new Error("pubsub(): pushEndpoint must use HTTP or HTTPS");
|
|
22
|
-
return seconds;
|
|
23
|
-
}
|
|
24
|
-
/** Stateless emulator REST client. No credentials or gRPC channel to preserve across forks.
|
|
25
|
-
* Resource arguments are short IDs in projectId; publish accepts UTF-8 text or bytes.
|
|
26
|
-
*/
|
|
27
|
-
export function pubsubClient(endpoint, projectId = "spectest", fetcher = rawFetch) {
|
|
28
|
-
projectName(projectId);
|
|
29
|
-
const base = endpoint.replace(/\/$/, "");
|
|
30
|
-
const resource = (kind, id) => `projects/${projectId}/${kind}/${resourceId(id)}`;
|
|
31
|
-
const path = (kind, id) => `/v1/projects/${projectId}/${kind}/${encodeURIComponent(resourceId(id))}`;
|
|
32
|
-
async function request(url, method, body, timeoutMs = 10000) {
|
|
33
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
34
|
-
throw new Error("pubsub(): timeoutMs must be positive");
|
|
35
|
-
const response = await fetcher(`${base}${url}`, {
|
|
36
|
-
method, headers: { "content-type": "application/json" },
|
|
37
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
38
|
-
signal: AbortSignal.timeout(Math.ceil(timeoutMs)),
|
|
39
|
-
});
|
|
40
|
-
const text = await response.text();
|
|
41
|
-
if (!response.ok)
|
|
42
|
-
throw new Error(`Pub/Sub ${method} ${url}: HTTP ${response.status}: ${text.slice(0, 1000)}`);
|
|
43
|
-
return (text ? JSON.parse(text) : {});
|
|
44
|
-
}
|
|
45
|
-
return {
|
|
46
|
-
projectId, endpoint: base,
|
|
47
|
-
topicName: (id) => resource("topics", id),
|
|
48
|
-
subscriptionName: (id) => resource("subscriptions", id),
|
|
49
|
-
createTopic(id) {
|
|
50
|
-
return request(path("topics", id), "PUT", {});
|
|
51
|
-
},
|
|
52
|
-
createSubscription(id, options) {
|
|
53
|
-
const ackDeadlineSeconds = subscriptionOptions(options);
|
|
54
|
-
return request(path("subscriptions", id), "PUT", {
|
|
55
|
-
topic: resource("topics", options.topic), ackDeadlineSeconds,
|
|
56
|
-
...(options.pushEndpoint ? { pushConfig: { pushEndpoint: options.pushEndpoint } } : {}),
|
|
57
|
-
});
|
|
58
|
-
},
|
|
59
|
-
async publish(topic, data, attributes) {
|
|
60
|
-
const result = await request(`${path("topics", topic)}:publish`, "POST", {
|
|
61
|
-
messages: [{ data: Buffer.from(data).toString("base64"), ...(attributes ? { attributes } : {}) }],
|
|
62
|
-
});
|
|
63
|
-
return result.messageIds[0];
|
|
64
|
-
},
|
|
65
|
-
/** A pull leases messages; call acknowledge() to remove them. Empty is valid.
|
|
66
|
-
* Uses the emulator's immediate pull mode for bounded test polling.
|
|
67
|
-
*/
|
|
68
|
-
async pull(subscription, options = {}) {
|
|
69
|
-
const maxMessages = options.maxMessages ?? 10;
|
|
70
|
-
if (!Number.isInteger(maxMessages) || maxMessages < 1)
|
|
71
|
-
throw new Error("pubsub(): maxMessages must be a positive integer");
|
|
72
|
-
const result = await request(`${path("subscriptions", subscription)}:pull`, "POST", { maxMessages, returnImmediately: true }, options.timeoutMs);
|
|
73
|
-
return result.receivedMessages ?? [];
|
|
74
|
-
},
|
|
75
|
-
async acknowledge(subscription, ackIds) {
|
|
76
|
-
await request(`${path("subscriptions", subscription)}:acknowledge`, "POST", { ackIds });
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
/** Google Cloud's real local Pub/Sub emulator, ready for Google client libraries.
|
|
81
|
-
* Mount directly in services: { pubsub: pubsub({ topics: ["orders"] }) }.
|
|
82
|
-
* Applications use their default pubsub.googleapis.com:443 endpoint with
|
|
83
|
-
* verified TLS and local test credentials. No emulator endpoint override.
|
|
84
|
-
* State survives VM snapshots/forks, but not an emulator process restart.
|
|
85
|
-
*/
|
|
86
|
-
export function pubsub(opts = {}) {
|
|
87
|
-
const projectId = projectName(opts.projectId ?? "spectest");
|
|
88
|
-
const port = opts.port ?? 8085;
|
|
89
|
-
const hostname = opts.tls === false ? undefined : opts.tls?.hostname ?? "pubsub.googleapis.com";
|
|
90
|
-
if (hostname !== undefined) {
|
|
91
|
-
certificate([hostname]);
|
|
92
|
-
if (hostname.startsWith("*."))
|
|
93
|
-
throw new Error("pubsub(): TLS hostname must be an exact hostname");
|
|
94
|
-
}
|
|
95
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
96
|
-
throw new Error("pubsub(): port must be an integer between 1 and 65535");
|
|
97
|
-
const subscriptions = Object.entries(opts.subscriptions ?? {});
|
|
98
|
-
for (const [id, options] of subscriptions) {
|
|
99
|
-
resourceId(id);
|
|
100
|
-
subscriptionOptions(options);
|
|
101
|
-
}
|
|
102
|
-
const topics = [...new Set([...(opts.topics ?? []), ...subscriptions.map(([, options]) => options.topic)])].map(resourceId);
|
|
103
|
-
if (hostname && port === 443)
|
|
104
|
-
throw new Error("pubsub(): the plaintext emulator port must differ from TLS port 443");
|
|
105
|
-
return serviceGroup({
|
|
106
|
-
primary: "emulator",
|
|
107
|
-
services: () => {
|
|
108
|
-
const emulator = {
|
|
109
|
-
image: { type: "registry", reference: opts.image ?? "gcr.io/google.com/cloudsdktool/google-cloud-cli:573.0.0-emulators" },
|
|
110
|
-
args: ["gcloud", "beta", "emulators", "pubsub", "start", `--project=${projectId}`, `--host-port=0.0.0.0:${port}`, "--quiet"],
|
|
111
|
-
ports: [port],
|
|
112
|
-
...(hostname ? { tls: [{ hostname, port, http2: true }] } : {}),
|
|
113
|
-
readyCheck: { type: "http", port, path: `/v1/projects/${projectId}/topics`, timeoutSecs: 60 },
|
|
114
|
-
setup: async ({ name }) => {
|
|
115
|
-
const client = pubsubClient(`http://${name}:${port}`, projectId);
|
|
116
|
-
for (const topic of topics)
|
|
117
|
-
await client.createTopic(topic);
|
|
118
|
-
for (const [id, options] of subscriptions)
|
|
119
|
-
await client.createSubscription(id, options);
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
return { emulator };
|
|
123
|
-
},
|
|
124
|
-
helpers: (ctx) => ({
|
|
125
|
-
...pubsubClient(hostname ? `https://${hostname}` : `http://${ctx.name}:${port}`, projectId, async (input, init) => readRaw(await ctx.fetch(String(input), init))),
|
|
126
|
-
emulatorHost: `${ctx.name}:${port}`,
|
|
127
|
-
}),
|
|
128
|
-
setup: async (ctx) => {
|
|
129
|
-
// Prove the TLS endpoint and certificate are ready before dependents.
|
|
130
|
-
if (hostname) {
|
|
131
|
-
const response = await rawFetch(`https://${hostname}/v1/projects/${projectId}/topics`, { signal: AbortSignal.timeout(10000) });
|
|
132
|
-
if (!response.ok)
|
|
133
|
-
throw new Error(`pubsub(): TLS readiness failed: HTTP ${response.status}`);
|
|
134
|
-
await response.text();
|
|
135
|
-
}
|
|
136
|
-
await opts.setup?.(ctx);
|
|
137
|
-
},
|
|
138
|
-
});
|
|
139
|
-
}
|
package/src/components/gcp.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/** Google Cloud components for local Spectest environments. */
|
|
2
|
-
export {
|
|
3
|
-
pubsub,
|
|
4
|
-
pubsubClient,
|
|
5
|
-
type PubSubOptions,
|
|
6
|
-
type PubSubClient,
|
|
7
|
-
type PubSubHelpers,
|
|
8
|
-
type PubSubSubscriptionOptions,
|
|
9
|
-
type PubSubMessage,
|
|
10
|
-
type PubSubReceivedMessage,
|
|
11
|
-
} from "./pubsub.js";
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { defineEnvironment } from "../index.js";
|
|
3
|
-
import { pubsub, pubsubClient } from "./pubsub.js";
|
|
4
|
-
|
|
5
|
-
describe("Pub/Sub", () => {
|
|
6
|
-
test("custom port and project configure both the emulator and API readiness", () => {
|
|
7
|
-
const env = defineEnvironment({ name: "test", services: { messaging: pubsub({ projectId: "local-project", port: 9095 }) } });
|
|
8
|
-
const service = env.config.services.messaging;
|
|
9
|
-
expect(service.args).toContain("--host-port=0.0.0.0:9095");
|
|
10
|
-
expect(service.args).toContain("--project=local-project");
|
|
11
|
-
expect(service.tls).toEqual([{ hostname: "pubsub.googleapis.com", port: 9095, http2: true }]);
|
|
12
|
-
expect(Object.keys(env.config.services)).toEqual(["messaging"]);
|
|
13
|
-
const direct = defineEnvironment({ name: "test", services: { messaging: pubsub({ tls: false }) } });
|
|
14
|
-
expect(Object.keys(direct.config.services)).toEqual(["messaging"]);
|
|
15
|
-
expect(direct.config.services.messaging.certificates).toBeUndefined();
|
|
16
|
-
const custom = defineEnvironment({ name: "test", services: { messaging: pubsub({ tls: { hostname: "other.pubsub.test" } }) } });
|
|
17
|
-
expect(custom.config.services.messaging.tls).toEqual([{ hostname: "other.pubsub.test", port: 8085, http2: true }]);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("rejects invalid configuration before launching a container", () => {
|
|
21
|
-
for (const port of [0, 65536, 1.5, NaN]) expect(() => pubsub({ port })).toThrow("port");
|
|
22
|
-
expect(() => pubsub({ projectId: "../bad" })).toThrow("projectId");
|
|
23
|
-
expect(() => pubsub({ port: 443 })).toThrow("TLS port");
|
|
24
|
-
expect(() => pubsub({ tls: { hostname: "invalid/path" } })).toThrow("hostname");
|
|
25
|
-
expect(() => pubsub({ tls: { hostname: "" } })).toThrow("hostname");
|
|
26
|
-
expect(() => pubsub({ tls: { hostname: "*.pubsub.test" } })).toThrow("exact hostname");
|
|
27
|
-
for (const topic of ["ab", "goog-topic", "projects/another/topics/topic", "../topic"]) expect(() => pubsub({ topics: [topic] })).toThrow("ID");
|
|
28
|
-
expect(() => pubsub({ subscriptions: { worker: { topic: "orders", ackDeadlineSeconds: 601 } } })).toThrow("ackDeadlineSeconds");
|
|
29
|
-
expect(() => pubsub({ subscriptions: { worker: { topic: "orders", pushEndpoint: "ftp://app" } } })).toThrow("HTTP");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("publish encodes UTF-8 and arbitrary bytes exactly once and preserves attributes", async () => {
|
|
33
|
-
const bodies: any[] = [];
|
|
34
|
-
const client = pubsubClient("http://messaging:8085", "spectest", async (_, init) => {
|
|
35
|
-
bodies.push(JSON.parse(init!.body as string));
|
|
36
|
-
return Response.json({ messageIds: ["123"] });
|
|
37
|
-
});
|
|
38
|
-
expect(await client.publish("orders", "café ☕", { source: "test" })).toBe("123");
|
|
39
|
-
expect(Buffer.from(bodies[0].messages[0].data, "base64").toString()).toBe("café ☕");
|
|
40
|
-
expect(bodies[0].messages[0].attributes).toEqual({ source: "test" });
|
|
41
|
-
await client.publish("orders", new Uint8Array([0, 128, 255]));
|
|
42
|
-
expect([...Buffer.from(bodies[1].messages[0].data, "base64")]).toEqual([0, 128, 255]);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("pull returns ack IDs and leaves acknowledgement explicit", async () => {
|
|
46
|
-
const calls: { url: string; body: any }[] = [];
|
|
47
|
-
const client = pubsubClient("http://messaging:8085/", "spectest", async (url, init) => {
|
|
48
|
-
calls.push({ url: String(url), body: JSON.parse(init!.body as string) });
|
|
49
|
-
return Response.json(calls.length === 1 ? { receivedMessages: [{ ackId: "ack-1", message: { data: "aGk=", messageId: "1" } }] } : {});
|
|
50
|
-
});
|
|
51
|
-
const messages = await client.pull("worker", { maxMessages: 1 });
|
|
52
|
-
expect(messages[0].ackId).toBe("ack-1");
|
|
53
|
-
expect(calls).toHaveLength(1);
|
|
54
|
-
expect(calls[0].body).toEqual({ maxMessages: 1, returnImmediately: true });
|
|
55
|
-
await client.acknowledge("worker", [messages[0].ackId]);
|
|
56
|
-
expect(calls[1]).toEqual({ url: "http://messaging:8085/v1/projects/spectest/subscriptions/worker:acknowledge", body: { ackIds: ["ack-1"] } });
|
|
57
|
-
expect(await client.pull("worker")).toEqual([]);
|
|
58
|
-
await expect(client.pull("worker", { maxMessages: 0 })).rejects.toThrow("maxMessages");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("API errors include operation and status and never retry a publish", async () => {
|
|
62
|
-
let calls = 0;
|
|
63
|
-
const client = pubsubClient("http://messaging:8085", "spectest", async () => {
|
|
64
|
-
calls++;
|
|
65
|
-
return Response.json({ error: { message: "Topic not found" } }, { status: 404 });
|
|
66
|
-
});
|
|
67
|
-
await expect(client.publish("missing", "data")).rejects.toThrow("HTTP 404");
|
|
68
|
-
expect(calls).toBe(1);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("resource IDs are escaped in URLs but remain canonical in subscription bodies", async () => {
|
|
72
|
-
let requestUrl = "", body: any;
|
|
73
|
-
const client = pubsubClient("http://messaging:8085", "spectest", async (url, init) => {
|
|
74
|
-
requestUrl = String(url); body = JSON.parse(init!.body as string);
|
|
75
|
-
return Response.json({});
|
|
76
|
-
});
|
|
77
|
-
await client.createSubscription("worker%one", { topic: "topic+one", pushEndpoint: "http://app:3000/push" });
|
|
78
|
-
expect(requestUrl).toEndWith("/subscriptions/worker%25one");
|
|
79
|
-
expect(body).toEqual({ topic: "projects/spectest/topics/topic+one", ackDeadlineSeconds: 10, pushConfig: { pushEndpoint: "http://app:3000/push" } });
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("every request has a bounded timeout", async () => {
|
|
83
|
-
const client = pubsubClient("http://messaging:8085", "spectest", async (_, init) => {
|
|
84
|
-
return new Promise<Response>((_, reject) => init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }));
|
|
85
|
-
});
|
|
86
|
-
await expect(client.pull("worker", { timeoutMs: 10 })).rejects.toThrow();
|
|
87
|
-
});
|
|
88
|
-
});
|
package/src/components/pubsub.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import type { ServiceDefinition, ServiceHelpersContext, ServiceSetupContext, ServicesMap } from "../index.js";
|
|
2
|
-
import { serviceGroup, certificate } from "../index.js";
|
|
3
|
-
import { rawFetch } from "../harness/raw-fetch.js";
|
|
4
|
-
import { readRaw } from "../inspect.js";
|
|
5
|
-
|
|
6
|
-
export interface PubSubSubscriptionOptions {
|
|
7
|
-
/** Topic ID in this component's project. */
|
|
8
|
-
topic: string;
|
|
9
|
-
ackDeadlineSeconds?: number;
|
|
10
|
-
/** HTTP is supported by the emulator, including other service names. */
|
|
11
|
-
pushEndpoint?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface PubSubOptions {
|
|
15
|
-
/** Local project ID; no Google Cloud account is needed. Default "spectest". */
|
|
16
|
-
projectId?: string;
|
|
17
|
-
/** Official Cloud CLI image with the Pub/Sub emulator installed. */
|
|
18
|
-
image?: string;
|
|
19
|
-
/** Emulator HTTP and gRPC port. Default 8085. */
|
|
20
|
-
port?: number;
|
|
21
|
-
/** TLS on pubsub.googleapis.com by default. Set false for direct emulator
|
|
22
|
-
* access, or choose a different hostname for a second isolated instance. */
|
|
23
|
-
tls?: false | { hostname: string };
|
|
24
|
-
topics?: string[];
|
|
25
|
-
/** Subscription ID → options. Referenced topics are created automatically. */
|
|
26
|
-
subscriptions?: Record<string, PubSubSubscriptionOptions>;
|
|
27
|
-
/** Runs after the declared topics and subscriptions have been created. */
|
|
28
|
-
setup?: (ctx: ServiceSetupContext<PubSubHelpers>) => void | Promise<void>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface PubSubMessage {
|
|
32
|
-
/** Base64 encoded data, as returned by the Pub/Sub REST API. */
|
|
33
|
-
data?: string;
|
|
34
|
-
attributes?: Record<string, string>;
|
|
35
|
-
messageId: string;
|
|
36
|
-
publishTime: string;
|
|
37
|
-
orderingKey?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface PubSubReceivedMessage {
|
|
41
|
-
ackId: string;
|
|
42
|
-
message: PubSubMessage;
|
|
43
|
-
deliveryAttempt?: number;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function resourceId(id: string): string {
|
|
47
|
-
if (!/^[A-Za-z][A-Za-z0-9._~+%-]{2,254}$/.test(id) || id.startsWith("goog")) {
|
|
48
|
-
throw new Error(`pubsub(): invalid topic or subscription ID ${JSON.stringify(id)}; use a 3–255 character ID starting with a letter, not "goog"`);
|
|
49
|
-
}
|
|
50
|
-
return id;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function projectName(id: string): string {
|
|
54
|
-
if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(id)) throw new Error("pubsub(): projectId must be a 6–30 character Google Cloud project ID");
|
|
55
|
-
return id;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function subscriptionOptions(options: PubSubSubscriptionOptions) {
|
|
59
|
-
resourceId(options.topic);
|
|
60
|
-
const seconds = options.ackDeadlineSeconds ?? 10;
|
|
61
|
-
if (!Number.isInteger(seconds) || seconds < 10 || seconds > 600) throw new Error("pubsub(): ackDeadlineSeconds must be an integer between 10 and 600");
|
|
62
|
-
if (options.pushEndpoint && !/^https?:$/.test(new URL(options.pushEndpoint).protocol)) throw new Error("pubsub(): pushEndpoint must use HTTP or HTTPS");
|
|
63
|
-
return seconds;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Stateless emulator REST client. No credentials or gRPC channel to preserve across forks.
|
|
67
|
-
* Resource arguments are short IDs in projectId; publish accepts UTF-8 text or bytes.
|
|
68
|
-
*/
|
|
69
|
-
export function pubsubClient(
|
|
70
|
-
endpoint: string,
|
|
71
|
-
projectId = "spectest",
|
|
72
|
-
fetcher: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = rawFetch,
|
|
73
|
-
) {
|
|
74
|
-
projectName(projectId);
|
|
75
|
-
const base = endpoint.replace(/\/$/, "");
|
|
76
|
-
const resource = (kind: "topics" | "subscriptions", id: string) => `projects/${projectId}/${kind}/${resourceId(id)}`;
|
|
77
|
-
const path = (kind: "topics" | "subscriptions", id: string) => `/v1/projects/${projectId}/${kind}/${encodeURIComponent(resourceId(id))}`;
|
|
78
|
-
async function request<T>(url: string, method: string, body?: unknown, timeoutMs = 10000): Promise<T> {
|
|
79
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("pubsub(): timeoutMs must be positive");
|
|
80
|
-
const response = await fetcher(`${base}${url}`, {
|
|
81
|
-
method, headers: { "content-type": "application/json" },
|
|
82
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
83
|
-
signal: AbortSignal.timeout(Math.ceil(timeoutMs)),
|
|
84
|
-
});
|
|
85
|
-
const text = await response.text();
|
|
86
|
-
if (!response.ok) throw new Error(`Pub/Sub ${method} ${url}: HTTP ${response.status}: ${text.slice(0, 1000)}`);
|
|
87
|
-
return (text ? JSON.parse(text) : {}) as T;
|
|
88
|
-
}
|
|
89
|
-
return {
|
|
90
|
-
projectId, endpoint: base,
|
|
91
|
-
topicName: (id: string) => resource("topics", id),
|
|
92
|
-
subscriptionName: (id: string) => resource("subscriptions", id),
|
|
93
|
-
createTopic(id: string) {
|
|
94
|
-
return request<{ name: string }>(path("topics", id), "PUT", {});
|
|
95
|
-
},
|
|
96
|
-
createSubscription(id: string, options: PubSubSubscriptionOptions) {
|
|
97
|
-
const ackDeadlineSeconds = subscriptionOptions(options);
|
|
98
|
-
return request<{ name: string; topic: string; ackDeadlineSeconds: number }>(path("subscriptions", id), "PUT", {
|
|
99
|
-
topic: resource("topics", options.topic), ackDeadlineSeconds,
|
|
100
|
-
...(options.pushEndpoint ? { pushConfig: { pushEndpoint: options.pushEndpoint } } : {}),
|
|
101
|
-
});
|
|
102
|
-
},
|
|
103
|
-
async publish(topic: string, data: string | Uint8Array, attributes?: Record<string, string>): Promise<string> {
|
|
104
|
-
const result = await request<{ messageIds: string[] }>(`${path("topics", topic)}:publish`, "POST", {
|
|
105
|
-
messages: [{ data: Buffer.from(data).toString("base64"), ...(attributes ? { attributes } : {}) }],
|
|
106
|
-
});
|
|
107
|
-
return result.messageIds[0];
|
|
108
|
-
},
|
|
109
|
-
/** A pull leases messages; call acknowledge() to remove them. Empty is valid.
|
|
110
|
-
* Uses the emulator's immediate pull mode for bounded test polling.
|
|
111
|
-
*/
|
|
112
|
-
async pull(subscription: string, options: { maxMessages?: number; timeoutMs?: number } = {}): Promise<PubSubReceivedMessage[]> {
|
|
113
|
-
const maxMessages = options.maxMessages ?? 10;
|
|
114
|
-
if (!Number.isInteger(maxMessages) || maxMessages < 1) throw new Error("pubsub(): maxMessages must be a positive integer");
|
|
115
|
-
const result = await request<{ receivedMessages?: PubSubReceivedMessage[] }>(`${path("subscriptions", subscription)}:pull`, "POST",
|
|
116
|
-
{ maxMessages, returnImmediately: true }, options.timeoutMs);
|
|
117
|
-
return result.receivedMessages ?? [];
|
|
118
|
-
},
|
|
119
|
-
async acknowledge(subscription: string, ackIds: string[]): Promise<void> {
|
|
120
|
-
await request(`${path("subscriptions", subscription)}:acknowledge`, "POST", { ackIds });
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export type PubSubClient = ReturnType<typeof pubsubClient>;
|
|
126
|
-
export interface PubSubHelpers extends PubSubClient {
|
|
127
|
-
/** Direct plaintext address for clients explicitly using emulator mode. */
|
|
128
|
-
emulatorHost: string;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Google Cloud's real local Pub/Sub emulator, ready for Google client libraries.
|
|
132
|
-
* Mount directly in services: { pubsub: pubsub({ topics: ["orders"] }) }.
|
|
133
|
-
* Applications use their default pubsub.googleapis.com:443 endpoint with
|
|
134
|
-
* verified TLS and local test credentials. No emulator endpoint override.
|
|
135
|
-
* State survives VM snapshots/forks, but not an emulator process restart.
|
|
136
|
-
*/
|
|
137
|
-
export function pubsub(opts: PubSubOptions = {}) {
|
|
138
|
-
const projectId = projectName(opts.projectId ?? "spectest");
|
|
139
|
-
const port = opts.port ?? 8085;
|
|
140
|
-
const hostname = opts.tls === false ? undefined : opts.tls?.hostname ?? "pubsub.googleapis.com";
|
|
141
|
-
if (hostname !== undefined) {
|
|
142
|
-
certificate([hostname]);
|
|
143
|
-
if (hostname.startsWith("*.")) throw new Error("pubsub(): TLS hostname must be an exact hostname");
|
|
144
|
-
}
|
|
145
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("pubsub(): port must be an integer between 1 and 65535");
|
|
146
|
-
const subscriptions = Object.entries(opts.subscriptions ?? {});
|
|
147
|
-
for (const [id, options] of subscriptions) { resourceId(id); subscriptionOptions(options); }
|
|
148
|
-
const topics = [...new Set([...(opts.topics ?? []), ...subscriptions.map(([, options]) => options.topic)])].map(resourceId);
|
|
149
|
-
if (hostname && port === 443) throw new Error("pubsub(): the plaintext emulator port must differ from TLS port 443");
|
|
150
|
-
return serviceGroup({
|
|
151
|
-
primary: "emulator",
|
|
152
|
-
services: (): ServicesMap => {
|
|
153
|
-
const emulator: ServiceDefinition = {
|
|
154
|
-
image: { type: "registry", reference: opts.image ?? "gcr.io/google.com/cloudsdktool/google-cloud-cli:573.0.0-emulators" },
|
|
155
|
-
args: ["gcloud", "beta", "emulators", "pubsub", "start", `--project=${projectId}`, `--host-port=0.0.0.0:${port}`, "--quiet"],
|
|
156
|
-
ports: [port],
|
|
157
|
-
...(hostname ? { tls: [{ hostname, port, http2: true }] } : {}),
|
|
158
|
-
readyCheck: { type: "http", port, path: `/v1/projects/${projectId}/topics`, timeoutSecs: 60 },
|
|
159
|
-
setup: async ({ name }) => {
|
|
160
|
-
const client = pubsubClient(`http://${name}:${port}`, projectId);
|
|
161
|
-
for (const topic of topics) await client.createTopic(topic);
|
|
162
|
-
for (const [id, options] of subscriptions) await client.createSubscription(id, options);
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
return { emulator };
|
|
166
|
-
},
|
|
167
|
-
helpers: (ctx: ServiceHelpersContext): PubSubHelpers => ({
|
|
168
|
-
...pubsubClient(hostname ? `https://${hostname}` : `http://${ctx.name}:${port}`, projectId,
|
|
169
|
-
async (input, init) => readRaw(await ctx.fetch(String(input), init)) as unknown as Response),
|
|
170
|
-
emulatorHost: `${ctx.name}:${port}`,
|
|
171
|
-
}),
|
|
172
|
-
setup: async (ctx: ServiceSetupContext<PubSubHelpers>) => {
|
|
173
|
-
// Prove the TLS endpoint and certificate are ready before dependents.
|
|
174
|
-
if (hostname) {
|
|
175
|
-
const response = await rawFetch(`https://${hostname}/v1/projects/${projectId}/topics`, { signal: AbortSignal.timeout(10000) });
|
|
176
|
-
if (!response.ok) throw new Error(`pubsub(): TLS readiness failed: HTTP ${response.status}`);
|
|
177
|
-
await response.text();
|
|
178
|
-
}
|
|
179
|
-
await opts.setup?.(ctx);
|
|
180
|
-
},
|
|
181
|
-
});
|
|
182
|
-
}
|