create-ailk 0.19.0 → 0.19.1
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/add-module.js +7 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +9 -3
- package/dist/programmatic.js +8 -1
- package/dist/site-roster.js +21 -4
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +31 -1
- package/dist/topology.js +29 -1
- package/package.json +3 -3
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +10 -4
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +11 -3
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +111 -16
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +122 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +66 -4
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +41 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +54 -14
- package/templates/database/CHANGELOG.md +16 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/inbox/schema.prisma +76 -10
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/package.json +1 -1
- package/versions.json +35 -35
package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log out in AccountFrame (#8465).
|
|
3
|
+
*
|
|
4
|
+
* A spoke that signs in through its hub holds its session in the sealed
|
|
5
|
+
* hub-session cookie, which only `POST /api/hub-session/sign-out` clears
|
|
6
|
+
* (and which also revokes the hub credential); Better Auth's `signOut()` hits
|
|
7
|
+
* a route that spoke does not serve. The server layout hands the seam's
|
|
8
|
+
* `SIGN_OUT_ENDPOINT` down as `signOutEndpoint`:
|
|
9
|
+
*
|
|
10
|
+
* - with an endpoint, Log out POSTs it (same-origin), never calls Better
|
|
11
|
+
* Auth, and lands on the sign-in page once the answer is 2xx;
|
|
12
|
+
* - a refused or failed sign-out (403 on an Origin mismatch, 5xx, a
|
|
13
|
+
* rejected fetch) stays on the page, shows the translated error, and
|
|
14
|
+
* navigates nowhere — the cookie is still valid (#8467 review addendum);
|
|
15
|
+
* - without an endpoint (a hub or a single site) it calls Better Auth and
|
|
16
|
+
* routes home, as before.
|
|
17
|
+
*/
|
|
18
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
19
|
+
import type { ReactNode } from "react";
|
|
20
|
+
|
|
21
|
+
const mockSignOut = jest.fn();
|
|
22
|
+
jest.mock("better-auth/react", () => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
createAuthClient: () => ({
|
|
25
|
+
signOut: (...args: unknown[]) => mockSignOut(...args),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const mockPush = jest.fn();
|
|
30
|
+
const mockRefresh = jest.fn();
|
|
31
|
+
jest.mock("next/navigation", () => ({
|
|
32
|
+
__esModule: true,
|
|
33
|
+
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
|
|
34
|
+
usePathname: () => "/account",
|
|
35
|
+
useSearchParams: () => new URLSearchParams(""),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
jest.mock("@working-theory/templates/shells", () => ({
|
|
39
|
+
__esModule: true,
|
|
40
|
+
SignedInShell: ({
|
|
41
|
+
onSignOut,
|
|
42
|
+
children,
|
|
43
|
+
}: {
|
|
44
|
+
onSignOut: () => void;
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
}) => (
|
|
47
|
+
<div>
|
|
48
|
+
<button type="button" data-testid="sign-out" onClick={onSignOut} />
|
|
49
|
+
{children}
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
jest.mock("@working-theory/templates/account", () => ({
|
|
55
|
+
__esModule: true,
|
|
56
|
+
ForYouPanel: () => null,
|
|
57
|
+
PhoneTabProvider: ({ children }: { children: ReactNode }) => children,
|
|
58
|
+
useForYouDismissed: () => [new Set<string>(), jest.fn()],
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
import { AccountFrame, type AccountFrameProps } from "../account-frame";
|
|
62
|
+
|
|
63
|
+
const ENDPOINT = "/api/hub-session/sign-out";
|
|
64
|
+
const FAILED = "We couldn't log you out. You're still signed in; try again.";
|
|
65
|
+
const mockFetch = jest.fn();
|
|
66
|
+
|
|
67
|
+
function renderFrame(extra: Partial<AccountFrameProps> = {}): void {
|
|
68
|
+
render(
|
|
69
|
+
<AccountFrame
|
|
70
|
+
locale="en"
|
|
71
|
+
user={{ name: "Ada", email: "ada@example.com" }}
|
|
72
|
+
logo={null}
|
|
73
|
+
helpItems={[]}
|
|
74
|
+
// eslint-disable-next-line no-restricted-syntax -- partial fixture: the suite exercises only the sign-out form, so the full forYou/labels shapes are not built
|
|
75
|
+
forYou={{ items: [] } as unknown as AccountFrameProps["forYou"]}
|
|
76
|
+
labels={
|
|
77
|
+
// eslint-disable-next-line no-restricted-syntax -- partial fixture (see forYou above)
|
|
78
|
+
{
|
|
79
|
+
home: "Home",
|
|
80
|
+
signOutFailed: FAILED,
|
|
81
|
+
} as unknown as AccountFrameProps["labels"]
|
|
82
|
+
}
|
|
83
|
+
{...extra}
|
|
84
|
+
>
|
|
85
|
+
<p>page</p>
|
|
86
|
+
</AccountFrame>,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
jest.clearAllMocks();
|
|
92
|
+
globalThis.fetch = mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("AccountFrame Log out — a spoke that signs in through its hub", () => {
|
|
96
|
+
it("POSTs the hub sign-out endpoint, never Better Auth, then lands on the sign-in page and refreshes", async () => {
|
|
97
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
98
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
99
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/account"));
|
|
100
|
+
expect(mockFetch).toHaveBeenCalledWith(ENDPOINT, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
credentials: "same-origin",
|
|
103
|
+
});
|
|
104
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
105
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
106
|
+
expect(
|
|
107
|
+
screen.queryByTestId("account-sign-out-error"),
|
|
108
|
+
).not.toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("falls back to the site home when no sign-in href is given", async () => {
|
|
112
|
+
renderFrame({ signOutEndpoint: ENDPOINT });
|
|
113
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
114
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each([
|
|
118
|
+
["a 403 (Origin mismatch)", { ok: false, status: 403 }],
|
|
119
|
+
["a 500", { ok: false, status: 500 }],
|
|
120
|
+
["a 404", { ok: false, status: 404 }],
|
|
121
|
+
])(
|
|
122
|
+
"%s stays on the page with the error and routes nowhere",
|
|
123
|
+
async (_label, response) => {
|
|
124
|
+
mockFetch.mockResolvedValue(response);
|
|
125
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
126
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
127
|
+
expect(
|
|
128
|
+
await screen.findByTestId("account-sign-out-error"),
|
|
129
|
+
).toHaveTextContent(FAILED);
|
|
130
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
131
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
132
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
it("a rejected request stays on the page with the error and routes nowhere", async () => {
|
|
137
|
+
mockFetch.mockRejectedValue(new TypeError("network down"));
|
|
138
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
139
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
140
|
+
expect(
|
|
141
|
+
await screen.findByTestId("account-sign-out-error"),
|
|
142
|
+
).toHaveTextContent(FAILED);
|
|
143
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
144
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("a retry that succeeds clears the error and lands on the sign-in page", async () => {
|
|
148
|
+
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
149
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
150
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
151
|
+
await screen.findByTestId("account-sign-out-error");
|
|
152
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
153
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/account"));
|
|
154
|
+
expect(
|
|
155
|
+
screen.queryByTestId("account-sign-out-error"),
|
|
156
|
+
).not.toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("AccountFrame Log out — a hub or a single site", () => {
|
|
161
|
+
it.each([
|
|
162
|
+
["absent", {}],
|
|
163
|
+
["null", { signOutEndpoint: null }],
|
|
164
|
+
])(
|
|
165
|
+
"with the endpoint %s calls Better Auth, then routes home and refreshes",
|
|
166
|
+
async (_label, extra) => {
|
|
167
|
+
renderFrame(extra);
|
|
168
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
169
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
|
|
170
|
+
expect(mockSignOut).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
172
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
});
|
|
@@ -131,8 +131,22 @@ describe("the frame (D7)", () => {
|
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
describe("the standalone shape (#8430)", () => {
|
|
135
|
+
it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
|
|
136
|
+
await renderPage();
|
|
137
|
+
const settings = screen.getByTestId("account-settings");
|
|
138
|
+
expect(settings).toHaveClass("min-h-screen");
|
|
139
|
+
const column = within(settings).getByRole("complementary");
|
|
140
|
+
expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
|
|
141
|
+
expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
|
|
142
|
+
expect(within(settings).getByRole("main")).toContainElement(
|
|
143
|
+
screen.getByRole("heading", { level: 1 }),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
134
148
|
describe("AC-5 — Billing as the current-plan card (D8)", () => {
|
|
135
|
-
it("(a) shows the plan, the status, the card and Manage
|
|
149
|
+
it("(a) shows the plan, the status, the card and Manage billing under the Current plan heading", async () => {
|
|
136
150
|
await renderPage();
|
|
137
151
|
expect(screen.getByText("Current plan")).toBeInTheDocument();
|
|
138
152
|
const card = screen.getByTestId("account-billing-card");
|
|
@@ -141,7 +155,7 @@ describe("AC-5 — Billing as the current-plan card (D8)", () => {
|
|
|
141
155
|
expect(within(card).getByTestId("account-billing-payment")).toHaveTextContent(
|
|
142
156
|
"visa ending in 4242, expires 04/2027",
|
|
143
157
|
);
|
|
144
|
-
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage
|
|
158
|
+
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
|
|
145
159
|
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
146
160
|
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
147
161
|
});
|
|
@@ -262,7 +276,7 @@ describe("AC-2 (e) — contributed billing sections render after the base conten
|
|
|
262
276
|
});
|
|
263
277
|
});
|
|
264
278
|
|
|
265
|
-
describe("#8217 AC-1 — the single site's Manage
|
|
279
|
+
describe("#8217 AC-1 — the single site's Manage billing (D10, D11)", () => {
|
|
266
280
|
const PORTAL_URL = "https://billing.stripe.com/p/session/test_single_site";
|
|
267
281
|
|
|
268
282
|
it("mints the portal once through the hub's route with the cookie init, then redirects there", async () => {
|
|
@@ -71,6 +71,8 @@ jest.mock("../../../../../auth", () => ({
|
|
|
71
71
|
const mockPieces = jest.fn();
|
|
72
72
|
const mockUpdateProfile = jest.fn();
|
|
73
73
|
const mockRequestDeletion = jest.fn();
|
|
74
|
+
const mockContact = jest.fn();
|
|
75
|
+
const mockUpdateContact = jest.fn();
|
|
74
76
|
const mockHubMe = jest.fn();
|
|
75
77
|
const mockLeadsSubmit = jest.fn();
|
|
76
78
|
jest.mock("@working-theory/api-client", () => ({
|
|
@@ -83,6 +85,8 @@ jest.mock("@working-theory/api-client", () => ({
|
|
|
83
85
|
pieces: (...args: unknown[]) => mockPieces(...args),
|
|
84
86
|
updateProfile: (...args: unknown[]) => mockUpdateProfile(...args),
|
|
85
87
|
requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
|
|
88
|
+
contact: (...args: unknown[]) => mockContact(...args),
|
|
89
|
+
updateContact: (...args: unknown[]) => mockUpdateContact(...args),
|
|
86
90
|
},
|
|
87
91
|
hubSignIn: { me: (...args: unknown[]) => mockHubMe(...args) },
|
|
88
92
|
visitorForwardHeaders: () => null,
|
|
@@ -120,6 +124,7 @@ import type { AccountSettingsSection } from "@working-theory/templates/account";
|
|
|
120
124
|
import { accountSettingsSections } from "../../../../../lib/account-config";
|
|
121
125
|
import { seal } from "../../../../../lib/hub-session/seal";
|
|
122
126
|
import { BillingAnchor } from "../general/billing-anchor";
|
|
127
|
+
import { saveContact } from "../general/contact-actions";
|
|
123
128
|
import Page from "../general/page";
|
|
124
129
|
import { requestAccountDeletion, saveProfile } from "../profile-actions";
|
|
125
130
|
|
|
@@ -179,6 +184,8 @@ beforeEach(() => {
|
|
|
179
184
|
mockPieces.mockReset().mockResolvedValue(ok([]));
|
|
180
185
|
mockUpdateProfile.mockReset().mockResolvedValue(ok({ name: "Ada King", image: PHOTO }));
|
|
181
186
|
mockRequestDeletion.mockReset().mockResolvedValue(ok({ sentTo: "site-operator" }));
|
|
187
|
+
mockContact.mockReset().mockResolvedValue(ok({ contact: null }));
|
|
188
|
+
mockUpdateContact.mockReset().mockResolvedValue(ok({ contact: null }));
|
|
182
189
|
mockHubMe.mockReset();
|
|
183
190
|
mockLeadsSubmit.mockReset();
|
|
184
191
|
mockUpdateUser.mockReset().mockResolvedValue({ data: { status: true }, error: null });
|
|
@@ -296,6 +303,20 @@ describe("AC-1 — the frame (D2, D7)", () => {
|
|
|
296
303
|
});
|
|
297
304
|
});
|
|
298
305
|
|
|
306
|
+
describe("the standalone shape (#8430)", () => {
|
|
307
|
+
it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
|
|
308
|
+
await renderPage();
|
|
309
|
+
const settings = screen.getByTestId("account-settings");
|
|
310
|
+
expect(settings).toHaveClass("min-h-screen");
|
|
311
|
+
const column = within(settings).getByRole("complementary");
|
|
312
|
+
expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
|
|
313
|
+
expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
|
|
314
|
+
expect(within(settings).getByRole("main")).toContainElement(
|
|
315
|
+
screen.getByRole("heading", { level: 1 }),
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
299
320
|
// ── 2. The profile form on the member path (AC-3) ─────────────────────────────
|
|
300
321
|
|
|
301
322
|
describe("AC-3 — name and photo through the member API (D4, D8)", () => {
|
|
@@ -482,6 +503,141 @@ describe("AC-3 — a single site with no member API (a 404 from the pieces read)
|
|
|
482
503
|
});
|
|
483
504
|
});
|
|
484
505
|
|
|
506
|
+
// ── #8435 D13 (AC-6) — the contact details on this site ───────────────────────
|
|
507
|
+
|
|
508
|
+
describe("AC-6 — the contact details (#8435 D13)", () => {
|
|
509
|
+
const CONTACT = {
|
|
510
|
+
givenName: "Ada",
|
|
511
|
+
familyName: "Lovelace",
|
|
512
|
+
telephone: "+442071234567",
|
|
513
|
+
address: {
|
|
514
|
+
addressLine1: "1 Analytical Way",
|
|
515
|
+
addressLine2: null,
|
|
516
|
+
city: "London",
|
|
517
|
+
region: "LDN",
|
|
518
|
+
postalCode: "N1 1AA",
|
|
519
|
+
country: "GB",
|
|
520
|
+
},
|
|
521
|
+
companyName: "Engines Ltd",
|
|
522
|
+
jobTitle: "Countess",
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
it("with the member API, renders the contact section seeded from member.contact", async () => {
|
|
526
|
+
mockContact.mockResolvedValue(ok({ contact: CONTACT }));
|
|
527
|
+
await renderPage();
|
|
528
|
+
const section = screen.getByTestId("account-contact-section");
|
|
529
|
+
expect(mockContact).toHaveBeenCalledWith(COOKIE_INIT);
|
|
530
|
+
expect(within(section).getByTestId("account-contact-given-name")).toHaveValue("Ada");
|
|
531
|
+
expect(within(section).getByTestId("account-contact-company")).toHaveValue("Engines Ltd");
|
|
532
|
+
expect(within(section).getByTestId("field-addressLine1")).toHaveValue("1 Analytical Way");
|
|
533
|
+
// The profile form's single "Save changes" button stays unique.
|
|
534
|
+
expect(screen.getAllByRole("button", { name: "Save changes" })).toHaveLength(1);
|
|
535
|
+
expect(within(section).getByTestId("account-contact-save")).toHaveTextContent("Save contact details");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("with no confirmed contact yet, the form starts blank", async () => {
|
|
539
|
+
await renderPage();
|
|
540
|
+
expect(screen.getByTestId("account-contact-given-name")).toHaveValue("");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("a failed read renders the section's unavailable line, not the form", async () => {
|
|
544
|
+
mockContact.mockResolvedValue(refusal(503, "tenant_unresolved"));
|
|
545
|
+
await renderPage();
|
|
546
|
+
expect(screen.queryByTestId("account-contact-form")).not.toBeInTheDocument();
|
|
547
|
+
expect(screen.getByTestId("account-contact-unavailable")).toHaveTextContent(
|
|
548
|
+
"Contact details aren't available on this site right now.",
|
|
549
|
+
);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("a read that throws is the same unavailable line", async () => {
|
|
553
|
+
mockContact.mockRejectedValue(new Error("down"));
|
|
554
|
+
await renderPage();
|
|
555
|
+
expect(screen.getByTestId("account-contact-unavailable")).toBeInTheDocument();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("with no member API (a 404 from the pieces read) the section does not render", async () => {
|
|
559
|
+
mockPieces.mockResolvedValue(refusal(404, "not_found"));
|
|
560
|
+
await renderPage();
|
|
561
|
+
expect(screen.queryByTestId("account-contact-section")).not.toBeInTheDocument();
|
|
562
|
+
expect(mockContact).not.toHaveBeenCalled();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("saving posts all six keys through saveContact and answers Saved", async () => {
|
|
566
|
+
mockContact.mockResolvedValue(ok({ contact: CONTACT }));
|
|
567
|
+
await renderPage();
|
|
568
|
+
fireEvent.change(screen.getByTestId("account-contact-company"), { target: { value: "Acme" } });
|
|
569
|
+
await act(async () => {
|
|
570
|
+
fireEvent.submit(screen.getByTestId("account-contact-form"));
|
|
571
|
+
});
|
|
572
|
+
await waitFor(() => expect(mockUpdateContact).toHaveBeenCalledTimes(1));
|
|
573
|
+
expect(mockUpdateContact).toHaveBeenCalledWith(
|
|
574
|
+
{
|
|
575
|
+
givenName: "Ada",
|
|
576
|
+
familyName: "Lovelace",
|
|
577
|
+
telephone: "+442071234567",
|
|
578
|
+
address: CONTACT.address,
|
|
579
|
+
companyName: "Acme",
|
|
580
|
+
jobTitle: "Countess",
|
|
581
|
+
},
|
|
582
|
+
COOKIE_INIT,
|
|
583
|
+
);
|
|
584
|
+
expect(await screen.findByTestId("account-contact-message")).toHaveTextContent("Saved.");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("a refused telephone shows its own line and keeps the fields as typed", async () => {
|
|
588
|
+
mockUpdateContact.mockResolvedValue(refusal(400, "telephone_invalid"));
|
|
589
|
+
await renderPage();
|
|
590
|
+
fireEvent.change(screen.getByTestId("account-contact-company"), { target: { value: "Acme" } });
|
|
591
|
+
await act(async () => {
|
|
592
|
+
fireEvent.submit(screen.getByTestId("account-contact-form"));
|
|
593
|
+
});
|
|
594
|
+
expect(await screen.findByTestId("account-contact-message")).toHaveTextContent(
|
|
595
|
+
"Enter the phone number with its country code.",
|
|
596
|
+
);
|
|
597
|
+
expect(screen.getByTestId("account-contact-company")).toHaveValue("Acme");
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe("AC-6 — saveContact (#8435 D13)", () => {
|
|
602
|
+
const BODY = {
|
|
603
|
+
givenName: "Ada",
|
|
604
|
+
familyName: null,
|
|
605
|
+
telephone: null,
|
|
606
|
+
address: null,
|
|
607
|
+
companyName: null,
|
|
608
|
+
jobTitle: null,
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
it("calls member.updateContact with forwardedCookieInit()'s init", async () => {
|
|
612
|
+
mockHeaders.mockResolvedValue(new Headers({ origin: "https://site.example.com" }));
|
|
613
|
+
await expect(saveContact(BODY)).resolves.toEqual({ ok: true });
|
|
614
|
+
expect(mockUpdateContact).toHaveBeenCalledWith(BODY, {
|
|
615
|
+
headers: { cookie: "session=abc", origin: "https://site.example.com" },
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it.each([
|
|
620
|
+
[refusal(400, "telephone_invalid"), "telephone"],
|
|
621
|
+
[refusal(400, "invalid_input"), "invalid"],
|
|
622
|
+
[refusal(403, "email_unverified"), "unverified"],
|
|
623
|
+
[refusal(404, "not_found"), "unavailable"],
|
|
624
|
+
[refusal(409, "contact_unavailable"), "unavailable"],
|
|
625
|
+
[refusal(503, "tenant_unresolved"), "unavailable"],
|
|
626
|
+
[refusal(500, "internal_error"), "failed"],
|
|
627
|
+
])("maps %j to %s", async (res, reason) => {
|
|
628
|
+
mockUpdateContact.mockResolvedValue(res);
|
|
629
|
+
await expect(saveContact(BODY)).resolves.toEqual({ ok: false, reason });
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("a throw is failed, and a non-object body makes no call", async () => {
|
|
633
|
+
mockUpdateContact.mockRejectedValue(new Error("down"));
|
|
634
|
+
await expect(saveContact(BODY)).resolves.toEqual({ ok: false, reason: "failed" });
|
|
635
|
+
mockUpdateContact.mockClear();
|
|
636
|
+
await expect(saveContact(null as never)).resolves.toEqual({ ok: false, reason: "invalid" });
|
|
637
|
+
expect(mockUpdateContact).not.toHaveBeenCalled();
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
485
641
|
// ── 4. The deletion request (AC-4) ────────────────────────────────────────────
|
|
486
642
|
|
|
487
643
|
describe("AC-4 — Request account deletion through the member API (D5)", () => {
|
|
@@ -25,11 +25,16 @@
|
|
|
25
25
|
* there is no subscription without reading `subscription.me`.
|
|
26
26
|
* - #8217 AC-1 (spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
|
|
27
27
|
* D10–D12): Billing reads the seam's `loadSpokeBilling` — the plan, the
|
|
28
|
-
* card and Manage
|
|
28
|
+
* card and Manage billing for a subscription; the plain none line with
|
|
29
29
|
* no button for none or a spoke without the action (`null`); the
|
|
30
30
|
* unreadable line; the error row for a failed read — and never calls
|
|
31
31
|
* `subscription.me` or `member.billing` itself. `openBillingPortal`
|
|
32
32
|
* redirects to the loader's portal URL, and to nothing without one.
|
|
33
|
+
* - #8431 AC-4 (spec docs/specs/8431-account-security-tab-human-sign-spec.md
|
|
34
|
+
* D1–D3): Security says the hub manages sign-in and links to the hub's
|
|
35
|
+
* own Security page, offers no password control and never reads the
|
|
36
|
+
* seam's accounts; Log out POSTs the spoke's sign-out endpoint, never
|
|
37
|
+
* Better Auth, and lands on `/account` only when it succeeded.
|
|
33
38
|
*/
|
|
34
39
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
35
40
|
import { tmpdir } from "node:os";
|
|
@@ -73,6 +78,7 @@ jest.mock("next/headers", () => ({
|
|
|
73
78
|
|
|
74
79
|
const mockReadSession = jest.fn();
|
|
75
80
|
const mockLoadSpokeBilling = jest.fn();
|
|
81
|
+
const mockReadOwnAccounts = jest.fn();
|
|
76
82
|
// eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); its `signIn: "hub"` render is the boundary under test, and no env or HTTP surface selects it.
|
|
77
83
|
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
78
84
|
__esModule: true,
|
|
@@ -86,6 +92,8 @@ jest.mock("../../../../../lib/topology.generated", () => ({
|
|
|
86
92
|
registerSiteStores: () => Promise.resolve(),
|
|
87
93
|
// #8217 D7 — the billing render's loader; `null` is a spoke without the action.
|
|
88
94
|
loadSpokeBilling: (...args: unknown[]) => mockLoadSpokeBilling(...args),
|
|
95
|
+
// #8431 D2a — a spoke has no local accounts; Security must never ask.
|
|
96
|
+
readOwnAccounts: (...args: unknown[]) => mockReadOwnAccounts(...args),
|
|
89
97
|
}));
|
|
90
98
|
|
|
91
99
|
const mockLeadsSubmit = jest.fn();
|
|
@@ -126,18 +134,20 @@ jest.mock("@working-theory/api-client", () => ({
|
|
|
126
134
|
}));
|
|
127
135
|
|
|
128
136
|
const mockUpdateUser = jest.fn();
|
|
137
|
+
const mockSignOut = jest.fn();
|
|
129
138
|
jest.mock("better-auth/react", () => ({
|
|
130
139
|
__esModule: true,
|
|
131
140
|
createAuthClient: () => ({
|
|
132
|
-
signOut:
|
|
141
|
+
signOut: (...args: unknown[]) => mockSignOut(...args),
|
|
133
142
|
updateUser: (...args: unknown[]) => mockUpdateUser(...args),
|
|
134
143
|
}),
|
|
135
144
|
}));
|
|
136
145
|
|
|
137
146
|
const mockRedirect = jest.fn();
|
|
147
|
+
const mockPush = jest.fn();
|
|
138
148
|
jest.mock("next/navigation", () => ({
|
|
139
149
|
__esModule: true,
|
|
140
|
-
useRouter: () => ({ push:
|
|
150
|
+
useRouter: () => ({ push: mockPush, refresh: jest.fn(), replace: jest.fn() }),
|
|
141
151
|
usePathname: () => "/account/general",
|
|
142
152
|
redirect: (...args: unknown[]) => mockRedirect(...args),
|
|
143
153
|
}));
|
|
@@ -196,6 +206,9 @@ beforeEach(() => {
|
|
|
196
206
|
mockRedirect.mockReset();
|
|
197
207
|
mockCookieSet.mockReset();
|
|
198
208
|
mockUpdateUser.mockReset();
|
|
209
|
+
mockReadOwnAccounts.mockReset().mockResolvedValue(null);
|
|
210
|
+
mockSignOut.mockReset();
|
|
211
|
+
mockPush.mockReset();
|
|
199
212
|
});
|
|
200
213
|
|
|
201
214
|
afterEach(() => {
|
|
@@ -247,15 +260,6 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
|
|
|
247
260
|
expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
|
|
248
261
|
});
|
|
249
262
|
|
|
250
|
-
it("the Security tab's sign-in line reads the hub, by the same name read (D7, moved from General)", async () => {
|
|
251
|
-
const { default: SecurityPage } = await import("../security/page");
|
|
252
|
-
render(await SecurityPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
253
|
-
expect(screen.getByTestId("account-signin-method")).toHaveTextContent(
|
|
254
|
-
"You sign in through hub.example.com.",
|
|
255
|
-
);
|
|
256
|
-
expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
|
|
257
|
-
});
|
|
258
|
-
|
|
259
263
|
it("saves the name through member.updateProfile with the Bearer credential, then re-seals the session", async () => {
|
|
260
264
|
mockUpdateProfile.mockResolvedValue({ ok: true, body: { name: "Ada King", image: null } });
|
|
261
265
|
mockHubMe.mockResolvedValue({ ok: true, body: { user: { ...USER, name: "Ada King" }, billingCustomerId: null } });
|
|
@@ -326,6 +330,77 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
|
|
|
326
330
|
});
|
|
327
331
|
});
|
|
328
332
|
|
|
333
|
+
describe("#8431 AC-4 — Security on a hub-mode spoke", () => {
|
|
334
|
+
const realFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch");
|
|
335
|
+
const mockFetch = jest.fn();
|
|
336
|
+
|
|
337
|
+
beforeEach(() => {
|
|
338
|
+
mockFetch.mockReset();
|
|
339
|
+
Object.defineProperty(globalThis, "fetch", { value: mockFetch, writable: true, configurable: true });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
afterEach(() => {
|
|
343
|
+
if (realFetch) Object.defineProperty(globalThis, "fetch", realFetch);
|
|
344
|
+
else Reflect.deleteProperty(globalThis, "fetch");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
async function renderSecurity(): Promise<void> {
|
|
348
|
+
const { default: SecurityPage } = await import("../security/page");
|
|
349
|
+
render(await SecurityPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
it("says the hub manages sign-in and links to the hub's Security page — no password control", async () => {
|
|
353
|
+
await renderSecurity();
|
|
354
|
+
expect(screen.getByTestId("account-security-hub").textContent).toBe(
|
|
355
|
+
"Your sign-in is managed at hub.example.com.",
|
|
356
|
+
);
|
|
357
|
+
const links = screen.getAllByTestId("account-security-hub-link");
|
|
358
|
+
expect(links).toHaveLength(1);
|
|
359
|
+
expect(links[0]).toHaveAttribute("href", `${HUB}/account/security`);
|
|
360
|
+
expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
|
|
361
|
+
expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
|
|
362
|
+
expect(screen.queryByTestId("account-security-sentence")).not.toBeInTheDocument();
|
|
363
|
+
expect(screen.queryByText(/other devices/i)).toBeNull();
|
|
364
|
+
expect(mockReadOwnAccounts).not.toHaveBeenCalled();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("with the hub config absent says 'our main site' and links nowhere", async () => {
|
|
368
|
+
delete process.env.AILK_HUB_CLIENT_SECRET;
|
|
369
|
+
await renderSecurity();
|
|
370
|
+
expect(screen.getByTestId("account-security-hub").textContent).toBe(
|
|
371
|
+
"Your sign-in is managed by our main site.",
|
|
372
|
+
);
|
|
373
|
+
expect(screen.queryByTestId("account-security-hub-link")).not.toBeInTheDocument();
|
|
374
|
+
expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("Log out POSTs the spoke's sign-out endpoint, never Better Auth, and on a 204 lands on /account", async () => {
|
|
378
|
+
mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
|
379
|
+
await renderSecurity();
|
|
380
|
+
await act(async () => {
|
|
381
|
+
fireEvent.click(screen.getByTestId("account-security-log-out"));
|
|
382
|
+
});
|
|
383
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
384
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/hub-session/sign-out", {
|
|
385
|
+
method: "POST",
|
|
386
|
+
credentials: "same-origin",
|
|
387
|
+
});
|
|
388
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
389
|
+
expect(mockPush).toHaveBeenCalledWith("/account");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("a refused sign-out (403) navigates nowhere and shows the error", async () => {
|
|
393
|
+
mockFetch.mockResolvedValue({ ok: false, status: 403 });
|
|
394
|
+
await renderSecurity();
|
|
395
|
+
await act(async () => {
|
|
396
|
+
fireEvent.click(screen.getByTestId("account-security-log-out"));
|
|
397
|
+
});
|
|
398
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
399
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
400
|
+
expect(screen.getByTestId("account-security-log-out-error")).toBeInTheDocument();
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
329
404
|
describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke billing (D10–D12)", () => {
|
|
330
405
|
const VISA = { brand: "visa", last4: "4242", expMonth: 4, expYear: 2030 };
|
|
331
406
|
const PORTAL_URL = "https://billing.stripe.com/p/session/test_fake";
|
|
@@ -347,7 +422,7 @@ describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke bill
|
|
|
347
422
|
expect(mockMemberBilling).not.toHaveBeenCalled();
|
|
348
423
|
});
|
|
349
424
|
|
|
350
|
-
it("a subscription renders the plan, the card and Manage
|
|
425
|
+
it("a subscription renders the plan, the card and Manage billing", async () => {
|
|
351
426
|
mockLoadSpokeBilling.mockResolvedValue(
|
|
352
427
|
stub({ kind: "subscription", status: "active", planName: "Pro", paymentMethod: VISA, unreadable: false }),
|
|
353
428
|
);
|
|
@@ -356,7 +431,7 @@ describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke bill
|
|
|
356
431
|
expect(card).toHaveTextContent("Pro");
|
|
357
432
|
expect(card).toHaveTextContent("Active");
|
|
358
433
|
expect(card).toHaveTextContent("visa ending in 4242, expires 04/2030");
|
|
359
|
-
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage
|
|
434
|
+
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
|
|
360
435
|
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
361
436
|
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
362
437
|
});
|