@capybara-hq/testing 0.0.0-stage → 0.1.0-rc.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.
Files changed (89) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +34 -2
  4. package/dist/containers.d.ts +102 -0
  5. package/dist/containers.js +314 -0
  6. package/dist/dual-replica.d.ts +121 -0
  7. package/dist/dual-replica.js +247 -0
  8. package/dist/extension-config.d.ts +9 -0
  9. package/dist/extension-config.js +16 -0
  10. package/dist/fake-channel.d.ts +162 -0
  11. package/dist/fake-channel.js +325 -0
  12. package/dist/fake-e2b.d.ts +104 -0
  13. package/dist/fake-e2b.js +500 -0
  14. package/dist/fake-git-server.d.ts +57 -0
  15. package/dist/fake-git-server.js +323 -0
  16. package/dist/fake-lark.d.ts +234 -0
  17. package/dist/fake-lark.js +837 -0
  18. package/dist/fake-marketplace.d.ts +25 -0
  19. package/dist/fake-marketplace.js +58 -0
  20. package/dist/fake-mcp-server.d.ts +134 -0
  21. package/dist/fake-mcp-server.js +308 -0
  22. package/dist/fake-model-server.d.ts +28 -0
  23. package/dist/fake-model-server.js +67 -0
  24. package/dist/fake-npm-registry.d.ts +59 -0
  25. package/dist/fake-npm-registry.js +174 -0
  26. package/dist/fake-oauth-server.d.ts +63 -0
  27. package/dist/fake-oauth-server.js +284 -0
  28. package/dist/fake-openapi-server.d.ts +50 -0
  29. package/dist/fake-openapi-server.js +278 -0
  30. package/dist/fake-slack.d.ts +258 -0
  31. package/dist/fake-slack.js +654 -0
  32. package/dist/fixtures.d.ts +47 -0
  33. package/dist/fixtures.js +90 -0
  34. package/dist/global-setup.d.ts +15 -0
  35. package/dist/global-setup.js +58 -0
  36. package/dist/index.d.ts +25 -0
  37. package/dist/index.js +25 -0
  38. package/dist/kernel-helpers.d.ts +125 -0
  39. package/dist/kernel-helpers.js +336 -0
  40. package/dist/media-samples.d.ts +10 -0
  41. package/dist/media-samples.js +76 -0
  42. package/dist/model-conformance.d.ts +61 -0
  43. package/dist/model-conformance.js +287 -0
  44. package/dist/replica-runner.d.ts +1 -0
  45. package/dist/replica-runner.js +59 -0
  46. package/dist/sse.d.ts +20 -0
  47. package/dist/sse.js +87 -0
  48. package/fixtures/extensions/fake-bootstrap-a/.capybara-plugin/plugin.json +7 -0
  49. package/fixtures/extensions/fake-bootstrap-a/package.json +12 -0
  50. package/fixtures/extensions/fake-bootstrap-a/src/index.ts +62 -0
  51. package/fixtures/extensions/fake-bootstrap-b/.capybara-plugin/plugin.json +7 -0
  52. package/fixtures/extensions/fake-bootstrap-b/migrations/0001_init.sql +10 -0
  53. package/fixtures/extensions/fake-bootstrap-b/package.json +12 -0
  54. package/fixtures/extensions/fake-bootstrap-b/src/index.ts +123 -0
  55. package/fixtures/extensions/fake-connector/.capybara-plugin/plugin.json +7 -0
  56. package/fixtures/extensions/fake-connector/connectors/fake-demo.json +12 -0
  57. package/fixtures/extensions/fake-connector/package.json +13 -0
  58. package/fixtures/extensions/fake-connector/src/index.ts +477 -0
  59. package/fixtures/extensions/fake-dep-provider/.capybara-plugin/plugin.json +7 -0
  60. package/fixtures/extensions/fake-dep-provider/package.json +12 -0
  61. package/fixtures/extensions/fake-dep-provider/src/index.ts +19 -0
  62. package/fixtures/extensions/fake-dep-requirer/.capybara-plugin/plugin.json +8 -0
  63. package/fixtures/extensions/fake-dep-requirer/package.json +12 -0
  64. package/fixtures/extensions/fake-dep-requirer/src/index.ts +21 -0
  65. package/fixtures/extensions/fake-embed/.capybara-plugin/plugin.json +7 -0
  66. package/fixtures/extensions/fake-embed/package.json +12 -0
  67. package/fixtures/extensions/fake-embed/src/index.ts +207 -0
  68. package/fixtures/extensions/fake-im/.capybara-plugin/plugin.json +7 -0
  69. package/fixtures/extensions/fake-im/package.json +12 -0
  70. package/fixtures/extensions/fake-im/src/channel.ts +224 -0
  71. package/fixtures/extensions/fake-im/src/directory.ts +25 -0
  72. package/fixtures/extensions/fake-im/src/identity.ts +66 -0
  73. package/fixtures/extensions/fake-im/src/index.ts +91 -0
  74. package/fixtures/extensions/fake-object-store/.capybara-plugin/plugin.json +7 -0
  75. package/fixtures/extensions/fake-object-store/package.json +15 -0
  76. package/fixtures/extensions/fake-object-store/src/index.ts +210 -0
  77. package/fixtures/extensions/fake-openapi/.capybara-plugin/plugin.json +7 -0
  78. package/fixtures/extensions/fake-openapi/package.json +12 -0
  79. package/fixtures/extensions/fake-openapi/src/index.ts +258 -0
  80. package/fixtures/extensions/fake-storage-user/.capybara-plugin/plugin.json +7 -0
  81. package/fixtures/extensions/fake-storage-user/package.json +12 -0
  82. package/fixtures/extensions/fake-storage-user/src/index.ts +58 -0
  83. package/fixtures/extensions/fake-tools-ui/.capybara-plugin/plugin.json +7 -0
  84. package/fixtures/extensions/fake-tools-ui/connectors/fake-notes.json +11 -0
  85. package/fixtures/extensions/fake-tools-ui/migrations/0001_init.sql +7 -0
  86. package/fixtures/extensions/fake-tools-ui/package.json +12 -0
  87. package/fixtures/extensions/fake-tools-ui/skills/fake-notes/SKILL.md +7 -0
  88. package/fixtures/extensions/fake-tools-ui/src/index.ts +140 -0
  89. package/package.json +66 -3
@@ -0,0 +1,174 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ const sri = (bytes) => `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
4
+ /**
5
+ * An attestations answer in the shape npm serves (`{ attestations: [...] }`): the npm publish
6
+ * attestation and the SLSA v1 provenance, each a sigstore bundle with a DSSE envelope whose payload
7
+ * is a base64 in-toto statement about `pkg:npm/<name>@<version>` (subject digest = the tgz's sha512,
8
+ * or `subjectSha512` to make it about another tarball). The signatures are placeholders (the kernel
9
+ * does not verify them). `predicate` replaces the SLSA predicate (to test missing fields).
10
+ */
11
+ export function npmProvenanceAttestations(o) {
12
+ const purl = `pkg:npm/${o.name.replace(/^@/, "%40")}@${o.version}`;
13
+ const subject = [
14
+ {
15
+ name: purl,
16
+ digest: { sha512: o.subjectSha512 ?? createHash("sha512").update(o.tgz).digest("hex") },
17
+ },
18
+ ];
19
+ const ref = o.ref ?? "refs/heads/main";
20
+ const predicate = o.predicate ?? {
21
+ buildDefinition: {
22
+ buildType: "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
23
+ externalParameters: {
24
+ workflow: {
25
+ ref,
26
+ repository: o.repository,
27
+ path: o.workflow ?? ".github/workflows/release.yml",
28
+ },
29
+ },
30
+ internalParameters: {
31
+ github: { event_name: "push", repository_id: "1", repository_owner_id: "2" },
32
+ },
33
+ resolvedDependencies: [
34
+ { uri: `git+${o.repository}@${ref}`, digest: { gitCommit: o.commit } },
35
+ ],
36
+ },
37
+ runDetails: {
38
+ builder: { id: "https://github.com/actions/runner/github-hosted" },
39
+ metadata: { invocationId: `${o.repository}/actions/runs/1/attempts/1` },
40
+ },
41
+ };
42
+ const bundle = (predicateType, pred) => ({
43
+ mediaType: "application/vnd.dev.sigstore.bundle+json;version=0.2",
44
+ verificationMaterial: { tlogEntries: [], x509CertificateChain: { certificates: [] } },
45
+ dsseEnvelope: {
46
+ payload: Buffer.from(JSON.stringify({
47
+ _type: "https://in-toto.io/Statement/v1",
48
+ subject,
49
+ predicateType,
50
+ predicate: pred,
51
+ })).toString("base64"),
52
+ payloadType: "application/vnd.in-toto+json",
53
+ signatures: [{ sig: "MEUCIQ-placeholder", keyid: "" }],
54
+ },
55
+ });
56
+ const publish = "https://github.com/npm/attestation/tree/main/specs/publish/v0.1";
57
+ return {
58
+ attestations: [
59
+ {
60
+ predicateType: publish,
61
+ bundle: bundle(publish, {
62
+ name: o.name,
63
+ version: o.version,
64
+ registry: "https://registry.npmjs.org",
65
+ }),
66
+ },
67
+ {
68
+ predicateType: "https://slsa.dev/provenance/v1",
69
+ bundle: bundle("https://slsa.dev/provenance/v1", predicate),
70
+ },
71
+ ],
72
+ };
73
+ }
74
+ const tarballName = (p) => `${p.name.replace(/^@[^/]+\//, "")}-${p.version}.tgz`;
75
+ /**
76
+ * A fake npm registry: `GET /<name>` (a scoped package written as npm does, `@scope%2fname`; an
77
+ * unencoded `/` is accepted too) returns the packument (`versions[v].dist.{tarball, integrity}` and
78
+ * `dist-tags`), `GET /<name>/-/<file>.tgz` returns the tarball, and
79
+ * `GET /-/npm/v1/attestations/<name>@<version>` returns that version's `attestations` (404 when it
80
+ * has none). Unknown packages are 404.
81
+ */
82
+ export async function startFakeNpmRegistry(opts) {
83
+ const requests = [];
84
+ const authorizations = [];
85
+ const accepts = [];
86
+ let base = "";
87
+ const server = createServer((req, res) => {
88
+ const path = req.url ?? "/";
89
+ requests.push(path);
90
+ authorizations.push(req.headers.authorization);
91
+ accepts.push(req.headers.accept);
92
+ if (req.method !== "GET") {
93
+ res.writeHead(405).end();
94
+ return;
95
+ }
96
+ const decoded = decodeURIComponent(path.split("?")[0]).slice(1);
97
+ const ATT = "-/npm/v1/attestations/";
98
+ if (decoded.startsWith(ATT)) {
99
+ const spec = decoded.slice(ATT.length);
100
+ const at = spec.lastIndexOf("@");
101
+ const p = opts.packages.find((x) => at > 0 && x.name === spec.slice(0, at) && x.version === spec.slice(at + 1));
102
+ if (p?.attestations === undefined) {
103
+ res.writeHead(404, { "content-type": "application/json" }).end('{"error":"Not found"}');
104
+ return;
105
+ }
106
+ res
107
+ .writeHead(200, { "content-type": "application/json" })
108
+ .end(JSON.stringify(p.attestations));
109
+ return;
110
+ }
111
+ const tarAt = decoded.indexOf("/-/");
112
+ if (tarAt >= 0) {
113
+ const name = decoded.slice(0, tarAt);
114
+ const rest = decoded.slice(tarAt + 3);
115
+ const redirect = rest.startsWith("r/");
116
+ const file = redirect ? rest.slice(2) : rest;
117
+ const p = opts.packages.find((x) => x.name === name && tarballName(x) === file);
118
+ if (!p) {
119
+ res.writeHead(404).end();
120
+ return;
121
+ }
122
+ if (redirect) {
123
+ res.writeHead(302, { location: `${base}/${name}/-/${file}` }).end();
124
+ return;
125
+ }
126
+ res
127
+ .writeHead(200, {
128
+ "content-type": "application/octet-stream",
129
+ "content-length": String(p.tgz.byteLength),
130
+ })
131
+ .end(Buffer.from(p.tgz));
132
+ return;
133
+ }
134
+ const versions = opts.packages.filter((x) => x.name === decoded);
135
+ if (versions.length === 0) {
136
+ res.writeHead(404, { "content-type": "application/json" }).end('{"error":"Not found"}');
137
+ return;
138
+ }
139
+ const distTags = {};
140
+ for (const v of versions)
141
+ for (const t of v.tags ?? [])
142
+ distTags[t] = v.version;
143
+ distTags.latest ??= versions.at(-1).version;
144
+ const doc = {
145
+ name: decoded,
146
+ "dist-tags": distTags,
147
+ versions: Object.fromEntries(versions.map((v) => [
148
+ v.version,
149
+ {
150
+ name: v.name,
151
+ version: v.version,
152
+ dist: {
153
+ tarball: v.tarballUrl ??
154
+ `${base}/${v.name}/-/${v.redirectTarball ? "r/" : ""}${tarballName(v)}`,
155
+ integrity: v.integrity ?? sri(v.tgz),
156
+ },
157
+ },
158
+ ])),
159
+ };
160
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(doc));
161
+ });
162
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
163
+ base = `http://127.0.0.1:${server.address().port}`;
164
+ return {
165
+ url: base,
166
+ requests,
167
+ authorizations,
168
+ accepts,
169
+ stop: () => new Promise((resolve) => {
170
+ server.closeAllConnections();
171
+ server.close(() => resolve());
172
+ }),
173
+ };
174
+ }
@@ -0,0 +1,63 @@
1
+ /** 预注册的客户端(client_credentials 与「手填 client」用)。 */
2
+ export interface FakeOAuthClient {
3
+ clientId: string;
4
+ /** 不给 = 公开客户端(只能走授权码 + PKCE)。 */
5
+ clientSecret?: string;
6
+ /** 允许的回调地址;不给 = 任意。 */
7
+ redirectUris?: string[];
8
+ }
9
+ export interface FakeOAuthServerOptions {
10
+ /** 暴露 DCR(RFC 7591)注册端点;默认 true,运行中可用 `setDcr` 切换。 */
11
+ dcr?: boolean;
12
+ /** 授权服务器元数据的位置:RFC 8414(默认)或只有 OpenID 发现文档。 */
13
+ metadata?: "oauth" | "openid";
14
+ /** access token 有效期(秒),默认 3600;0 = 令牌响应不带 expires_in。 */
15
+ accessTokenTtl?: number;
16
+ clients?: FakeOAuthClient[];
17
+ /** 令牌端点每次响应前等待的毫秒数(放大并发刷新的竞态窗口)。 */
18
+ tokenDelayMs?: number;
19
+ scopesSupported?: string[];
20
+ /** 元数据里的 `code_challenge_methods_supported`;null = 不声明。默认 `["S256"]`。 */
21
+ pkceMethods?: string[] | null;
22
+ }
23
+ /** 令牌端点收到的一次请求(不含任何密钥)。 */
24
+ export interface FakeTokenRequest {
25
+ grantType: string;
26
+ clientId: string | undefined;
27
+ resource: string | undefined;
28
+ scope: string | undefined;
29
+ /** 客户端认证方式:Basic 头、表单里的 client_secret、或只有 client_id。 */
30
+ clientAuth: "basic" | "post" | "none";
31
+ /** 本次响应的 HTTP 状态。 */
32
+ status: number;
33
+ }
34
+ export interface FakeOAuthServer {
35
+ /** issuer(`http://127.0.0.1:<port>`),也是各端点的基址。 */
36
+ issuer: string;
37
+ tokenRequests: FakeTokenRequest[];
38
+ /** DCR 注册请求体。 */
39
+ registrations: Record<string, unknown>[];
40
+ /** 授权端点收到的查询参数。 */
41
+ authorizeRequests: URLSearchParams[];
42
+ /** 该 access token 当前有效(存在、未过期、未作废)。 */
43
+ verify(accessToken: string): boolean;
44
+ /** 已签发的全部 access / refresh token 与客户端密钥(断言「不出现在日志 / 事件」用)。 */
45
+ issuedSecrets(): string[];
46
+ /** 作废全部 access token(refresh token 仍有效):资源服务随之返回 401。 */
47
+ revokeAccessTokens(): void;
48
+ /** 作废全部 refresh token:之后的刷新返回 `invalid_grant`。 */
49
+ revokeRefreshTokens(): void;
50
+ /** 下一次授权请求以该 OAuth 错误回跳。 */
51
+ denyNext(error: string, description?: string): void;
52
+ setDcr(enabled: boolean): void;
53
+ /** 接下来 n 次令牌请求直接以该状态与正文响应(模拟限流 429、WAF 403、临时错误等)。 */
54
+ failTokenRequests(n: number, status: number, body?: unknown): void;
55
+ /** 下一次授权回跳的 RFC 9207 `iss` 改为该值(默认回跳带真实 issuer)。 */
56
+ forgeIssNext(iss: string): void;
57
+ stop(): Promise<void>;
58
+ }
59
+ /**
60
+ * 本地假 OAuth 授权服务器(127.0.0.1 随机端口):元数据(RFC 8414 或 OpenID 发现)、授权端点(不出页面、自动
61
+ * 批准,强制 PKCE S256)、令牌端点(授权码 + PKCE 校验、refresh 轮换、client_credentials)、可开关的 DCR 端点。
62
+ */
63
+ export declare function startFakeOAuthServer(opts?: FakeOAuthServerOptions): Promise<FakeOAuthServer>;
@@ -0,0 +1,284 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ const b64url = (b) => b.toString("base64url");
4
+ const rand = (prefix) => `${prefix}${b64url(randomBytes(18))}`;
5
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
+ const readBody = (req) => new Promise((resolve, reject) => {
7
+ const chunks = [];
8
+ req.on("data", (c) => chunks.push(c));
9
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
10
+ req.on("error", reject);
11
+ });
12
+ const json = (res, status, body) => {
13
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
14
+ res.end(JSON.stringify(body));
15
+ };
16
+ /**
17
+ * 本地假 OAuth 授权服务器(127.0.0.1 随机端口):元数据(RFC 8414 或 OpenID 发现)、授权端点(不出页面、自动
18
+ * 批准,强制 PKCE S256)、令牌端点(授权码 + PKCE 校验、refresh 轮换、client_credentials)、可开关的 DCR 端点。
19
+ */
20
+ export async function startFakeOAuthServer(opts = {}) {
21
+ let dcr = opts.dcr ?? true;
22
+ const ttl = opts.accessTokenTtl ?? 3600;
23
+ const clients = new Map((opts.clients ?? []).map((c) => [
24
+ c.clientId,
25
+ { clientId: c.clientId, clientSecret: c.clientSecret, redirectUris: c.redirectUris },
26
+ ]));
27
+ const codes = new Map();
28
+ const access = new Map();
29
+ const refresh = new Map();
30
+ const secrets = new Set();
31
+ const tokenRequests = [];
32
+ const registrations = [];
33
+ const authorizeRequests = [];
34
+ let deny;
35
+ let tokenFailures;
36
+ let forgedIss;
37
+ let issuer = "";
38
+ const metadata = () => ({
39
+ issuer,
40
+ authorization_endpoint: `${issuer}/authorize`,
41
+ token_endpoint: `${issuer}/token`,
42
+ ...(dcr && { registration_endpoint: `${issuer}/register` }),
43
+ response_types_supported: ["code"],
44
+ grant_types_supported: ["authorization_code", "refresh_token", "client_credentials"],
45
+ ...(opts.pkceMethods !== null && {
46
+ code_challenge_methods_supported: opts.pkceMethods ?? ["S256"],
47
+ }),
48
+ authorization_response_iss_parameter_supported: true,
49
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
50
+ ...(opts.scopesSupported !== undefined && { scopes_supported: opts.scopesSupported }),
51
+ });
52
+ const issue = (clientId, scope, withRefresh) => {
53
+ const accessToken = rand("at_");
54
+ access.set(accessToken, { expiresAt: ttl > 0 ? Date.now() + ttl * 1000 : null });
55
+ secrets.add(accessToken);
56
+ const body = { access_token: accessToken, token_type: "Bearer" };
57
+ if (ttl > 0)
58
+ body.expires_in = ttl;
59
+ if (scope !== undefined)
60
+ body.scope = scope;
61
+ if (withRefresh) {
62
+ const refreshToken = rand("rt_");
63
+ refresh.set(refreshToken, { clientId, scope });
64
+ secrets.add(refreshToken);
65
+ body.refresh_token = refreshToken;
66
+ }
67
+ return body;
68
+ };
69
+ /** 客户端认证:Basic 头或表单里的 client_id / client_secret;公开客户端只带 client_id。 */
70
+ const authenticate = (req, form) => {
71
+ let id = form.get("client_id") ?? undefined;
72
+ let secret = form.get("client_secret") ?? undefined;
73
+ const h = req.headers.authorization;
74
+ if (h?.startsWith("Basic ")) {
75
+ const decoded = Buffer.from(h.slice(6), "base64").toString("utf8");
76
+ const i = decoded.indexOf(":");
77
+ id = decodeURIComponent(decoded.slice(0, i));
78
+ secret = decodeURIComponent(decoded.slice(i + 1));
79
+ }
80
+ const c = id === undefined ? undefined : clients.get(id);
81
+ if (!c)
82
+ return undefined;
83
+ if (c.clientSecret !== undefined && c.clientSecret !== secret)
84
+ return undefined;
85
+ return c;
86
+ };
87
+ const token = async (req, res) => {
88
+ const form = new URLSearchParams(await readBody(req));
89
+ if (opts.tokenDelayMs)
90
+ await sleep(opts.tokenDelayMs);
91
+ const grantType = form.get("grant_type") ?? "";
92
+ const entry = {
93
+ grantType,
94
+ clientId: undefined,
95
+ resource: form.get("resource") ?? undefined,
96
+ scope: form.get("scope") ?? undefined,
97
+ clientAuth: req.headers.authorization?.startsWith("Basic ")
98
+ ? "basic"
99
+ : form.has("client_secret")
100
+ ? "post"
101
+ : "none",
102
+ status: 200,
103
+ };
104
+ tokenRequests.push(entry);
105
+ if (tokenFailures && tokenFailures.left > 0) {
106
+ tokenFailures.left--;
107
+ entry.status = tokenFailures.status;
108
+ return json(res, tokenFailures.status, tokenFailures.body ?? { message: "fake failure" });
109
+ }
110
+ const fail = (status, error) => {
111
+ entry.status = status;
112
+ json(res, status, { error, error_description: `fake: ${error}` });
113
+ };
114
+ const client = authenticate(req, form);
115
+ if (!client)
116
+ return fail(401, "invalid_client");
117
+ entry.clientId = client.clientId;
118
+ if (grantType === "authorization_code") {
119
+ const code = form.get("code") ?? "";
120
+ const grant = codes.get(code);
121
+ codes.delete(code);
122
+ if (!grant || grant.clientId !== client.clientId)
123
+ return fail(400, "invalid_grant");
124
+ if (form.get("redirect_uri") !== grant.redirectUri)
125
+ return fail(400, "invalid_grant");
126
+ const verifier = form.get("code_verifier") ?? "";
127
+ if (b64url(createHash("sha256").update(verifier).digest()) !== grant.challenge) {
128
+ return fail(400, "invalid_grant");
129
+ }
130
+ if (grant.resource !== undefined && form.get("resource") !== grant.resource) {
131
+ return fail(400, "invalid_target");
132
+ }
133
+ return json(res, 200, issue(client.clientId, grant.scope, true));
134
+ }
135
+ if (grantType === "refresh_token") {
136
+ const rt = form.get("refresh_token") ?? "";
137
+ const r = refresh.get(rt);
138
+ if (!r || r.clientId !== client.clientId)
139
+ return fail(400, "invalid_grant");
140
+ // 轮换:旧 refresh token 立即作废
141
+ refresh.delete(rt);
142
+ return json(res, 200, issue(client.clientId, r.scope, true));
143
+ }
144
+ if (grantType === "client_credentials") {
145
+ if (client.clientSecret === undefined)
146
+ return fail(401, "invalid_client");
147
+ return json(res, 200, issue(client.clientId, form.get("scope") ?? undefined, false));
148
+ }
149
+ return fail(400, "unsupported_grant_type");
150
+ };
151
+ const authorize = (url, res) => {
152
+ const q = url.searchParams;
153
+ authorizeRequests.push(new URLSearchParams(q));
154
+ const client = clients.get(q.get("client_id") ?? "");
155
+ const redirectUri = q.get("redirect_uri") ?? "";
156
+ if (!client || (client.redirectUris && !client.redirectUris.includes(redirectUri))) {
157
+ res.writeHead(400, { "content-type": "text/plain" });
158
+ return res.end("invalid client or redirect_uri");
159
+ }
160
+ const back = new URL(redirectUri);
161
+ const state = q.get("state");
162
+ if (state !== null)
163
+ back.searchParams.set("state", state);
164
+ back.searchParams.set("iss", forgedIss ?? issuer);
165
+ forgedIss = undefined;
166
+ const challenge = q.get("code_challenge");
167
+ if (deny) {
168
+ back.searchParams.set("error", deny.error);
169
+ if (deny.description !== undefined)
170
+ back.searchParams.set("error_description", deny.description);
171
+ deny = undefined;
172
+ }
173
+ else if (q.get("response_type") !== "code") {
174
+ back.searchParams.set("error", "unsupported_response_type");
175
+ }
176
+ else if (!challenge || q.get("code_challenge_method") !== "S256") {
177
+ back.searchParams.set("error", "invalid_request");
178
+ back.searchParams.set("error_description", "PKCE S256 is required");
179
+ }
180
+ else {
181
+ const code = rand("code_");
182
+ codes.set(code, {
183
+ clientId: client.clientId,
184
+ redirectUri,
185
+ challenge,
186
+ scope: q.get("scope") ?? undefined,
187
+ resource: q.get("resource") ?? undefined,
188
+ });
189
+ back.searchParams.set("code", code);
190
+ }
191
+ res.writeHead(302, { location: back.toString() });
192
+ res.end();
193
+ };
194
+ const register = async (req, res) => {
195
+ const body = JSON.parse((await readBody(req)) || "{}");
196
+ registrations.push(body);
197
+ const uris = body.redirect_uris;
198
+ if (!Array.isArray(uris) || uris.length === 0) {
199
+ return json(res, 400, { error: "invalid_redirect_uri" });
200
+ }
201
+ const method = typeof body.token_endpoint_auth_method === "string"
202
+ ? body.token_endpoint_auth_method
203
+ : "client_secret_basic";
204
+ const clientId = rand("client_");
205
+ const clientSecret = method === "none" ? undefined : rand("secret_");
206
+ if (clientSecret)
207
+ secrets.add(clientSecret);
208
+ clients.set(clientId, { clientId, clientSecret, redirectUris: uris });
209
+ json(res, 201, {
210
+ ...body,
211
+ client_id: clientId,
212
+ ...(clientSecret !== undefined && {
213
+ client_secret: clientSecret,
214
+ client_secret_expires_at: 0,
215
+ }),
216
+ client_id_issued_at: Math.floor(Date.now() / 1000),
217
+ token_endpoint_auth_method: method,
218
+ });
219
+ };
220
+ const handle = async (req, res) => {
221
+ const url = new URL(req.url ?? "/", issuer);
222
+ const path = url.pathname;
223
+ if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") {
224
+ if ((opts.metadata ?? "oauth") !== "oauth")
225
+ return json(res, 404, { error: "not_found" });
226
+ return json(res, 200, metadata());
227
+ }
228
+ if (req.method === "GET" && path === "/.well-known/openid-configuration") {
229
+ if (opts.metadata !== "openid")
230
+ return json(res, 404, { error: "not_found" });
231
+ return json(res, 200, { ...metadata(), subject_types_supported: ["public"] });
232
+ }
233
+ if (req.method === "GET" && path === "/authorize")
234
+ return authorize(url, res);
235
+ if (req.method === "POST" && path === "/token")
236
+ return token(req, res);
237
+ if (req.method === "POST" && path === "/register") {
238
+ if (!dcr)
239
+ return json(res, 404, { error: "not_found" });
240
+ return register(req, res);
241
+ }
242
+ json(res, 404, { error: "not_found" });
243
+ };
244
+ const http = createServer((req, res) => {
245
+ handle(req, res).catch((e) => {
246
+ if (!res.headersSent)
247
+ json(res, 500, { error: "server_error", detail: String(e) });
248
+ else
249
+ res.end();
250
+ });
251
+ });
252
+ await new Promise((resolve) => http.listen(0, "127.0.0.1", resolve));
253
+ const { port } = http.address();
254
+ issuer = `http://127.0.0.1:${port}`;
255
+ return {
256
+ issuer,
257
+ tokenRequests,
258
+ registrations,
259
+ authorizeRequests,
260
+ verify: (t) => {
261
+ const a = access.get(t);
262
+ return a !== undefined && (a.expiresAt === null || a.expiresAt > Date.now());
263
+ },
264
+ issuedSecrets: () => [...secrets],
265
+ revokeAccessTokens: () => access.clear(),
266
+ revokeRefreshTokens: () => refresh.clear(),
267
+ failTokenRequests: (n, status, body) => {
268
+ tokenFailures = { left: n, status, body };
269
+ },
270
+ forgeIssNext: (iss) => {
271
+ forgedIss = iss;
272
+ },
273
+ denyNext: (error, description) => {
274
+ deny = { error, description };
275
+ },
276
+ setDcr: (enabled) => {
277
+ dcr = enabled;
278
+ },
279
+ stop: () => new Promise((resolve, reject) => {
280
+ http.closeAllConnections();
281
+ http.close((err) => (err ? reject(err) : resolve()));
282
+ }),
283
+ };
284
+ }
@@ -0,0 +1,50 @@
1
+ export interface FakeOpenApiServerOptions {
2
+ /** 设了就要求每个 API 请求带这个 API key(不符 → 401);不设 = 无需认证。 */
3
+ apiKey?: string;
4
+ /** API key 的位置:请求头 `X-API-Key`(默认)或 query 参数 `api_key`。 */
5
+ apiKeyIn?: "header" | "query";
6
+ /** 错误正文(401 / 404 / 500)回显收到的 API key(测试内核打码用)。 */
7
+ echoKey?: boolean;
8
+ /** 规范地址(`/openapi.json`)以 302 重定向到别处,而不是给出文档。 */
9
+ specRedirect?: boolean;
10
+ /** 覆盖默认文档:参数为服务基址 `http://127.0.0.1:<port>`。 */
11
+ spec?: (base: string) => unknown;
12
+ }
13
+ /** 服务收到的一次 API 请求(规范地址的请求也记录)。 */
14
+ export interface FakeOpenApiRequest {
15
+ method: string;
16
+ /** 路径(不含 query)。 */
17
+ path: string;
18
+ /** 原始 query 串(不含 `?`)。 */
19
+ query: string;
20
+ headers: Record<string, string | string[] | undefined>;
21
+ body: string;
22
+ }
23
+ export interface FakePet {
24
+ id: number;
25
+ name: string;
26
+ tag?: string;
27
+ }
28
+ export interface FakeOpenApiServer {
29
+ /** 服务基址 `http://127.0.0.1:<port>`。 */
30
+ url: string;
31
+ /** 规范地址 `<url>/openapi.json`。 */
32
+ specUrl: string;
33
+ /** 当前提供的文档(JSON)。 */
34
+ spec: () => unknown;
35
+ /** 运行中替换文档(漂移测试用)。 */
36
+ setSpec: (spec: unknown) => void;
37
+ requests: FakeOpenApiRequest[];
38
+ pets: () => FakePet[];
39
+ stop(): Promise<void>;
40
+ }
41
+ /**
42
+ * 默认文档(OpenAPI 3.0.3):宠物 CRUD + 几个用于测试的操作。servers 用服务器变量给出端口(测试默认值替换),
43
+ * 参数与模式用内部 `$ref`,认证为 API key。
44
+ */
45
+ export declare function defaultFakeOpenApiSpec(base: string, apiKeyIn?: "header" | "query"): Record<string, unknown>;
46
+ /**
47
+ * 本地假 OpenAPI 服务(127.0.0.1 随机端口):`/openapi.json` 给出文档(或按选项 302),`/v1/*` 实现文档里的
48
+ * 宠物 CRUD(内存)、回显、纯文本、二进制、500 与 302 操作;可要求 API key(请求头或 query)。
49
+ */
50
+ export declare function startFakeOpenApiServer(opts?: FakeOpenApiServerOptions): Promise<FakeOpenApiServer>;