@awsmbiz/create-inbis-app 0.0.0-stage → 0.1.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 +28 -2
- package/dist/cli.mjs +850 -0
- package/dist/cli.mjs.map +1 -0
- package/package.json +43 -3
- package/template/.dev.vars.example +4 -0
- package/template/README.md +115 -0
- package/template/e2e/accessibility.spec.ts +33 -0
- package/template/e2e/axe.ts +47 -0
- package/template/e2e/shell.spec.ts +33 -0
- package/template/e2e/tsconfig.json +8 -0
- package/template/eslint.config.js +7 -0
- package/template/migrations/0001_app_initial.sql +8 -0
- package/template/modules/README.md +6 -0
- package/template/modules/access-control/files/src/access-control-routes.ts +36 -0
- package/template/modules/access-control/files/src/access-control.ts +46 -0
- package/template/modules/access-control/files/test/access-control.test.ts +78 -0
- package/template/modules/access-control/module.json +54 -0
- package/template/modules/access-control/wiring/admin/admin.imports.tsx +1 -0
- package/template/modules/access-control/wiring/admin/admin.nav.tsx +2 -0
- package/template/modules/access-control/wiring/admin/admin.pages.tsx +4 -0
- package/template/modules/access-control/wiring/admin/admin.userSections.tsx +1 -0
- package/template/modules/access-control/wiring/admin/readme.modules.md +3 -0
- package/template/modules/access-control/wiring/admin/web.messages.ts +4 -0
- package/template/modules/access-control/wiring/audit/audit.catalogues.imports.ts +1 -0
- package/template/modules/access-control/wiring/audit/audit.catalogues.ts +1 -0
- package/template/modules/access-control/wiring/audit/readme.modules.md +4 -0
- package/template/modules/access-control/wiring/audit/web.messages.ts +6 -0
- package/template/modules/access-control/wiring/d1.jsonc +8 -0
- package/template/modules/access-control/wiring/env.bindings.ts +2 -0
- package/template/modules/access-control/wiring/readme.modules.md +20 -0
- package/template/modules/access-control/wiring/test.env.ts +1 -0
- package/template/modules/access-control/wiring/test.migrate.ts +1 -0
- package/template/modules/access-control/wiring/vitest.bindings.ts +1 -0
- package/template/modules/access-control/wiring/worker.imports.ts +1 -0
- package/template/modules/access-control/wiring/worker.routes.ts +2 -0
- package/template/modules/access-control/with-admin/e2e/access-roles.spec.ts +51 -0
- package/template/modules/access-control/with-admin/web/src/admin-roles.tsx +38 -0
- package/template/modules/access-control/with-audit/test/access-control-audit.test.ts +25 -0
- package/template/modules/admin/files/e2e/admin-accessibility.spec.ts +86 -0
- package/template/modules/admin/files/e2e/admin-area.spec.ts +109 -0
- package/template/modules/admin/files/e2e/admin-helpers.ts +39 -0
- package/template/modules/admin/files/src/admin.ts +46 -0
- package/template/modules/admin/files/test/admin.test.ts +47 -0
- package/template/modules/admin/files/web/src/admin-area.tsx +140 -0
- package/template/modules/admin/files/web/src/admin-client.ts +49 -0
- package/template/modules/admin/files/web/src/admin-example.tsx +62 -0
- package/template/modules/admin/files/web/src/admin-gate.tsx +112 -0
- package/template/modules/admin/module.json +50 -0
- package/template/modules/admin/wiring/account.sections.tsx +16 -0
- package/template/modules/admin/wiring/audit/admin.authAudit.ts +2 -0
- package/template/modules/admin/wiring/audit/admin.imports.tsx +1 -0
- package/template/modules/admin/wiring/audit/admin.nav.tsx +1 -0
- package/template/modules/admin/wiring/audit/admin.pages.tsx +1 -0
- package/template/modules/admin/wiring/audit/admin.server.imports.ts +3 -0
- package/template/modules/admin/wiring/audit/admin.stats.ts +3 -0
- package/template/modules/admin/wiring/audit/admin.userSections.tsx +1 -0
- package/template/modules/admin/wiring/audit/readme.modules.md +4 -0
- package/template/modules/admin/wiring/audit/web.messages.ts +1 -0
- package/template/modules/admin/wiring/readme.modules.md +26 -0
- package/template/modules/admin/wiring/web.messages.ts +22 -0
- package/template/modules/admin/wiring/web.routes.imports.tsx +1 -0
- package/template/modules/admin/wiring/web.routes.tsx +2 -0
- package/template/modules/admin/wiring/web.styles.css +1 -0
- package/template/modules/admin/wiring/worker.imports.ts +1 -0
- package/template/modules/admin/wiring/worker.routes.ts +2 -0
- package/template/modules/admin/with-audit/e2e/admin-activity.spec.ts +37 -0
- package/template/modules/admin/with-audit/test/admin-audit.test.ts +37 -0
- package/template/modules/admin/with-audit/web/src/admin-activity.tsx +21 -0
- package/template/modules/audit/files/src/audit-actions.ts +20 -0
- package/template/modules/audit/files/src/audit-log.ts +33 -0
- package/template/modules/audit/files/test/audit.test.ts +47 -0
- package/template/modules/audit/module.json +36 -0
- package/template/modules/audit/wiring/audit.imports.ts +1 -0
- package/template/modules/audit/wiring/audit.sink.ts +2 -0
- package/template/modules/audit/wiring/auth/audit.catalogues.imports.ts +1 -0
- package/template/modules/audit/wiring/auth/audit.catalogues.ts +1 -0
- package/template/modules/audit/wiring/auth/readme.modules.md +7 -0
- package/template/modules/audit/wiring/auth/web.messages.ts +26 -0
- package/template/modules/audit/wiring/auth/web.routes.imports.tsx +1 -0
- package/template/modules/audit/wiring/auth/web.routes.tsx +1 -0
- package/template/modules/audit/wiring/auth/worker.imports.ts +1 -0
- package/template/modules/audit/wiring/auth/worker.routes.ts +2 -0
- package/template/modules/audit/wiring/d1.jsonc +8 -0
- package/template/modules/audit/wiring/env.bindings.ts +4 -0
- package/template/modules/audit/wiring/index.imports.ts +1 -0
- package/template/modules/audit/wiring/index.setup.ts +2 -0
- package/template/modules/audit/wiring/readme.modules.md +16 -0
- package/template/modules/audit/wiring/test.env.ts +1 -0
- package/template/modules/audit/wiring/test.migrate.ts +1 -0
- package/template/modules/audit/wiring/vars.deployed.jsonc +2 -0
- package/template/modules/audit/wiring/vars.dev.jsonc +2 -0
- package/template/modules/audit/wiring/vitest.bindings.ts +1 -0
- package/template/modules/audit/with-auth/e2e/my-activity.spec.ts +89 -0
- package/template/modules/audit/with-auth/src/audit-routes.ts +21 -0
- package/template/modules/audit/with-auth/test/audit-routes.test.ts +17 -0
- package/template/modules/audit/with-auth/web/src/my-activity.tsx +168 -0
- package/template/modules/auth/files/e2e/auth-accessibility.spec.ts +68 -0
- package/template/modules/auth/files/e2e/auth-bootstrap-admin.spec.ts +58 -0
- package/template/modules/auth/files/e2e/auth-helpers.ts +74 -0
- package/template/modules/auth/files/e2e/auth-invitations.spec.ts +51 -0
- package/template/modules/auth/files/e2e/auth-phone.spec.ts +41 -0
- package/template/modules/auth/files/e2e/auth-seeded.ts +97 -0
- package/template/modules/auth/files/e2e/auth-sign-in.spec.ts +57 -0
- package/template/modules/auth/files/e2e/auth-two-factor.spec.ts +44 -0
- package/template/modules/auth/files/scripts/seed-user.mjs +64 -0
- package/template/modules/auth/files/src/auth-choices.ts +33 -0
- package/template/modules/auth/files/src/auth-config.ts +55 -0
- package/template/modules/auth/files/src/auth.ts +68 -0
- package/template/modules/auth/files/src/local-test-mode.ts +49 -0
- package/template/modules/auth/files/src/notifications.ts +94 -0
- package/template/modules/auth/files/test/auth-fixtures.ts +135 -0
- package/template/modules/auth/files/test/auth.test.ts +210 -0
- package/template/modules/auth/files/test/totp.ts +49 -0
- package/template/modules/auth/files/test/turnstile-fake.ts +42 -0
- package/template/modules/auth/files/web/src/account-menu.tsx +25 -0
- package/template/modules/auth/files/web/src/account.tsx +87 -0
- package/template/modules/auth/files/web/src/auth-client.ts +60 -0
- package/template/modules/auth/files/web/src/auth-pages.tsx +152 -0
- package/template/modules/auth/module.json +56 -0
- package/template/modules/auth/wiring/bindings.deployed.jsonc +2 -0
- package/template/modules/auth/wiring/d1.jsonc +7 -0
- package/template/modules/auth/wiring/devvars.txt +12 -0
- package/template/modules/auth/wiring/env.bindings.ts +26 -0
- package/template/modules/auth/wiring/index.deps.ts +1 -0
- package/template/modules/auth/wiring/index.imports.ts +4 -0
- package/template/modules/auth/wiring/index.setup.ts +7 -0
- package/template/modules/auth/wiring/playwright.imports.ts +1 -0
- package/template/modules/auth/wiring/playwright.servers.ts +12 -0
- package/template/modules/auth/wiring/playwright.setup.ts +2 -0
- package/template/modules/auth/wiring/playwright.vars.ts +2 -0
- package/template/modules/auth/wiring/readme.modules.md +30 -0
- package/template/modules/auth/wiring/readme.provisioning.md +11 -0
- package/template/modules/auth/wiring/secrets.jsonc +2 -0
- package/template/modules/auth/wiring/test.env.ts +1 -0
- package/template/modules/auth/wiring/test.migrate.ts +1 -0
- package/template/modules/auth/wiring/vars.dev.jsonc +19 -0
- package/template/modules/auth/wiring/vars.production.jsonc +13 -0
- package/template/modules/auth/wiring/vars.staging.jsonc +13 -0
- package/template/modules/auth/wiring/vitest.bindings.ts +6 -0
- package/template/modules/auth/wiring/web.header.tsx +1 -0
- package/template/modules/auth/wiring/web.layout.imports.tsx +1 -0
- package/template/modules/auth/wiring/web.messages.ts +17 -0
- package/template/modules/auth/wiring/web.routes.imports.tsx +12 -0
- package/template/modules/auth/wiring/web.routes.tsx +11 -0
- package/template/modules/auth/wiring/web.styles.css +1 -0
- package/template/modules/auth/wiring/worker.deps.ts +2 -0
- package/template/modules/auth/wiring/worker.imports.ts +1 -0
- package/template/modules/auth/wiring/worker.routes.ts +2 -0
- package/template/modules/example/files/e2e/example-notes.spec.ts +39 -0
- package/template/modules/example/files/migrations/0002_example_notes.sql +12 -0
- package/template/modules/example/files/src/example-audit-actions.ts +12 -0
- package/template/modules/example/files/src/example-notes.ts +136 -0
- package/template/modules/example/files/test/example-notes-fixtures.ts +30 -0
- package/template/modules/example/files/test/example-notes.test.ts +52 -0
- package/template/modules/example/files/web/src/example-notes.tsx +176 -0
- package/template/modules/example/module.json +61 -0
- package/template/modules/example/wiring/access-control/access.permissions.ts +3 -0
- package/template/modules/example/wiring/access-control/access.roles.ts +2 -0
- package/template/modules/example/wiring/access-control/example.e2e.imports.ts +2 -0
- package/template/modules/example/wiring/access-control/example.e2e.setup.ts +18 -0
- package/template/modules/example/wiring/access-control/example.imports.ts +3 -0
- package/template/modules/example/wiring/access-control/example.policy.ts +10 -0
- package/template/modules/example/wiring/access-control/example.test.caller.ts +17 -0
- package/template/modules/example/wiring/access-control/example.test.imports.ts +1 -0
- package/template/modules/example/wiring/access-control/readme.modules.md +5 -0
- package/template/modules/example/wiring/admin/admin.imports.tsx +1 -0
- package/template/modules/example/wiring/admin/admin.nav.tsx +2 -0
- package/template/modules/example/wiring/admin/admin.pages.tsx +2 -0
- package/template/modules/example/wiring/admin/readme.modules.md +4 -0
- package/template/modules/example/wiring/admin/web.messages.ts +11 -0
- package/template/modules/example/wiring/admin/worker.imports.ts +1 -0
- package/template/modules/example/wiring/admin/worker.routes.ts +2 -0
- package/template/modules/example/wiring/audit/audit.catalogues.imports.ts +1 -0
- package/template/modules/example/wiring/audit/audit.catalogues.ts +2 -0
- package/template/modules/example/wiring/audit/readme.modules.md +4 -0
- package/template/modules/example/wiring/readme.modules.md +16 -0
- package/template/modules/example/wiring/web.messages.ts +19 -0
- package/template/modules/example/wiring/web.routes.imports.tsx +1 -0
- package/template/modules/example/wiring/web.routes.tsx +2 -0
- package/template/modules/example/wiring/worker.imports.ts +1 -0
- package/template/modules/example/wiring/worker.routes.ts +2 -0
- package/template/modules/example/with-access-control/test/example-notes-access.test.ts +51 -0
- package/template/modules/example/with-admin/e2e/example-notes-admin.spec.ts +55 -0
- package/template/modules/example/with-admin/src/example-notes-admin.ts +62 -0
- package/template/modules/example/with-admin/test/example-notes-admin.test.ts +42 -0
- package/template/modules/example/with-admin/web/src/admin-notes.tsx +99 -0
- package/template/modules/example/with-audit/test/example-notes-audit.test.ts +22 -0
- package/template/package.json +57 -0
- package/template/playwright.config.ts +38 -0
- package/template/renovate.json +12 -0
- package/template/src/app.ts +46 -0
- package/template/src/audit-catalogues.ts +9 -0
- package/template/src/audit-sink.ts +21 -0
- package/template/src/env.d.ts +21 -0
- package/template/src/index.ts +18 -0
- package/template/src/v1.ts +24 -0
- package/template/standalone/.github/workflows/ci.yml +21 -0
- package/template/standalone/.github/workflows/deploy.yml +32 -0
- package/template/standalone/.nvmrc +1 -0
- package/template/standalone/_gitignore +9 -0
- package/template/standalone/pnpm-workspace.yaml +11 -0
- package/template/template.json +56 -0
- package/template/test/env.d.ts +11 -0
- package/template/test/migrate.ts +11 -0
- package/template/test/migrations.test.ts +22 -0
- package/template/test/worker.test.ts +65 -0
- package/template/tsconfig.json +7 -0
- package/template/vitest.config.ts +24 -0
- package/template/web/index.html +12 -0
- package/template/web/src/app.tsx +44 -0
- package/template/web/src/home.tsx +51 -0
- package/template/web/src/layout.tsx +48 -0
- package/template/web/src/main.tsx +16 -0
- package/template/web/src/messages.ts +38 -0
- package/template/web/src/navigation.tsx +34 -0
- package/template/web/src/route-focus.ts +49 -0
- package/template/web/src/routes.tsx +38 -0
- package/template/web/src/styles.css +11 -0
- package/template/web/src/system-theme.ts +16 -0
- package/template/web/tsconfig.json +7 -0
- package/template/web/vite.config.ts +15 -0
- package/template/wrangler.jsonc +96 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { expect, test, type Page } from "@playwright/test";
|
|
2
|
+
import { expectNoPageScroll, expectNoViolations, THEMES, VIEWPORTS } from "./axe.js";
|
|
3
|
+
import { signInWithPassword } from "./auth-helpers.js";
|
|
4
|
+
import { AUTH_CHOICES, USERS } from "./auth-seeded.js";
|
|
5
|
+
|
|
6
|
+
// Zero axe violations on every sign-in page the app offers, in light and dark, at desktop
|
|
7
|
+
// and phone sizes; the account pages are checked signed in.
|
|
8
|
+
interface Screen {
|
|
9
|
+
name: string;
|
|
10
|
+
path: string;
|
|
11
|
+
signedIn?: boolean;
|
|
12
|
+
on: boolean;
|
|
13
|
+
ready: (page: Page) => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const heading = (name: string) => (page: Page) => expect(page.getByRole("heading", { level: 1, name })).toBeVisible();
|
|
17
|
+
const email = AUTH_CHOICES.emailPassword;
|
|
18
|
+
|
|
19
|
+
const SCREENS: Screen[] = [
|
|
20
|
+
{ name: "sign in", path: "/sign-in", on: true, ready: heading("Sign in") },
|
|
21
|
+
{ name: "phone sign-in", path: "/sign-in/phone", on: AUTH_CHOICES.phoneOtp !== false, ready: heading("Sign in with your phone") },
|
|
22
|
+
{ name: "sign up", path: "/sign-up", on: email && AUTH_CHOICES.registration === "open", ready: heading("Create an account") },
|
|
23
|
+
{ name: "forgot password", path: "/forgot-password", on: email, ready: (p) => expect(p.getByRole("heading", { level: 1 })).toBeVisible() },
|
|
24
|
+
{
|
|
25
|
+
name: "invalid invitation",
|
|
26
|
+
path: "/accept-invitation?token=not-a-real-token",
|
|
27
|
+
on: true,
|
|
28
|
+
ready: (p) => expect(p.getByText("This invitation link is invalid")).toBeVisible(),
|
|
29
|
+
},
|
|
30
|
+
{ name: "account", path: "/account", signedIn: true, on: email, ready: heading("Your account") },
|
|
31
|
+
{
|
|
32
|
+
name: "two-factor settings",
|
|
33
|
+
path: "/account/security",
|
|
34
|
+
signedIn: true,
|
|
35
|
+
on: email && AUTH_CHOICES.twoFactor,
|
|
36
|
+
ready: (p) => expect(p.getByText("Two-factor authentication is off.")).toBeVisible(),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "sessions",
|
|
40
|
+
path: "/account/sessions",
|
|
41
|
+
signedIn: true,
|
|
42
|
+
on: email,
|
|
43
|
+
ready: (p) => expect(p.getByRole("heading", { level: 1 })).toBeVisible(),
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const screen of SCREENS.filter((s) => s.on)) {
|
|
48
|
+
test.describe(screen.name, () => {
|
|
49
|
+
if (screen.signedIn === true) {
|
|
50
|
+
test.beforeEach(async ({ page }) => {
|
|
51
|
+
await signInWithPassword(page, USERS.axe, "/account");
|
|
52
|
+
await expect(page.getByRole("heading", { level: 1, name: "Your account" })).toBeVisible();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
for (const theme of THEMES) {
|
|
56
|
+
for (const [viewport, size] of Object.entries(VIEWPORTS)) {
|
|
57
|
+
test(`${screen.name} (${theme}, ${viewport}) has no accessibility violations`, async ({ page }) => {
|
|
58
|
+
await page.emulateMedia({ colorScheme: theme });
|
|
59
|
+
await page.setViewportSize(size);
|
|
60
|
+
await page.goto(screen.path);
|
|
61
|
+
await screen.ready(page);
|
|
62
|
+
await expectNoPageScroll(page);
|
|
63
|
+
await expectNoViolations(page);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { expect, test } from "@playwright/test";
|
|
3
|
+
import { totpCode } from "../test/totp.js";
|
|
4
|
+
import { emailedLink, signInWithPassword } from "./auth-helpers.js";
|
|
5
|
+
import { adminsCanSignIn, BOOTSTRAP_STATE, BOOTSTRAP_URL } from "./auth-seeded.js";
|
|
6
|
+
|
|
7
|
+
// The first administrator of a fresh installation, against its own server
|
|
8
|
+
// (playwright.config.ts, a second webServer with no users at all). The operator runs the
|
|
9
|
+
// real CLI against that server's local D1, as `pnpm auth:bootstrap-admin` would.
|
|
10
|
+
const FIRST = { email: "first.admin@example.test", name: "Ada First", password: "the very first admin pass" };
|
|
11
|
+
|
|
12
|
+
test.use({ baseURL: BOOTSTRAP_URL });
|
|
13
|
+
test.skip(!adminsCanSignIn, "administrators need email + password and 2FA (src/auth-choices.ts)");
|
|
14
|
+
|
|
15
|
+
function bootstrapAdmin(...args: string[]) {
|
|
16
|
+
const result = spawnSync("pnpm", ["exec", "inbis-auth", "bootstrap-admin", ...args, "--local", "--persist-to", BOOTSTRAP_STATE], {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
19
|
+
});
|
|
20
|
+
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("bootstrap-admin issues a one-time link; the first administrator joins and sets up 2FA; a second run is refused", async ({ page }) => {
|
|
24
|
+
const issued = bootstrapAdmin("--email", FIRST.email, "--url", BOOTSTRAP_URL, "--send-email");
|
|
25
|
+
expect(issued.code, issued.stdout + issued.stderr).toBe(0);
|
|
26
|
+
const link = /https?:\/\/\S+/.exec(issued.stdout)?.[0] ?? "";
|
|
27
|
+
expect(link.startsWith(`${BOOTSTRAP_URL}/accept-invitation?token=`)).toBe(true);
|
|
28
|
+
// --send-email: the app emailed the same link.
|
|
29
|
+
expect(await emailedLink(page.request, FIRST.email, "")).toBe(link);
|
|
30
|
+
|
|
31
|
+
await page.goto(link);
|
|
32
|
+
await expect(page.getByRole("heading", { name: "Accept your invitation" })).toBeVisible();
|
|
33
|
+
await page.getByLabel("Name").fill(FIRST.name);
|
|
34
|
+
await page.getByLabel("Password", { exact: true }).fill(FIRST.password);
|
|
35
|
+
await page.getByLabel("Confirm password").fill(FIRST.password);
|
|
36
|
+
await page.getByRole("button", { name: "Create account" }).click();
|
|
37
|
+
await expect(page.getByText("Your account is ready")).toBeVisible();
|
|
38
|
+
|
|
39
|
+
// Administrators need 2FA: they set it up from the account page.
|
|
40
|
+
await signInWithPassword(page, FIRST, "/account/security");
|
|
41
|
+
await page.getByRole("button", { name: "Set up authenticator app" }).click();
|
|
42
|
+
await page.getByLabel("Current password").fill(FIRST.password);
|
|
43
|
+
await page.getByRole("button", { name: "Continue" }).click();
|
|
44
|
+
const secret = (await page.getByTestId("totp-secret").textContent()) ?? "";
|
|
45
|
+
await page.getByLabel("6-digit code").fill(await totpCode(secret));
|
|
46
|
+
await page.getByRole("button", { name: "Verify and turn on" }).click();
|
|
47
|
+
await page.getByRole("button", { name: "I have saved my backup codes" }).click();
|
|
48
|
+
await expect(page.getByText("Two-factor authentication is on.")).toBeVisible();
|
|
49
|
+
|
|
50
|
+
const me = (await (await page.request.get("/api/me")).json()) as { user: { role: string } };
|
|
51
|
+
expect(me.user.role).toBe("admin");
|
|
52
|
+
|
|
53
|
+
// An administrator exists now: the CLI refuses and issues nothing.
|
|
54
|
+
const again = bootstrapAdmin("--email", "second.admin@example.test", "--url", BOOTSTRAP_URL);
|
|
55
|
+
expect(again.code).toBe(1);
|
|
56
|
+
expect(again.stderr).toContain("an administrator already exists");
|
|
57
|
+
expect(again.stdout).not.toContain("token=");
|
|
58
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expect, type APIRequestContext, type Page, type PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
|
+
import { totpCode } from "../test/totp.js";
|
|
3
|
+
import { ADMIN_TOTP_SECRET, TURNSTILE_TEST_TOKEN, USERS } from "./auth-seeded.js";
|
|
4
|
+
|
|
5
|
+
/** Signs in on the sign-in screen with email and password. */
|
|
6
|
+
export async function signInWithPassword(page: Page, user: { email: string; password: string }, next?: string) {
|
|
7
|
+
await page.goto(next === undefined ? "/sign-in" : `/sign-in?next=${encodeURIComponent(next)}`);
|
|
8
|
+
await page.getByLabel("Email").fill(user.email);
|
|
9
|
+
await page.getByLabel("Password").fill(user.password);
|
|
10
|
+
await page.getByRole("button", { name: "Sign in" }).click();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface OutboxEmail {
|
|
14
|
+
to: string;
|
|
15
|
+
subject: string;
|
|
16
|
+
links: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The first link of the newest email to `to` whose subject contains `subject` (local outbox). */
|
|
20
|
+
export async function emailedLink(request: APIRequestContext, to: string, subject: string): Promise<string> {
|
|
21
|
+
let link: string | undefined;
|
|
22
|
+
await expect
|
|
23
|
+
.poll(async () => {
|
|
24
|
+
const { emails } = (await (await request.get("/api/dev/outbox")).json()) as { emails: OutboxEmail[] };
|
|
25
|
+
link = emails.find((email) => email.to === to && email.subject.includes(subject))?.links[0];
|
|
26
|
+
return link !== undefined;
|
|
27
|
+
})
|
|
28
|
+
.toBe(true);
|
|
29
|
+
return link ?? "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface OutboxSms {
|
|
33
|
+
to: string;
|
|
34
|
+
text: string;
|
|
35
|
+
templateId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How many sign-in codes went to `to` so far (local SMS outbox). */
|
|
39
|
+
export async function codesSent(request: APIRequestContext, to: string): Promise<number> {
|
|
40
|
+
const { messages } = (await (await request.get("/api/dev/sms-outbox")).json()) as { messages: OutboxSms[] };
|
|
41
|
+
return messages.filter((sms) => sms.to === to && sms.templateId === "auth.phone-otp").length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The newest sign-in code to `to`, once more than `before` have been sent. */
|
|
45
|
+
export async function newestCode(request: APIRequestContext, to: string, before: number): Promise<string> {
|
|
46
|
+
let code = "";
|
|
47
|
+
await expect
|
|
48
|
+
.poll(async () => {
|
|
49
|
+
const { messages } = (await (await request.get("/api/dev/sms-outbox")).json()) as { messages: OutboxSms[] };
|
|
50
|
+
const codes = messages.filter((sms) => sms.to === to && sms.templateId === "auth.phone-otp");
|
|
51
|
+
code = codes.length > before ? (/^(\d{6}) /.exec(codes[0]?.text ?? "")?.[1] ?? "") : "";
|
|
52
|
+
return code !== "";
|
|
53
|
+
})
|
|
54
|
+
.toBe(true);
|
|
55
|
+
return code;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An API client signed in as the seeded administrator, in its own cookie jar: password,
|
|
60
|
+
* then the authenticator code (administrators need a 2FA-verified session).
|
|
61
|
+
*/
|
|
62
|
+
export async function adminApi(playwright: PlaywrightWorkerArgs["playwright"], baseURL: string | undefined): Promise<APIRequestContext> {
|
|
63
|
+
const admin = await playwright.request.newContext({
|
|
64
|
+
baseURL: baseURL ?? "",
|
|
65
|
+
// Sign-in needs a Turnstile token: the one the testing site key hands out, sent like the widget would.
|
|
66
|
+
extraHTTPHeaders: { origin: baseURL ?? "", "x-captcha-response": TURNSTILE_TEST_TOKEN },
|
|
67
|
+
});
|
|
68
|
+
const signIn = await admin.post("/api/auth/sign-in/email", { data: { email: USERS.admin.email, password: USERS.admin.password } });
|
|
69
|
+
expect(signIn.status()).toBe(200);
|
|
70
|
+
expect(await signIn.json()).toMatchObject({ twoFactorRedirect: true });
|
|
71
|
+
const verified = await admin.post("/api/auth/two-factor/verify-totp", { data: { code: await totpCode(ADMIN_TOTP_SECRET) } });
|
|
72
|
+
expect(verified.status()).toBe(200);
|
|
73
|
+
return admin;
|
|
74
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { adminApi, emailedLink, signInWithPassword } from "./auth-helpers.js";
|
|
3
|
+
import { adminsCanSignIn, AUTH_CHOICES, invitationsAllowed } from "./auth-seeded.js";
|
|
4
|
+
|
|
5
|
+
// Invitations: the seeded administrator invites through the API; the invitee has only the
|
|
6
|
+
// browser and the emailed link. Registration "admin-only" and "closed" refuse invitations.
|
|
7
|
+
const INVITEE = { email: "invitee@example.test", name: "Ivy Invitee", password: "an invited pass phrase" };
|
|
8
|
+
|
|
9
|
+
test.skip(!adminsCanSignIn, "administrators need email + password and 2FA (src/auth-choices.ts)");
|
|
10
|
+
|
|
11
|
+
test("an invitee accepts an emailed invitation, then signs in; the link works once", async ({ page, playwright, baseURL }) => {
|
|
12
|
+
test.skip(!invitationsAllowed, `registration "${AUTH_CHOICES.registration}" refuses invitations`);
|
|
13
|
+
const admin = await adminApi(playwright, baseURL);
|
|
14
|
+
const created = await admin.post("/api/auth/admin/create-invitation", { data: { email: INVITEE.email } });
|
|
15
|
+
expect(created.status()).toBe(200);
|
|
16
|
+
const link = await emailedLink(admin, INVITEE.email, "invited you");
|
|
17
|
+
await admin.dispose();
|
|
18
|
+
|
|
19
|
+
await page.goto(link);
|
|
20
|
+
await expect(page).toHaveURL(/\/accept-invitation\?token=/);
|
|
21
|
+
await expect(page.getByRole("heading", { name: "Accept your invitation" })).toBeVisible();
|
|
22
|
+
await expect(page.getByText(`account for ${INVITEE.email}`)).toBeVisible();
|
|
23
|
+
await page.getByLabel("Name").fill(INVITEE.name);
|
|
24
|
+
await page.getByLabel("Password", { exact: true }).fill(INVITEE.password);
|
|
25
|
+
await page.getByLabel("Confirm password").fill(INVITEE.password);
|
|
26
|
+
await page.getByRole("button", { name: "Create account" }).click();
|
|
27
|
+
await expect(page.getByText("Your account is ready")).toBeVisible();
|
|
28
|
+
|
|
29
|
+
await signInWithPassword(page, INVITEE, "/account");
|
|
30
|
+
await expect(page.getByText(`Signed in as ${INVITEE.name} (${INVITEE.email}).`)).toBeVisible();
|
|
31
|
+
|
|
32
|
+
// The same link again: refused, no form.
|
|
33
|
+
await page.goto(link);
|
|
34
|
+
await expect(page.getByText("This invitation link is invalid, has expired or was already used")).toBeVisible();
|
|
35
|
+
await expect(page.getByLabel("Name")).toHaveCount(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the registration mode refuses invitations", async ({ playwright, baseURL }) => {
|
|
39
|
+
test.skip(invitationsAllowed, `registration "${AUTH_CHOICES.registration}" allows invitations`);
|
|
40
|
+
const admin = await adminApi(playwright, baseURL);
|
|
41
|
+
const created = await admin.post("/api/auth/admin/create-invitation", { data: { email: INVITEE.email } });
|
|
42
|
+
expect(created.status()).toBe(403);
|
|
43
|
+
expect(await created.text()).toContain("REGISTRATION_NOT_ALLOWED");
|
|
44
|
+
await admin.dispose();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("an invalid invitation link shows no form", async ({ page }) => {
|
|
48
|
+
await page.goto("/accept-invitation?token=not-a-real-token");
|
|
49
|
+
await expect(page.getByText("This invitation link is invalid, has expired or was already used")).toBeVisible();
|
|
50
|
+
await expect(page.getByRole("button", { name: "Create account" })).toHaveCount(0);
|
|
51
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { codesSent, newestCode } from "./auth-helpers.js";
|
|
3
|
+
import { AUTH_CHOICES, PHONE_USER, refusedNumber } from "./auth-seeded.js";
|
|
4
|
+
|
|
5
|
+
// Phone sign-in by SMS code. The seeded user's number is one the app's country rules
|
|
6
|
+
// accept; codes are read from the local SMS outbox (GET /api/dev/sms-outbox).
|
|
7
|
+
|
|
8
|
+
test.skip(AUTH_CHOICES.phoneOtp === false, "phone sign-in is off (src/auth-choices.ts)");
|
|
9
|
+
|
|
10
|
+
test("a user signs in with a code sent to their phone", async ({ page }) => {
|
|
11
|
+
const number = PHONE_USER.phone ?? "";
|
|
12
|
+
test.skip(number === "", "no test number passes the app's country rules");
|
|
13
|
+
|
|
14
|
+
await page.goto("/sign-in/phone");
|
|
15
|
+
await expect(page.getByRole("heading", { name: "Sign in with your phone" })).toBeVisible();
|
|
16
|
+
await page.getByLabel("Phone number").fill(number);
|
|
17
|
+
const before = await codesSent(page.request, number);
|
|
18
|
+
await page.getByRole("button", { name: "Send code" }).click();
|
|
19
|
+
|
|
20
|
+
// A wrong code is refused with a clear message, then the right one signs in.
|
|
21
|
+
await page.getByLabel("6-digit code").fill("000000");
|
|
22
|
+
await page.getByRole("button", { name: "Verify" }).click();
|
|
23
|
+
await expect(page.getByRole("alert")).toContainText("That code is not right");
|
|
24
|
+
await page.getByLabel("6-digit code").fill(await newestCode(page.request, number, before));
|
|
25
|
+
await page.getByRole("button", { name: "Verify" }).click();
|
|
26
|
+
|
|
27
|
+
await expect(page).toHaveURL(/\/$/);
|
|
28
|
+
await page.goto("/account");
|
|
29
|
+
await expect(page.getByText(`Signed in as ${PHONE_USER.name}`)).toBeVisible();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a number from a country the app does not serve gets no code", async ({ page }) => {
|
|
33
|
+
const number = refusedNumber();
|
|
34
|
+
test.skip(number === undefined, "the app's country rules refuse no number");
|
|
35
|
+
|
|
36
|
+
await page.goto("/sign-in/phone");
|
|
37
|
+
await page.getByLabel("Phone number").fill(number ?? "");
|
|
38
|
+
await page.getByRole("button", { name: "Send code" }).click();
|
|
39
|
+
await expect(page.getByRole("alert")).toContainText("Phone numbers from this country cannot be used here.");
|
|
40
|
+
expect(await codesSent(page.request, number ?? "")).toBe(0);
|
|
41
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// What playwright.config.ts seeds into the local D1 for the auth specs, and what the specs
|
|
2
|
+
// derive from the app's sign-in choices (src/auth-choices.ts). Local values only, never
|
|
3
|
+
// real secrets.
|
|
4
|
+
import { AUTH_CHOICES, type AuthChoices } from "../src/auth-choices.js";
|
|
5
|
+
|
|
6
|
+
export { AUTH_CHOICES };
|
|
7
|
+
|
|
8
|
+
/** The Worker's AUTH_SECRET in e2e runs (passed with --var). */
|
|
9
|
+
export const E2E_AUTH_SECRET = "e2e-only-secret-0123456789abcdefghijkl";
|
|
10
|
+
|
|
11
|
+
/** Cloudflare's Turnstile testing secret, which accepts any token. */
|
|
12
|
+
export const TURNSTILE_TEST_SECRET = "1x0000000000000000000000000000000AA";
|
|
13
|
+
|
|
14
|
+
/** The token Turnstile's testing site key hands out (sent like the widget would). */
|
|
15
|
+
export const TURNSTILE_TEST_TOKEN = "XXXX.DUMMY.TOKEN.XXXX";
|
|
16
|
+
|
|
17
|
+
/** The seeded administrators' authenticator (base32, as in an otpauth:// URI). */
|
|
18
|
+
export const ADMIN_TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
|
|
19
|
+
|
|
20
|
+
const PASSWORD = "correct horse battery";
|
|
21
|
+
|
|
22
|
+
/** Seeded users, each for one spec so specs never disturb each other. */
|
|
23
|
+
export const USERS = {
|
|
24
|
+
/** Signs in and out (auth-sign-in.spec.ts). */
|
|
25
|
+
member: { name: "Ada Lovelace", email: "ada@example.test", password: PASSWORD },
|
|
26
|
+
/** Turns 2FA on (auth-two-factor.spec.ts). */
|
|
27
|
+
totp: { name: "Grace Hopper", email: "totp@example.test", password: PASSWORD },
|
|
28
|
+
/** Pages checked with axe while signed in (auth-accessibility.spec.ts). */
|
|
29
|
+
axe: { name: "Alan Turing", email: "axe@example.test", password: PASSWORD },
|
|
30
|
+
/** An administrator with 2FA, who sends invitations (auth-invitations.spec.ts). */
|
|
31
|
+
admin: { name: "Grace Admin", email: "admin@example.test", password: PASSWORD },
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
const SAMPLE_NUMBERS = ["+447700900123", "+15550100123", "+61491570123", "+33612345678", "+819012345678"];
|
|
35
|
+
|
|
36
|
+
/** An E.164 test number starting with `prefix`: 11 digits in all. */
|
|
37
|
+
const numberFor = (prefix: string) => `${prefix}${"0".repeat(Math.max(0, 11 - prefix.length))}1`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A number the app's phone country rules accept (an allowed prefix, not a blocked one),
|
|
41
|
+
* for the seeded phone user, or `undefined` when phone sign-in is off.
|
|
42
|
+
*/
|
|
43
|
+
export function acceptedNumber(choices: AuthChoices = AUTH_CHOICES): string | undefined {
|
|
44
|
+
const phone = choices.phoneOtp;
|
|
45
|
+
if (phone === false) return undefined;
|
|
46
|
+
const blocked = (n: string) => phone.blockedCountries.some((prefix) => n.startsWith(prefix));
|
|
47
|
+
const candidates = phone.allowedCountries === undefined ? SAMPLE_NUMBERS : phone.allowedCountries.map(numberFor);
|
|
48
|
+
return candidates.find((n) => !blocked(n));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A number the app's phone country rules refuse, or `undefined` when they refuse none. */
|
|
52
|
+
export function refusedNumber(choices: AuthChoices = AUTH_CHOICES): string | undefined {
|
|
53
|
+
const phone = choices.phoneOtp;
|
|
54
|
+
if (phone === false) return undefined;
|
|
55
|
+
const first = phone.blockedCountries[0];
|
|
56
|
+
if (first !== undefined) return numberFor(first);
|
|
57
|
+
const allowed = phone.allowedCountries;
|
|
58
|
+
return allowed === undefined ? undefined : SAMPLE_NUMBERS.find((n) => !allowed.some((prefix) => n.startsWith(prefix)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A seeded user with a verified phone number, who signs in by SMS code (auth-phone.spec.ts). */
|
|
62
|
+
export const PHONE_USER = { name: "Pat Phone", email: "phone@example.test", password: PASSWORD, phone: acceptedNumber() };
|
|
63
|
+
|
|
64
|
+
/** The `wrangler dev` flags of an e2e server at `baseURL`: test secrets, and local test mode on. */
|
|
65
|
+
export function authDevVars(baseURL: string): string {
|
|
66
|
+
return [
|
|
67
|
+
`--var AUTH_SECRET:${E2E_AUTH_SECRET}`,
|
|
68
|
+
`--var TURNSTILE_SECRET_KEY:${TURNSTILE_TEST_SECRET}`,
|
|
69
|
+
`--var AUTH_BASE_URL:${baseURL}`,
|
|
70
|
+
"--var LOCAL_TEST_MODE:true",
|
|
71
|
+
].join(" ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The shell commands that seed the users above into the e2e database at `state`. */
|
|
75
|
+
export function authSeedCommands(state: string): string[] {
|
|
76
|
+
const seed = (user: { name: string; email: string; password: string }, extra = "") =>
|
|
77
|
+
`node scripts/seed-user.mjs --persist-to ${state} --email ${user.email} --name '${user.name}' --password '${user.password}'${extra}`;
|
|
78
|
+
const admin2fa = ` --role admin --totp-secret ${ADMIN_TOTP_SECRET} --auth-secret ${E2E_AUTH_SECRET}`;
|
|
79
|
+
return [
|
|
80
|
+
seed(USERS.member),
|
|
81
|
+
seed(USERS.totp),
|
|
82
|
+
seed(USERS.axe),
|
|
83
|
+
seed(USERS.admin, admin2fa),
|
|
84
|
+
...(PHONE_USER.phone === undefined ? [] : [seed(PHONE_USER, ` --phone ${PHONE_USER.phone}`)]),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The e2e server with a fresh installation (no users), for bootstrap-admin: the port after the main one. */
|
|
89
|
+
export const BOOTSTRAP_PORT = Number(process.env["E2E_PORT"] ?? 8790) + 1;
|
|
90
|
+
export const BOOTSTRAP_URL = `http://localhost:${BOOTSTRAP_PORT}`;
|
|
91
|
+
export const BOOTSTRAP_STATE = ".wrangler/e2e-bootstrap";
|
|
92
|
+
|
|
93
|
+
/** Whether this app's registration mode lets administrators invite (`open`, `invite-only`). */
|
|
94
|
+
export const invitationsAllowed = AUTH_CHOICES.registration === "open" || AUTH_CHOICES.registration === "invite-only";
|
|
95
|
+
|
|
96
|
+
/** Whether an administrator can sign in (email + password with 2FA). */
|
|
97
|
+
export const adminsCanSignIn = AUTH_CHOICES.emailPassword && AUTH_CHOICES.twoFactor;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { signInWithPassword } from "./auth-helpers.js";
|
|
3
|
+
import { AUTH_CHOICES, USERS } from "./auth-seeded.js";
|
|
4
|
+
|
|
5
|
+
// Email + password sign-in, the account page and signing out, as a visitor does it.
|
|
6
|
+
const USER = USERS.member;
|
|
7
|
+
|
|
8
|
+
test.skip(!AUTH_CHOICES.emailPassword, "email + password sign-in is off (src/auth-choices.ts)");
|
|
9
|
+
|
|
10
|
+
test("a visitor signs in from the header, reaches the account page, and signs out", async ({ page }) => {
|
|
11
|
+
await page.goto("/");
|
|
12
|
+
await page.getByRole("link", { name: "Sign in" }).click();
|
|
13
|
+
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible();
|
|
14
|
+
await expect(page.getByText("Sign in to React Hono App")).toBeVisible();
|
|
15
|
+
|
|
16
|
+
await page.getByLabel("Email").fill(USER.email);
|
|
17
|
+
await page.getByLabel("Password").fill(USER.password);
|
|
18
|
+
await page.getByRole("button", { name: "Sign in" }).click();
|
|
19
|
+
|
|
20
|
+
// Back where they came from, with their name in the header.
|
|
21
|
+
await expect(page).toHaveURL(/\/$/);
|
|
22
|
+
await page.getByRole("link", { name: `Account: ${USER.name}` }).click();
|
|
23
|
+
await expect(page.getByRole("heading", { level: 1, name: "Your account" })).toBeVisible();
|
|
24
|
+
await expect(page.getByText(`Signed in as ${USER.name} (${USER.email}).`)).toBeVisible();
|
|
25
|
+
|
|
26
|
+
// The session cookie is HttpOnly, so page scripts cannot read it; it survives a reload.
|
|
27
|
+
const session = (await page.context().cookies()).find((c) => c.name.endsWith("session_token"));
|
|
28
|
+
expect(session?.httpOnly).toBe(true);
|
|
29
|
+
await page.reload();
|
|
30
|
+
await expect(page.getByRole("heading", { level: 1, name: "Your account" })).toBeVisible();
|
|
31
|
+
|
|
32
|
+
await page.getByRole("button", { name: "Sign out" }).click();
|
|
33
|
+
await expect(page).toHaveURL(/\/sign-in$/);
|
|
34
|
+
await page.goto("/account");
|
|
35
|
+
await expect(page).toHaveURL(/\/sign-in\?next=%2Faccount$/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the account page sends a signed-out visitor to sign in and back", async ({ page }) => {
|
|
39
|
+
await page.goto("/account");
|
|
40
|
+
await expect(page).toHaveURL(/\/sign-in\?next=%2Faccount$/);
|
|
41
|
+
await page.getByLabel("Email").fill(USER.email);
|
|
42
|
+
await page.getByLabel("Password").fill(USER.password);
|
|
43
|
+
await page.getByRole("button", { name: "Sign in" }).click();
|
|
44
|
+
await expect(page.getByRole("heading", { level: 1, name: "Your account" })).toBeVisible();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a wrong password shows an error and does not sign in", async ({ page }) => {
|
|
48
|
+
await signInWithPassword(page, { email: USER.email, password: "not the right password" });
|
|
49
|
+
await expect(page.getByRole("alert")).toContainText("The email or password is incorrect.");
|
|
50
|
+
await expect(page).toHaveURL(/\/sign-in$/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("the sign-in screen offers sign-up exactly when registration is open", async ({ page }) => {
|
|
54
|
+
await page.goto("/sign-in");
|
|
55
|
+
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible();
|
|
56
|
+
await expect(page.getByRole("button", { name: "Create an account" })).toHaveCount(AUTH_CHOICES.registration === "open" ? 1 : 0);
|
|
57
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { totpCode } from "../test/totp.js";
|
|
3
|
+
import { signInWithPassword } from "./auth-helpers.js";
|
|
4
|
+
import { AUTH_CHOICES, USERS } from "./auth-seeded.js";
|
|
5
|
+
|
|
6
|
+
// Two-factor authentication: one user's lifecycle, in order (each test starts where the
|
|
7
|
+
// last one left off): enrol an authenticator, then sign in with its code.
|
|
8
|
+
const USER = USERS.totp;
|
|
9
|
+
let secret = "";
|
|
10
|
+
|
|
11
|
+
test.describe.configure({ mode: "serial" });
|
|
12
|
+
test.skip(!AUTH_CHOICES.twoFactor, "two-factor authentication is off (src/auth-choices.ts)");
|
|
13
|
+
|
|
14
|
+
test("a user enrols an authenticator app from the account page", async ({ page }) => {
|
|
15
|
+
await signInWithPassword(page, USER, "/account");
|
|
16
|
+
await page.getByRole("link", { name: "Two-factor authentication" }).click();
|
|
17
|
+
await expect(page.getByRole("heading", { name: "Two-factor authentication" })).toBeVisible();
|
|
18
|
+
await expect(page.getByText("Two-factor authentication is off.")).toBeVisible();
|
|
19
|
+
|
|
20
|
+
await page.getByRole("button", { name: "Set up authenticator app" }).click();
|
|
21
|
+
await page.getByLabel("Current password").fill(USER.password);
|
|
22
|
+
await page.getByRole("button", { name: "Continue" }).click();
|
|
23
|
+
await expect(page.getByRole("img", { name: "QR code for your authenticator app" })).toBeVisible();
|
|
24
|
+
secret = (await page.getByTestId("totp-secret").textContent()) ?? "";
|
|
25
|
+
expect(secret).toMatch(/^[A-Z2-7]+$/);
|
|
26
|
+
|
|
27
|
+
await page.getByLabel("6-digit code").fill(await totpCode(secret));
|
|
28
|
+
await page.getByRole("button", { name: "Verify and turn on" }).click();
|
|
29
|
+
await expect(page.getByRole("heading", { name: "Save your backup codes" })).toBeVisible();
|
|
30
|
+
await page.getByRole("button", { name: "I have saved my backup codes" }).click();
|
|
31
|
+
await expect(page.getByText("Two-factor authentication is on.")).toBeVisible();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("sign-in then asks for the authenticator code", async ({ page }) => {
|
|
35
|
+
await signInWithPassword(page, USER, "/account");
|
|
36
|
+
await expect(page.getByRole("heading", { name: "Two-factor authentication" })).toBeVisible();
|
|
37
|
+
await page.getByLabel("6-digit code").fill("000000");
|
|
38
|
+
await page.getByRole("button", { name: "Verify" }).click();
|
|
39
|
+
await expect(page.getByRole("alert")).toContainText("That code is not valid.");
|
|
40
|
+
|
|
41
|
+
await page.getByLabel("6-digit code").fill(await totpCode(secret));
|
|
42
|
+
await page.getByRole("button", { name: "Verify" }).click();
|
|
43
|
+
await expect(page.getByRole("heading", { level: 1, name: "Your account" })).toBeVisible();
|
|
44
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Seeds (or re-seeds) one verified email + password user into the LOCAL D1 database,
|
|
3
|
+
// for trying sign-in by hand and for the Playwright suite:
|
|
4
|
+
//
|
|
5
|
+
// pnpm db:migrate && pnpm db:seed [--email a@b.test] [--password '...'] [--name 'Ada'] [--role admin]
|
|
6
|
+
// [--phone +15550100123] [--totp-secret BASE32 --auth-secret <AUTH_SECRET>] [--persist-to <dir>]
|
|
7
|
+
//
|
|
8
|
+
// The password is hashed with Better Auth's own hasher, so the account is exactly what a
|
|
9
|
+
// sign-up would have created. Local only: it never touches a remote database. The first
|
|
10
|
+
// administrator of a real installation comes from `pnpm auth:bootstrap-admin`.
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { stdout } from "node:process";
|
|
13
|
+
import { parseArgs } from "node:util";
|
|
14
|
+
import { createId } from "@awsmbiz/inbis-core";
|
|
15
|
+
import { hashPassword, symmetricEncrypt } from "better-auth/crypto";
|
|
16
|
+
|
|
17
|
+
const { values } = parseArgs({
|
|
18
|
+
options: {
|
|
19
|
+
email: { type: "string", default: "ada@example.test" },
|
|
20
|
+
password: { type: "string", default: "correct horse battery" },
|
|
21
|
+
name: { type: "string", default: "Ada Lovelace" },
|
|
22
|
+
"persist-to": { type: "string" },
|
|
23
|
+
// The coarse platform role: "user" (default) or "admin".
|
|
24
|
+
role: { type: "string", default: "user" },
|
|
25
|
+
// A verified phone number (E.164), so the user can also sign in by SMS code.
|
|
26
|
+
phone: { type: "string" },
|
|
27
|
+
// An enrolled authenticator with this base32 TOTP secret (2FA on). Needs --auth-secret.
|
|
28
|
+
"totp-secret": { type: "string" },
|
|
29
|
+
// The Worker's AUTH_SECRET, which encrypts the stored TOTP secret.
|
|
30
|
+
"auth-secret": { type: "string" },
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
if (values.role !== "user" && values.role !== "admin") throw new Error('--role must be "user" or "admin".');
|
|
34
|
+
if (values["totp-secret"] !== undefined && values["auth-secret"] === undefined) {
|
|
35
|
+
throw new Error("--totp-secret needs --auth-secret (the Worker's AUTH_SECRET).");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const quote = (value) => `'${String(value).replaceAll("'", "''")}'`;
|
|
39
|
+
const userId = createId();
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const hash = await hashPassword(values.password);
|
|
42
|
+
const totp = values["totp-secret"];
|
|
43
|
+
const phone = values.phone === undefined ? "NULL" : quote(values.phone);
|
|
44
|
+
|
|
45
|
+
const sql = [
|
|
46
|
+
// Cascades to the user's accounts, sessions and authenticator.
|
|
47
|
+
`DELETE FROM user WHERE email = ${quote(values.email)};`,
|
|
48
|
+
`INSERT INTO user (id, name, email, email_verified, role, phone_number, phone_number_verified, two_factor_enabled, created_at, updated_at) VALUES (${quote(userId)}, ${quote(values.name)}, ${quote(values.email)}, 1, ${quote(values.role)}, ${phone}, ${values.phone === undefined ? 0 : 1}, ${totp === undefined ? 0 : 1}, ${now}, ${now});`,
|
|
49
|
+
`INSERT INTO account (id, account_id, provider_id, user_id, password, created_at, updated_at) VALUES (${quote(createId())}, ${quote(userId)}, 'credential', ${quote(userId)}, ${quote(hash)}, ${now}, ${now});`,
|
|
50
|
+
];
|
|
51
|
+
if (totp !== undefined) {
|
|
52
|
+
// Better Auth stores imported secrets as `base32:<secret>`, encrypted with the auth secret.
|
|
53
|
+
const key = values["auth-secret"];
|
|
54
|
+
const secret = await symmetricEncrypt({ key, data: `base32:${totp}` });
|
|
55
|
+
const backupCodes = await symmetricEncrypt({ key, data: JSON.stringify([]) });
|
|
56
|
+
sql.push(
|
|
57
|
+
`INSERT INTO two_factor (id, secret, backup_codes, user_id, verified) VALUES (${quote(createId())}, ${quote(secret)}, ${quote(backupCodes)}, ${quote(userId)}, 1);`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const args = ["d1", "execute", "AUTH_DB", "--local", "--command", sql.join("\n")];
|
|
62
|
+
if (values["persist-to"] !== undefined) args.push("--persist-to", values["persist-to"]);
|
|
63
|
+
execFileSync("wrangler", args, { stdio: ["ignore", "ignore", "inherit"] });
|
|
64
|
+
stdout.write(`Seeded ${values.email} (local D1).\n`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RegistrationMode } from "@awsmbiz/inbis-auth";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The sign-in choices made when this app was created (create-inbis-app). They are
|
|
5
|
+
* yours to change: src/auth-config.ts turns them into the auth config, and the
|
|
6
|
+
* tests read them to know which journeys apply.
|
|
7
|
+
*/
|
|
8
|
+
export interface AuthChoices {
|
|
9
|
+
/** Email + password sign-in (and password reset, email verification). */
|
|
10
|
+
emailPassword: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Sign-in with an SMS code, or `false`. Countries are calling-code prefixes
|
|
13
|
+
* (`"+44"`, `"+1876"`): absent `allowedCountries` means every country;
|
|
14
|
+
* `blockedCountries` wins over it. There is no platform default.
|
|
15
|
+
*/
|
|
16
|
+
phoneOtp: false | { allowedCountries?: readonly string[]; blockedCountries: readonly string[] };
|
|
17
|
+
/** Two-factor authentication with an authenticator app (password sign-ins). Administrators need it. */
|
|
18
|
+
twoFactor: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Who may create an account: `open` (anyone signs up), `invite-only`
|
|
21
|
+
* (administrators create accounts or invite), `admin-only` (administrators
|
|
22
|
+
* create accounts; invitations are refused), `closed` (no new accounts but
|
|
23
|
+
* the first administrator).
|
|
24
|
+
*/
|
|
25
|
+
registration: RegistrationMode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const AUTH_CHOICES: AuthChoices = {
|
|
29
|
+
emailPassword: true,
|
|
30
|
+
phoneOtp: false,
|
|
31
|
+
twoFactor: true,
|
|
32
|
+
registration: "invite-only",
|
|
33
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { defineAuthConfig, type InbisAuthConfig } from "@awsmbiz/inbis-auth";
|
|
2
|
+
import { AUTH_CHOICES, type AuthChoices } from "./auth-choices.js";
|
|
3
|
+
import { localTestMode } from "./local-test-mode.js";
|
|
4
|
+
|
|
5
|
+
/** The Worker variables the auth config reads (they differ per environment). */
|
|
6
|
+
export type AuthConfigVars = Pick<Env, "AUTH_BASE_URL" | "TRUSTED_ORIGINS" | "TURNSTILE_SITE_KEY" | "EMAIL_FROM" | "LOCAL_TEST_MODE">;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Raised auth rate limits for the Playwright suite only (`LOCAL_TEST_MODE`, local hosts).
|
|
10
|
+
* Better Auth's count for a route starts over only after a whole window with no request,
|
|
11
|
+
* so over a suite run every page load adds up; deployed Workers keep the defaults.
|
|
12
|
+
*/
|
|
13
|
+
const LOCAL_TEST_RATE_LIMIT = {
|
|
14
|
+
default: { window: 60, max: 5000 },
|
|
15
|
+
signIn: { window: 60, max: 500 },
|
|
16
|
+
passwordReset: { window: 60, max: 100 },
|
|
17
|
+
otp: { window: 60, max: 100 },
|
|
18
|
+
twoFactor: { window: 60, max: 500 },
|
|
19
|
+
};
|
|
20
|
+
const LOCAL_TEST_PHONE_LIMITS = {
|
|
21
|
+
perNumberLimit: { window: 3600, max: 50 },
|
|
22
|
+
perIpLimit: { window: 3600, max: 200 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The app's auth config (`@awsmbiz/inbis-auth`): the sign-in choices from
|
|
27
|
+
* src/auth-choices.ts, plus what varies per environment from Worker variables.
|
|
28
|
+
* Everything else is the platform's defaults; see the package README for every option.
|
|
29
|
+
*/
|
|
30
|
+
export function appAuthConfig(vars: AuthConfigVars, choices: AuthChoices = AUTH_CHOICES): InbisAuthConfig {
|
|
31
|
+
const testing = localTestMode(vars);
|
|
32
|
+
const phoneOtp = choices.phoneOtp;
|
|
33
|
+
return defineAuthConfig({
|
|
34
|
+
app: {
|
|
35
|
+
name: "React Hono App",
|
|
36
|
+
baseURL: vars.AUTH_BASE_URL,
|
|
37
|
+
trustedOrigins: vars.TRUSTED_ORIGINS.split(",")
|
|
38
|
+
.map((origin) => origin.trim())
|
|
39
|
+
.filter((origin) => origin !== ""),
|
|
40
|
+
},
|
|
41
|
+
registration: { mode: choices.registration },
|
|
42
|
+
methods: {
|
|
43
|
+
emailPassword: choices.emailPassword ? {} : false,
|
|
44
|
+
phoneOtp: phoneOtp === false ? false : { ...phoneOtp, ...(testing ? LOCAL_TEST_PHONE_LIMITS : {}) },
|
|
45
|
+
},
|
|
46
|
+
twoFactor: choices.twoFactor ? {} : false,
|
|
47
|
+
// Verification, password reset and email invitations (sent through src/notifications.ts).
|
|
48
|
+
...(choices.emailPassword ? { emails: { from: vars.EMAIL_FROM } } : {}),
|
|
49
|
+
security: {
|
|
50
|
+
// Turnstile before sign-in, sign-up, password reset and phone code requests.
|
|
51
|
+
captcha: { provider: "cloudflare-turnstile", siteKey: vars.TURNSTILE_SITE_KEY },
|
|
52
|
+
...(testing ? { rateLimit: LOCAL_TEST_RATE_LIMIT } : {}),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|