create-ailk 0.26.0 → 0.28.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 +86 -9
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +25 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +112 -11
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +90 -6
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +12 -2
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +16 -5
- package/templates/apps/api/CLAUDE.md +32 -11
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +27 -0
- package/templates/apps/web/CLAUDE.md +56 -8
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +161 -128
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +44 -4
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +144 -3
- package/templates/apps/web/middleware.ts +179 -12
- package/templates/apps/web/next.config.mjs +220 -3
- package/templates/apps/web/package.json +3 -2
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/versions.json +48 -48
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared Pricing render (#10044 D5, AC-4): which offering pages `/pricing`
|
|
3
|
+
* lists, the card it lists each with, and `plans: []` reading no Stripe data.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
PageSectionsSchema,
|
|
7
|
+
type PageSection,
|
|
8
|
+
} from "@working-theory/validation";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
offeringPricingPages,
|
|
12
|
+
pagePlans,
|
|
13
|
+
PRICING_HUB_SLUG,
|
|
14
|
+
pricingPageCard,
|
|
15
|
+
pricingSections,
|
|
16
|
+
} from "../pricing-page";
|
|
17
|
+
import type { DetailPage } from "../route-family";
|
|
18
|
+
|
|
19
|
+
const fetchMock: jest.Mock = jest.fn(async () => ({
|
|
20
|
+
ok: true,
|
|
21
|
+
status: 200,
|
|
22
|
+
json: async () => ({ ok: true, data: { status: "unconnected" } }),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
global.fetch = fetchMock;
|
|
27
|
+
fetchMock.mockClear();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function page(slug: string, frontmatter: Record<string, unknown>): DetailPage {
|
|
31
|
+
return { slug, frontmatter: { type: "Pricing", slug, ...frontmatter } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("offeringPricingPages (#10044 AC-4)", () => {
|
|
35
|
+
it("keeps a page whose frontmatter slug is its file slug, drops the hub and a mismatched slug, in input order", () => {
|
|
36
|
+
const pages = [
|
|
37
|
+
page("a", { title: "A" }),
|
|
38
|
+
page("b", { title: "B" }),
|
|
39
|
+
page(PRICING_HUB_SLUG, { title: "Pricing" }),
|
|
40
|
+
{ slug: "x", frontmatter: { type: "Pricing", slug: "pricing" } },
|
|
41
|
+
{ slug: "y", frontmatter: { type: "Pricing", slug: "other" } },
|
|
42
|
+
];
|
|
43
|
+
expect(offeringPricingPages(pages).map((p) => p.slug)).toEqual(["a", "b"]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("pricingPageCard (#10044 AC-4)", () => {
|
|
48
|
+
const labels = { viewPricing: "See pricing" };
|
|
49
|
+
|
|
50
|
+
it("carries the title, the summary over the description, and a link to /pricing/<slug>", () => {
|
|
51
|
+
expect(
|
|
52
|
+
pricingPageCard(
|
|
53
|
+
page("a", { title: "A", summary: "Short", description: "Long" }),
|
|
54
|
+
labels,
|
|
55
|
+
),
|
|
56
|
+
).toEqual({
|
|
57
|
+
title: "A",
|
|
58
|
+
description: "Short",
|
|
59
|
+
link: { label: "See pricing", href: "/pricing/a" },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to the description without a summary", () => {
|
|
64
|
+
expect(
|
|
65
|
+
pricingPageCard(page("b", { title: "B", description: "Long" }), labels)
|
|
66
|
+
.description,
|
|
67
|
+
).toBe("Long");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("builds a pricing-pages section that parses PageSectionsSchema", () => {
|
|
71
|
+
const section = {
|
|
72
|
+
type: "items",
|
|
73
|
+
id: "pricing-pages",
|
|
74
|
+
data: {
|
|
75
|
+
headline: "Pricing by offering",
|
|
76
|
+
variant: "tile",
|
|
77
|
+
tileHover: "outline",
|
|
78
|
+
items: [
|
|
79
|
+
pricingPageCard(
|
|
80
|
+
page("a", { title: "A", summary: "S", description: "D" }),
|
|
81
|
+
labels,
|
|
82
|
+
),
|
|
83
|
+
pricingPageCard(page("b", { title: "B", description: "D" }), labels),
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
expect(PageSectionsSchema.safeParse([section]).success).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("pagePlans (#10044 D1)", () => {
|
|
92
|
+
it("reads the page's plans, or undefined when it names none", () => {
|
|
93
|
+
expect(pagePlans({ plans: ["a", "b"] })).toEqual(["a", "b"]);
|
|
94
|
+
expect(pagePlans({ plans: [] })).toEqual([]);
|
|
95
|
+
expect(pagePlans({})).toBeUndefined();
|
|
96
|
+
expect(pagePlans(null)).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("pricingSections (#10044 AC-4)", () => {
|
|
101
|
+
const declared: PageSection[] = PageSectionsSchema.parse([
|
|
102
|
+
{
|
|
103
|
+
id: "plans",
|
|
104
|
+
type: "pricing",
|
|
105
|
+
data: {
|
|
106
|
+
variant: "three-tiers-with-toggle",
|
|
107
|
+
billingToggle: false,
|
|
108
|
+
plans: [
|
|
109
|
+
{
|
|
110
|
+
key: "basic",
|
|
111
|
+
name: "Basic",
|
|
112
|
+
price: { monthly: "$29" },
|
|
113
|
+
features: [],
|
|
114
|
+
cta: { label: "Choose Basic", href: "/checkout/basic" },
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
it("makes no shown-offerings read for `plans: []` and returns the declared sections", async () => {
|
|
122
|
+
const sections = await pricingSections(declared, {
|
|
123
|
+
plans: [],
|
|
124
|
+
plan: undefined,
|
|
125
|
+
locale: "en",
|
|
126
|
+
});
|
|
127
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
128
|
+
expect(sections).toBe(declared);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("moves `?plan=` onto the written sections for `plans: []`", async () => {
|
|
132
|
+
const sections = await pricingSections(declared, {
|
|
133
|
+
plans: [],
|
|
134
|
+
plan: "basic",
|
|
135
|
+
locale: "en",
|
|
136
|
+
});
|
|
137
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
138
|
+
expect((sections[0]!.data as { recommended?: string }).recommended).toBe(
|
|
139
|
+
"basic",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -10,10 +10,18 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Spec: docs/specs/7644-the-browser-talks-only-to-spec.md §5.1 AC-1 to AC-4.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { ensureConsentVisitor } from "../consent-visitor";
|
|
14
|
+
import { BOT_CHECKED_ROUTES, handleRelay, RELAY_ROUTES } from "../relay";
|
|
14
15
|
import { __resetRelayRateLimit, __relayRateLimitBucketCount } from "../relay";
|
|
15
16
|
import { TOPOLOGY } from "../topology.generated";
|
|
16
17
|
|
|
18
|
+
// #9889 — the booking bot check's verdict. `checkBotId` reads Vercel's
|
|
19
|
+
// request context, which jest has none of, so every case decides it here.
|
|
20
|
+
const checkBotIdMock = jest.fn();
|
|
21
|
+
jest.mock("botid/server", () => ({
|
|
22
|
+
checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
|
|
23
|
+
}));
|
|
24
|
+
|
|
17
25
|
const HUB = "https://hub.example";
|
|
18
26
|
const SITE = "https://spoke.example";
|
|
19
27
|
const K1 = "K1";
|
|
@@ -27,6 +35,9 @@ const ENV_KEYS = [
|
|
|
27
35
|
"NEXT_PUBLIC_API_URL",
|
|
28
36
|
"AILK_SPOKE_KEY",
|
|
29
37
|
"AILK_VISITOR_FORWARD_KEY",
|
|
38
|
+
"VERCEL",
|
|
39
|
+
"AILK_BOOKING_BOT_CHECK",
|
|
40
|
+
"AILK_BOT_CHECK",
|
|
30
41
|
] as const;
|
|
31
42
|
const priorEnv = new Map<string, string | undefined>(
|
|
32
43
|
ENV_KEYS.map((name) => [name, process.env[name]]),
|
|
@@ -460,14 +471,84 @@ describe("the banner's visitor id (#8438 D12)", () => {
|
|
|
460
471
|
const VALID_ID = "11111111-2222-4333-8444-555555555555";
|
|
461
472
|
const LEAD_PATHS = ["/v1/leads/submit", "/v1/leads/subscribe", "/v1/leads/callback"];
|
|
462
473
|
|
|
474
|
+
/** A cookie value the site signed (#8556), and the UUID it carries. */
|
|
475
|
+
function signedCookie(): { id: string; value: string } {
|
|
476
|
+
const set = jest.fn();
|
|
477
|
+
const id = ensureConsentVisitor({ get: () => undefined, set });
|
|
478
|
+
return { id, value: set.mock.calls[0][1] as string };
|
|
479
|
+
}
|
|
480
|
+
|
|
463
481
|
it.each(LEAD_PATHS)(
|
|
464
|
-
"%s carries x-ailk-consent-visitor from the request's own ailk-vid cookie",
|
|
482
|
+
"%s carries x-ailk-consent-visitor from the request's own signed ailk-vid cookie",
|
|
465
483
|
async (path) => {
|
|
466
|
-
|
|
467
|
-
|
|
484
|
+
const { id, value } = signedCookie();
|
|
485
|
+
await handleRelay(relayRequest(path, { headers: { cookie: `ailk-vid=${value}` } }));
|
|
486
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBe(id);
|
|
468
487
|
},
|
|
469
488
|
);
|
|
470
489
|
|
|
490
|
+
// #8556: a well-formed id the browser chose is not one this site minted.
|
|
491
|
+
it("sends no header when the cookie is a bare UUID the site did not sign", async () => {
|
|
492
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: `ailk-vid=${VALID_ID}` } }));
|
|
493
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("sends no header when the cookie's signature has been changed", async () => {
|
|
497
|
+
const { value } = signedCookie();
|
|
498
|
+
const at = value.indexOf(".") + 1;
|
|
499
|
+
const changed = `${value.slice(0, at)}${value[at] === "A" ? "B" : "A"}${value.slice(at + 1)}`;
|
|
500
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: `ailk-vid=${changed}` } }));
|
|
501
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
/** Run `fn` as a production process with exactly `vars` among the signing secrets. */
|
|
505
|
+
async function inProduction(vars: Record<string, string>, fn: () => Promise<void>): Promise<void> {
|
|
506
|
+
const keys = ["NODE_ENV", "NEXT_PHASE", "BETTER_AUTH_SECRET", "AILK_HUB_SESSION_SECRET", "AILK_SPOKE_KEY"];
|
|
507
|
+
const saved = new Map(keys.map((key) => [key, process.env[key]]));
|
|
508
|
+
for (const key of keys) delete process.env[key];
|
|
509
|
+
Object.assign(process.env, { NODE_ENV: "production" }, vars);
|
|
510
|
+
try {
|
|
511
|
+
await fn();
|
|
512
|
+
} finally {
|
|
513
|
+
for (const [key, value] of saved) {
|
|
514
|
+
if (value === undefined) delete process.env[key];
|
|
515
|
+
else process.env[key] = value;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
it("sends no header, and still relays, when the site has no usable secret", async () => {
|
|
521
|
+
const { value } = signedCookie();
|
|
522
|
+
await inProduction({}, async () => {
|
|
523
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: `ailk-vid=${value}` } }));
|
|
524
|
+
expect(res.status).toBe(201);
|
|
525
|
+
});
|
|
526
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// #8556 option (b): a production spoke has no BETTER_AUTH_SECRET by design.
|
|
530
|
+
// A minted spoke key is `ailk_spk_` plus 43 characters; the signer skips one under 32.
|
|
531
|
+
const SPOKE_KEY = `ailk_spk_${"k".repeat(43)}`;
|
|
532
|
+
|
|
533
|
+
it("a production spoke holding only AILK_SPOKE_KEY forwards the id it signed", async () => {
|
|
534
|
+
await inProduction({ AILK_SPOKE_KEY: SPOKE_KEY }, async () => {
|
|
535
|
+
const { id, value } = signedCookie();
|
|
536
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: `ailk-vid=${value}` } }));
|
|
537
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBe(id);
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("a production spoke sends no header for an id another spoke key signed", async () => {
|
|
542
|
+
let value = "";
|
|
543
|
+
await inProduction({ AILK_SPOKE_KEY: `ailk_spk_${"o".repeat(43)}` }, async () => {
|
|
544
|
+
value = signedCookie().value;
|
|
545
|
+
});
|
|
546
|
+
await inProduction({ AILK_SPOKE_KEY: SPOKE_KEY }, async () => {
|
|
547
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: `ailk-vid=${value}` } }));
|
|
548
|
+
});
|
|
549
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
|
|
550
|
+
});
|
|
551
|
+
|
|
471
552
|
it("sends no header when the request carries no ailk-vid cookie", async () => {
|
|
472
553
|
await handleRelay(relayRequest("/v1/leads/submit"));
|
|
473
554
|
expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
|
|
@@ -885,3 +966,402 @@ describe("the per-visitor rate limit (#7707)", () => {
|
|
|
885
966
|
);
|
|
886
967
|
});
|
|
887
968
|
});
|
|
969
|
+
|
|
970
|
+
// The booking bot check (#9889, spec
|
|
971
|
+
// docs/specs/9889-the-booking-bot-check-vercel-spec.md §5.1 AC-1 to AC-5).
|
|
972
|
+
describe("the booking bot check (#9889)", () => {
|
|
973
|
+
const BOOK = "/v1/schedule/book";
|
|
974
|
+
const BOT = {
|
|
975
|
+
isHuman: false,
|
|
976
|
+
isBot: true,
|
|
977
|
+
isVerifiedBot: false,
|
|
978
|
+
bypassed: false,
|
|
979
|
+
};
|
|
980
|
+
const HUMAN = {
|
|
981
|
+
isHuman: true,
|
|
982
|
+
isBot: false,
|
|
983
|
+
isVerifiedBot: false,
|
|
984
|
+
bypassed: false,
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
let warnSpy: jest.SpyInstance;
|
|
988
|
+
|
|
989
|
+
beforeEach(() => {
|
|
990
|
+
checkBotIdMock.mockReset();
|
|
991
|
+
process.env.VERCEL = "1";
|
|
992
|
+
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
afterEach(() => {
|
|
996
|
+
warnSpy.mockRestore();
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
describe("AC-1 — a bot verdict is refused before the hub is called", () => {
|
|
1000
|
+
it.each([
|
|
1001
|
+
["a bot", BOT],
|
|
1002
|
+
["a verified bot", { ...BOT, isVerifiedBot: true }],
|
|
1003
|
+
])("%s answers 403 bot_detected", async (_label, verdict) => {
|
|
1004
|
+
checkBotIdMock.mockResolvedValue(verdict);
|
|
1005
|
+
const res = await handleRelay(relayRequest(BOOK));
|
|
1006
|
+
expect(res.status).toBe(403);
|
|
1007
|
+
expect(await errorCode(res)).toBe("bot_detected");
|
|
1008
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
1009
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1010
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
1011
|
+
expect(checkBotIdMock.mock.calls[0]).toEqual([]);
|
|
1012
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
1013
|
+
expect(warnSpy).toHaveBeenCalledWith("[relay] bot refused", {
|
|
1014
|
+
path: BOOK,
|
|
1015
|
+
outcome: "bot-detected",
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
describe("AC-2 — a human verdict forwards exactly as with the check off", () => {
|
|
1021
|
+
it("the same URL, method, body and headers, and no BotID header", async () => {
|
|
1022
|
+
checkBotIdMock.mockResolvedValue(HUMAN);
|
|
1023
|
+
const botIdHeaders = {
|
|
1024
|
+
"x-is-human": "{}",
|
|
1025
|
+
"x-path": BOOK,
|
|
1026
|
+
"x-method": "POST",
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
delete process.env.VERCEL;
|
|
1030
|
+
expect((await handleRelay(relayRequest(BOOK, { headers: botIdHeaders }))).status).toBe(201);
|
|
1031
|
+
process.env.VERCEL = "1";
|
|
1032
|
+
expect((await handleRelay(relayRequest(BOOK, { headers: botIdHeaders }))).status).toBe(201);
|
|
1033
|
+
|
|
1034
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
1035
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1036
|
+
const [off, on] = fetchMock.mock.calls.map(([url, init]) => ({
|
|
1037
|
+
url,
|
|
1038
|
+
method: init.method,
|
|
1039
|
+
body: init.body,
|
|
1040
|
+
headers: [...new Headers(init.headers).entries()],
|
|
1041
|
+
}));
|
|
1042
|
+
expect(on).toEqual(off);
|
|
1043
|
+
const names = (on?.headers ?? []).map(([name]) => name);
|
|
1044
|
+
for (const name of Object.keys(botIdHeaders)) {
|
|
1045
|
+
expect(names).not.toContain(name);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
describe("AC-3 — a check that cannot decide fails closed", () => {
|
|
1051
|
+
function expectUnavailable(error: string): void {
|
|
1052
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1053
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
1054
|
+
expect(errorSpy).toHaveBeenCalledWith("[relay] bot check failed", {
|
|
1055
|
+
path: BOOK,
|
|
1056
|
+
outcome: "bot-check-failed",
|
|
1057
|
+
error,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
it("a throw answers 503 and logs the error's name, never its message", async () => {
|
|
1062
|
+
checkBotIdMock.mockRejectedValue(new Error("token abc"));
|
|
1063
|
+
const res = await handleRelay(relayRequest(BOOK));
|
|
1064
|
+
expect(res.status).toBe(503);
|
|
1065
|
+
expect(await errorCode(res)).toBe("bot_check_unavailable");
|
|
1066
|
+
expectUnavailable("Error");
|
|
1067
|
+
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain("token abc");
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it.each([
|
|
1071
|
+
["an empty verdict", {}],
|
|
1072
|
+
[
|
|
1073
|
+
"Vercel's error answer",
|
|
1074
|
+
{
|
|
1075
|
+
isHuman: undefined,
|
|
1076
|
+
isBot: undefined,
|
|
1077
|
+
isVerifiedBot: undefined,
|
|
1078
|
+
bypassed: undefined,
|
|
1079
|
+
},
|
|
1080
|
+
],
|
|
1081
|
+
["a non-boolean isBot", { ...HUMAN, isBot: "false" }],
|
|
1082
|
+
])("%s answers 503 InvalidVerdictError", async (_label, verdict) => {
|
|
1083
|
+
checkBotIdMock.mockResolvedValue(verdict);
|
|
1084
|
+
const res = await handleRelay(relayRequest(BOOK));
|
|
1085
|
+
expect(res.status).toBe(503);
|
|
1086
|
+
expect(await errorCode(res)).toBe("bot_check_unavailable");
|
|
1087
|
+
expectUnavailable("InvalidVerdictError");
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
describe("with fake timers", () => {
|
|
1091
|
+
beforeEach(() => {
|
|
1092
|
+
jest.useFakeTimers({
|
|
1093
|
+
doNotFake: ["nextTick", "queueMicrotask", "setImmediate"],
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
afterEach(() => {
|
|
1097
|
+
jest.useRealTimers();
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it("no verdict within 5 000 ms answers 503 TimeoutError", async () => {
|
|
1101
|
+
checkBotIdMock.mockReturnValue(new Promise(() => {}));
|
|
1102
|
+
const pending = handleRelay(relayRequest(BOOK));
|
|
1103
|
+
await jest.advanceTimersByTimeAsync(5_000);
|
|
1104
|
+
const res = await pending;
|
|
1105
|
+
expect(res.status).toBe(503);
|
|
1106
|
+
expect(await errorCode(res)).toBe("bot_check_unavailable");
|
|
1107
|
+
expectUnavailable("TimeoutError");
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it("a check that settles in time leaves no timer pending", async () => {
|
|
1111
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1112
|
+
const res = await handleRelay(relayRequest(BOOK));
|
|
1113
|
+
expect(res.status).toBe(403);
|
|
1114
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
describe("AC-4 — the switch", () => {
|
|
1120
|
+
it.each([
|
|
1121
|
+
["VERCEL=1, AILK_BOOKING_BOT_CHECK=off", "1", "off"],
|
|
1122
|
+
['VERCEL=1, AILK_BOOKING_BOT_CHECK=" OFF "', "1", " OFF "],
|
|
1123
|
+
["VERCEL unset, switch unset", undefined, undefined],
|
|
1124
|
+
])("%s forwards with no BotID call", async (_label, vercel, check) => {
|
|
1125
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1126
|
+
if (vercel === undefined) delete process.env.VERCEL;
|
|
1127
|
+
else process.env.VERCEL = vercel;
|
|
1128
|
+
if (check === undefined) delete process.env.AILK_BOOKING_BOT_CHECK;
|
|
1129
|
+
else process.env.AILK_BOOKING_BOT_CHECK = check;
|
|
1130
|
+
const res = await handleRelay(relayRequest(BOOK));
|
|
1131
|
+
expect(res.status).toBe(201);
|
|
1132
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1133
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it.each(["on", "0"])(
|
|
1137
|
+
"VERCEL=1, AILK_BOOKING_BOT_CHECK=%s keeps the check on",
|
|
1138
|
+
async (check) => {
|
|
1139
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1140
|
+
process.env.AILK_BOOKING_BOT_CHECK = check;
|
|
1141
|
+
const res = await handleRelay(relayRequest(BOOK));
|
|
1142
|
+
expect(res.status).toBe(403);
|
|
1143
|
+
expect(await errorCode(res)).toBe("bot_detected");
|
|
1144
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1145
|
+
},
|
|
1146
|
+
);
|
|
1147
|
+
|
|
1148
|
+
describe("in production, off Vercel", () => {
|
|
1149
|
+
const env = process.env as Record<string, string | undefined>;
|
|
1150
|
+
const priorNodeEnv = env.NODE_ENV;
|
|
1151
|
+
beforeEach(() => {
|
|
1152
|
+
env.NODE_ENV = "production";
|
|
1153
|
+
delete process.env.VERCEL;
|
|
1154
|
+
});
|
|
1155
|
+
afterEach(() => {
|
|
1156
|
+
env.NODE_ENV = priorNodeEnv;
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
async function twoBookings(): Promise<void> {
|
|
1160
|
+
await jest.isolateModulesAsync(async () => {
|
|
1161
|
+
const fresh = await import("../relay");
|
|
1162
|
+
for (let i = 0; i < 2; i++) {
|
|
1163
|
+
const res = await fresh.handleRelay(relayRequest(BOOK));
|
|
1164
|
+
expect(res.status).toBe(201);
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
it("warns once per process that the check is not running", async () => {
|
|
1170
|
+
await twoBookings();
|
|
1171
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1172
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
1173
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1174
|
+
"[relay] booking bot check inactive",
|
|
1175
|
+
{ outcome: "not-on-vercel" },
|
|
1176
|
+
);
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
it("with the switch off, warns nothing", async () => {
|
|
1180
|
+
process.env.AILK_BOOKING_BOT_CHECK = "off";
|
|
1181
|
+
await twoBookings();
|
|
1182
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1183
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
1184
|
+
});
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
describe("AC-5 — only the booking, and only after the local refusals", () => {
|
|
1189
|
+
beforeEach(() => {
|
|
1190
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
const isChecked = (e: { method: string; path: string }) =>
|
|
1194
|
+
BOT_CHECKED_ROUTES.some((r) => r.method === e.method && r.path === e.path);
|
|
1195
|
+
|
|
1196
|
+
it.each(ENTRIES.filter((e) => !isChecked(e)))(
|
|
1197
|
+
"$method $path forwards with no BotID call",
|
|
1198
|
+
async (entry) => {
|
|
1199
|
+
const res = await handleRelay(entryRequest(entry));
|
|
1200
|
+
expect(res.status).toBe(201);
|
|
1201
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1202
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1203
|
+
},
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
it("a cross-site booking answers 403 cross_site with no BotID call", async () => {
|
|
1207
|
+
const res = await handleRelay(
|
|
1208
|
+
relayRequest(BOOK, { headers: { "sec-fetch-site": "cross-site" } }),
|
|
1209
|
+
);
|
|
1210
|
+
expect(res.status).toBe(403);
|
|
1211
|
+
expect(await errorCode(res)).toBe("cross_site");
|
|
1212
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it("an over-budget booking answers 429 with no BotID call", async () => {
|
|
1216
|
+
const visitor = { "x-forwarded-for": "203.0.113.9" };
|
|
1217
|
+
for (let i = 0; i < 8; i++) {
|
|
1218
|
+
await handleRelay(
|
|
1219
|
+
relayRequest(`/v1/schedule/availability${QUERY}`, { method: "GET", headers: visitor }),
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
const res = await handleRelay(relayRequest(BOOK, { headers: visitor }));
|
|
1223
|
+
expect(res.status).toBe(429);
|
|
1224
|
+
expect(await errorCode(res)).toBe("rate_limited");
|
|
1225
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
it("a non-JSON booking answers 415 with no BotID call", async () => {
|
|
1229
|
+
const res = await handleRelay(
|
|
1230
|
+
relayRequest(BOOK, { headers: { "content-type": "text/plain" } }),
|
|
1231
|
+
);
|
|
1232
|
+
expect(res.status).toBe(415);
|
|
1233
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
it("an oversized booking answers 413 with no BotID call", async () => {
|
|
1237
|
+
const res = await handleRelay(
|
|
1238
|
+
relayRequest(BOOK, { body: "x".repeat(1_048_577) }),
|
|
1239
|
+
);
|
|
1240
|
+
expect(res.status).toBe(413);
|
|
1241
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
it("BOT_CHECKED_ROUTES is the three lead pairs and the booking pair, frozen, and RELAY_ROUTES' own entries", () => {
|
|
1245
|
+
expect(BOT_CHECKED_ROUTES).toEqual([
|
|
1246
|
+
{ method: "POST", path: "/v1/leads/submit" },
|
|
1247
|
+
{ method: "POST", path: "/v1/leads/subscribe" },
|
|
1248
|
+
{ method: "POST", path: "/v1/leads/callback" },
|
|
1249
|
+
{ method: "POST", path: BOOK },
|
|
1250
|
+
]);
|
|
1251
|
+
expect(Object.isFrozen(BOT_CHECKED_ROUTES)).toBe(true);
|
|
1252
|
+
for (const checked of BOT_CHECKED_ROUTES) {
|
|
1253
|
+
expect(checked).toBe(
|
|
1254
|
+
RELAY_ROUTES.find((r) => r.method === checked.method && r.path === checked.path),
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
// The write bot check (#9255, spec
|
|
1262
|
+
// docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-3): the relay's
|
|
1263
|
+
// three lead routes join the booking's check.
|
|
1264
|
+
describe("the write bot check on the lead routes (#9255)", () => {
|
|
1265
|
+
const LEAD_ROUTES = ["/v1/leads/submit", "/v1/leads/subscribe", "/v1/leads/callback"];
|
|
1266
|
+
const BOT = {
|
|
1267
|
+
isHuman: false,
|
|
1268
|
+
isBot: true,
|
|
1269
|
+
isVerifiedBot: false,
|
|
1270
|
+
bypassed: false,
|
|
1271
|
+
};
|
|
1272
|
+
const HUMAN = { ...BOT, isHuman: true, isBot: false };
|
|
1273
|
+
|
|
1274
|
+
let warnSpy: jest.SpyInstance;
|
|
1275
|
+
|
|
1276
|
+
beforeEach(() => {
|
|
1277
|
+
checkBotIdMock.mockReset();
|
|
1278
|
+
process.env.VERCEL = "1";
|
|
1279
|
+
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
afterEach(() => {
|
|
1283
|
+
warnSpy.mockRestore();
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
it.each(LEAD_ROUTES)("POST %s: a bot answers 403 bot_detected with no hub call", async (path) => {
|
|
1287
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1288
|
+
const res = await handleRelay(relayRequest(path));
|
|
1289
|
+
expect(res.status).toBe(403);
|
|
1290
|
+
expect(await errorCode(res)).toBe("bot_detected");
|
|
1291
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1292
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
1293
|
+
expect(warnSpy).toHaveBeenCalledWith("[relay] bot refused", {
|
|
1294
|
+
path,
|
|
1295
|
+
outcome: "bot-detected",
|
|
1296
|
+
});
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it.each(LEAD_ROUTES)(
|
|
1300
|
+
"POST %s: a check that throws answers 503 bot_check_unavailable with no hub call",
|
|
1301
|
+
async (path) => {
|
|
1302
|
+
checkBotIdMock.mockRejectedValue(new Error("token abc"));
|
|
1303
|
+
const res = await handleRelay(relayRequest(path));
|
|
1304
|
+
expect(res.status).toBe(503);
|
|
1305
|
+
expect(await errorCode(res)).toBe("bot_check_unavailable");
|
|
1306
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1307
|
+
expect(errorSpy).toHaveBeenCalledWith("[relay] bot check failed", {
|
|
1308
|
+
path,
|
|
1309
|
+
outcome: "bot-check-failed",
|
|
1310
|
+
error: "Error",
|
|
1311
|
+
});
|
|
1312
|
+
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain("token abc");
|
|
1313
|
+
},
|
|
1314
|
+
);
|
|
1315
|
+
|
|
1316
|
+
it.each(LEAD_ROUTES)(
|
|
1317
|
+
"POST %s: a human forwards once, exactly as with the check off",
|
|
1318
|
+
async (path) => {
|
|
1319
|
+
checkBotIdMock.mockResolvedValue(HUMAN);
|
|
1320
|
+
const botIdHeaders = { "x-is-human": "{}", "x-path": path, "x-method": "POST" };
|
|
1321
|
+
|
|
1322
|
+
delete process.env.VERCEL;
|
|
1323
|
+
expect((await handleRelay(relayRequest(path, { headers: botIdHeaders }))).status).toBe(201);
|
|
1324
|
+
process.env.VERCEL = "1";
|
|
1325
|
+
expect((await handleRelay(relayRequest(path, { headers: botIdHeaders }))).status).toBe(201);
|
|
1326
|
+
|
|
1327
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
1328
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1329
|
+
const [off, on] = fetchMock.mock.calls.map(([url, init]) => ({
|
|
1330
|
+
url,
|
|
1331
|
+
method: init.method,
|
|
1332
|
+
body: init.body,
|
|
1333
|
+
headers: [...new Headers(init.headers).entries()],
|
|
1334
|
+
}));
|
|
1335
|
+
expect(on).toEqual(off);
|
|
1336
|
+
expect(on?.url).toBe(`${HUB}${path}`);
|
|
1337
|
+
},
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
it.each([
|
|
1341
|
+
{ method: "GET", path: "/v1/schedule/availability" },
|
|
1342
|
+
{ method: "POST", path: "/v1/commerce/invoices" },
|
|
1343
|
+
])("$method $path forwards with no BotID call", async (entry) => {
|
|
1344
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1345
|
+
const res = await handleRelay(entryRequest(entry));
|
|
1346
|
+
expect(res.status).toBe(201);
|
|
1347
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1348
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
it("AILK_BOT_CHECK=off forwards a lead with no BotID call", async () => {
|
|
1352
|
+
process.env.AILK_BOT_CHECK = "off";
|
|
1353
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1354
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
1355
|
+
expect(res.status).toBe(201);
|
|
1356
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it("AILK_BOOKING_BOT_CHECK=off leaves the lead routes checked", async () => {
|
|
1360
|
+
process.env.AILK_BOOKING_BOT_CHECK = "off";
|
|
1361
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1362
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
1363
|
+
expect(res.status).toBe(403);
|
|
1364
|
+
expect(await errorCode(res)).toBe("bot_detected");
|
|
1365
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1366
|
+
});
|
|
1367
|
+
});
|