@0x0501/mystack 0.0.0-stage → 0.1.0-bootstrap.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/LICENSE +21 -0
- package/README.md +68 -2
- package/assets/auth.sqlite.schema.ts +479 -0
- package/assets/bun-sqlite.test.ts +46 -0
- package/assets/d1.test.ts +41 -0
- package/assets/drizzle.config.ts +2 -0
- package/assets/generate-auth-schema.mjs +25 -0
- package/assets/platform.sqlite.schema.ts +46 -0
- package/assets/polar-checkout.ts +10 -0
- package/assets/polar-route.ts +19 -0
- package/assets/polar.test.ts +30 -0
- package/assets/polar.ts +12 -0
- package/assets/purchase.bun-sqlite.ts +38 -0
- package/assets/purchase.sqlite.ts +39 -0
- package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
- package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
- package/assets/sqlite-migrations/meta/_journal.json +13 -0
- package/assets/turso.test.ts +40 -0
- package/package.json +44 -5
- package/src/adapters/database.mjs +448 -0
- package/src/adapters/deployment.mjs +101 -0
- package/src/adapters/email.mjs +75 -0
- package/src/adapters/payments.mjs +270 -0
- package/src/catalog.mjs +128 -0
- package/src/cli.mjs +294 -0
- package/src/generate.mjs +183 -0
- package/src/template.mjs +93 -0
- package/template/.env.docker +16 -0
- package/template/.env.example +67 -0
- package/template/DESIGN.md +58 -0
- package/template/LICENSE +21 -0
- package/template/biome.json +46 -0
- package/template/docker-compose.yml +47 -0
- package/template/docs/project-guide.md +61 -0
- package/template/drizzle/0000_create-app-role.sql +54 -0
- package/template/drizzle/0001_add-rls-context.sql +21 -0
- package/template/drizzle/0002_platform_tables.sql +320 -0
- package/template/drizzle/meta/0002_snapshot.json +2125 -0
- package/template/drizzle/meta/_journal.json +27 -0
- package/template/drizzle.config.ts +25 -0
- package/template/e2e/authenticated-ui.spec.ts +40 -0
- package/template/e2e/headless-design.spec.ts +66 -0
- package/template/e2e/navigation-loading.spec.ts +70 -0
- package/template/e2e/theme-responsive.spec.ts +260 -0
- package/template/gitignore +24 -0
- package/template/messages/de.json +212 -0
- package/template/messages/en.json +212 -0
- package/template/package.json +101 -0
- package/template/paraglide.config.ts +49 -0
- package/template/playwright.config.ts +58 -0
- package/template/project.inlang/settings.json +12 -0
- package/template/public/drizzle.svg +1 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logo192.png +0 -0
- package/template/public/logo512.png +0 -0
- package/template/public/manifest.json +25 -0
- package/template/public/robots.txt +3 -0
- package/template/scripts/check-client-bundle.ts +150 -0
- package/template/scripts/check-server-fns.ts +290 -0
- package/template/scripts/check-sql-raw.ts +50 -0
- package/template/scripts/compile-paraglide.ts +12 -0
- package/template/scripts/postgres/README.md +76 -0
- package/template/scripts/postgres/create-app-role.sh +60 -0
- package/template/scripts/seed-local.ts +331 -0
- package/template/scripts/validate-prod-env.ts +53 -0
- package/template/src/__tests__/README.md +9 -0
- package/template/src/__tests__/account-security.test.ts +664 -0
- package/template/src/__tests__/admin-http-surface.test.ts +311 -0
- package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
- package/template/src/__tests__/auth-harness.ts +104 -0
- package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
- package/template/src/__tests__/auth-redirect.test.ts +120 -0
- package/template/src/__tests__/body-limit.test.ts +47 -0
- package/template/src/__tests__/creem.test.ts +62 -0
- package/template/src/__tests__/demo-checkout.test.ts +141 -0
- package/template/src/__tests__/email-locale.test.ts +110 -0
- package/template/src/__tests__/global-setup.ts +80 -0
- package/template/src/__tests__/input-rules.test.ts +0 -0
- package/template/src/__tests__/message-parity.test.ts +38 -0
- package/template/src/__tests__/navigation-loading.test.tsx +252 -0
- package/template/src/__tests__/nowpayments.test.ts +106 -0
- package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
- package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
- package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
- package/template/src/__tests__/rls-policy.test.ts +120 -0
- package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
- package/template/src/__tests__/search-params-oauth.test.ts +57 -0
- package/template/src/__tests__/security-headers.test.ts +12 -0
- package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
- package/template/src/__tests__/stripe.test.ts +84 -0
- package/template/src/__tests__/test-db.ts +32 -0
- package/template/src/__tests__/test-rls-db.ts +23 -0
- package/template/src/__tests__/user-update-guard.test.ts +109 -0
- package/template/src/components/LocaleSwitcher.tsx +79 -0
- package/template/src/components/ThemeToggle.tsx +47 -0
- package/template/src/components/auth/social.tsx +90 -0
- package/template/src/components/auth/turnstile.tsx +67 -0
- package/template/src/components/root/navigation-progress.tsx +32 -0
- package/template/src/components/root/not-found.tsx +20 -0
- package/template/src/components/root/pending.tsx +9 -0
- package/template/src/components/root/root-document.tsx +37 -0
- package/template/src/components/root/root-error.tsx +34 -0
- package/template/src/components/shells.tsx +201 -0
- package/template/src/components/ui/styles.ts +9 -0
- package/template/src/db/auth.schema.ts +455 -0
- package/template/src/db/helper.ts +38 -0
- package/template/src/db/index.ts +15 -0
- package/template/src/db/platform.schema.ts +122 -0
- package/template/src/db/schema.ts +2 -0
- package/template/src/env.ts +65 -0
- package/template/src/hooks/use-theme.ts +32 -0
- package/template/src/integrations/better-auth/header-user.tsx +36 -0
- package/template/src/integrations/better-auth/session-query.ts +11 -0
- package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
- package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
- package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
- package/template/src/integrations/react-email/theme.ts +75 -0
- package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
- package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
- package/template/src/lib/auth-client.ts +27 -0
- package/template/src/lib/auth-paths.ts +94 -0
- package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
- package/template/src/lib/auth-plugins/field-rules.ts +84 -0
- package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
- package/template/src/lib/auth-timing.ts +9 -0
- package/template/src/lib/auth.ts +279 -0
- package/template/src/lib/captcha.ts +11 -0
- package/template/src/lib/email.tsx +162 -0
- package/template/src/lib/github.ts +18 -0
- package/template/src/lib/locale-cookie.ts +12 -0
- package/template/src/lib/localized-pages.ts +25 -0
- package/template/src/lib/oauth-metadata.ts +19 -0
- package/template/src/lib/permissions.ts +29 -0
- package/template/src/lib/security-headers.ts +20 -0
- package/template/src/lib/site.ts +59 -0
- package/template/src/lib/theme.ts +42 -0
- package/template/src/lib/user-update-guard.ts +35 -0
- package/template/src/middlewares/admin.ts +46 -0
- package/template/src/middlewares/auth-rate-limit.ts +37 -0
- package/template/src/middlewares/better-auth.ts +15 -0
- package/template/src/middlewares/body-limit.ts +62 -0
- package/template/src/middlewares/database.ts +13 -0
- package/template/src/middlewares/protected.ts +45 -0
- package/template/src/routeTree.gen.ts +536 -0
- package/template/src/router.tsx +53 -0
- package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
- package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
- package/template/src/routes/__root.tsx +175 -0
- package/template/src/routes/admin/audit.tsx +74 -0
- package/template/src/routes/admin/index.tsx +7 -0
- package/template/src/routes/admin/oauth-apps.tsx +164 -0
- package/template/src/routes/admin/route.tsx +27 -0
- package/template/src/routes/admin/users.tsx +215 -0
- package/template/src/routes/api/auth/$.ts +14 -0
- package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
- package/template/src/routes/api/webhooks/creem.ts +69 -0
- package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
- package/template/src/routes/api/webhooks/stripe.ts +74 -0
- package/template/src/routes/dashboard/account.tsx +430 -0
- package/template/src/routes/dashboard/index.tsx +128 -0
- package/template/src/routes/dashboard/route.tsx +17 -0
- package/template/src/routes/forgot-password.tsx +65 -0
- package/template/src/routes/index.tsx +85 -0
- package/template/src/routes/oauth/consent.tsx +77 -0
- package/template/src/routes/reset-password.tsx +78 -0
- package/template/src/routes/sign-in.tsx +174 -0
- package/template/src/routes/sign-up.tsx +135 -0
- package/template/src/routes/two-factor.tsx +74 -0
- package/template/src/server/README.md +3 -0
- package/template/src/server/account-security.ts +163 -0
- package/template/src/server/admin-audit.ts +37 -0
- package/template/src/server/admin-users.ts +112 -0
- package/template/src/server/auth.ts +44 -0
- package/template/src/server/demo-checkout.ts +67 -0
- package/template/src/server/oauth-clients.ts +83 -0
- package/template/src/server/payment-toggles.ts +39 -0
- package/template/src/server.ts +20 -0
- package/template/src/services/README.md +3 -0
- package/template/src/services/account-security.ts +893 -0
- package/template/src/services/admin-security.ts +164 -0
- package/template/src/services/admin-users.ts +181 -0
- package/template/src/services/auth-rate-limit.ts +32 -0
- package/template/src/services/creem.ts +83 -0
- package/template/src/services/demo-checkout.ts +100 -0
- package/template/src/services/nowpayments.ts +154 -0
- package/template/src/services/oauth-client.ts +85 -0
- package/template/src/services/oauth-clients.ts +15 -0
- package/template/src/services/payment-providers.ts +157 -0
- package/template/src/services/payment-toggles.ts +48 -0
- package/template/src/services/purchase.ts +233 -0
- package/template/src/services/stripe.ts +106 -0
- package/template/src/start.ts +35 -0
- package/template/src/styles/layout.css +93 -0
- package/template/src/styles/theme.css +84 -0
- package/template/src/styles/ui.css +233 -0
- package/template/src/styles.css +90 -0
- package/template/src/utils/api-error.ts +129 -0
- package/template/src/utils/auth-errors.ts +157 -0
- package/template/src/utils/auth-redirect.ts +61 -0
- package/template/src/utils/body-limit.ts +84 -0
- package/template/src/utils/cn.ts +6 -0
- package/template/src/utils/input-rules.ts +172 -0
- package/template/src/utils/locale-paths.ts +18 -0
- package/template/src/utils/same-page-locale-href.ts +20 -0
- package/template/src/utils/search-params.ts +71 -0
- package/template/tsconfig.json +30 -0
- package/template/tsr.config.json +6 -0
- package/template/vite.config.ts +24 -0
- package/template/vitest.config.ts +26 -0
- package/template/worker-configuration.d.ts +14660 -0
- package/template/wrangler.jsonc +39 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { dehydrate, hydrate, QueryObserver } from "@tanstack/react-query";
|
|
4
|
+
import { createMemoryHistory, RouterProvider } from "@tanstack/react-router";
|
|
5
|
+
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import type { RootSession } from "@/server/auth";
|
|
9
|
+
|
|
10
|
+
const { getSession, loadPage } = vi.hoisted(() => ({
|
|
11
|
+
getSession: vi.fn<() => Promise<RootSession | null>>(),
|
|
12
|
+
loadPage: vi.fn<() => Promise<string>>(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("@/server/auth", () => ({ getSession }));
|
|
16
|
+
vi.mock("#/components/root/root-document.tsx", () => ({
|
|
17
|
+
RootDocument: ({ children }: { children: ReactNode }) => children,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Keep the real root beforeLoad, router defaults and SSR Query integration.
|
|
21
|
+
// Only replace the child pages and their network/database responses.
|
|
22
|
+
vi.mock("@/routeTree.gen", async () => {
|
|
23
|
+
const { createRoute, Link, redirect } = await import(
|
|
24
|
+
"@tanstack/react-router"
|
|
25
|
+
);
|
|
26
|
+
const { Route: root } = await import("@/routes/__root");
|
|
27
|
+
const home = createRoute({
|
|
28
|
+
getParentRoute: () => root,
|
|
29
|
+
path: "/",
|
|
30
|
+
component: () => (
|
|
31
|
+
<div>
|
|
32
|
+
<h1>Previous page</h1>
|
|
33
|
+
<Link to="/dashboard">Next page</Link>
|
|
34
|
+
</div>
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
const dashboard = createRoute({
|
|
38
|
+
getParentRoute: () => root,
|
|
39
|
+
path: "/dashboard",
|
|
40
|
+
beforeLoad: ({ context }) => {
|
|
41
|
+
if (!context.session) throw redirect({ to: "/sign-in" });
|
|
42
|
+
},
|
|
43
|
+
loader: ({ context }) =>
|
|
44
|
+
context.queryClient.ensureQueryData({
|
|
45
|
+
queryKey: ["page"],
|
|
46
|
+
queryFn: loadPage,
|
|
47
|
+
}),
|
|
48
|
+
component: () => <h1>Next page</h1>,
|
|
49
|
+
});
|
|
50
|
+
const signIn = createRoute({
|
|
51
|
+
getParentRoute: () => root,
|
|
52
|
+
path: "/sign-in",
|
|
53
|
+
component: () => <h1>Sign in</h1>,
|
|
54
|
+
});
|
|
55
|
+
return { routeTree: root.addChildren([home, dashboard, signIn]) };
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const { getRouter } = await import("@/router");
|
|
59
|
+
const { sessionQueryOptions } = await import(
|
|
60
|
+
"@/integrations/better-auth/session-query"
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const session: RootSession = {
|
|
64
|
+
user: {
|
|
65
|
+
id: "user-1",
|
|
66
|
+
email: "user@example.com",
|
|
67
|
+
name: "User",
|
|
68
|
+
emailVerified: true,
|
|
69
|
+
role: "user",
|
|
70
|
+
twoFactorEnabled: false,
|
|
71
|
+
locale: "en",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function deferred<T>() {
|
|
76
|
+
let resolve!: (value: T) => void;
|
|
77
|
+
const promise = new Promise<T>((done) => {
|
|
78
|
+
resolve = done;
|
|
79
|
+
});
|
|
80
|
+
return { promise, resolve };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const routers: ReturnType<typeof getRouter>[] = [];
|
|
84
|
+
|
|
85
|
+
function createTestRouter() {
|
|
86
|
+
const router = getRouter();
|
|
87
|
+
router.update({
|
|
88
|
+
context: router.options.context,
|
|
89
|
+
history: createMemoryHistory({ initialEntries: ["/"] }),
|
|
90
|
+
});
|
|
91
|
+
routers.push(router);
|
|
92
|
+
return router;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function start() {
|
|
96
|
+
const router = createTestRouter();
|
|
97
|
+
await router.load();
|
|
98
|
+
render(<RouterProvider router={router} />);
|
|
99
|
+
await screen.findByRole("heading", { name: "Previous page" });
|
|
100
|
+
return router;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
|
105
|
+
getSession.mockReset().mockResolvedValue(session);
|
|
106
|
+
loadPage.mockReset().mockResolvedValue("page data");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
cleanup();
|
|
111
|
+
vi.restoreAllMocks();
|
|
112
|
+
for (const router of routers.splice(0)) {
|
|
113
|
+
router.options.context.queryClient.clear();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("navigation with production network latency", () => {
|
|
118
|
+
it("isolates router caches and reuses the hydrated session in the browser", async () => {
|
|
119
|
+
const firstRequest = createTestRouter();
|
|
120
|
+
const secondRequest = createTestRouter();
|
|
121
|
+
await firstRequest.load();
|
|
122
|
+
getSession.mockResolvedValue(null);
|
|
123
|
+
await secondRequest.load();
|
|
124
|
+
expect(firstRequest.state.matches[0]?.context.session).toEqual(session);
|
|
125
|
+
expect(secondRequest.state.matches[0]?.context.session).toBeNull();
|
|
126
|
+
expect(getSession).toHaveBeenCalledTimes(2);
|
|
127
|
+
|
|
128
|
+
const browser = createTestRouter();
|
|
129
|
+
hydrate(
|
|
130
|
+
browser.options.context.queryClient,
|
|
131
|
+
dehydrate(firstRequest.options.context.queryClient),
|
|
132
|
+
);
|
|
133
|
+
await browser.load();
|
|
134
|
+
expect(browser.state.matches[0]?.context.session).toEqual(session);
|
|
135
|
+
expect(getSession).toHaveBeenCalledTimes(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("does not hold a failed session check through retry backoff", async () => {
|
|
139
|
+
const router = createTestRouter();
|
|
140
|
+
getSession.mockRejectedValue(new TypeError("Failed to fetch"));
|
|
141
|
+
await expect(
|
|
142
|
+
router.options.context.queryClient.fetchQuery(sessionQueryOptions),
|
|
143
|
+
).rejects.toThrow("Failed to fetch");
|
|
144
|
+
expect(getSession).toHaveBeenCalledTimes(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("reuses the session across intent preloading and client navigation", async () => {
|
|
148
|
+
const router = await start();
|
|
149
|
+
await act(() => router.preloadRoute({ to: "/dashboard" }));
|
|
150
|
+
await act(() => router.navigate({ to: "/dashboard" }));
|
|
151
|
+
|
|
152
|
+
await screen.findByRole("heading", { name: "Next page" });
|
|
153
|
+
expect(getSession).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(loadPage).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("revalidates an expired session before entering a protected page", async () => {
|
|
158
|
+
const router = await start();
|
|
159
|
+
router.options.context.queryClient.setQueryData(
|
|
160
|
+
["auth", "session"],
|
|
161
|
+
session,
|
|
162
|
+
{
|
|
163
|
+
updatedAt: Date.now() - 31_000,
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
getSession.mockResolvedValue(null);
|
|
167
|
+
await act(() => router.navigate({ to: "/dashboard" }));
|
|
168
|
+
|
|
169
|
+
await screen.findByRole("heading", { name: "Sign in" });
|
|
170
|
+
expect(loadPage).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("shows loading feedback while a session check is waiting", async () => {
|
|
174
|
+
const router = await start();
|
|
175
|
+
const gate = deferred<RootSession | null>();
|
|
176
|
+
getSession.mockReturnValue(gate.promise);
|
|
177
|
+
await router.options.context.queryClient.invalidateQueries({
|
|
178
|
+
queryKey: ["auth", "session"],
|
|
179
|
+
});
|
|
180
|
+
let navigation!: Promise<void>;
|
|
181
|
+
act(() => {
|
|
182
|
+
navigation = router.navigate({ to: "/dashboard" });
|
|
183
|
+
});
|
|
184
|
+
try {
|
|
185
|
+
await screen.findByRole("status", {}, { timeout: 600 });
|
|
186
|
+
expect(loadPage).not.toHaveBeenCalled();
|
|
187
|
+
} finally {
|
|
188
|
+
await act(async () => {
|
|
189
|
+
gate.resolve(session);
|
|
190
|
+
await navigation;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
await screen.findByRole("heading", { name: "Next page" });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("shows loading feedback during a slow loader and renders as soon as it resolves", async () => {
|
|
197
|
+
const router = await start();
|
|
198
|
+
const gate = deferred<string>();
|
|
199
|
+
loadPage.mockReturnValue(gate.promise);
|
|
200
|
+
let navigation!: Promise<void>;
|
|
201
|
+
act(() => {
|
|
202
|
+
navigation = router.navigate({ to: "/dashboard" });
|
|
203
|
+
});
|
|
204
|
+
let resolvedAt = 0;
|
|
205
|
+
try {
|
|
206
|
+
await screen.findByRole("status", {}, { timeout: 600 });
|
|
207
|
+
expect(
|
|
208
|
+
screen.queryByRole("heading", { name: "Previous page" }),
|
|
209
|
+
).toBeNull();
|
|
210
|
+
} finally {
|
|
211
|
+
await act(async () => {
|
|
212
|
+
resolvedAt = performance.now();
|
|
213
|
+
gate.resolve("page data");
|
|
214
|
+
await navigation;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
await screen.findByRole("heading", { name: "Next page" }, { timeout: 200 });
|
|
218
|
+
expect(performance.now() - resolvedAt).toBeLessThan(300);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("does not fetch loader data again when the page mounts its query", async () => {
|
|
222
|
+
const router = await start();
|
|
223
|
+
await act(() => router.navigate({ to: "/dashboard" }));
|
|
224
|
+
const observer = new QueryObserver(router.options.context.queryClient, {
|
|
225
|
+
queryKey: ["page"],
|
|
226
|
+
queryFn: loadPage,
|
|
227
|
+
});
|
|
228
|
+
const unsubscribe = observer.subscribe(() => {});
|
|
229
|
+
try {
|
|
230
|
+
await waitFor(() =>
|
|
231
|
+
expect(observer.getCurrentResult().isFetching).toBe(false),
|
|
232
|
+
);
|
|
233
|
+
expect(loadPage).toHaveBeenCalledTimes(1);
|
|
234
|
+
} finally {
|
|
235
|
+
unsubscribe();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("refreshes invalidated session data when route guards are rerun", async () => {
|
|
240
|
+
const router = await start();
|
|
241
|
+
getSession.mockResolvedValue({
|
|
242
|
+
...session,
|
|
243
|
+
user: { ...session.user, name: "Updated" },
|
|
244
|
+
});
|
|
245
|
+
await router.options.context.queryClient.invalidateQueries({
|
|
246
|
+
queryKey: ["auth", "session"],
|
|
247
|
+
});
|
|
248
|
+
await act(() => router.invalidate());
|
|
249
|
+
expect(router.state.matches[0]?.context.session?.user.name).toBe("Updated");
|
|
250
|
+
expect(getSession).toHaveBeenCalledTimes(2);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { Database } from "@/db";
|
|
4
|
+
import {
|
|
5
|
+
handleNowPaymentsWebhook,
|
|
6
|
+
verifyNowPaymentsSignature,
|
|
7
|
+
} from "@/services/nowpayments";
|
|
8
|
+
|
|
9
|
+
/** A db that fails the test if the handler touches it. */
|
|
10
|
+
const unusableDb = new Proxy({} as Database, {
|
|
11
|
+
get() {
|
|
12
|
+
throw new Error("handler reached the database on an unverified callback");
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function sign(secret: string, canonical: string): string {
|
|
17
|
+
return createHmac("sha512", secret).update(canonical).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("verifyNowPaymentsSignature", () => {
|
|
21
|
+
it("accepts HMAC-SHA512 over recursively sorted JSON keys", async () => {
|
|
22
|
+
const body = JSON.stringify({ payment_status: "finished", b: 1, a: 2 });
|
|
23
|
+
const canonical = JSON.stringify({
|
|
24
|
+
a: 2,
|
|
25
|
+
b: 1,
|
|
26
|
+
payment_status: "finished",
|
|
27
|
+
});
|
|
28
|
+
expect(
|
|
29
|
+
await verifyNowPaymentsSignature(
|
|
30
|
+
"ipn_sec",
|
|
31
|
+
body,
|
|
32
|
+
sign("ipn_sec", canonical),
|
|
33
|
+
),
|
|
34
|
+
).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("sorts nested objects but keeps array order", async () => {
|
|
38
|
+
const body = JSON.stringify({ z: { b: 1, a: [2, 1] }, y: 0 });
|
|
39
|
+
const canonical = JSON.stringify({ y: 0, z: { a: [2, 1], b: 1 } });
|
|
40
|
+
expect(
|
|
41
|
+
await verifyNowPaymentsSignature(
|
|
42
|
+
"ipn_sec",
|
|
43
|
+
body,
|
|
44
|
+
sign("ipn_sec", canonical),
|
|
45
|
+
),
|
|
46
|
+
).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects a missing or wrong signature", async () => {
|
|
50
|
+
const body = '{"payment_status":"finished"}';
|
|
51
|
+
expect(await verifyNowPaymentsSignature("ipn_sec", body, null)).toBe(false);
|
|
52
|
+
expect(await verifyNowPaymentsSignature("ipn_sec", body, "deadbeef")).toBe(
|
|
53
|
+
false,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// A refusal, not a crash: a 500 here would tell NowPayments to retry, and a
|
|
58
|
+
// hostile body would be replayed forever.
|
|
59
|
+
it("refuses an unparseable body instead of throwing", async () => {
|
|
60
|
+
expect(await verifyNowPaymentsSignature("ipn_sec", "{not json", "aa")).toBe(
|
|
61
|
+
false,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("refuses a body nested past the depth limit instead of exhausting the stack", async () => {
|
|
66
|
+
let deep = "1";
|
|
67
|
+
for (let i = 0; i < 200; i++) deep = `{"a":${deep}}`;
|
|
68
|
+
expect(await verifyNowPaymentsSignature("ipn_sec", deep, "aa")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("handleNowPaymentsWebhook", () => {
|
|
73
|
+
it("refuses an unverified callback without touching the database", async () => {
|
|
74
|
+
const result = await handleNowPaymentsWebhook(unusableDb, {
|
|
75
|
+
rawBody: '{"order_id":"o1","payment_status":"finished"}',
|
|
76
|
+
signature: "deadbeef",
|
|
77
|
+
ipnKey: "ipn_sec",
|
|
78
|
+
});
|
|
79
|
+
expect(result).toBe("invalid_signature");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("leaves a non-terminal status alone", async () => {
|
|
83
|
+
const body = JSON.stringify({ order_id: "o1", payment_status: "waiting" });
|
|
84
|
+
const result = await handleNowPaymentsWebhook(unusableDb, {
|
|
85
|
+
rawBody: body,
|
|
86
|
+
signature: sign("ipn_sec", body),
|
|
87
|
+
ipnKey: "ipn_sec",
|
|
88
|
+
});
|
|
89
|
+
expect(result).toBe("noop");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not fulfill an underpaid invoice", async () => {
|
|
93
|
+
const body = JSON.stringify({
|
|
94
|
+
actually_paid: 9,
|
|
95
|
+
order_id: "o1",
|
|
96
|
+
payment_status: "finished",
|
|
97
|
+
price_amount: 10,
|
|
98
|
+
});
|
|
99
|
+
const result = await handleNowPaymentsWebhook(unusableDb, {
|
|
100
|
+
rawBody: body,
|
|
101
|
+
signature: sign("ipn_sec", body),
|
|
102
|
+
ipnKey: "ipn_sec",
|
|
103
|
+
});
|
|
104
|
+
expect(result).toBe("underpaid");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { deLocalizeHref, localizeHref } from "@/paraglide/runtime";
|
|
3
|
+
|
|
4
|
+
// Seam: Paraglide URL contract (ADR 0005 / 0010). Full-app i18n prefixes
|
|
5
|
+
// every user-facing HTML tree; machine paths stay identity (never locale-prefixed).
|
|
6
|
+
// Asserted through the *real compiled runtime* so urlPatterns regressions fail CI.
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
if (
|
|
10
|
+
typeof (globalThis as { URLPattern?: unknown }).URLPattern === "undefined"
|
|
11
|
+
) {
|
|
12
|
+
const mod = (await import("@inlang/paraglide-js/urlpattern-polyfill")) as {
|
|
13
|
+
URLPattern?: unknown;
|
|
14
|
+
default?: unknown;
|
|
15
|
+
};
|
|
16
|
+
(globalThis as { URLPattern?: unknown }).URLPattern =
|
|
17
|
+
mod.URLPattern ?? mod.default;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("paraglide URL contract", () => {
|
|
22
|
+
it("keeps English on bare paths", () => {
|
|
23
|
+
expect(localizeHref("/sign-in", { locale: "en" })).toBe("/sign-in");
|
|
24
|
+
expect(localizeHref("/", { locale: "en" })).toBe("/");
|
|
25
|
+
expect(localizeHref("/dashboard", { locale: "en" })).toBe("/dashboard");
|
|
26
|
+
expect(localizeHref("/admin/users", { locale: "en" })).toBe("/admin/users");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("prefixes German for public, auth, dashboard, and admin", () => {
|
|
30
|
+
expect(localizeHref("/", { locale: "de" })).toBe("/de/");
|
|
31
|
+
expect(localizeHref("/sign-in", { locale: "de" })).toBe("/de/sign-in");
|
|
32
|
+
expect(localizeHref("/sign-up", { locale: "de" })).toBe("/de/sign-up");
|
|
33
|
+
expect(localizeHref("/oauth/consent", { locale: "de" })).toBe(
|
|
34
|
+
"/de/oauth/consent",
|
|
35
|
+
);
|
|
36
|
+
expect(localizeHref("/dashboard", { locale: "de" })).toBe("/de/dashboard");
|
|
37
|
+
expect(localizeHref("/dashboard/account", { locale: "de" })).toBe(
|
|
38
|
+
"/de/dashboard/account",
|
|
39
|
+
);
|
|
40
|
+
expect(localizeHref("/admin", { locale: "de" })).toBe("/de/admin");
|
|
41
|
+
expect(localizeHref("/admin/users", { locale: "de" })).toBe(
|
|
42
|
+
"/de/admin/users",
|
|
43
|
+
);
|
|
44
|
+
expect(localizeHref("/admin/oauth-apps", { locale: "de" })).toBe(
|
|
45
|
+
"/de/admin/oauth-apps",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("never localizes machine paths", () => {
|
|
50
|
+
for (const p of [
|
|
51
|
+
"/api/auth/session",
|
|
52
|
+
"/api/webhooks/stripe",
|
|
53
|
+
"/.well-known/oauth-authorization-server",
|
|
54
|
+
"/.well-known/openid-configuration",
|
|
55
|
+
"/api/auth/.well-known/openid-configuration",
|
|
56
|
+
]) {
|
|
57
|
+
expect(localizeHref(p, { locale: "de" })).toBe(p);
|
|
58
|
+
expect(localizeHref(p, { locale: "en" })).toBe(p);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("delocalizes German back to the bare path", () => {
|
|
63
|
+
expect(deLocalizeHref("/de/sign-in")).toBe("/sign-in");
|
|
64
|
+
expect(deLocalizeHref("/de/")).toBe("/");
|
|
65
|
+
expect(deLocalizeHref("/de/dashboard")).toBe("/dashboard");
|
|
66
|
+
expect(deLocalizeHref("/de/dashboard/account")).toBe("/dashboard/account");
|
|
67
|
+
expect(deLocalizeHref("/de/admin/users")).toBe("/admin/users");
|
|
68
|
+
expect(deLocalizeHref("/dashboard")).toBe("/dashboard");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("localizes trailing-slash user-facing paths to the same-locale URL", () => {
|
|
72
|
+
expect(localizeHref("/sign-in/", { locale: "de" })).toBe("/de/sign-in/");
|
|
73
|
+
expect(localizeHref("/dashboard/", { locale: "de" })).toBe(
|
|
74
|
+
"/de/dashboard/",
|
|
75
|
+
);
|
|
76
|
+
expect(localizeHref("/admin/users/", { locale: "de" })).toBe(
|
|
77
|
+
"/de/admin/users/",
|
|
78
|
+
);
|
|
79
|
+
expect(localizeHref("/sign-in/", { locale: "en" })).toBe("/sign-in/");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("delocalizes trailing-slash German paths back to the bare form", () => {
|
|
83
|
+
expect(deLocalizeHref("/de/sign-in/")).toBe("/sign-in/");
|
|
84
|
+
expect(deLocalizeHref("/de/dashboard/")).toBe("/dashboard/");
|
|
85
|
+
expect(deLocalizeHref("/de/admin/users/")).toBe("/admin/users/");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purchase fulfillment seam: the purchase-paid hook runs inside the same
|
|
3
|
+
* transaction as the pending→paid flip, so a delivery is exactly-once across
|
|
4
|
+
* webhook retries — never twice, and never silently lost.
|
|
5
|
+
*
|
|
6
|
+
* Runs against the real Postgres container: the atomicity under test is the
|
|
7
|
+
* database's, and an in-memory double cannot fail the way this needs to.
|
|
8
|
+
*/
|
|
9
|
+
import { and, eq } from "drizzle-orm";
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
import type { Database } from "@/db";
|
|
12
|
+
import { adminAction, purchase } from "@/db/schema";
|
|
13
|
+
import { markPurchasePaid, type Purchase } from "@/services/purchase";
|
|
14
|
+
import { createTestDatabase, hasTestDatabase } from "./test-db";
|
|
15
|
+
|
|
16
|
+
const PURCHASE_DDL = `
|
|
17
|
+
CREATE TEMP TABLE "purchase" (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
user_id TEXT NOT NULL,
|
|
20
|
+
provider TEXT NOT NULL,
|
|
21
|
+
external_id TEXT NOT NULL,
|
|
22
|
+
amount TEXT NOT NULL,
|
|
23
|
+
currency TEXT NOT NULL DEFAULT 'usd',
|
|
24
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
25
|
+
metadata JSONB,
|
|
26
|
+
paid_at TIMESTAMPTZ,
|
|
27
|
+
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
28
|
+
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
29
|
+
UNIQUE (provider, external_id)
|
|
30
|
+
);
|
|
31
|
+
CREATE TEMP TABLE "admin_action" (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
actor_id TEXT,
|
|
34
|
+
action TEXT NOT NULL,
|
|
35
|
+
target_type TEXT,
|
|
36
|
+
target_id TEXT,
|
|
37
|
+
detail JSONB,
|
|
38
|
+
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
|
39
|
+
);
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
async function seedPending(
|
|
43
|
+
db: Database,
|
|
44
|
+
overrides: Partial<{ externalId: string; amount: number }> = {},
|
|
45
|
+
) {
|
|
46
|
+
const id = crypto.randomUUID();
|
|
47
|
+
await db.insert(purchase).values({
|
|
48
|
+
id,
|
|
49
|
+
userId: "user-1",
|
|
50
|
+
provider: "stripe",
|
|
51
|
+
externalId: overrides.externalId ?? "cs_test_1",
|
|
52
|
+
amount: String(overrides.amount ?? 1999),
|
|
53
|
+
currency: "usd",
|
|
54
|
+
status: "pending",
|
|
55
|
+
});
|
|
56
|
+
return id;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readRow(db: Database, externalId: string) {
|
|
60
|
+
return db
|
|
61
|
+
.select()
|
|
62
|
+
.from(purchase)
|
|
63
|
+
.where(
|
|
64
|
+
and(eq(purchase.provider, "stripe"), eq(purchase.externalId, externalId)),
|
|
65
|
+
)
|
|
66
|
+
.limit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readAudits(db: Database) {
|
|
70
|
+
return db.select().from(adminAction);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe.skipIf(!hasTestDatabase)("markPurchasePaid", () => {
|
|
74
|
+
it("fulfills a pending purchase once, with the row's own amount and user", async () => {
|
|
75
|
+
const db = await createTestDatabase(PURCHASE_DDL);
|
|
76
|
+
await seedPending(db);
|
|
77
|
+
const hook = vi.fn(async (_tx: Database, _p: Purchase) => {});
|
|
78
|
+
|
|
79
|
+
const result = await markPurchasePaid(
|
|
80
|
+
db,
|
|
81
|
+
{ provider: "stripe", externalId: "cs_test_1" },
|
|
82
|
+
hook,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(result.status).toBe("fulfilled");
|
|
86
|
+
expect(hook).toHaveBeenCalledTimes(1);
|
|
87
|
+
// Trusted values come from the row, never from the callback.
|
|
88
|
+
expect(hook.mock.calls[0][1]).toMatchObject({
|
|
89
|
+
userId: "user-1",
|
|
90
|
+
amount: 1999,
|
|
91
|
+
currency: "usd",
|
|
92
|
+
status: "paid",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const [row] = await readRow(db, "cs_test_1");
|
|
96
|
+
expect(row.status).toBe("paid");
|
|
97
|
+
expect(row.paidAt).not.toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("ignores a duplicate delivery without running the hook again", async () => {
|
|
101
|
+
const db = await createTestDatabase(PURCHASE_DDL);
|
|
102
|
+
await seedPending(db);
|
|
103
|
+
const hook = vi.fn(async (_tx: Database, _p: Purchase) => {});
|
|
104
|
+
|
|
105
|
+
await markPurchasePaid(
|
|
106
|
+
db,
|
|
107
|
+
{ provider: "stripe", externalId: "cs_test_1" },
|
|
108
|
+
hook,
|
|
109
|
+
);
|
|
110
|
+
const again = await markPurchasePaid(
|
|
111
|
+
db,
|
|
112
|
+
{ provider: "stripe", externalId: "cs_test_1" },
|
|
113
|
+
hook,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(again.status).toBe("already_paid");
|
|
117
|
+
expect(hook).toHaveBeenCalledTimes(1);
|
|
118
|
+
|
|
119
|
+
const audits = await readAudits(db);
|
|
120
|
+
expect(audits.map((a) => a.action)).toEqual([
|
|
121
|
+
"purchase.paid",
|
|
122
|
+
"purchase.duplicate_ignored",
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("reports not_found for an external id it does not know", async () => {
|
|
127
|
+
const db = await createTestDatabase(PURCHASE_DDL);
|
|
128
|
+
const hook = vi.fn(async (_tx: Database, _p: Purchase) => {});
|
|
129
|
+
|
|
130
|
+
const result = await markPurchasePaid(
|
|
131
|
+
db,
|
|
132
|
+
{ provider: "stripe", externalId: "cs_missing" },
|
|
133
|
+
hook,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(result.status).toBe("not_found");
|
|
137
|
+
expect(hook).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("fails closed when the reported amount disagrees with the row", async () => {
|
|
141
|
+
const db = await createTestDatabase(PURCHASE_DDL);
|
|
142
|
+
await seedPending(db, { amount: 1999 });
|
|
143
|
+
const hook = vi.fn(async (_tx: Database, _p: Purchase) => {});
|
|
144
|
+
|
|
145
|
+
const result = await markPurchasePaid(
|
|
146
|
+
db,
|
|
147
|
+
{ provider: "stripe", externalId: "cs_test_1", reportedAmount: 100 },
|
|
148
|
+
hook,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(result.status).toBe("amount_mismatch");
|
|
152
|
+
expect(hook).not.toHaveBeenCalled();
|
|
153
|
+
const [row] = await readRow(db, "cs_test_1");
|
|
154
|
+
expect(row.status).toBe("pending");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// The reason the hook takes `tx`. Without the shared transaction the flip
|
|
158
|
+
// survives a failed hook, the provider's retry short-circuits on
|
|
159
|
+
// `already_paid`, and the delivery is lost while the audit log claims it
|
|
160
|
+
// happened.
|
|
161
|
+
it("rolls the flip back when the hook throws, and the retry delivers exactly once", async () => {
|
|
162
|
+
const db = await createTestDatabase(PURCHASE_DDL);
|
|
163
|
+
await seedPending(db);
|
|
164
|
+
|
|
165
|
+
let attempts = 0;
|
|
166
|
+
const delivered: string[] = [];
|
|
167
|
+
const hook = async (_tx: Database, p: Purchase) => {
|
|
168
|
+
attempts += 1;
|
|
169
|
+
if (attempts === 1) throw new Error("fulfillment backend is down");
|
|
170
|
+
delivered.push(p.id);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
await expect(
|
|
174
|
+
markPurchasePaid(
|
|
175
|
+
db,
|
|
176
|
+
{ provider: "stripe", externalId: "cs_test_1" },
|
|
177
|
+
hook,
|
|
178
|
+
),
|
|
179
|
+
).rejects.toThrow("fulfillment backend is down");
|
|
180
|
+
|
|
181
|
+
// The row is back to pending, so the retry is not short-circuited.
|
|
182
|
+
const [afterFailure] = await readRow(db, "cs_test_1");
|
|
183
|
+
expect(afterFailure.status).toBe("pending");
|
|
184
|
+
expect(afterFailure.paidAt).toBeNull();
|
|
185
|
+
expect(delivered).toEqual([]);
|
|
186
|
+
|
|
187
|
+
// And nothing claimed the purchase was delivered.
|
|
188
|
+
expect(await readAudits(db)).toHaveLength(0);
|
|
189
|
+
|
|
190
|
+
const retry = await markPurchasePaid(
|
|
191
|
+
db,
|
|
192
|
+
{ provider: "stripe", externalId: "cs_test_1" },
|
|
193
|
+
hook,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(retry.status).toBe("fulfilled");
|
|
197
|
+
expect(delivered).toHaveLength(1);
|
|
198
|
+
const [afterRetry] = await readRow(db, "cs_test_1");
|
|
199
|
+
expect(afterRetry.status).toBe("paid");
|
|
200
|
+
expect((await readAudits(db)).map((a) => a.action)).toEqual([
|
|
201
|
+
"purchase.paid",
|
|
202
|
+
]);
|
|
203
|
+
});
|
|
204
|
+
});
|