126 lines
5.9 KiB
TypeScript
126 lines
5.9 KiB
TypeScript
// Users admin screen (example plugin): the pure view-model + Kratos-payload builders. The HTTP
|
|
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
|
|
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import type { Identity } from "#plugin-api";
|
|
import {
|
|
buildUserFormModel,
|
|
buildUsersListModel,
|
|
createIdentityPayload,
|
|
setStatePayload,
|
|
toUserView,
|
|
updateIdentityPayload,
|
|
} from "./admin-users.ts";
|
|
|
|
const id = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
|
const identity = (n: number, over: Partial<Identity> = {}): Identity => ({
|
|
id: id(n),
|
|
schema_id: "default",
|
|
state: "active",
|
|
traits: { email: `user${n}@example.com`, name: { first: `First${n}`, last: `Last${n}` } },
|
|
...over,
|
|
});
|
|
|
|
test("toUserView maps traits → name/initials/email/state; falls back to the email local part", () => {
|
|
const named = toUserView(identity(1));
|
|
assert.equal(named.name, "First1 Last1");
|
|
assert.equal(named.initials, "FL");
|
|
assert.equal(named.email, "user1@example.com");
|
|
assert.equal(named.state, "active");
|
|
|
|
// No name trait → derive from the email local part.
|
|
const bare = toUserView({ id: id(2), state: "inactive", traits: { email: "ada.lovelace@example.com" } });
|
|
assert.equal(bare.name, "ada.lovelace");
|
|
assert.equal(bare.initials, "AD");
|
|
assert.equal(bare.state, "inactive");
|
|
});
|
|
|
|
test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
|
|
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
|
|
|
|
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
|
|
assert.equal(all.pagination.summary.total, 30);
|
|
assert.equal(all.table.rows.length, 25); // default page size
|
|
assert.equal(all.title, "Users");
|
|
|
|
// Search narrows to one and shows a pill.
|
|
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
|
|
assert.equal(one.pagination.summary.total, 1);
|
|
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
|
|
|
// Status filter keeps only inactive identities.
|
|
const inactive = buildUsersListModel({ identities: people, url: "http://x/admin/users?status=inactive" });
|
|
assert.equal(inactive.pagination.summary.total, 15);
|
|
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
|
|
|
|
// Sort by email descending reverses the order.
|
|
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
|
|
const desc = buildUsersListModel({ identities: people, url: "http://x/admin/users?sort=-email" });
|
|
assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
|
|
|
|
// Edit link points at the per-identity route.
|
|
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
|
|
});
|
|
|
|
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
|
const m = buildUserFormModel({ csrfToken: "tok.sig" });
|
|
assert.equal(m.title, "New user");
|
|
assert.equal(m.form.action, "/admin/users");
|
|
assert.equal(m.form.submitLabel, "Create user");
|
|
assert.equal(m.form.csrfToken, "tok.sig");
|
|
const names = m.form.fields.map((f) => f.name);
|
|
assert.deepEqual(names, ["email", "first", "last", "password"]);
|
|
assert.equal(m.form.fields.find((f) => f.name === "email")!.readonly, undefined); // editable on create
|
|
assert.equal(m.edit, undefined);
|
|
});
|
|
|
|
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
|
|
const m = buildUserFormModel({ identity: identity(3) });
|
|
assert.equal(m.title, "Edit user");
|
|
assert.equal(m.form.action, `/admin/users/${id(3)}`);
|
|
assert.equal(m.form.submitLabel, "Save changes");
|
|
const email = m.form.fields.find((f) => f.name === "email")!;
|
|
assert.equal(email.value, "user3@example.com");
|
|
assert.equal(email.readonly, true);
|
|
assert.ok(!m.form.fields.some((f) => f.name === "password")); // no password field when editing
|
|
assert.equal(m.form.fields.find((f) => f.name === "first")!.value, "First3");
|
|
assert.equal(m.edit!.nextLabel, "Deactivate"); // active → offers Deactivate
|
|
assert.match(m.edit!.deleteAction, /\/delete$/);
|
|
assert.match(m.edit!.recoveryAction, /\/recovery$/);
|
|
|
|
// An inactive identity offers Reactivate.
|
|
assert.equal(buildUserFormModel({ identity: identity(4, { state: "inactive" }) }).edit!.nextLabel, "Reactivate");
|
|
});
|
|
|
|
test("createIdentityPayload: schema/state/traits, name only when given, password only when set", () => {
|
|
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), {
|
|
schema_id: "default",
|
|
state: "active",
|
|
traits: { email: "a@b.c", name: { first: "Ada", last: "Lovelace" } },
|
|
});
|
|
// No name parts → omit the name trait; a password → credentials.
|
|
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "", last: "", password: "s3cret" }), {
|
|
credentials: { password: { config: { password: "s3cret" } } },
|
|
schema_id: "default",
|
|
state: "active",
|
|
traits: { email: "a@b.c" },
|
|
});
|
|
});
|
|
|
|
test("updateIdentityPayload preserves email/schema/state and rewrites the name; setStatePayload flips state", () => {
|
|
const existing = identity(5);
|
|
assert.deepEqual(updateIdentityPayload(existing, { email: "ignored@x", first: "New", last: "Name", password: "" }), {
|
|
schema_id: "default",
|
|
state: "active",
|
|
traits: { email: "user5@example.com", name: { first: "New", last: "Name" } }, // email kept, not the submitted one
|
|
});
|
|
// Clearing both name parts drops the name trait.
|
|
assert.deepEqual(updateIdentityPayload(existing, { email: "", first: "", last: "", password: "" }).traits, { email: "user5@example.com" });
|
|
|
|
assert.deepEqual(setStatePayload(existing, "inactive"), {
|
|
schema_id: "default",
|
|
state: "inactive",
|
|
traits: { email: "user5@example.com", name: { first: "First5", last: "Last5" } },
|
|
});
|
|
});
|