@podosoft/podokit 0.13.0 → 0.15.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 +3 -0
- package/dist/add.d.ts +27 -1
- package/dist/add.js +3 -1
- package/dist/index.js +6 -3
- package/dist/lib.d.ts +1 -1
- package/dist/lockfile.d.ts +4 -1
- package/dist/lockfile.js +6 -3
- package/dist/module-migrations.d.ts +25 -0
- package/dist/module-migrations.js +233 -0
- package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +3 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +3 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/input/input.svelte +3 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +5 -0
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +2 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +1 -0
- package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +6 -4
- package/dist/templates/fullstack-nest-svelte/tests/playwright.extensions.ts +44 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image-upload.interceptor.ts +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.controller.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.module.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +43 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +110 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.spec.ts +79 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +35 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +155 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +33 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +13 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +13 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +78 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/+layout.server.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/+layout.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.svelte +18 -10
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +2 -47
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +4 -0
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +11 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +18 -8
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +96 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +15 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +83 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +13 -10
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +69 -3
- package/dist/templates/modules/admin-dashboard/files/tests/seed.teardown.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +16 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +13 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +3 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +4 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +4 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +34 -28
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +29 -39
- package/dist/templates/modules/admin-dashboard/module.manifest.json +74 -11
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +5 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.spec.ts +42 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.ts +27 -0
- package/dist/templates/modules/auth/module.manifest.json +1 -1
- package/dist/templates/todo/Dockerfile.dev +3 -1
- package/dist/templates/todo/apps/api/package.json +1 -1
- package/dist/templates/todo/apps/web/package.json +1 -1
- package/dist/templates/todo/compose.dev.yaml +6 -0
- package/dist/templates/todo/tests/playwright.config.ts +6 -4
- package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
- package/dist/update.d.ts +8 -1
- package/dist/update.js +85 -25
- package/package.json +1 -1
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/{app-sidebar.svelte → admin-sidebar.svelte} +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.server.ts +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/general-settings.svelte +0 -0
- /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.server.ts +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect, test } from "
|
|
1
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ADMIN, USER } from "../helpers/accounts";
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
@@ -13,7 +13,7 @@ async function signedIn(
|
|
|
13
13
|
return ctx;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
test("audit log records auth/admin actions", async ({ playwright }) => {
|
|
16
|
+
test("audit log records auth/admin actions", async ({ playwright, disposableUsers }) => {
|
|
17
17
|
const admin = await signedIn(playwright, ADMIN);
|
|
18
18
|
const probe = await admin.get("/api/audit-logs");
|
|
19
19
|
test.skip(probe.status() === 404, "audit-log module not installed");
|
|
@@ -24,7 +24,7 @@ test("audit log records auth/admin actions", async ({ playwright }) => {
|
|
|
24
24
|
data: { email, password: "Podokit3e-Str0ng!pw", name: "Audit", role: "user" },
|
|
25
25
|
});
|
|
26
26
|
expect(created.ok()).toBeTruthy();
|
|
27
|
-
const userId =
|
|
27
|
+
const userId = await disposableUsers.trackResponse(created);
|
|
28
28
|
|
|
29
29
|
const res = await admin.get("/api/audit-logs");
|
|
30
30
|
expect(res.ok()).toBeTruthy();
|
|
@@ -34,6 +34,7 @@ test("audit log records auth/admin actions", async ({ playwright }) => {
|
|
|
34
34
|
expect(entry?.actorEmail, "audit entry records the acting admin").toBe(ADMIN.email);
|
|
35
35
|
|
|
36
36
|
await admin.post("/api/auth/admin/remove-user", { data: { userId } });
|
|
37
|
+
disposableUsers.forget(userId);
|
|
37
38
|
await admin.dispose();
|
|
38
39
|
});
|
|
39
40
|
|
|
@@ -47,11 +48,14 @@ test("the audit log is admin-only", async ({ playwright }) => {
|
|
|
47
48
|
await user.dispose();
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
test("sending a verification email is audited", async ({ playwright }) => {
|
|
51
|
+
test("sending a verification email is audited", async ({ playwright, disposableUsers }) => {
|
|
51
52
|
const admin = await signedIn(playwright, ADMIN);
|
|
52
53
|
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
53
54
|
const email = `verif-audit-${Date.now()}@example.com`;
|
|
54
|
-
await admin.post("/api/auth/admin/create-user", {
|
|
55
|
+
const created = await admin.post("/api/auth/admin/create-user", {
|
|
56
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "VA", role: "user" },
|
|
57
|
+
});
|
|
58
|
+
await disposableUsers.trackResponse(created);
|
|
55
59
|
await admin.post("/api/auth/send-verification-email", { data: { email, callbackURL: `${base}/admin` } });
|
|
56
60
|
const entries = (await (await admin.get("/api/audit-logs")).json()) as Array<{ action: string; targetLabel: string | null }>;
|
|
57
61
|
expect(entries.some((e) => e.action === "auth.verification_sent"), "verification send should be audited").toBeTruthy();
|
|
@@ -60,7 +64,7 @@ test("sending a verification email is audited", async ({ playwright }) => {
|
|
|
60
64
|
|
|
61
65
|
// Audit logging is a DB-backed on/off toggle (auth_config `server`, applied live).
|
|
62
66
|
// Runs after the recording tests and restores the flag so nothing else is affected.
|
|
63
|
-
test("audit logging honours the DB on/off toggle @smoke", async ({ playwright }) => {
|
|
67
|
+
test("audit logging honours the DB on/off toggle @smoke", async ({ playwright, disposableUsers }) => {
|
|
64
68
|
const admin = await signedIn(playwright, ADMIN);
|
|
65
69
|
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
66
70
|
const auditLog = async () => (await (await admin.get("/api/account/capabilities")).json()).auditLog as boolean;
|
|
@@ -68,8 +72,12 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright })
|
|
|
68
72
|
((await (await admin.get("/api/audit-logs")).json()) as Array<{ action: string; targetLabel: string | null }>).some(
|
|
69
73
|
(e) => e.action === "user.create" && e.targetLabel === label,
|
|
70
74
|
);
|
|
71
|
-
const createUser = async (email: string) =>
|
|
72
|
-
|
|
75
|
+
const createUser = async (email: string): Promise<string> => {
|
|
76
|
+
const response = await admin.post("/api/auth/admin/create-user", {
|
|
77
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "T", role: "user" },
|
|
78
|
+
});
|
|
79
|
+
return disposableUsers.trackResponse(response);
|
|
80
|
+
};
|
|
73
81
|
|
|
74
82
|
try {
|
|
75
83
|
// OFF → the action is not recorded. Wait past the config-store cache TTL so the
|
|
@@ -81,6 +89,7 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright })
|
|
|
81
89
|
const offId = await createUser(offEmail);
|
|
82
90
|
expect(await recorded(offEmail), "no audit entry while disabled").toBe(false);
|
|
83
91
|
await admin.post("/api/auth/admin/remove-user", { data: { userId: offId } });
|
|
92
|
+
disposableUsers.forget(offId);
|
|
84
93
|
|
|
85
94
|
// ON → recording resumes.
|
|
86
95
|
await admin.put("/api/account/auth-config", { data: { server: { auditLog: true } } });
|
|
@@ -90,6 +99,7 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright })
|
|
|
90
99
|
const onId = await createUser(onEmail);
|
|
91
100
|
expect(await recorded(onEmail), "audit entry recorded once re-enabled").toBe(true);
|
|
92
101
|
await admin.post("/api/auth/admin/remove-user", { data: { userId: onId } });
|
|
102
|
+
disposableUsers.forget(onId);
|
|
93
103
|
} finally {
|
|
94
104
|
await admin.put("/api/account/auth-config", { data: { server: { auditLog: true } } });
|
|
95
105
|
await admin.dispose();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { ADMIN } from "../helpers/accounts";
|
|
3
|
+
|
|
4
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
|
+
const origin = { origin: base };
|
|
6
|
+
const PNG = Buffer.from(
|
|
7
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAADAQMAAACDJEzCAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCSUTzXBDTwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozNzoxOSswMDowMOuehLIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzc6MTkrMDA6MDCawzwOAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM3OjE5KzAwOjAwzdYd0QAAAAtJREFUCNdjYAABAAAGAAFm9MlsAAAAAElFTkSuQmCC",
|
|
8
|
+
"base64",
|
|
9
|
+
);
|
|
10
|
+
const TOO_WIDE_PNG = Buffer.from(
|
|
11
|
+
"iVBORw0KGgoAAAANSUhEUgAACAEAAAABAQMAAACfGLePAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCScoTk3I6QAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozOTo0MCswMDowMCgv/7YAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzk6NDArMDA6MDBZckcKAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM5OjQwKzAwOjAwDmdm1QAAAAxJREFUGNNjYBjpAAABAgABRf+HpwAAAABJRU5ErkJggg==",
|
|
12
|
+
"base64",
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
async function adminContext(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
|
|
16
|
+
const context = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
17
|
+
await context.post("/api/auth/sign-in/email", {
|
|
18
|
+
data: { email: ADMIN.email, password: ADMIN.password },
|
|
19
|
+
});
|
|
20
|
+
return context;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("profile image upload requires authentication", async ({ playwright }) => {
|
|
24
|
+
const anonymous = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
25
|
+
const response = await anonymous.post("/api/account/profile-image", {
|
|
26
|
+
multipart: { file: { name: "avatar.png", mimeType: "image/png", buffer: PNG } },
|
|
27
|
+
});
|
|
28
|
+
expect(response.status()).toBe(401);
|
|
29
|
+
await anonymous.dispose();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("profile image upload, replacement, public read, and removal", async ({ playwright }) => {
|
|
33
|
+
const admin = await adminContext(playwright);
|
|
34
|
+
await admin.delete("/api/account/profile-image");
|
|
35
|
+
|
|
36
|
+
const first = await admin.post("/api/account/profile-image", {
|
|
37
|
+
multipart: { file: { name: "avatar.png", mimeType: "image/png", buffer: PNG } },
|
|
38
|
+
});
|
|
39
|
+
expect(first.status()).toBe(201);
|
|
40
|
+
const firstBody = await first.json() as { image: string };
|
|
41
|
+
expect(firstBody.image).toMatch(/^\/api\/profile-images\/[0-9a-f-]+\.png$/);
|
|
42
|
+
|
|
43
|
+
const sessionAfterUpload = await (await admin.get("/api/auth/get-session")).json() as {
|
|
44
|
+
user: { image?: string | null };
|
|
45
|
+
};
|
|
46
|
+
expect(sessionAfterUpload.user.image).toBe(firstBody.image);
|
|
47
|
+
const firstImage = await admin.get(firstBody.image);
|
|
48
|
+
expect(firstImage.ok()).toBeTruthy();
|
|
49
|
+
expect(firstImage.headers()["content-type"]).toContain("image/png");
|
|
50
|
+
expect(firstImage.headers()["cache-control"]).toBe("public, max-age=31536000, immutable");
|
|
51
|
+
|
|
52
|
+
const replacement = await admin.post("/api/account/profile-image", {
|
|
53
|
+
multipart: { file: { name: "replacement.png", mimeType: "image/png", buffer: PNG } },
|
|
54
|
+
});
|
|
55
|
+
expect(replacement.status()).toBe(201);
|
|
56
|
+
const replacementBody = await replacement.json() as { image: string };
|
|
57
|
+
expect(replacementBody.image).not.toBe(firstBody.image);
|
|
58
|
+
expect((await admin.get(firstBody.image)).status()).toBe(404);
|
|
59
|
+
|
|
60
|
+
const removed = await admin.delete("/api/account/profile-image");
|
|
61
|
+
expect(await removed.json()).toEqual({ image: null });
|
|
62
|
+
expect((await admin.get(replacementBody.image)).status()).toBe(404);
|
|
63
|
+
const sessionAfterRemoval = await (await admin.get("/api/auth/get-session")).json() as {
|
|
64
|
+
user: { image?: string | null };
|
|
65
|
+
};
|
|
66
|
+
expect(sessionAfterRemoval.user.image).toBeNull();
|
|
67
|
+
await admin.dispose();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("profile image upload returns stable validation errors", async ({ playwright }) => {
|
|
71
|
+
const admin = await adminContext(playwright);
|
|
72
|
+
const invalidType = await admin.post("/api/account/profile-image", {
|
|
73
|
+
multipart: { file: { name: "avatar.png", mimeType: "image/png", buffer: Buffer.from("not an image") } },
|
|
74
|
+
});
|
|
75
|
+
expect(invalidType.status()).toBe(400);
|
|
76
|
+
expect((await invalidType.json()).error.code).toBe("PROFILE_IMAGE_TYPE_INVALID");
|
|
77
|
+
|
|
78
|
+
const tooWide = await admin.post("/api/account/profile-image", {
|
|
79
|
+
multipart: { file: { name: "wide.png", mimeType: "image/png", buffer: TOO_WIDE_PNG } },
|
|
80
|
+
});
|
|
81
|
+
expect(tooWide.status()).toBe(400);
|
|
82
|
+
expect((await tooWide.json()).error.code).toBe("PROFILE_IMAGE_DIMENSIONS_INVALID");
|
|
83
|
+
|
|
84
|
+
const tooLarge = await admin.post("/api/account/profile-image", {
|
|
85
|
+
multipart: {
|
|
86
|
+
file: {
|
|
87
|
+
name: "large.png",
|
|
88
|
+
mimeType: "image/png",
|
|
89
|
+
buffer: Buffer.alloc(2 * 1024 * 1024 + 1),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
expect(tooLarge.status()).toBe(413);
|
|
94
|
+
expect((await tooLarge.json()).error.code).toBe("PROFILE_IMAGE_TOO_LARGE");
|
|
95
|
+
await admin.dispose();
|
|
96
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect, test } from "
|
|
1
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ADMIN, USER } from "../helpers/accounts";
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
@@ -13,7 +13,7 @@ async function signedIn(
|
|
|
13
13
|
return ctx;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
test("admin can create a user, set its password, and remove it", async ({ playwright }) => {
|
|
16
|
+
test("admin can create a user, set its password, and remove it", async ({ playwright, disposableUsers }) => {
|
|
17
17
|
const admin = await signedIn(playwright, ADMIN);
|
|
18
18
|
const email = `api-crud-${Date.now()}@example.com`;
|
|
19
19
|
|
|
@@ -21,7 +21,7 @@ test("admin can create a user, set its password, and remove it", async ({ playwr
|
|
|
21
21
|
data: { email, password: "Podokit3e-Str0ng!pw", name: "API CRUD", role: "user" },
|
|
22
22
|
});
|
|
23
23
|
expect(created.ok()).toBeTruthy();
|
|
24
|
-
const id =
|
|
24
|
+
const id = await disposableUsers.trackResponse(created);
|
|
25
25
|
expect(id).toBeTruthy();
|
|
26
26
|
|
|
27
27
|
const setPw = await admin.post("/api/auth/admin/set-user-password", {
|
|
@@ -37,6 +37,7 @@ test("admin can create a user, set its password, and remove it", async ({ playwr
|
|
|
37
37
|
|
|
38
38
|
const removed = await admin.post("/api/auth/admin/remove-user", { data: { userId: id } });
|
|
39
39
|
expect(removed.ok()).toBeTruthy();
|
|
40
|
+
disposableUsers.forget(id);
|
|
40
41
|
await admin.dispose();
|
|
41
42
|
});
|
|
42
43
|
|
|
@@ -49,13 +50,13 @@ test("a normal user cannot create users (403)", async ({ playwright }) => {
|
|
|
49
50
|
await user.dispose();
|
|
50
51
|
});
|
|
51
52
|
|
|
52
|
-
test("admin can list and revoke a user's sessions", async ({ playwright }) => {
|
|
53
|
+
test("admin can list and revoke a user's sessions", async ({ playwright, disposableUsers }) => {
|
|
53
54
|
const admin = await signedIn(playwright, ADMIN);
|
|
54
55
|
const email = `api-sess-${Date.now()}@example.com`;
|
|
55
56
|
const created = await admin.post("/api/auth/admin/create-user", {
|
|
56
57
|
data: { email, password: "Podokit3e-Str0ng!pw", name: "API Sess", role: "user" },
|
|
57
58
|
});
|
|
58
|
-
const userId =
|
|
59
|
+
const userId = await disposableUsers.trackResponse(created);
|
|
59
60
|
|
|
60
61
|
// the user signs in -> creates a session
|
|
61
62
|
const theirs = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
@@ -69,14 +70,18 @@ test("admin can list and revoke a user's sessions", async ({ playwright }) => {
|
|
|
69
70
|
const revoked = await admin.post("/api/auth/admin/revoke-user-sessions", { data: { userId } });
|
|
70
71
|
expect(revoked.ok()).toBeTruthy();
|
|
71
72
|
await admin.post("/api/auth/admin/remove-user", { data: { userId } });
|
|
73
|
+
disposableUsers.forget(userId);
|
|
72
74
|
await admin.dispose();
|
|
73
75
|
});
|
|
74
76
|
|
|
75
|
-
test("revokeOtherSessions keeps the current session valid", async ({ playwright }) => {
|
|
77
|
+
test("revokeOtherSessions keeps the current session valid", async ({ playwright, disposableUsers }) => {
|
|
76
78
|
// Use a throwaway account so we don't revoke the shared USER's ui storageState.
|
|
77
79
|
const admin = await signedIn(playwright, ADMIN);
|
|
78
80
|
const email = `api-revoke-others-${Date.now()}@example.com`;
|
|
79
|
-
await admin.post("/api/auth/admin/create-user", {
|
|
81
|
+
const created = await admin.post("/api/auth/admin/create-user", {
|
|
82
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "RO", role: "user" },
|
|
83
|
+
});
|
|
84
|
+
await disposableUsers.trackResponse(created);
|
|
80
85
|
await admin.dispose();
|
|
81
86
|
|
|
82
87
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
@@ -109,13 +114,13 @@ test("account capabilities reports optional-feature flags", async ({ playwright
|
|
|
109
114
|
await ctx.dispose();
|
|
110
115
|
});
|
|
111
116
|
|
|
112
|
-
test("admin can update a user and impersonate them", async ({ playwright }) => {
|
|
117
|
+
test("admin can update a user and impersonate them", async ({ playwright, disposableUsers }) => {
|
|
113
118
|
const admin = await signedIn(playwright, ADMIN);
|
|
114
119
|
const email = `api-edit-${Date.now()}@example.com`;
|
|
115
120
|
const created = await admin.post("/api/auth/admin/create-user", {
|
|
116
121
|
data: { email, password: "Podokit3e-Str0ng!pw", name: "Edit Me", role: "user" },
|
|
117
122
|
});
|
|
118
|
-
const userId =
|
|
123
|
+
const userId = await disposableUsers.trackResponse(created);
|
|
119
124
|
|
|
120
125
|
const updated = await admin.post("/api/auth/admin/update-user", { data: { userId, data: { name: "Updated" } } });
|
|
121
126
|
expect(updated.ok()).toBeTruthy();
|
|
@@ -131,5 +136,6 @@ test("admin can update a user and impersonate them", async ({ playwright }) => {
|
|
|
131
136
|
await imp.dispose();
|
|
132
137
|
|
|
133
138
|
await admin.post("/api/auth/admin/remove-user", { data: { userId } });
|
|
139
|
+
disposableUsers.forget(userId);
|
|
134
140
|
await admin.dispose();
|
|
135
141
|
});
|
|
@@ -3,4 +3,5 @@ export const ADMIN = { name: "Admin", email: "admin@example.com", password: "Pod
|
|
|
3
3
|
export const USER = { name: "Normal User", email: "user@example.com", password: "Podokit3e-Str0ng!pw" };
|
|
4
4
|
export const adminState = "playwright/.auth/admin.json";
|
|
5
5
|
export const userState = "playwright/.auth/user.json";
|
|
6
|
+
export const userBaselineState = "playwright/.auth/user-baseline.json";
|
|
6
7
|
export const anonState = { cookies: [], origins: [] } as const;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
expect,
|
|
3
|
+
test as base,
|
|
4
|
+
type APIResponse,
|
|
5
|
+
} from "@playwright/test";
|
|
6
|
+
|
|
7
|
+
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
const origin = { origin: baseURL };
|
|
9
|
+
|
|
10
|
+
type CreateUserInput = {
|
|
11
|
+
email: string;
|
|
12
|
+
password?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
role?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DisposableUsers = {
|
|
18
|
+
create(input: CreateUserInput): Promise<string>;
|
|
19
|
+
trackResponse(response: APIResponse): Promise<string>;
|
|
20
|
+
track(userId: string): void;
|
|
21
|
+
forget(userId: string): void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function readCreatedUserId(response: APIResponse): Promise<string> {
|
|
25
|
+
expect(response.ok()).toBeTruthy();
|
|
26
|
+
const body = (await response.json()) as { user?: { id?: unknown } };
|
|
27
|
+
expect(typeof body.user?.id).toBe("string");
|
|
28
|
+
return body.user?.id as string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const test = base.extend<{ disposableUsers: DisposableUsers }>({
|
|
32
|
+
disposableUsers: async ({ context }, use) => {
|
|
33
|
+
// Reuse the browser context's storageState-backed request client. Creating
|
|
34
|
+
// a second admin login here can revoke the browser session in applications
|
|
35
|
+
// that enforce a single concurrent session.
|
|
36
|
+
const request = context.request;
|
|
37
|
+
const userIds = new Set<string>();
|
|
38
|
+
|
|
39
|
+
const disposableUsers: DisposableUsers = {
|
|
40
|
+
async create(input): Promise<string> {
|
|
41
|
+
const response = await request.post("/api/auth/admin/create-user", {
|
|
42
|
+
headers: origin,
|
|
43
|
+
data: {
|
|
44
|
+
password: "Podokit3e-Str0ng!pw",
|
|
45
|
+
name: "Throwaway",
|
|
46
|
+
role: "user",
|
|
47
|
+
...input,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const userId = await readCreatedUserId(response);
|
|
51
|
+
userIds.add(userId);
|
|
52
|
+
return userId;
|
|
53
|
+
},
|
|
54
|
+
async trackResponse(response): Promise<string> {
|
|
55
|
+
const userId = await readCreatedUserId(response);
|
|
56
|
+
userIds.add(userId);
|
|
57
|
+
return userId;
|
|
58
|
+
},
|
|
59
|
+
track(userId): void {
|
|
60
|
+
userIds.add(userId);
|
|
61
|
+
},
|
|
62
|
+
forget(userId): void {
|
|
63
|
+
userIds.delete(userId);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await use(disposableUsers);
|
|
69
|
+
} finally {
|
|
70
|
+
for (const userId of userIds) {
|
|
71
|
+
const response = await request.post("/api/auth/admin/remove-user", {
|
|
72
|
+
headers: origin,
|
|
73
|
+
data: { userId },
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok() && response.status() !== 404) {
|
|
76
|
+
throw new Error(`Failed to remove disposable user ${userId}: HTTP ${response.status()}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export { expect };
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
import { loadPlaywrightProjects } from "./playwright.extensions";
|
|
2
3
|
|
|
3
4
|
// admin-dashboard overlay: adds a `setup` project that seeds admin/user sessions
|
|
4
5
|
// (storageState) which the `ui` project reuses. Serial + single worker because
|
|
5
6
|
// tests share one backend/DB. Runs against a live stack on E2E_BASE_URL.
|
|
6
7
|
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
const coreProjects = [
|
|
9
|
+
{ name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
|
|
10
|
+
{ name: "setup", testMatch: /.*\.setup\.ts/, teardown: "cleanup" },
|
|
11
|
+
{ name: "cleanup", testMatch: /.*\.teardown\.ts/ },
|
|
12
|
+
{
|
|
13
|
+
name: "ui",
|
|
14
|
+
testMatch: /.*\.ui\.spec\.ts/,
|
|
15
|
+
dependencies: ["setup"],
|
|
16
|
+
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/admin.json" },
|
|
17
|
+
},
|
|
18
|
+
];
|
|
7
19
|
|
|
8
20
|
export default defineConfig({
|
|
9
21
|
testDir: ".",
|
|
@@ -13,14 +25,5 @@ export default defineConfig({
|
|
|
13
25
|
workers: 1,
|
|
14
26
|
reporter: [["list"], ["html", { open: "never" }]],
|
|
15
27
|
use: { baseURL, trace: "on-first-retry", screenshot: "only-on-failure" },
|
|
16
|
-
projects: [
|
|
17
|
-
{ name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
|
|
18
|
-
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
|
19
|
-
{
|
|
20
|
-
name: "ui",
|
|
21
|
-
testMatch: /.*\.ui\.spec\.ts/,
|
|
22
|
-
dependencies: ["setup"],
|
|
23
|
-
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/admin.json" },
|
|
24
|
-
},
|
|
25
|
-
],
|
|
28
|
+
projects: [...coreProjects, ...loadPlaywrightProjects(coreProjects)],
|
|
26
29
|
});
|
|
@@ -1,10 +1,46 @@
|
|
|
1
1
|
import { test as setup, expect } from "@playwright/test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
ADMIN,
|
|
6
|
+
USER,
|
|
7
|
+
adminState,
|
|
8
|
+
userState,
|
|
9
|
+
userBaselineState,
|
|
10
|
+
type Account,
|
|
11
|
+
} from "./helpers/accounts";
|
|
3
12
|
|
|
4
13
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
14
|
+
mkdirSync(dirname(adminState), { recursive: true });
|
|
15
|
+
|
|
16
|
+
type StorageState = Awaited<
|
|
17
|
+
ReturnType<import("@playwright/test").APIRequestContext["storageState"]>
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
function withTestLocale(state: StorageState): StorageState {
|
|
21
|
+
const url = new URL(base);
|
|
22
|
+
return {
|
|
23
|
+
...state,
|
|
24
|
+
cookies: [
|
|
25
|
+
...state.cookies.filter((cookie) => cookie.name !== "locale"),
|
|
26
|
+
{
|
|
27
|
+
name: "locale",
|
|
28
|
+
value: "en",
|
|
29
|
+
domain: url.hostname,
|
|
30
|
+
path: "/",
|
|
31
|
+
expires: -1,
|
|
32
|
+
httpOnly: false,
|
|
33
|
+
secure: url.protocol === "https:",
|
|
34
|
+
sameSite: "Lax",
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
5
39
|
|
|
6
40
|
// Seed a session via the API (reliable, no UI hydration races) and save its
|
|
7
|
-
// cookies as storageState for the browser `ui` project to reuse.
|
|
41
|
+
// cookies as storageState for the browser `ui` project to reuse. Pin the suite
|
|
42
|
+
// locale because UI locators use the generated English catalog and must not
|
|
43
|
+
// inherit an application's configured default language.
|
|
8
44
|
async function seedSession(
|
|
9
45
|
playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
|
|
10
46
|
account: Account,
|
|
@@ -26,7 +62,7 @@ async function seedSession(
|
|
|
26
62
|
if (!Array.isArray(sessions) || sessions.length <= 1) break;
|
|
27
63
|
expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
|
|
28
64
|
}
|
|
29
|
-
await ctx.storageState(
|
|
65
|
+
writeFileSync(path, `${JSON.stringify(withTestLocale(await ctx.storageState()), null, 2)}\n`);
|
|
30
66
|
await ctx.dispose();
|
|
31
67
|
}
|
|
32
68
|
|
|
@@ -48,3 +84,33 @@ setup("enable optional features", async ({ playwright }) => {
|
|
|
48
84
|
setup("seed user session", async ({ playwright }) => {
|
|
49
85
|
await seedSession(playwright, USER, userState);
|
|
50
86
|
});
|
|
87
|
+
|
|
88
|
+
setup("capture the user cleanup baseline", async ({ playwright }) => {
|
|
89
|
+
const ctx = await playwright.request.newContext({
|
|
90
|
+
baseURL: base,
|
|
91
|
+
extraHTTPHeaders: { origin: base },
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
const signIn = await ctx.post("/api/auth/sign-in/email", {
|
|
95
|
+
data: { email: ADMIN.email, password: ADMIN.password },
|
|
96
|
+
});
|
|
97
|
+
expect(signIn.ok(), "sign in before capturing the user baseline").toBeTruthy();
|
|
98
|
+
const ids: string[] = [];
|
|
99
|
+
const limit = 100;
|
|
100
|
+
for (let offset = 0; ; offset += limit) {
|
|
101
|
+
const response = await ctx.get(`/api/auth/admin/list-users?limit=${limit}&offset=${offset}`);
|
|
102
|
+
expect(response.ok(), "list users before the suite").toBeTruthy();
|
|
103
|
+
const body = (await response.json()) as { users?: Array<{ id?: unknown }> };
|
|
104
|
+
const page = body.users ?? [];
|
|
105
|
+
ids.push(
|
|
106
|
+
...page
|
|
107
|
+
.map((user) => user.id)
|
|
108
|
+
.filter((id): id is string => typeof id === "string"),
|
|
109
|
+
);
|
|
110
|
+
if (page.length < limit) break;
|
|
111
|
+
}
|
|
112
|
+
writeFileSync(userBaselineState, `${JSON.stringify(ids, null, 2)}\n`);
|
|
113
|
+
} finally {
|
|
114
|
+
await ctx.dispose();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { expect, test as cleanup } from "@playwright/test";
|
|
3
|
+
import { ADMIN, userBaselineState } from "./helpers/accounts";
|
|
4
|
+
|
|
5
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
|
+
|
|
7
|
+
type ListedUser = {
|
|
8
|
+
id: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
cleanup("remove users created by the suite", async ({ playwright }) => {
|
|
12
|
+
if (!existsSync(userBaselineState)) return;
|
|
13
|
+
const baseline = new Set(JSON.parse(readFileSync(userBaselineState, "utf8")) as string[]);
|
|
14
|
+
const ctx = await playwright.request.newContext({
|
|
15
|
+
baseURL: base,
|
|
16
|
+
extraHTTPHeaders: { origin: base },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const signIn = await ctx.post("/api/auth/sign-in/email", {
|
|
21
|
+
data: { email: ADMIN.email, password: ADMIN.password },
|
|
22
|
+
});
|
|
23
|
+
expect(signIn.ok(), "sign in before cleaning users").toBeTruthy();
|
|
24
|
+
|
|
25
|
+
const users: ListedUser[] = [];
|
|
26
|
+
const limit = 100;
|
|
27
|
+
for (let offset = 0; ; offset += limit) {
|
|
28
|
+
const response = await ctx.get(`/api/auth/admin/list-users?limit=${limit}&offset=${offset}`);
|
|
29
|
+
expect(response.ok(), "list users during cleanup").toBeTruthy();
|
|
30
|
+
const body = (await response.json()) as { users?: ListedUser[] };
|
|
31
|
+
const page = body.users ?? [];
|
|
32
|
+
users.push(...page);
|
|
33
|
+
if (page.length < limit) break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const user of users) {
|
|
37
|
+
if (baseline.has(user.id)) continue;
|
|
38
|
+
const response = await ctx.post("/api/auth/admin/remove-user", {
|
|
39
|
+
data: { userId: user.id },
|
|
40
|
+
});
|
|
41
|
+
expect(response.ok(), `remove disposable user ${user.id}`).toBeTruthy();
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
await ctx.dispose();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -2,6 +2,15 @@ import { expect, test } from "@playwright/test";
|
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
import { ADMIN } from "../helpers/accounts";
|
|
4
4
|
|
|
5
|
+
const PNG = Buffer.from(
|
|
6
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAADAQMAAACDJEzCAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCSUTzXBDTwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozNzoxOSswMDowMOuehLIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzc6MTkrMDA6MDCawzwOAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM3OjE5KzAwOjAwzdYd0QAAAAtJREFUCNdjYAABAAAGAAFm9MlsAAAAAElFTkSuQmCC",
|
|
7
|
+
"base64",
|
|
8
|
+
);
|
|
9
|
+
const TOO_WIDE_PNG = Buffer.from(
|
|
10
|
+
"iVBORw0KGgoAAAANSUhEUgAACAEAAAABAQMAAACfGLePAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCScoTk3I6QAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozOTo0MCswMDowMCgv/7YAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzk6NDArMDA6MDBZckcKAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM5OjQwKzAwOjAwDmdm1QAAAAxJREFUGNNjYBjpAAABAgABRf+HpwAAAABJRU5ErkJggg==",
|
|
11
|
+
"base64",
|
|
12
|
+
);
|
|
13
|
+
|
|
5
14
|
// admin storageState (project default)
|
|
6
15
|
test("account opens on the profile section @smoke", async ({ page }) => {
|
|
7
16
|
await ready(page, "/admin/account");
|
|
@@ -14,6 +23,60 @@ test("account opens on the profile section @smoke", async ({ page }) => {
|
|
|
14
23
|
await expect(page.getByRole("button", { name: "Save changes" })).toBeEnabled();
|
|
15
24
|
});
|
|
16
25
|
|
|
26
|
+
test("protected app routes do not inherit the admin shell @smoke", async ({ page }) => {
|
|
27
|
+
await ready(page, "/accept-invitation/not-a-real-invitation");
|
|
28
|
+
await expect(page.getByText("Organization invitation", { exact: true })).toBeVisible();
|
|
29
|
+
await expect(page.getByTestId("sidebar-brand")).toHaveCount(0);
|
|
30
|
+
|
|
31
|
+
await ready(page, "/admin");
|
|
32
|
+
await expect(page.getByTestId("sidebar-brand")).toBeVisible();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("account displays profile image limits and updates every avatar", async ({ page }) => {
|
|
36
|
+
await ready(page, "/account");
|
|
37
|
+
await page.request.delete("/api/account/profile-image");
|
|
38
|
+
await ready(page, "/account");
|
|
39
|
+
await expect(page.getByTestId("profile-image-hint")).toContainText(/PNG, JPEG, (?:or )?WebP/);
|
|
40
|
+
await expect(page.getByTestId("profile-image-hint")).toContainText(/2 ?MB/);
|
|
41
|
+
await expect(page.getByTestId("profile-image-hint")).toContainText(/2048 × 2048 ?px/);
|
|
42
|
+
|
|
43
|
+
await page.locator("#profile-image").setInputFiles({
|
|
44
|
+
name: "avatar.png",
|
|
45
|
+
mimeType: "image/png",
|
|
46
|
+
buffer: PNG,
|
|
47
|
+
});
|
|
48
|
+
await expect(page.getByText(/Profile image updated|프로필 이미지를 변경했습니다/)).toBeVisible();
|
|
49
|
+
await expect(page.getByTestId("profile-image-preview").locator("img")).toBeVisible();
|
|
50
|
+
|
|
51
|
+
await ready(page, "/");
|
|
52
|
+
await expect(page.getByTestId("account-menu").locator("img")).toBeVisible();
|
|
53
|
+
await ready(page, "/admin");
|
|
54
|
+
await expect(page.getByTestId("sidebar-user-menu").locator("img")).toBeVisible();
|
|
55
|
+
|
|
56
|
+
await ready(page, "/account");
|
|
57
|
+
await page.getByRole("button", { name: /Remove image|이미지 제거/ }).click();
|
|
58
|
+
await expect(page.getByText(/Profile image removed|프로필 이미지를 제거했습니다/)).toBeVisible();
|
|
59
|
+
await expect(page.getByTestId("profile-image-preview").locator("img")).toHaveCount(0);
|
|
60
|
+
await expect(page.getByRole("button", { name: /Upload image|이미지 업로드/ })).toBeVisible();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("account rejects invalid profile images before upload", async ({ page }) => {
|
|
64
|
+
await ready(page, "/admin/account");
|
|
65
|
+
await page.locator("#profile-image").setInputFiles({
|
|
66
|
+
name: "avatar.txt",
|
|
67
|
+
mimeType: "text/plain",
|
|
68
|
+
buffer: Buffer.from("not an image"),
|
|
69
|
+
});
|
|
70
|
+
await expect(page.getByText(/Choose a valid PNG, JPEG, or WebP image|올바른 PNG, JPEG 또는 WebP 이미지를 선택해 주세요/)).toBeVisible();
|
|
71
|
+
|
|
72
|
+
await page.locator("#profile-image").setInputFiles({
|
|
73
|
+
name: "wide.png",
|
|
74
|
+
mimeType: "image/png",
|
|
75
|
+
buffer: TOO_WIDE_PNG,
|
|
76
|
+
});
|
|
77
|
+
await expect(page.getByText(/The image must not exceed 2048 × 2048 px|이미지는 2048 × 2048px를 초과할 수 없습니다/)).toBeVisible();
|
|
78
|
+
});
|
|
79
|
+
|
|
17
80
|
test("account exposes a change-email action when the address is edited", async ({ page }) => {
|
|
18
81
|
await ready(page, "/admin/account");
|
|
19
82
|
const email = page.getByLabel("Email");
|
|
@@ -106,7 +169,7 @@ test("account security and sessions sub-navigation", async ({ page }) => {
|
|
|
106
169
|
|
|
107
170
|
test("account nav shows the core sections", async ({ page }) => {
|
|
108
171
|
await ready(page, "/admin/account");
|
|
109
|
-
await expect(page.getByRole("button", { name: "Profile" })).toBeVisible();
|
|
172
|
+
await expect(page.getByRole("button", { name: "Profile", exact: true })).toBeVisible();
|
|
110
173
|
await expect(page.getByRole("button", { name: "Security" })).toBeVisible();
|
|
111
174
|
await expect(page.getByRole("button", { name: "Sessions" })).toBeVisible();
|
|
112
175
|
});
|
|
@@ -10,6 +10,22 @@ test("overview shows the signed-in admin @smoke", async ({ page }) => {
|
|
|
10
10
|
await expect(page.getByRole("main").getByText(ADMIN.email)).toBeVisible();
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
test("landing shows language, theme, and signed-in account actions", async ({ page }) => {
|
|
14
|
+
await ready(page, "/");
|
|
15
|
+
const language = page.getByRole("button", { name: /^(Language|언어)$/ });
|
|
16
|
+
const theme = page.getByRole("button", { name: /^(Toggle theme|테마 전환)$/ });
|
|
17
|
+
await expect(language).toBeVisible();
|
|
18
|
+
await expect(language.locator("svg")).toBeVisible();
|
|
19
|
+
await expect(theme).toBeVisible();
|
|
20
|
+
await expect(theme.locator("svg:visible")).toBeVisible();
|
|
21
|
+
await page.getByTestId("account-menu").click();
|
|
22
|
+
const label = page.getByRole("menu").locator('[data-slot="dropdown-menu-label"]');
|
|
23
|
+
await expect(label.getByText(ADMIN.name, { exact: true })).toBeVisible();
|
|
24
|
+
await expect(label.getByText(ADMIN.email, { exact: true })).toBeVisible();
|
|
25
|
+
await expect(page.getByRole("menuitem", { name: "Account" })).toBeVisible();
|
|
26
|
+
await expect(page.getByRole("menuitem", { name: "Sign out" })).toBeVisible();
|
|
27
|
+
});
|
|
28
|
+
|
|
13
29
|
test("sidebar navigates to sessions and account @smoke", async ({ page }) => {
|
|
14
30
|
await ready(page, "/admin");
|
|
15
31
|
await page.getByRole("link", { name: "Sessions" }).click();
|