@specific.dev/spectest 0.43.0 → 0.45.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/components/apple.d.ts +47 -0
- package/dist/components/apple.js +59 -0
- package/dist/components/emulate/service.d.ts +126 -0
- package/dist/components/emulate/service.js +153 -0
- package/dist/components/github.d.ts +52 -0
- package/dist/components/github.js +90 -0
- package/dist/components/google.d.ts +57 -0
- package/dist/components/google.js +92 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +5 -0
- package/dist/components/microsoft.d.ts +55 -0
- package/dist/components/microsoft.js +76 -0
- package/dist/components/okta.d.ts +51 -0
- package/dist/components/okta.js +78 -0
- package/dist/daemon.js +28 -8
- package/dist/harness/http-body.d.ts +55 -0
- package/dist/harness/http-body.js +116 -0
- package/dist/recorder.d.ts +33 -2
- package/package.json +1 -1
- package/src/components/apple.ts +65 -0
- package/src/components/emulate/entry.mjs +244 -0
- package/src/components/emulate/service.ts +235 -0
- package/src/components/github.ts +96 -0
- package/src/components/google.ts +104 -0
- package/src/components/index.ts +14 -0
- package/src/components/microsoft.ts +89 -0
- package/src/components/okta.ts +93 -0
- package/src/daemon.ts +33 -9
- package/src/harness/http-body.test.ts +118 -0
- package/src/harness/http-body.ts +122 -0
- package/src/recorder.ts +34 -2
|
@@ -6,4 +6,10 @@ export { expo, type ExpoOptions, type ExpoHelpers, } from "./expo.js";
|
|
|
6
6
|
export { supabase, type SupabaseOptions, type SupabaseHelpers, type SupabaseStack, } from "./supabase.js";
|
|
7
7
|
export { email, type EmailOptions, type EmailHelpers, type EmailMessage, type EmailSummary, type EmailMatch, type EmailAttachment, } from "./email.js";
|
|
8
8
|
export { aws, type AwsOptions, type LambdaOptions, } from "./aws.js";
|
|
9
|
+
export { type ProviderOptions, type ProviderUser, type OAuthClient, } from "./emulate/service.js";
|
|
10
|
+
export { google, type GoogleOptions } from "./google.js";
|
|
11
|
+
export { github, type GithubOptions } from "./github.js";
|
|
12
|
+
export { apple, type AppleOptions } from "./apple.js";
|
|
13
|
+
export { microsoft, type MicrosoftOptions } from "./microsoft.js";
|
|
14
|
+
export { okta, type OktaOptions } from "./okta.js";
|
|
9
15
|
export { replayFake, type ReplayFakeOptions, type ReplayMatch, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
|
package/dist/components/index.js
CHANGED
|
@@ -16,4 +16,9 @@ export { expo, } from "./expo.js";
|
|
|
16
16
|
export { supabase, } from "./supabase.js";
|
|
17
17
|
export { email, } from "./email.js";
|
|
18
18
|
export { aws, } from "./aws.js";
|
|
19
|
+
export { google } from "./google.js";
|
|
20
|
+
export { github } from "./github.js";
|
|
21
|
+
export { apple } from "./apple.js";
|
|
22
|
+
export { microsoft } from "./microsoft.js";
|
|
23
|
+
export { okta } from "./okta.js";
|
|
19
24
|
export { replayFake, } from "./replayFake.js";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ProviderOptions } from "./emulate/service.js";
|
|
2
|
+
export interface MicrosoftOptions extends ProviderOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Directory (tenant) the app signs in against — whatever its authority
|
|
5
|
+
* URL uses. It appears in the issuer and in every endpoint path, so a
|
|
6
|
+
* client that checks the issuer sees what it expects. Default `"common"`.
|
|
7
|
+
*/
|
|
8
|
+
tenantId?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Microsoft, answering at its real endpoints. Drop into
|
|
12
|
+
* `environment.services`:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { microsoft } from "@specific.dev/spectest/components";
|
|
16
|
+
*
|
|
17
|
+
* services: {
|
|
18
|
+
* microsoft: microsoft({
|
|
19
|
+
* tenantId: "11111111-2222-3333-4444-555555555555",
|
|
20
|
+
* users: [{ email: "bob@example.com", name: "Bob Example" }],
|
|
21
|
+
* client: {
|
|
22
|
+
* clientId: "6f1a2b3c-…",
|
|
23
|
+
* clientSecret: "secret",
|
|
24
|
+
* redirectUris: ["https://app.test/callback/microsoft"],
|
|
25
|
+
* },
|
|
26
|
+
* }),
|
|
27
|
+
* app: { …, dependsOn: ["microsoft"] },
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* The app keeps its production configuration — discovery at
|
|
32
|
+
* `https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration`,
|
|
33
|
+
* and the account read back from `https://graph.microsoft.com/v1.0/me`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function microsoft(opts?: MicrosoftOptions): {
|
|
36
|
+
image: {
|
|
37
|
+
type: "dockerfile";
|
|
38
|
+
content: string;
|
|
39
|
+
};
|
|
40
|
+
command: string;
|
|
41
|
+
files: {
|
|
42
|
+
path: string;
|
|
43
|
+
content: string;
|
|
44
|
+
}[];
|
|
45
|
+
env: {
|
|
46
|
+
SPECTEST_AUTH_CONFIG: string;
|
|
47
|
+
};
|
|
48
|
+
ports: number[];
|
|
49
|
+
readyCheck: {
|
|
50
|
+
type: "http";
|
|
51
|
+
port: number;
|
|
52
|
+
path: string;
|
|
53
|
+
timeoutSecs: number;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `microsoft()` — Microsoft, emulated inside the environment, answering at
|
|
2
|
+
// its real endpoints.
|
|
3
|
+
//
|
|
4
|
+
// Today that is Sign in with Microsoft (Entra ID) plus the part of Graph an
|
|
5
|
+
// app reads straight after: discovery, the account picker, the token
|
|
6
|
+
// exchange, `/oidc/userinfo` and `/v1.0/me`. The component is named for the
|
|
7
|
+
// provider rather than for the feature — Graph is already claimed, and
|
|
8
|
+
// widening what it serves is a table entry.
|
|
9
|
+
import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
|
|
10
|
+
function spec(users, opts) {
|
|
11
|
+
const authority = `https://login.microsoftonline.com/${opts.tenantId || "common"}`;
|
|
12
|
+
return {
|
|
13
|
+
name: "microsoft",
|
|
14
|
+
module: "@emulators/microsoft",
|
|
15
|
+
pluginExport: "microsoftPlugin",
|
|
16
|
+
baseUrl: "https://login.microsoftonline.com",
|
|
17
|
+
hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
|
|
18
|
+
// The emulator has no tenant, so its issuer is the bare host. A client
|
|
19
|
+
// that checks the issuer against the authority it configured would
|
|
20
|
+
// reject that; the re-issue step corrects it.
|
|
21
|
+
issuer: `${authority}/v2.0`,
|
|
22
|
+
discovery: {
|
|
23
|
+
issuer: `${authority}/v2.0`,
|
|
24
|
+
authorization_endpoint: `${authority}/oauth2/v2.0/authorize`,
|
|
25
|
+
token_endpoint: `${authority}/oauth2/v2.0/token`,
|
|
26
|
+
jwks_uri: `${authority}/discovery/v2.0/keys`,
|
|
27
|
+
end_session_endpoint: `${authority}/oauth2/v2.0/logout`,
|
|
28
|
+
userinfo_endpoint: "https://graph.microsoft.com/oidc/userinfo",
|
|
29
|
+
},
|
|
30
|
+
jwksPath: "^/discovery/v2\\.0/keys$",
|
|
31
|
+
rewrites: [
|
|
32
|
+
// Real Entra paths carry the tenant; the emulator's do not. The
|
|
33
|
+
// discovery route is the exception — it handles the tenant itself.
|
|
34
|
+
{ from: "^/[^/]+/(oauth2/v2\\.0/.*)$", to: "/$1" },
|
|
35
|
+
{ from: "^/[^/]+/(discovery/v2\\.0/keys)$", to: "/$1" },
|
|
36
|
+
],
|
|
37
|
+
fallbackUser: {
|
|
38
|
+
login: users[0].email,
|
|
39
|
+
id: 1,
|
|
40
|
+
scopes: ["openid", "email", "profile", "User.Read"],
|
|
41
|
+
},
|
|
42
|
+
seed: {
|
|
43
|
+
users: users.map((u) => ({ email: u.email, name: u.name })),
|
|
44
|
+
...oauthClients(opts.client),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Microsoft, answering at its real endpoints. Drop into
|
|
50
|
+
* `environment.services`:
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { microsoft } from "@specific.dev/spectest/components";
|
|
54
|
+
*
|
|
55
|
+
* services: {
|
|
56
|
+
* microsoft: microsoft({
|
|
57
|
+
* tenantId: "11111111-2222-3333-4444-555555555555",
|
|
58
|
+
* users: [{ email: "bob@example.com", name: "Bob Example" }],
|
|
59
|
+
* client: {
|
|
60
|
+
* clientId: "6f1a2b3c-…",
|
|
61
|
+
* clientSecret: "secret",
|
|
62
|
+
* redirectUris: ["https://app.test/callback/microsoft"],
|
|
63
|
+
* },
|
|
64
|
+
* }),
|
|
65
|
+
* app: { …, dependsOn: ["microsoft"] },
|
|
66
|
+
* }
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* The app keeps its production configuration — discovery at
|
|
70
|
+
* `https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration`,
|
|
71
|
+
* and the account read back from `https://graph.microsoft.com/v1.0/me`.
|
|
72
|
+
*/
|
|
73
|
+
export function microsoft(opts = {}) {
|
|
74
|
+
const users = resolveUsers("microsoft", opts.users);
|
|
75
|
+
return emulatorService(spec(users, opts));
|
|
76
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ProviderOptions } from "./emulate/service.js";
|
|
2
|
+
export interface OktaOptions extends ProviderOptions {
|
|
3
|
+
/** The org's domain, e.g. `"dev-12345.okta.com"`. */
|
|
4
|
+
domain: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Okta, answering at your org's real domain. Drop into
|
|
8
|
+
* `environment.services`:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { okta } from "@specific.dev/spectest/components";
|
|
12
|
+
*
|
|
13
|
+
* services: {
|
|
14
|
+
* okta: okta({
|
|
15
|
+
* domain: "dev-12345.okta.com",
|
|
16
|
+
* users: [{ email: "alice@example.com", name: "Alice Example" }],
|
|
17
|
+
* client: {
|
|
18
|
+
* clientId: "0oaexample",
|
|
19
|
+
* clientSecret: "secret",
|
|
20
|
+
* redirectUris: ["https://app.test/callback/okta"],
|
|
21
|
+
* },
|
|
22
|
+
* }),
|
|
23
|
+
* app: { …, dependsOn: ["okta"] },
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* The app keeps its production configuration — discovery at
|
|
28
|
+
* `https://dev-12345.okta.com/.well-known/openid-configuration`, and
|
|
29
|
+
* everything it names.
|
|
30
|
+
*/
|
|
31
|
+
export declare function okta(opts: OktaOptions): {
|
|
32
|
+
image: {
|
|
33
|
+
type: "dockerfile";
|
|
34
|
+
content: string;
|
|
35
|
+
};
|
|
36
|
+
command: string;
|
|
37
|
+
files: {
|
|
38
|
+
path: string;
|
|
39
|
+
content: string;
|
|
40
|
+
}[];
|
|
41
|
+
env: {
|
|
42
|
+
SPECTEST_AUTH_CONFIG: string;
|
|
43
|
+
};
|
|
44
|
+
ports: number[];
|
|
45
|
+
readyCheck: {
|
|
46
|
+
type: "http";
|
|
47
|
+
port: number;
|
|
48
|
+
path: string;
|
|
49
|
+
timeoutSecs: number;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// `okta()` — Okta, emulated inside the environment, answering at your org's
|
|
2
|
+
// real domain.
|
|
3
|
+
//
|
|
4
|
+
// Today that is Okta sign-in: discovery, the account picker, the token
|
|
5
|
+
// exchange, userinfo, introspection and revocation, on both the org
|
|
6
|
+
// authorization server (`/oauth2/v1/…`) and a custom one
|
|
7
|
+
// (`/oauth2/default/…`). The component is named for the provider rather
|
|
8
|
+
// than for the feature — the backing emulator also carries the users,
|
|
9
|
+
// groups and apps management API.
|
|
10
|
+
//
|
|
11
|
+
// Okta has no fixed hostname: every organization gets its own domain, so
|
|
12
|
+
// `domain` is required where the other providers need nothing.
|
|
13
|
+
import { emulatorService, familyName, givenName, oauthClients, resolveUsers, } from "./emulate/service.js";
|
|
14
|
+
/** Okta's picker identifies an account by its opaque Okta id, so the id has
|
|
15
|
+
* to be seeded rather than generated — otherwise nothing on the page can be
|
|
16
|
+
* mapped back to an address. */
|
|
17
|
+
function oktaId(email) {
|
|
18
|
+
return `u-${email.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
|
|
19
|
+
}
|
|
20
|
+
function spec(users, opts) {
|
|
21
|
+
const domain = opts.domain.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
22
|
+
return {
|
|
23
|
+
name: "okta",
|
|
24
|
+
module: "@emulators/okta",
|
|
25
|
+
pluginExport: "oktaPlugin",
|
|
26
|
+
baseUrl: `https://${domain}`,
|
|
27
|
+
hosts: [domain],
|
|
28
|
+
// Okta's issuer is per authorization server and the emulator already
|
|
29
|
+
// builds it from the base URL, which here is the real org domain.
|
|
30
|
+
jwksPath: "^/oauth2/(?:[^/]+/)?v1/keys$",
|
|
31
|
+
aliases: Object.fromEntries(users.map((u) => [oktaId(u.email), u.email])),
|
|
32
|
+
fallbackUser: { login: users[0].email, id: 1, scopes: ["openid", "profile", "email"] },
|
|
33
|
+
seed: {
|
|
34
|
+
users: users.map((u) => ({
|
|
35
|
+
okta_id: oktaId(u.email),
|
|
36
|
+
// An Okta login is the address, not the GitHub-style handle that
|
|
37
|
+
// `login` carries — it comes back as `preferred_username`.
|
|
38
|
+
login: u.email,
|
|
39
|
+
email: u.email,
|
|
40
|
+
first_name: givenName(u),
|
|
41
|
+
last_name: familyName(u),
|
|
42
|
+
display_name: u.name,
|
|
43
|
+
})),
|
|
44
|
+
...oauthClients(opts.client),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Okta, answering at your org's real domain. Drop into
|
|
50
|
+
* `environment.services`:
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { okta } from "@specific.dev/spectest/components";
|
|
54
|
+
*
|
|
55
|
+
* services: {
|
|
56
|
+
* okta: okta({
|
|
57
|
+
* domain: "dev-12345.okta.com",
|
|
58
|
+
* users: [{ email: "alice@example.com", name: "Alice Example" }],
|
|
59
|
+
* client: {
|
|
60
|
+
* clientId: "0oaexample",
|
|
61
|
+
* clientSecret: "secret",
|
|
62
|
+
* redirectUris: ["https://app.test/callback/okta"],
|
|
63
|
+
* },
|
|
64
|
+
* }),
|
|
65
|
+
* app: { …, dependsOn: ["okta"] },
|
|
66
|
+
* }
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* The app keeps its production configuration — discovery at
|
|
70
|
+
* `https://dev-12345.okta.com/.well-known/openid-configuration`, and
|
|
71
|
+
* everything it names.
|
|
72
|
+
*/
|
|
73
|
+
export function okta(opts) {
|
|
74
|
+
const users = resolveUsers("okta", opts.users);
|
|
75
|
+
if (!opts.domain)
|
|
76
|
+
throw new Error("okta(): `domain` is required (e.g. \"dev-12345.okta.com\")");
|
|
77
|
+
return emulatorService(spec(users, opts));
|
|
78
|
+
}
|
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
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
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
|
-
|
|
3083
|
-
|
|
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
|
+
]);
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `apple()` — Apple, emulated inside the environment, answering at its real
|
|
2
|
+
// endpoints.
|
|
3
|
+
//
|
|
4
|
+
// Today that is Sign in with Apple: discovery, the account picker, the
|
|
5
|
+
// token exchange, the refresh grant, and revocation, all on
|
|
6
|
+
// `appleid.apple.com`.
|
|
7
|
+
//
|
|
8
|
+
// Apple publishes **no userinfo endpoint** — the real one does not either,
|
|
9
|
+
// because for Apple the id_token is the whole profile. An app that wants to
|
|
10
|
+
// re-read an account uses the refresh grant, which is the only
|
|
11
|
+
// server-to-server call Apple offers.
|
|
12
|
+
|
|
13
|
+
import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
|
|
14
|
+
import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
|
|
15
|
+
|
|
16
|
+
export type AppleOptions = ProviderOptions;
|
|
17
|
+
|
|
18
|
+
function spec(users: ProviderUser[], opts: AppleOptions): ProviderSpec {
|
|
19
|
+
return {
|
|
20
|
+
name: "apple",
|
|
21
|
+
module: "@emulators/apple",
|
|
22
|
+
pluginExport: "applePlugin",
|
|
23
|
+
baseUrl: "https://appleid.apple.com",
|
|
24
|
+
// Every Apple endpoint is on this one host, so no path or discovery
|
|
25
|
+
// correction is needed.
|
|
26
|
+
hosts: ["appleid.apple.com"],
|
|
27
|
+
jwksPath: "^/auth/keys$",
|
|
28
|
+
fallbackUser: { login: users[0]!.email, id: 1, scopes: ["openid", "email", "name"] },
|
|
29
|
+
seed: {
|
|
30
|
+
users: users.map((u) => ({ email: u.email, name: u.name })),
|
|
31
|
+
// Apple authenticates the client with a signed assertion rather than
|
|
32
|
+
// a shared secret, so the registration carries a team id instead.
|
|
33
|
+
...oauthClients(opts.client, { team_id: "SPECTEST" }),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apple, answering at its real endpoints. Drop into
|
|
40
|
+
* `environment.services`:
|
|
41
|
+
*
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { apple } from "@specific.dev/spectest/components";
|
|
44
|
+
*
|
|
45
|
+
* services: {
|
|
46
|
+
* apple: apple({
|
|
47
|
+
* users: [{ email: "alice@example.com", name: "Alice Example" }],
|
|
48
|
+
* client: {
|
|
49
|
+
* clientId: "com.example.app",
|
|
50
|
+
* clientSecret: "client-assertion",
|
|
51
|
+
* redirectUris: ["https://app.test/callback/apple"],
|
|
52
|
+
* },
|
|
53
|
+
* }),
|
|
54
|
+
* app: { …, dependsOn: ["apple"] },
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* The app keeps its production configuration — it sends a browser to
|
|
59
|
+
* `https://appleid.apple.com/auth/authorize` and exchanges the code at
|
|
60
|
+
* `https://appleid.apple.com/auth/token`.
|
|
61
|
+
*/
|
|
62
|
+
export function apple(opts: AppleOptions = {}) {
|
|
63
|
+
const users = resolveUsers("apple", opts.users);
|
|
64
|
+
return emulatorService(spec(users, opts));
|
|
65
|
+
}
|