@specific.dev/spectest 0.44.0 → 0.46.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/dist/daemon.js CHANGED
@@ -34,6 +34,7 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
34
34
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
35
35
  import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
36
36
  import { pollUntilReady } from "./harness/ready-poll.js";
37
+ import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
37
38
  import { encodeRegistry } from "./harness/names-registry.js";
38
39
  import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
39
40
  import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, wildcardSuffix, } from "./harness/hostmatch.js";
@@ -3072,15 +3073,34 @@ function installFetchWrapper() {
3072
3073
  const res = await original(input, init);
3073
3074
  let responseBody;
3074
3075
  let responseBodyTruncated;
3075
- try {
3076
- const cloned = res.clone();
3077
- const text = await cloned.text();
3078
- const t = truncateUtf8(text);
3079
- responseBody = t.value;
3080
- responseBodyTruncated = t.truncated;
3076
+ // The reply the test gets is untouched: everything here reads a clone,
3077
+ // and a body known to be binary is not read at all. See
3078
+ // ./harness/http-body.ts for why a blanket `.text()` was wrong.
3079
+ const contentType = res.headers.get("content-type");
3080
+ const contentLength = () => parseContentLength(res.headers.get("content-length"));
3081
+ const textual = isTextualContentType(contentType);
3082
+ if (textual === false) {
3083
+ responseBody = omittedBody("binary", contentType, contentLength());
3081
3084
  }
3082
- catch {
3083
- // Binary or unreadable body — leave undefined.
3085
+ else {
3086
+ try {
3087
+ const cloned = res.clone();
3088
+ const text = await cloned.text();
3089
+ if (textual === undefined && looksBinary(text)) {
3090
+ // No content type (or a multipart one), and the bytes say this
3091
+ // was never text.
3092
+ responseBody = omittedBody("binary", contentType, contentLength());
3093
+ }
3094
+ else {
3095
+ const t = truncateUtf8(text);
3096
+ responseBody = t.value;
3097
+ responseBodyTruncated = t.truncated;
3098
+ }
3099
+ }
3100
+ catch {
3101
+ // The stream failed, or something had already consumed the body.
3102
+ responseBody = omittedBody("unreadable", contentType, contentLength());
3103
+ }
3084
3104
  }
3085
3105
  const seq = recordHttp({
3086
3106
  method,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What to record for an HTTP response body.
3
+ *
4
+ * The fetch recorder used to call `Response.text()` on every reply. That is
5
+ * correct for text and wrong for bytes. `text()` decodes as UTF-8 and it
6
+ * does **not** throw on binary: each undecodable byte becomes U+FFFD and
7
+ * each NUL byte survives as U+0000. So a PNG was recorded as a string that
8
+ * can no longer rebuild the image, and that string was then stored, shipped
9
+ * to the CLI, and drawn in the dashboard as noise. The `catch` that was
10
+ * supposed to cover this case never ran.
11
+ *
12
+ * The response is never changed by any of this — the recorder reads a clone,
13
+ * and an image reply reaches the test exactly as the server sent it. Only
14
+ * the recorded description changes: `responseBody` carries an
15
+ * {@link OmittedBody} naming and measuring the reply, in place of the text
16
+ * it would otherwise hold.
17
+ *
18
+ * Pure, so the boundary cases are tested rather than inferred.
19
+ */
20
+ import type { OmittedBody } from "../recorder.js";
21
+ /**
22
+ * Is this content type text?
23
+ *
24
+ * `true` = record the body. `false` = record a placeholder and do not even
25
+ * read the body. `undefined` = the type does not say (it is absent, or it is
26
+ * `multipart/*`, which mixes both), so the caller must read the body and
27
+ * examine it with [`looksBinary`].
28
+ */
29
+ export declare function isTextualContentType(contentType: string | null | undefined): boolean | undefined;
30
+ /**
31
+ * Does decoded text hold bytes that were never text?
32
+ *
33
+ * Only U+0000 counts. It cannot appear in a body that a server meant as
34
+ * text, and it is also the one character Postgres refuses inside a `jsonb`
35
+ * string, so a payload that holds it fails the event insert.
36
+ *
37
+ * A count of U+FFFD would find more binaries, and it would also condemn a
38
+ * text reply that carries a few characters of broken encoding. This runs
39
+ * only where the content type gave no answer, so the cheap test is enough.
40
+ */
41
+ export declare function looksBinary(text: string): boolean;
42
+ /**
43
+ * What the body field carries when the body itself was not recorded.
44
+ *
45
+ * Separate fields, not a sentence: a reader can group by content type, sum
46
+ * the bytes, or count what was dropped. A rendered string like
47
+ * "[binary body: image/png, 70 bytes]" can do none of that, and every
48
+ * consumer would have to parse it back apart.
49
+ *
50
+ * It replaces the body rather than sitting beside it, so the two states
51
+ * cannot both be set and there is one key to read.
52
+ */
53
+ export declare function omittedBody(reason: OmittedBody["reason"], contentType?: string | null, byteLength?: number | null): OmittedBody;
54
+ /** `Content-Length` as a number, or `undefined` if it is absent or unusable. */
55
+ export declare function parseContentLength(value: string | null | undefined): number | undefined;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * What to record for an HTTP response body.
3
+ *
4
+ * The fetch recorder used to call `Response.text()` on every reply. That is
5
+ * correct for text and wrong for bytes. `text()` decodes as UTF-8 and it
6
+ * does **not** throw on binary: each undecodable byte becomes U+FFFD and
7
+ * each NUL byte survives as U+0000. So a PNG was recorded as a string that
8
+ * can no longer rebuild the image, and that string was then stored, shipped
9
+ * to the CLI, and drawn in the dashboard as noise. The `catch` that was
10
+ * supposed to cover this case never ran.
11
+ *
12
+ * The response is never changed by any of this — the recorder reads a clone,
13
+ * and an image reply reaches the test exactly as the server sent it. Only
14
+ * the recorded description changes: `responseBody` carries an
15
+ * {@link OmittedBody} naming and measuring the reply, in place of the text
16
+ * it would otherwise hold.
17
+ *
18
+ * Pure, so the boundary cases are tested rather than inferred.
19
+ */
20
+ /**
21
+ * Is this content type text?
22
+ *
23
+ * `true` = record the body. `false` = record a placeholder and do not even
24
+ * read the body. `undefined` = the type does not say (it is absent, or it is
25
+ * `multipart/*`, which mixes both), so the caller must read the body and
26
+ * examine it with [`looksBinary`].
27
+ */
28
+ export function isTextualContentType(contentType) {
29
+ const essence = contentTypeEssence(contentType);
30
+ if (!essence)
31
+ return undefined;
32
+ // Structured-syntax suffixes come first: `image/svg+xml` is text, and its
33
+ // type alone would call it an image.
34
+ if (essence.endsWith("+json") || essence.endsWith("+xml") || essence.endsWith("+yaml")) {
35
+ return true;
36
+ }
37
+ if (essence.startsWith("text/"))
38
+ return true;
39
+ if (TEXTUAL_TYPES.has(essence))
40
+ return true;
41
+ // A multipart body holds text fields and file parts together. Only the
42
+ // bytes can say which one this is.
43
+ if (essence.startsWith("multipart/"))
44
+ return undefined;
45
+ return false;
46
+ }
47
+ /**
48
+ * Does decoded text hold bytes that were never text?
49
+ *
50
+ * Only U+0000 counts. It cannot appear in a body that a server meant as
51
+ * text, and it is also the one character Postgres refuses inside a `jsonb`
52
+ * string, so a payload that holds it fails the event insert.
53
+ *
54
+ * A count of U+FFFD would find more binaries, and it would also condemn a
55
+ * text reply that carries a few characters of broken encoding. This runs
56
+ * only where the content type gave no answer, so the cheap test is enough.
57
+ */
58
+ export function looksBinary(text) {
59
+ return text.includes("\u0000");
60
+ }
61
+ /**
62
+ * What the body field carries when the body itself was not recorded.
63
+ *
64
+ * Separate fields, not a sentence: a reader can group by content type, sum
65
+ * the bytes, or count what was dropped. A rendered string like
66
+ * "[binary body: image/png, 70 bytes]" can do none of that, and every
67
+ * consumer would have to parse it back apart.
68
+ *
69
+ * It replaces the body rather than sitting beside it, so the two states
70
+ * cannot both be set and there is one key to read.
71
+ */
72
+ export function omittedBody(reason, contentType, byteLength) {
73
+ const out = { reason };
74
+ const essence = contentTypeEssence(contentType);
75
+ if (essence)
76
+ out.contentType = essence;
77
+ if (typeof byteLength === "number" && Number.isFinite(byteLength) && byteLength >= 0) {
78
+ out.bytes = byteLength;
79
+ }
80
+ return out;
81
+ }
82
+ /** `Content-Length` as a number, or `undefined` if it is absent or unusable. */
83
+ export function parseContentLength(value) {
84
+ if (!value)
85
+ return undefined;
86
+ const n = Number(value.trim());
87
+ return Number.isSafeInteger(n) && n >= 0 ? n : undefined;
88
+ }
89
+ /** `image/png; charset=x` → `image/png`. Empty string → `undefined`. */
90
+ function contentTypeEssence(contentType) {
91
+ const essence = (contentType ?? "").split(";")[0]?.trim().toLowerCase();
92
+ return essence ? essence : undefined;
93
+ }
94
+ /**
95
+ * Text types whose name does not say so. Everything else is caught by
96
+ * `text/` or by a `+json`/`+xml`/`+yaml` suffix.
97
+ */
98
+ const TEXTUAL_TYPES = new Set([
99
+ "application/json",
100
+ "application/xml",
101
+ "application/yaml",
102
+ "application/x-yaml",
103
+ "application/javascript",
104
+ "application/x-javascript",
105
+ "application/ecmascript",
106
+ "application/graphql",
107
+ "application/graphql-response+json",
108
+ "application/x-ndjson",
109
+ "application/ndjson",
110
+ "application/jsonl",
111
+ "application/x-www-form-urlencoded",
112
+ "application/x-sh",
113
+ "application/sql",
114
+ "application/csp-report",
115
+ "application/problem+json",
116
+ ]);
@@ -171,6 +171,31 @@ export interface S3Event extends BaseEvent {
171
171
  /** Set if the op threw. */
172
172
  error?: string;
173
173
  }
174
+ /**
175
+ * A body that was not captured, described in place of the body itself.
176
+ *
177
+ * This is the object form of `requestBody` / `responseBody`: the field holds
178
+ * the text when the body was text, and this when it was not. One key, and
179
+ * the two states cannot both be set. A reader tells them apart with
180
+ * `typeof body === "string"` (`jsonb_typeof(...)` in SQL).
181
+ *
182
+ * A body that is not text is described rather than transcribed. The recorder
183
+ * used to force one into a string with `Response.text()`, which decodes
184
+ * lossily and cannot be reversed — see `harness/http-body.ts`. The fields are
185
+ * separate values, not a sentence, so a reader can group by content type,
186
+ * sum the bytes, or find every reply that was dropped.
187
+ */
188
+ export interface OmittedBody {
189
+ /** `binary` — the body was not text. `unreadable` — the stream failed, or
190
+ * something had already consumed it. */
191
+ reason: "binary" | "unreadable";
192
+ /** The content type the server declared, without its parameters. Absent
193
+ * when the server declared none. */
194
+ contentType?: string;
195
+ /** Size from `Content-Length`. Absent when the server declared none — the
196
+ * body is never read to count it. */
197
+ bytes?: number;
198
+ }
174
199
  export interface HttpEvent extends BaseEvent {
175
200
  kind: "http";
176
201
  method: string;
@@ -179,7 +204,10 @@ export interface HttpEvent extends BaseEvent {
179
204
  requestBody?: string;
180
205
  requestBodyTruncated?: boolean;
181
206
  status?: number;
182
- responseBody?: string;
207
+ /** The response body as text, or an {@link OmittedBody} describing what
208
+ * was there when it was not text. */
209
+ responseBody?: string | OmittedBody;
210
+ /** Only meaningful for a text body. */
183
211
  responseBodyTruncated?: boolean;
184
212
  durationMs: number;
185
213
  /** Set if the request threw (network error, abort, etc.). */
@@ -222,7 +250,10 @@ export interface KubeEvent extends BaseEvent {
222
250
  requestBody?: string;
223
251
  requestBodyTruncated?: boolean;
224
252
  status?: number;
225
- responseBody?: string;
253
+ /** The response body as text, or an {@link OmittedBody} describing what
254
+ * was there when it was not text. */
255
+ responseBody?: string | OmittedBody;
256
+ /** Only meaningful for a text body. */
226
257
  responseBodyTruncated?: boolean;
227
258
  durationMs: number;
228
259
  error?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.44.0",
3
+ "version": "0.46.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,261 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { mapSupabaseConfig } from "./supabase-config.js";
4
+
5
+ const toml = (s: string): unknown =>
6
+ (globalThis as unknown as { Bun: { TOML: { parse(s: string): unknown } } }).Bun.TOML.parse(s);
7
+
8
+ describe("mapSupabaseConfig", () => {
9
+ test("maps the auth settings a test can observe", () => {
10
+ const out = mapSupabaseConfig(
11
+ toml(`
12
+ [auth]
13
+ jwt_expiry = 7200
14
+ minimum_password_length = 10
15
+
16
+ [auth.email]
17
+ otp_length = 8
18
+
19
+ [auth.hook.before_user_created]
20
+ enabled = true
21
+ uri = "pg-functions://postgres/public/hook_restrict_signup"
22
+ `),
23
+ );
24
+
25
+ expect(out.auth).toMatchObject({
26
+ GOTRUE_JWT_EXP: "7200",
27
+ GOTRUE_PASSWORD_MIN_LENGTH: "10",
28
+ GOTRUE_MAILER_OTP_LENGTH: "8",
29
+ GOTRUE_HOOK_BEFORE_USER_CREATED_ENABLED: "true",
30
+ GOTRUE_HOOK_BEFORE_USER_CREATED_URI: "pg-functions://postgres/public/hook_restrict_signup",
31
+ });
32
+ expect(out.warnings).toEqual([]);
33
+ });
34
+
35
+ // The two mappings a copy-paste gets backwards without anyone noticing.
36
+ test("negates enable_signup and enable_confirmations", () => {
37
+ const on = mapSupabaseConfig(
38
+ toml(`
39
+ [auth]
40
+ enable_signup = true
41
+ [auth.email]
42
+ enable_confirmations = true
43
+ `),
44
+ );
45
+ expect(on.auth.GOTRUE_DISABLE_SIGNUP).toBe("false");
46
+ expect(on.auth.GOTRUE_MAILER_AUTOCONFIRM).toBe("false");
47
+
48
+ const off = mapSupabaseConfig(
49
+ toml(`
50
+ [auth]
51
+ enable_signup = false
52
+ [auth.email]
53
+ enable_confirmations = false
54
+ `),
55
+ );
56
+ expect(off.auth.GOTRUE_DISABLE_SIGNUP).toBe("true");
57
+ expect(off.auth.GOTRUE_MAILER_AUTOCONFIRM).toBe("true");
58
+ });
59
+
60
+ test("collects mail templates by id", () => {
61
+ const out = mapSupabaseConfig(
62
+ toml(`
63
+ [auth.email.template.invite]
64
+ subject = "You've been invited"
65
+ content_path = "./supabase/templates/invite.html"
66
+ `),
67
+ );
68
+ expect(out.templates.invite).toEqual({
69
+ path: "./supabase/templates/invite.html",
70
+ subject: "You've been invited",
71
+ });
72
+ });
73
+
74
+ // A subject with no content_path is how the CLI spells "default body, custom
75
+ // subject" — it must not invent a template path.
76
+ test("keeps a subject-only template out of the served set", () => {
77
+ const out = mapSupabaseConfig(
78
+ toml(`
79
+ [auth.email.template.recovery]
80
+ subject = "Reset it"
81
+ content_path = ""
82
+ `),
83
+ );
84
+ expect(out.templates.recovery).toEqual({ path: "", subject: "Reset it" });
85
+ });
86
+
87
+ test("reads per-function verify_jwt and the api schemas", () => {
88
+ const out = mapSupabaseConfig(
89
+ toml(`
90
+ [api]
91
+ schemas = ["public", "graphql_public"]
92
+ max_rows = 500
93
+ port = 54321
94
+
95
+ [functions.invoice-api]
96
+ verify_jwt = false
97
+ [functions.account-api]
98
+ verify_jwt = true
99
+ `),
100
+ );
101
+ expect(out.functionVerifyJwt).toEqual({ "invoice-api": false, "account-api": true });
102
+ expect(out.rest).toEqual({ PGRST_DB_SCHEMAS: "public,graphql_public", PGRST_DB_MAX_ROWS: "500" });
103
+ });
104
+
105
+ // Addresses are this environment's, not the file's: its URLs name the
106
+ // developer's machine, where nothing is listening inside a VM.
107
+ test("never takes site_url or the redirect allow list from the file", () => {
108
+ const out = mapSupabaseConfig(
109
+ toml(`
110
+ [auth]
111
+ site_url = "http://localhost:3000"
112
+ additional_redirect_urls = ["http://localhost:3000/**"]
113
+ `),
114
+ );
115
+ expect(out.auth).toEqual({});
116
+ expect(out.unsupported).toEqual([]);
117
+ expect(out.warnings).toEqual([]);
118
+ });
119
+
120
+ // Reported, never fatal — the file belongs to the project and describes more
121
+ // environments than this one.
122
+ test("reports a setting it cannot apply, with the reason", () => {
123
+ const out = mapSupabaseConfig(
124
+ toml(`
125
+ [auth.captcha]
126
+ enabled = true
127
+ provider = "hcaptcha"
128
+ secret = "env(HCAPTCHA_SECRET)"
129
+ `),
130
+ );
131
+ expect(out.unsupported).toHaveLength(1);
132
+ expect(out.unsupported[0]!.key).toBe("auth.captcha");
133
+ expect(out.unsupported[0]!.reason).toMatch(/captcha/);
134
+ });
135
+
136
+ // Social sign-in is collected, not refused: the component serves each
137
+ // provider it has an emulator for at that provider's real endpoints.
138
+ test("collects the social providers and where their credentials come from", () => {
139
+ const out = mapSupabaseConfig(
140
+ toml(`
141
+ [auth.external.google]
142
+ enabled = true
143
+ client_id = "env(GOOGLE_OAUTH_CLIENT_ID)"
144
+ secret = "env(GOOGLE_OAUTH_SECRET)"
145
+
146
+ [auth.external.azure]
147
+ enabled = true
148
+ client_id = "literal-azure-id"
149
+ url = "https://login.microsoftonline.com/tenant-1/v2.0"
150
+
151
+ [auth.external.facebook]
152
+ enabled = false
153
+ `),
154
+ );
155
+ expect(out.externalProviders.google).toEqual({
156
+ enabled: true,
157
+ clientIdFrom: "GOOGLE_OAUTH_CLIENT_ID",
158
+ secretFrom: "GOOGLE_OAUTH_SECRET",
159
+ });
160
+ expect(out.externalProviders.azure).toEqual({
161
+ enabled: true,
162
+ clientId: "literal-azure-id",
163
+ url: "https://login.microsoftonline.com/tenant-1/v2.0",
164
+ });
165
+ expect(out.externalProviders.facebook!.enabled).toBe(false);
166
+ expect(out.unsupported).toEqual([]);
167
+ });
168
+
169
+ // The common shape: a config lists a dozen providers with every one off.
170
+ // Nothing is lost by not applying them, so nothing is said.
171
+ test("stays quiet about an unsupported feature that is switched off", () => {
172
+ const out = mapSupabaseConfig(
173
+ toml(`
174
+ [auth.captcha]
175
+ enabled = false
176
+ provider = "hcaptcha"
177
+
178
+ [auth.sms]
179
+ enable_signup = false
180
+ template = "code {{ .Code }}"
181
+ `),
182
+ );
183
+ expect(out.unsupported).toEqual([]);
184
+ });
185
+
186
+ test("reports a section where only one feature is enabled", () => {
187
+ const out = mapSupabaseConfig(
188
+ toml(`
189
+ [auth.mfa.phone]
190
+ enroll_enabled = true
191
+ verify_enabled = false
192
+ `),
193
+ );
194
+ expect(out.unsupported.map((u) => u.key)).toEqual(["auth.mfa.phone"]);
195
+ });
196
+
197
+ // Both ways the file defers a value to the surrounding environment. Passing
198
+ // either through would hand a function the string `env(NAME)` or a base64
199
+ // ciphertext — the latter looking enough like a value to go unnoticed.
200
+ test("separates literal secrets from the two deferred forms", () => {
201
+ const out = mapSupabaseConfig(
202
+ toml(`
203
+ [edge_runtime.secrets]
204
+ FEATURE_FLAG = "true"
205
+ STRIPE_KEY = "env(STRIPE_SECRET_KEY)"
206
+ SENDGRID_KEY = "encrypted:c2VjcmV0LXZhbHVl"
207
+ `),
208
+ );
209
+ expect(out.functionSecrets).toEqual({ FEATURE_FLAG: "true" });
210
+ // `env(NAME)` records the name to read it from…
211
+ expect(out.deferredSecrets).toEqual({ STRIPE_KEY: "STRIPE_SECRET_KEY" });
212
+ // …while an inline ciphertext names nothing and cannot be supplied here.
213
+ expect(out.encryptedValues).toEqual(["edge_runtime.secrets.SENDGRID_KEY"]);
214
+ });
215
+
216
+ // A provider credential reads from the same `.env` as everything else — it
217
+ // is a field on the project's auth settings rather than a named secret, but
218
+ // the value gets there the same way.
219
+ test("records where a provider credential is read from", () => {
220
+ const out = mapSupabaseConfig(
221
+ toml(`
222
+ [auth.external.google]
223
+ enabled = true
224
+ client_id = "env(GOOGLE_OAUTH_CLIENT_ID)"
225
+ secret = "encrypted:c2hoaA=="
226
+ `),
227
+ );
228
+ expect(out.externalProviders.google).toEqual({
229
+ enabled: true,
230
+ clientIdFrom: "GOOGLE_OAUTH_CLIENT_ID",
231
+ });
232
+ // The ciphertext names nothing, so it is reported instead of guessed at.
233
+ expect(out.encryptedValues).toEqual(["auth.external.google.secret"]);
234
+ });
235
+
236
+ test("warns rather than fails on a key it has never seen", () => {
237
+ const out = mapSupabaseConfig(toml(`\n[auth]\nfuture_option = true\n`));
238
+ expect(out.warnings).toEqual(["auth.future_option"]);
239
+ });
240
+
241
+ // Sections spectest owns outright never reach the user as noise.
242
+ test("drops the topology sections in silence", () => {
243
+ const out = mapSupabaseConfig(
244
+ toml(`
245
+ project_id = "test"
246
+ [db]
247
+ port = 54322
248
+ [db.pooler]
249
+ enabled = true
250
+ [studio]
251
+ port = 54323
252
+ [inbucket]
253
+ port = 54324
254
+ [remotes.production.auth]
255
+ site_url = "https://example.com"
256
+ `),
257
+ );
258
+ expect(out.warnings).toEqual([]);
259
+ expect(out.auth).toEqual({});
260
+ });
261
+ });