Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ States: `not started` → `spec draft` → `spec approved` → `planned` → `in
| `settings` | M1 | [SPEC-settings](docs/specs/SPEC-settings.md) | [#18](https://github.com/isaacvarg/woolrus/issues/18) | done |
| `store-sync` | M1 | [SPEC-store-sync](docs/specs/SPEC-store-sync.md) | [#19](https://github.com/isaacvarg/woolrus/issues/19) | done |
| `workflow` | M1 | [SPEC-workflow](docs/specs/SPEC-workflow.md) | [#20](https://github.com/isaacvarg/woolrus/issues/20) | done |
| `identity` | M2 | [SPEC-identity](docs/specs/SPEC-identity.md) | [#21](https://github.com/isaacvarg/woolrus/issues/21) | done |
| `identity` | M2 | [SPEC-identity](docs/specs/SPEC-identity.md) | [#21](https://github.com/isaacvarg/woolrus/issues/21); A1 [#170](https://github.com/isaacvarg/woolrus/issues/170) | done; A1 (station-only users) done |
| `notes` | M2 | [SPEC-notes](docs/specs/SPEC-notes.md) | [#22](https://github.com/isaacvarg/woolrus/issues/22) | done |
| `picking` | M2 | [SPEC-picking](docs/specs/SPEC-picking.md) | [#23](https://github.com/isaacvarg/woolrus/issues/23) | done |
| `packing` | M2 | [SPEC-packing](docs/specs/SPEC-packing.md) | [#24](https://github.com/isaacvarg/woolrus/issues/24) | done |
Expand Down
65 changes: 65 additions & 0 deletions apps/web/e2e/station-user-email.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect, test } from "@playwright/test";
import { hashPin, stationOnlyPlaceholder } from "@woolrus/core";
import { createDb } from "@woolrus/db";
import { followMagicLink, signInAs } from "./support/auth";
import { findUserRow } from "./support/users";

// SPEC-identity amendment 1, success criterion 21: an admin adds an email to a station-only user,
// who then signs in personally by magic link as the same user, keeping their PIN.

const db = createDb();
// See users.spec.ts: /users renders slowly on a local database full of e2e users.
const slowRender = { timeout: 30_000 };

test("desktop: an admin adds an email to a station user, who then signs in as themselves", async ({
page,
browser,
}, testInfo) => {
test.skip(testInfo.project.name !== "desktop", "one viewport is enough for a dialog");
test.slow();
const name = `Email Luis ${crypto.randomUUID().slice(0, 8)}`;
const pinHash = await hashPin("482916");
const luis = await db.user.create({
data: { name, email: stationOnlyPlaceholder(), roles: ["PACKER"], pinHash },
});
const email = `luis-${crypto.randomUUID()}@example.test`;
await signInAs(page, ["ADMIN"]);

await page.goto("/users");
const row = await findUserRow(page, name);
await expect(row.getByText("Station only")).toBeVisible();
await row.getByRole("button", { name: `Actions for ${name}` }).click(slowRender);
await page.getByRole("menuitem", { name: "Add email" }).click();
const dialog = page.getByRole("dialog", { name: `Add an email for ${name}` });

// A typo in the confirmation is caught before anything is sent.
await dialog.getByLabel("Email", { exact: true }).fill(email);
await dialog.getByLabel("Confirm email").fill(`x${email}`);
await dialog.getByRole("button", { name: "Add email" }).click();
await expect(dialog.getByText("The two addresses don't match.")).toBeVisible();
expect((await db.user.findUniqueOrThrow({ where: { id: luis.id } })).email).not.toBe(email);

await dialog.getByLabel("Confirm email").fill(email);
await dialog.getByRole("button", { name: "Add email" }).click();
await expect(page.getByText(`${name} can now sign in with ${email}.`)).toBeVisible(slowRender);
await expect(row).toContainText(email);
await expect(row.getByText("Station only")).toHaveCount(0);

// Same row: id, roles and PIN kept.
expect(await db.user.findUniqueOrThrow({ where: { id: luis.id } })).toMatchObject({
email,
roles: ["PACKER"],
pinHash,
});

// Luis follows a magic link to the new address and is signed in as the same user.
const luisPage = await (await browser.newContext()).newPage();
await followMagicLink(luisPage, email);
await expect(luisPage).toHaveURL(/\/boxes$/);
const session = await db.session.findFirstOrThrow({
where: { user: { email } },
orderBy: { createdAt: "desc" },
});
expect(session.userId).toBe(luis.id);
expect((await db.user.findUniqueOrThrow({ where: { id: luis.id } })).emailVerified).toBe(true);
});
215 changes: 215 additions & 0 deletions apps/web/e2e/station-users.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { expect, test } from "@playwright/test";
import { createDb } from "@woolrus/db";
import { signInAs } from "./support/auth";
import { createOrder, deleteCreatedOrders } from "./support/orders";
import { chooseTile } from "./support/stations";
import { findUserRow } from "./support/users";

// SPEC-identity amendment 1, success criterion 20: a station-only user, added by an admin with a
// generated PIN, unlocks a station the admin just registered and picks as themselves there.

const db = createDb();
// See users.spec.ts: /users renders slowly on a local database full of e2e users.
const slowRender = { timeout: 30_000 };
const unique = () => crypto.randomUUID().slice(0, 8);

test.afterAll(deleteCreatedOrders);

test("tablet: an admin adds a station user who unlocks with the generated PIN and picks", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "tablet", "the tablet floor journey");
test.slow();
const customerName = `E2E ${crypto.randomUUID()}`;
const order = await createOrder({ customerName, quantity: 1 });
await signInAs(page, ["ADMIN"]);
const name = `Station Luis ${unique()}`;

// Add station user: name, Picker (Admin isn't offered), and a generated PIN.
await page.goto("/users");
await page.getByRole("button", { name: "Add station user" }).click(slowRender);
const add = page.getByRole("dialog", { name: "Add station user" });
await expect(add.getByRole("checkbox", { name: "Admin" })).toHaveCount(0);
await add.getByLabel("Name").fill(name);
await add.getByText("Picker", { exact: true }).click();
await add.getByRole("button", { name: "Generate" }).click();
const pin = (await add.getByRole("status").locator("strong").textContent()) ?? "";
expect(pin).toMatch(/^\d{6}$/);
await expect(add.getByLabel("New PIN")).toHaveValue(pin);
await add.getByRole("button", { name: "Add", exact: true }).click();

// Created: the dialog offers Issue badge, which opens the print view for them.
await expect(add.getByText(`${name} can now unlock any station with their PIN.`)).toBeVisible();
await add.getByRole("button", { name: "Issue badge" }).click();
const badge = page.getByRole("dialog", { name: `Badge for ${name}` });
await expect(badge.getByText(/^WRB-[0-9A-Z]{20}$/)).toBeVisible(slowRender);
await badge.getByRole("button", { name: "Close" }).click();

// Their row says Station only, never the placeholder, and Edit roles leaves out Admin.
const row = await findUserRow(page, name);
await expect(row.getByText("Station only")).toBeVisible();
await expect(row).not.toContainText("station.invalid");
await row.getByRole("button", { name: `Actions for ${name}` }).click(slowRender);
await page.getByRole("menuitem", { name: "Edit roles" }).click();
const roles = page.getByRole("dialog", { name: `Roles for ${name}` });
await expect(roles.getByRole("checkbox", { name: "Picker" })).toBeChecked();
await expect(roles.getByRole("checkbox", { name: "Admin" })).toHaveCount(0);
await roles.getByRole("button", { name: "Cancel" }).click();

// The admin makes this tablet a station: the new user's tile is on the lock screen.
const stationName = `Station Users ${unique()}`;
await page.goto("/stations");
await page.getByRole("button", { name: "Make this device a station" }).click(slowRender);
await page.getByRole("dialog").getByLabel("Name").fill(stationName);
await page.getByRole("dialog").getByRole("button", { name: "Make it a station" }).click();
await expect(page).toHaveURL(/\/station$/);
await expect(page.getByRole("heading", { name: stationName })).toBeVisible();
const station = await db.station.findUniqueOrThrow({ where: { name: stationName } });
const user = await db.user.findFirstOrThrow({ where: { name } });

// They unlock with the PIN, and the station acts as them, with no email to show.
await chooseTile(page, name);
await page.getByLabel(`PIN for ${name}`).fill(pin);
await expect(page).not.toHaveURL(/\/station$/);
expect(await (await page.request.get("/api/v1/me")).json()).toMatchObject({
id: user.id,
email: null,
roles: ["PICKER"],
stationId: station.id,
});

// Their pick records them and the station.
await page.goto("/queue/pending");
await page.getByRole("searchbox", { name: "Search orders" }).fill(customerName);
await page
.getByRole("row", { name: new RegExp(order.number) })
.getByRole("button", { name: "Begin Picking" })
.click();
await expect(page).toHaveURL(`/pick/${order.id}`);
const picked = page.waitForResponse(/\/rpc\/picking\/pickLine/);
await page
.getByTestId("pick-card")
.first()
.getByRole("button", { name: /^E2E item/, pressed: false })
.click();
await picked;

expect(
await db.orderStageEvent.findFirst({ where: { orderId: order.id, toStatus: "PICKING" } }),
).toMatchObject({ actorId: user.id, stationId: station.id });
expect(
await db.auditEvent.findFirst({ where: { action: "pick.pickLine", actorId: user.id } }),
).toMatchObject({ stationId: station.id });
});

// Success criterion 22 and decision 13: a taken name warns, ignoring case and surrounding spaces,
// but never blocks. A deactivated user's name doesn't count.
test("desktop: adding a station user with a taken name warns but still adds them", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "desktop", "one viewport is enough for a dialog");
const id = unique();
const taken = `Dup Luis ${id}`;
const gone = `Gone Luis ${id}`;
await db.user.createMany({
data: [
{ name: taken, email: `e2e-${crypto.randomUUID()}@example.test`, roles: ["PICKER"] },
{
name: gone,
email: `e2e-${crypto.randomUUID()}@example.test`,
roles: ["PICKER"],
active: false,
},
],
});
await signInAs(page, ["ADMIN"]);
await page.goto("/users");
await page.getByRole("button", { name: "Add station user" }).click(slowRender);
const add = page.getByRole("dialog", { name: "Add station user" });
const warning = add.getByText(/An active user is already called/);

// A deactivated user's name: no warning.
const checked = page.waitForResponse(
(response) =>
response.url().includes("/rpc/users/list") && !!response.request().postData()?.includes(gone),
);
await add.getByLabel("Name").fill(gone);
await checked;
await expect(warning).toHaveCount(0);

// An active user's name in another case, with spaces: the warning shows.
await add.getByLabel("Name").fill(` ${taken.toLowerCase()} `);
await expect(warning).toBeVisible();
await expect(warning).toContainText(taken);

// The admin can still add them.
await add.getByText("Picker", { exact: true }).click();
await add.getByRole("button", { name: "Generate" }).click();
await add.getByRole("button", { name: "Add", exact: true }).click();
await expect(add.getByText("can now unlock any station with their PIN.")).toBeVisible();
expect(await db.user.count({ where: { name: { equals: taken, mode: "insensitive" } } })).toBe(2);
});

// "Everything else is unchanged" (SPEC-identity amendment 1): clearing the PIN of a station-only
// user with no badge leaves them no way in, so it asks first, but it's still allowed.
test("desktop: clearing a badge-less station user's PIN warns first, then clears it", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "desktop", "one viewport is enough for a dialog");
const name = `No Badge Luis ${unique()}`;
const user = await db.user.create({
data: {
name,
email: `${crypto.randomUUID()}@station.invalid`,
roles: ["PICKER"],
pinHash: "not-a-real-hash",
},
});
await signInAs(page, ["ADMIN"]);
await page.goto("/users");
const row = await findUserRow(page, name);
const clearPin = async () => {
await row.getByRole("button", { name: `Actions for ${name}` }).click(slowRender);
await page.getByRole("menuitem", { name: "Clear PIN" }).click();
};
const confirm = page.getByRole("dialog", { name: `Clear ${name}'s PIN?` });

// Cancel keeps the PIN.
await clearPin();
await expect(confirm.getByText("can't unlock any station")).toBeVisible();
await confirm.getByRole("button", { name: "Cancel" }).click();
await expect(confirm).toHaveCount(0);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).pinHash).not.toBeNull();

// Confirming clears it.
await clearPin();
await confirm.getByRole("button", { name: "Clear PIN" }).click();
await expect(page.getByText(`PIN cleared for ${name}.`)).toBeVisible();
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).pinHash).toBeNull();
});

// The same for the badge: revoking it from a station-only user with no PIN asks first.
test("desktop: revoking a PIN-less station user's badge warns first, then revokes it", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "desktop", "one viewport is enough for a dialog");
const name = `No PIN Luis ${unique()}`;
const user = await db.user.create({
data: {
name,
email: `${crypto.randomUUID()}@station.invalid`,
roles: ["PICKER"],
badgeCode: `e2e-${crypto.randomUUID()}`,
},
});
await signInAs(page, ["ADMIN"]);
await page.goto("/users");
const row = await findUserRow(page, name);
await row.getByRole("button", { name: `Actions for ${name}` }).click(slowRender);
await page.getByRole("menuitem", { name: "Revoke badge" }).click();
const confirm = page.getByRole("dialog", { name: `Revoke ${name}'s badge?` });
await expect(confirm.getByText("can't unlock any station")).toBeVisible();
await confirm.getByRole("button", { name: "Revoke badge" }).click();
await expect(page.getByText(`Badge revoked for ${name}.`)).toBeVisible();
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).badgeCode).toBeNull();
});
4 changes: 2 additions & 2 deletions apps/web/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { can } from "@woolrus/core";
import { can, publicEmail } from "@woolrus/core";
import { headers } from "next/headers";
import Link from "next/link";
import { redirect } from "next/navigation";
Expand Down Expand Up @@ -120,7 +120,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{user && (
<NavFooter
name={user.name}
email={user.email}
email={publicEmail(user.email)}
stationName={station?.name ?? null}
preferences={{
locale: user.locale,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/(app)/nav-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function NavFooter({
preferences,
}: {
name: string;
email: string;
email: string | null;
stationName: string | null;
preferences: Preferences;
}) {
Expand Down
Loading
Loading