@podosoft/podokit 1.6.1 → 1.6.3
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/deploy-checks.d.ts +16 -5
- package/dist/deploy-checks.js +14 -6
- package/dist/deploy-render.js +28 -3
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +83 -17
- package/dist/index.js +9 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -1
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +3 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +24 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +609 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +178 -42
- package/dist/templates/modules/audit-log/module.manifest.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
- package/dist/templates/modules/auth/module.manifest.json +1 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +208 -62
- package/dist/update.d.ts +25 -5
- package/dist/update.js +86 -10
- package/package.json +1 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
|
-
import {
|
|
2
|
+
import { skipUnlessMailReachesMailpit, waitForLink, waitForOtp } from "../helpers/mailpit";
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
5
|
const origin = { origin: base };
|
|
6
6
|
|
|
7
7
|
test("email one-time code signs a user in", async ({ playwright }) => {
|
|
8
|
-
|
|
8
|
+
await skipUnlessMailReachesMailpit();
|
|
9
9
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
10
10
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
11
11
|
test.skip(!caps?.emailOtp, "email OTP not enabled");
|
|
@@ -49,7 +49,7 @@ test("organization invitation can be sent and accepted", async ({ playwright })
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
test("magic link signs a user in", async ({ playwright }) => {
|
|
52
|
-
|
|
52
|
+
await skipUnlessMailReachesMailpit();
|
|
53
53
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
54
54
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
55
55
|
test.skip(!caps?.magicLink, "magic link not enabled");
|
|
@@ -74,7 +74,7 @@ function tokenFromRedirect(location: string): string | null {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
test("password reset delivers an email and updates the password @smoke", async ({ playwright }) => {
|
|
77
|
-
|
|
77
|
+
await skipUnlessMailReachesMailpit();
|
|
78
78
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
79
79
|
const email = `reset-${Date.now()}@example.com`;
|
|
80
80
|
const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Reset" } });
|
|
@@ -99,7 +99,7 @@ test("password reset delivers an email and updates the password @smoke", async (
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
test("email verification blocks sign-in until the address is confirmed", async ({ playwright }) => {
|
|
102
|
-
|
|
102
|
+
await skipUnlessMailReachesMailpit();
|
|
103
103
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
104
104
|
const email = `verify-${Date.now()}@example.com`;
|
|
105
105
|
const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Verify", callbackURL: `${base}/admin` } });
|
|
@@ -1,33 +1,82 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { request, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// Helpers for the local Mailpit catcher (docker-compose / CI service). Each test
|
|
3
4
|
// mails an address of its own and reads the newest message to it; the inbox is
|
|
4
5
|
// shared with tests on other workers, so no test empties it.
|
|
5
6
|
const MAILPIT = process.env.MAILPIT_URL ?? "http://localhost:8025";
|
|
7
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
const PROBE_WAIT_MS = 5000;
|
|
9
|
+
|
|
10
|
+
type MailpitList = { messages: Array<{ ID: string; To: Array<{ Address: string }> }> };
|
|
11
|
+
type MailpitMessage = { Text?: string; HTML?: string };
|
|
6
12
|
|
|
7
|
-
|
|
13
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
|
|
15
|
+
async function newestMessageTo(to: string): Promise<string | undefined> {
|
|
16
|
+
const list = (await (await fetch(`${MAILPIT}/api/v1/messages`)).json()) as MailpitList;
|
|
17
|
+
return list.messages?.find((m) => m.To?.some((t) => t.Address === to))?.ID;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A Mailpit answering on MAILPIT_URL may belong to another stack while this app
|
|
21
|
+
// sends no mail or sends it elsewhere, so ask the app to mail an address of the
|
|
22
|
+
// probe's own and wait for that message. Returns why email tests cannot run, or
|
|
23
|
+
// null when the message arrives.
|
|
24
|
+
async function mailDeliveryProblem(): Promise<string | null> {
|
|
8
25
|
try {
|
|
9
|
-
|
|
26
|
+
if (!(await fetch(`${MAILPIT}/readyz`)).ok) return `Mailpit at ${MAILPIT} is not ready`;
|
|
10
27
|
} catch {
|
|
11
|
-
return
|
|
28
|
+
return `no Mailpit answers at ${MAILPIT}`;
|
|
12
29
|
}
|
|
30
|
+
const email = `mail-probe-${Date.now()}-${process.pid}@example.com`;
|
|
31
|
+
const app = await request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
|
|
32
|
+
try {
|
|
33
|
+
const signUp = await app.post("/api/auth/sign-up/email", {
|
|
34
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "Mail probe" },
|
|
35
|
+
});
|
|
36
|
+
if (!signUp.ok()) return `the mail probe could not sign up (HTTP ${signUp.status()})`;
|
|
37
|
+
const reset = await app.post("/api/auth/request-password-reset", {
|
|
38
|
+
data: { email, redirectTo: `${base}/reset-password` },
|
|
39
|
+
});
|
|
40
|
+
if (!reset.ok()) return `the app could not send the probe email (HTTP ${reset.status()})`;
|
|
41
|
+
} finally {
|
|
42
|
+
await app.dispose();
|
|
43
|
+
}
|
|
44
|
+
const deadline = Date.now() + PROBE_WAIT_MS;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
if (await newestMessageTo(email)) return null;
|
|
47
|
+
await sleep(250);
|
|
48
|
+
}
|
|
49
|
+
return `Mailpit at ${MAILPIT} does not receive this app's mail: a probe email to ${email} did not arrive within ${PROBE_WAIT_MS / 1000} s`;
|
|
13
50
|
}
|
|
14
51
|
|
|
15
|
-
|
|
16
|
-
|
|
52
|
+
let delivery: Promise<string | null> | undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Skip the current test unless the app under test delivers its mail to the
|
|
56
|
+
* Mailpit these helpers read. The probe runs once per worker. With
|
|
57
|
+
* E2E_REQUIRE_MAIL=true, which a harness that wires the app's SMTP to that
|
|
58
|
+
* Mailpit sets, a failed probe fails the test instead.
|
|
59
|
+
*/
|
|
60
|
+
export async function skipUnlessMailReachesMailpit(): Promise<void> {
|
|
61
|
+
delivery ??= mailDeliveryProblem().catch((error: unknown) => `the mail probe failed: ${String(error)}`);
|
|
62
|
+
const problem = await delivery;
|
|
63
|
+
if (problem === null) return;
|
|
64
|
+
if (process.env.E2E_REQUIRE_MAIL === "true") throw new Error(`E2E_REQUIRE_MAIL is set, but ${problem}`);
|
|
65
|
+
test.skip(true, problem);
|
|
66
|
+
}
|
|
17
67
|
|
|
18
68
|
// Poll for the newest message addressed to `to` and return the first match of
|
|
19
69
|
// `pattern` in its text/HTML body.
|
|
20
70
|
async function waitForMatch(to: string, pattern: RegExp, label: string, timeoutMs: number): Promise<string> {
|
|
21
71
|
const deadline = Date.now() + timeoutMs;
|
|
22
72
|
while (Date.now() < deadline) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const full = (await (await fetch(`${MAILPIT}/api/v1/message/${msg.ID}`)).json()) as MailpitMessage;
|
|
73
|
+
const id = await newestMessageTo(to);
|
|
74
|
+
if (id) {
|
|
75
|
+
const full = (await (await fetch(`${MAILPIT}/api/v1/message/${id}`)).json()) as MailpitMessage;
|
|
27
76
|
const match = `${full.Text ?? ""} ${full.HTML ?? ""}`.match(pattern);
|
|
28
77
|
if (match) return match[0];
|
|
29
78
|
}
|
|
30
|
-
await
|
|
79
|
+
await sleep(300);
|
|
31
80
|
}
|
|
32
81
|
throw new Error(`no email with ${label} to ${to} within ${timeoutMs}ms`);
|
|
33
82
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
|
-
import {
|
|
3
|
+
import { skipUnlessMailReachesMailpit, waitForLink } from "../helpers/mailpit";
|
|
4
4
|
|
|
5
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ test("verification badges stay hidden when the feature is off", async ({ page })
|
|
|
17
17
|
|
|
18
18
|
// Admin can trigger a password-reset email for a user (not verification-gated).
|
|
19
19
|
test("admin sends a password reset email to a user", async ({ page, disposableUsers }) => {
|
|
20
|
-
|
|
20
|
+
await skipUnlessMailReachesMailpit();
|
|
21
21
|
await ready(page, "/admin/users");
|
|
22
22
|
const email = `admin-reset-${Date.now()}@example.com`;
|
|
23
23
|
await disposableUsers.create({ email, name: "AR" });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { anonState } from "../helpers/accounts";
|
|
3
3
|
import { ready } from "../helpers/hydration";
|
|
4
|
-
import {
|
|
4
|
+
import { skipUnlessMailReachesMailpit, waitForLink } from "../helpers/mailpit";
|
|
5
5
|
|
|
6
6
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
7
7
|
const origin = { origin: base };
|
|
@@ -10,7 +10,7 @@ const origin = { origin: base };
|
|
|
10
10
|
test.use({ storageState: anonState });
|
|
11
11
|
|
|
12
12
|
test("forgot password: request, open the emailed link, set a new password, sign in", async ({ page, playwright }) => {
|
|
13
|
-
|
|
13
|
+
await skipUnlessMailReachesMailpit();
|
|
14
14
|
const email = `ui-reset-${Date.now()}@example.com`;
|
|
15
15
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
16
16
|
const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "UIReset" } });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { anonState } from "../helpers/accounts";
|
|
3
3
|
import { ready } from "../helpers/hydration";
|
|
4
|
-
import {
|
|
4
|
+
import { skipUnlessMailReachesMailpit, waitForOtp } from "../helpers/mailpit";
|
|
5
5
|
|
|
6
6
|
test.use({ storageState: anonState });
|
|
7
7
|
|
|
@@ -24,7 +24,7 @@ test("login: an emailed one-time code signs a user in", async ({ page, playwrigh
|
|
|
24
24
|
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
25
25
|
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
26
26
|
test.skip(!caps?.emailOtp, "email otp disabled");
|
|
27
|
-
|
|
27
|
+
await skipUnlessMailReachesMailpit();
|
|
28
28
|
|
|
29
29
|
const email = `ui-otp-${Date.now()}@example.com`;
|
|
30
30
|
await api.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "OTP" } });
|
|
@@ -57,8 +57,8 @@ test("settings: open and save the SMTP dialog", { tag: "@exclusive" }, async ({
|
|
|
57
57
|
await page.getByRole("button", { name: "SMTP settings" }).click();
|
|
58
58
|
const dialog = page.getByRole("dialog");
|
|
59
59
|
try {
|
|
60
|
-
//
|
|
61
|
-
//
|
|
60
|
+
// SMTP stays switched off, so saving these values can't re-route mail for the
|
|
61
|
+
// other email specs: the mailer keeps the environment's transport.
|
|
62
62
|
await dialog.locator("#smtp-host").fill("localhost");
|
|
63
63
|
await dialog.locator("#smtp-port").fill("1125");
|
|
64
64
|
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
|
@@ -10,11 +10,11 @@ async function chooseTheme(page: Page, mode: "Light" | "Dark" | "System"): Promi
|
|
|
10
10
|
// mode-watcher marks <html> with `.dark` or `.light`, so component libraries
|
|
11
11
|
// that follow either class, and fall back to the OS setting otherwise, match the
|
|
12
12
|
// theme the visitor chose rather than the system one. The managed ThemeToggle
|
|
13
|
-
// renders on the page app-paths.ts names as
|
|
13
|
+
// renders on the page app-paths.ts names as themeToggle.
|
|
14
14
|
test("the chosen theme is named on <html> in both modes", async ({ page }) => {
|
|
15
|
-
test.skip(paths.
|
|
15
|
+
test.skip(paths.themeToggle === null, "no page renders the theme toggle (tests/helpers/app-paths.ts)");
|
|
16
16
|
await page.emulateMedia({ colorScheme: "dark" });
|
|
17
|
-
await ready(page, paths.
|
|
17
|
+
await ready(page, paths.themeToggle ?? "/");
|
|
18
18
|
const html = page.locator("html");
|
|
19
19
|
|
|
20
20
|
await chooseTheme(page, "Light");
|
|
@@ -8,10 +8,26 @@ actor / action / target model. Every entry flows through one pipeline
|
|
|
8
8
|
|
|
9
9
|
Nothing is recorded automatically except the built-in **auth/admin actions**
|
|
10
10
|
(login, user create/update/delete, ban, role change, session revoke, ...),
|
|
11
|
-
which the
|
|
11
|
+
which the Better Auth plugin in `audit-hook.ts` maps to semantic action codes like
|
|
12
12
|
`user.create` and `auth.login`. Everything else is **opt-in** — you decide what
|
|
13
13
|
matters.
|
|
14
14
|
|
|
15
|
+
The plugin records an action only when the request succeeded, so a wrong
|
|
16
|
+
password or a forbidden admin call leaves no entry. Every sign-in method records
|
|
17
|
+
`auth.login` with the method in `metadata.method` (`password`, `email-otp`,
|
|
18
|
+
`magic-link`, `passkey`, `two-factor-totp`, ...) and, for social sign-in, the
|
|
19
|
+
provider in `metadata.provider`. A sign-in through an endpoint of a plugin the
|
|
20
|
+
app adds, such as `@better-auth/electron`'s `/electron/token`, is recorded too,
|
|
21
|
+
with the endpoint's path as the method: any request that ends with a new session
|
|
22
|
+
it did not come with is a sign-in, except the endpoints in `NOT_SIGN_INS`
|
|
23
|
+
(sign-up, impersonation, switching between a browser's sessions, and requests
|
|
24
|
+
that replace the signed-in user's own session). Session reads and updates that
|
|
25
|
+
refresh the request's own session, including bearer and API key requests, are
|
|
26
|
+
not. A two-factor sign-in is recorded when the second factor completes it, not
|
|
27
|
+
at the password step. `auth.ts` pushes the plugin after every other plugin:
|
|
28
|
+
Better Auth runs after hooks in plugin order, and two-factor changes the outcome
|
|
29
|
+
of a password sign-in in its own after hook.
|
|
30
|
+
|
|
15
31
|
## Audit your own Elysia routes
|
|
16
32
|
|
|
17
33
|
```ts
|
|
@@ -86,7 +102,13 @@ survives renames/deletes), `targetType/targetId/targetLabel`, `ip`, `metadata`
|
|
|
86
102
|
|
|
87
103
|
## Customize
|
|
88
104
|
|
|
89
|
-
- **Auth actions:**
|
|
105
|
+
- **Auth actions:** `ACTIONS` in `audit-hook.ts` maps Better Auth endpoints to
|
|
106
|
+
action codes, `SIGN_IN_METHODS` names the method of Better Auth's sign-in
|
|
107
|
+
endpoints, and `NOT_SIGN_INS` lists the endpoints that start or swap a session
|
|
108
|
+
without a sign-in. The `audit-hook.spec.ts` test fails when Better Auth or a
|
|
109
|
+
plugin PodoKit configures gains an endpoint that it has not classified; the
|
|
110
|
+
endpoints of plugins the app adds are recorded as sign-ins when they start a
|
|
111
|
+
session, without a change to these lists.
|
|
90
112
|
- **Your routes:** call `AuditService.recordRequest()` after successful operations;
|
|
91
113
|
pass `{ defer: true }` only for high-volume, non-security events.
|
|
92
114
|
- **Schema:** add columns with a migration, and keep the
|