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
12 changes: 6 additions & 6 deletions E2E_USER_STORIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ the PR gate.
| WP-E2E-06 | P1 | A deleted booking disappears from the listing (teardown really tears down). | **done** — `local/desk-booking.spec.ts` |
| WP-E2E-03 | P1 | Building/level selectors are populated from seeded zones, and changing them re-scopes what is bookable. | todo |
| WP-E2E-07 | P1 | "Your bookings" lists the user's own booking; cancelling it moves it out of the upcoming list. | todo |
| WP-E2E-08 | P1 | A booking made by one user is **not** visible in another user's "your bookings" (per-user scoping). | todosee AUTH-E2E-05 |
| WP-E2E-09 | P1 | Booking a **locker** end to end. Same metadata + per-worker-asset + sweep pattern as desks. | todo |
| WP-E2E-10 | P1 | Booking a **parking** space end to end. | todo |
| WP-E2E-08 | P1 | A booking made by one user is **not** visible in another user's listing, and cannot be deleted by them. | **done**`local/booking-scoping.spec.ts`. Red-checked: the other user's listing really is empty while the booking exists. |
| WP-E2E-09 | P1 | Booking a **locker** end to end. | todo — **more setup than desks**, not the same pattern. Lockers come from locker *banks* then lockers within them (`loadLockerResources`), so seeding is two-level. Budget accordingly. |
| WP-E2E-10 | P1 | Booking a **parking** space end to end. | todo — **more setup than desks**. Needs a level zone tagged `parking` plus spaces created through the parking API (`queryParkingSpacesForZones`), not Zone metadata. |
| WP-E2E-11 | P2 | Inviting a **visitor** end to end. | todo |
| WP-E2E-12 | P2 | Directory / colleagues search returns seeded users. | todo |
| WP-E2E-13 | P2 | The explore/map view renders for a seeded level and reflects availability. | todo — needs map metadata seeded |
Expand All @@ -133,11 +133,11 @@ environment, data or real-client gap that unit specs could not see.

| ID | P | Story | Status |
|----|---|-------|--------|
| AUTH-E2E-01 | P0 | Authorization-code + PKCE exchange in a real browser: no `client_secret` anywhere, `S256` challenge, token is a JWT. | **partial** — `login.spec.ts` asserts the exchange and token shape; the explicit no-secret / challenge-recomputation assertions still live in `tasks/PPT-2536/e2e/backoffice-login.spec.js` and should move here. |
| AUTH-E2E-01 | P0 | Authorization-code + PKCE exchange in a real browser: no `client_secret` anywhere, `S256` challenge, token is a JWT. | **done** — `local/pkce.spec.ts`. Asserts on the wire, not the response: a client that leaked a secret or dropped PKCE would still return a valid-looking token, so the response cannot tell you the handshake was sound. Also checks the password never appears in a URL and only ever reaches `/auth/signin`. Ported from `tasks/PPT-2536/e2e/backoffice-login.spec.js`. |
| AUTH-E2E-02 | P0 | A refreshed token keeps its scope, is rotated, preserves `sub`, and is still accepted by rest-api. | **done** — `login.spec.ts`. This is the exact 2026-07-23 revert (403 on `/oauth_apps` after refresh). |
| AUTH-E2E-03 | P1 | A refresh chain survives N sequential refreshes without degrading scope or access. | todo — covered API-only by `tasks/PPT-2536/integration/` (RF-03); wanted in-browser. |
| AUTH-E2E-04 | P1 | A stale/incompatible session cookie from a previous auth implementation does not break sign-in. | todo — verified manually (SC-01); needs automating. |
| AUTH-E2E-05 | P1 | A non-admin cannot read or mutate another user's bookings; an admin's own listing does not leak others'. | todo — **and it matters**: `GET /bookings` is caller-scoped, which we only learned by getting a leak check wrong. |
| AUTH-E2E-05 | P1 | A non-admin cannot read or mutate another user's bookings. | **done** — `local/booking-scoping.spec.ts`, same spec as WP-E2E-08. Covers both halves: the listing excludes it, and a delete attempt is rejected. Includes a control asserting you *can* see your own, so "nobody sees anything" can't pass as success. |
| AUTH-E2E-06 | P2 | Token expiry mid-session recovers without stranding the SPA. | todo |
| AUTH-E2E-07 | P2 | Malformed and hostile `/auth/*` requests return 4xx, never 5xx and never a backtrace. | todo — covered by auth.cr unit specs (SEC-01); browser-level coverage optional. |
| AUTH-E2E-08 | P1 | `SameSite` behaviour in a genuine third-party/iframe context. | **blocked** — Playwright Chromium cannot create a true third-party context. Known untested incident class (B.7). |
Expand All @@ -150,7 +150,7 @@ task that found it, so the row can be traced.
| ID | P | Story | Source | Status |
|----|---|-------|--------|--------|
| REG-01 | P0 | Scope is not lost on token refresh; downstream authorisation still passes. | PPT-2536, 2026-07-23 revert | **done** — AUTH-E2E-02 |
| REG-02 | P1 | An overlapping desk booking is rejected rather than silently accepted. | `2607.1` "Fix rejecting overlapping bookings on desk assignment" | todo |
| REG-02 | P1 | An overlapping desk booking is rejected rather than silently accepted. | `2607.1` "Fix rejecting overlapping bookings on desk assignment" | **done** — `local/desk-clash.spec.ts`. Identical and partially-overlapping slots both refused (409), attempted as a *second* user so a per-user-only check would fail. Includes a control that a non-overlapping slot is accepted, and that the desk frees up after deletion. Red-checked. |
| REG-03 | P1 | A clash check uses the **current** `booking_end`, not a stale one. | `2607.1` "Fix stale booking_end being used for clash check" | todo |
| REG-04 | P1 | Desk booking status displays correctly in the booking list. | `2606.1` "Fix status display for desk bookings" | todo |
| REG-05 | P2 | The authorised-user check has no race on boot (no flash of unauthorised). | `2607.1` "Fix race condition for authorised check" | todo |
Expand Down
173 changes: 173 additions & 0 deletions apps/workplace/e2e/local/booking-scoping.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* WP-E2E-08 / AUTH-E2E-05 — one user's bookings are not another user's business.
*
* This locks down a property we learned about the hard way. `GET /bookings` is
* scoped to the caller, which is easy to assume but was not obvious: an early
* leak-check written as an admin reported zero bookings while the database
* plainly held one, because the admin was only ever being shown their own.
*
* That behaviour is load-bearing in two directions. It is a privacy boundary
* (your colleagues cannot enumerate where you sit), and it is a trap for anyone
* writing tooling against the API. Worth a test either way, because a regression
* here would leak quietly rather than fail loudly.
*/
import { request } from '@playwright/test';
import { test, expect } from '../../../../e2e/support/fixtures';
import { BACKEND_URL, WORKERS, roleFor } from '../../../../e2e/support/env';
import { mintToken } from '../../../../e2e/support/auth';
import { APP_URL } from '../../../../e2e/support/env';
import {
STAFF_API,
deleteBooking,
listBookings,
releaseAsset,
uniqueTitle,
zonesWithTag,
} from '../../../../e2e/support/api';
import type { APIRequestContext } from '@playwright/test';
import { deskFor } from '../../../../e2e/support/env';


/**
* The org/building/level zone ids a booking carries, matching what the UI sends.
* Always queried by tag: `GET /zones` with no `tags` parameter comes back empty.
*/
async function bookingZones(api: APIRequestContext): Promise<string[]> {
const groups = await Promise.all(
['org', 'building', 'level'].map((tag) => zonesWithTag(api, tag)),
);
return groups.flat().map((z) => z.id);
}

const DAY = 86_400;
// Window comfortably wider than any booking these specs create. Keep it that
// way: a booking placed exactly on the boundary is not returned by the listing,
// which reads as "scoping is broken" rather than "the window was too tight".
const from = () => Math.floor(Date.now() / 1000) - 3 * DAY;
const to = () => Math.floor(Date.now() / 1000) + 3 * DAY;

test.describe('booking visibility between users', () => {
test('another user cannot see or delete your booking', async ({ staffApi }, testInfo) => {
const mine = testInfo.parallelIndex;
// A genuinely different seeded user. With one worker there is nobody else
// to compare against, so the spec would be meaningless.
const theirs = (mine + 1) % WORKERS;
test.skip(theirs === mine, 'needs at least two workers to have two distinct users');

const desk = deskFor(mine);
const title = uniqueTitle('E2E Scoping');
await releaseAsset(staffApi, 'desk', desk.id, from(), to());

// Create as *this* worker's user, through the API rather than the UI —
// the subject here is authorisation, not the booking form.
const me = await (await staffApi.get('/api/engine/v2/users/current')).json();
const zones = await bookingZones(staffApi);
const start = Math.floor(Date.now() / 1000) + DAY;

const created = await staffApi.post(`${STAFF_API}/bookings`, {
data: {
booking_type: 'desk',
asset_id: desk.id,
booking_start: start,
booking_end: start + 3600,
timezone: 'Etc/UTC',
user_email: me.email,
user_id: me.id,
user_name: me.name,
title,
zones,
},
});
expect(created.ok(), `creating the booking should succeed: ${created.status()}`).toBeTruthy();
const booking = await created.json();

// A second, genuinely different user.
const other_role = roleFor('staff', theirs);
const other_mint = await mintToken(
BACKEND_URL,
APP_URL,
other_role.email,
other_role.password,
);
const other = await request.newContext({
baseURL: BACKEND_URL,
ignoreHTTPSErrors: true,
extraHTTPHeaders: { Authorization: `Bearer ${other_mint.accessToken}` },
});

try {
// Sanity: the two identities really are different, or everything below
// would pass for the wrong reason.
const them = await (await other.get('/api/engine/v2/users/current')).json();
expect(them.email, 'the second user must be a different person').not.toBe(me.email);

// The privacy boundary.
const their_view = await listBookings(other, 'desk', from(), to());
expect(
their_view.map((b) => b.id),
"another user's default listing must not include your booking",
).not.toContain(booking.id);

// And they cannot remove it. A 2xx here would mean anyone can cancel
// anyone's desk, which is worse than merely being able to see it.
const their_delete = await other.delete(`${STAFF_API}/bookings/${booking.id}`);
expect(
their_delete.status(),
`another user must not be able to delete your booking ` +
`(got ${their_delete.status()})`,
).toBeGreaterThanOrEqual(400);

// Still there afterwards, from the owner's point of view.
const still_mine = await listBookings(staffApi, 'desk', from(), to());
expect(
still_mine.map((b) => b.id),
'the booking should survive the other user attempting to delete it',
).toContain(booking.id);
} finally {
await other.dispose();
await deleteBooking(staffApi, booking.id);
}
});

test('you can see your own booking in the listing', async ({ staffApi }, testInfo) => {
// The control for the test above. Without it, "they cannot see it" would
// also pass if nobody could see anything.
const desk = deskFor(testInfo.parallelIndex);
const title = uniqueTitle('E2E Scoping Control');
await releaseAsset(staffApi, 'desk', desk.id, from(), to());

const me = await (await staffApi.get('/api/engine/v2/users/current')).json();
const zones = await bookingZones(staffApi);
const start = Math.floor(Date.now() / 1000) + DAY;

const created = await staffApi.post(`${STAFF_API}/bookings`, {
data: {
booking_type: 'desk',
asset_id: desk.id,
booking_start: start,
booking_end: start + 3600,
timezone: 'Etc/UTC',
user_email: me.email,
user_id: me.id,
user_name: me.name,
title,
zones,
},
});
expect(
created.ok(),
`creating the booking should succeed: ${created.status()} ${await created.text()}`,
).toBeTruthy();
const booking = await created.json();

try {
const mine = await listBookings(staffApi, 'desk', from(), to());
expect(
mine.map((b) => b.id),
'you must be able to see your own booking',
).toContain(booking.id);
} finally {
await deleteBooking(staffApi, booking.id);
}
});
});
156 changes: 156 additions & 0 deletions apps/workplace/e2e/local/desk-clash.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* REG-02 — two people cannot hold the same desk at the same time.
*
* Maps to a real shipped fix: "Fix rejecting overlapping bookings on desk
* assignment" (release 2607.1). Double-booking is the kind of regression that
* doesn't announce itself. Nothing errors, nobody notices, and two people turn up
* to the same desk on Tuesday.
*
* Deliberately attempted as a SECOND user, because that's the real scenario and
* because a clash check that only looked at your own bookings would still pass a
* single-user version of this test.
*/
import { request } from '@playwright/test';
import { test, expect } from '../../../../e2e/support/fixtures';
import { APP_URL, BACKEND_URL, WORKERS, deskFor, roleFor } from '../../../../e2e/support/env';
import { mintToken } from '../../../../e2e/support/auth';
import {
STAFF_API,
deleteBooking,
releaseAsset,
uniqueTitle,
zonesWithTag,
} from '../../../../e2e/support/api';
import type { APIRequestContext } from '@playwright/test';

const DAY = 86_400;
const from = () => Math.floor(Date.now() / 1000) - 3 * DAY;
const to = () => Math.floor(Date.now() / 1000) + 3 * DAY;

async function bookingZones(api: APIRequestContext): Promise<string[]> {
const groups = await Promise.all(
['org', 'building', 'level'].map((tag) => zonesWithTag(api, tag)),
);
return groups.flat().map((z) => z.id);
}

async function book(
api: APIRequestContext,
asset_id: string,
start: number,
end: number,
zones: string[],
) {
const me = await (await api.get('/api/engine/v2/users/current')).json();
return api.post(`${STAFF_API}/bookings`, {
data: {
booking_type: 'desk',
asset_id,
booking_start: start,
booking_end: end,
timezone: 'Etc/UTC',
user_email: me.email,
user_id: me.id,
user_name: me.name,
title: uniqueTitle('E2E Clash'),
zones,
},
});
}

test.describe('desk double-booking', () => {
test('a second person cannot book a desk that is already taken', async ({
staffApi,
}, testInfo) => {
const mine = testInfo.parallelIndex;
const theirs = (mine + 1) % WORKERS;
test.skip(theirs === mine, 'needs at least two workers to have two distinct users');

const desk = deskFor(mine);
const zones = await bookingZones(staffApi);
await releaseAsset(staffApi, 'desk', desk.id, from(), to());

// A fixed, future window. Not "now", so the test can't be tripped by the
// clock crossing a boundary mid-run.
const start = Math.floor(Date.now() / 1000) + DAY;
const end = start + 3600;

const first = await book(staffApi, desk.id, start, end, zones);
expect(
first.ok(),
`the first booking should succeed: ${first.status()} ${await first.text()}`,
).toBeTruthy();
const booking = await first.json();

const other_role = roleFor('staff', theirs);
const other_mint = await mintToken(
BACKEND_URL,
APP_URL,
other_role.email,
other_role.password,
);
const other = await request.newContext({
baseURL: BACKEND_URL,
ignoreHTTPSErrors: true,
extraHTTPHeaders: { Authorization: `Bearer ${other_mint.accessToken}` },
});

try {
// Exactly the same slot.
const exact = await book(other, desk.id, start, end, zones);
expect(
exact.status(),
`an identical slot must be refused, got ${exact.status()}`,
).toBeGreaterThanOrEqual(400);

// And a partial overlap, which is the case a naive check misses: it
// starts before the existing booking ends.
const partial = await book(other, desk.id, start + 1800, end + 1800, zones);
expect(
partial.status(),
`an overlapping slot must be refused, got ${partial.status()}`,
).toBeGreaterThanOrEqual(400);

// Control: a slot that genuinely doesn't overlap is fine. Without this,
// a backend that rejected everything would pass the two checks above.
const clear = await book(other, desk.id, end + 3600, end + 7200, zones);
expect(
clear.ok(),
`a non-overlapping slot should be accepted: ${clear.status()} ${await clear.text()}`,
).toBeTruthy();
const clear_booking = await clear.json();
await deleteBooking(other, clear_booking.id);
} finally {
await other.dispose();
await deleteBooking(staffApi, booking.id);
}
});

test('the desk frees up once the booking is deleted', async ({ staffApi }, testInfo) => {
// Guards a nastier version of the same bug: a cancelled booking that still
// blocks the desk. Users would see it as free and be unable to book it,
// which is harder to diagnose than a straightforward double-booking.
const desk = deskFor(testInfo.parallelIndex);
const zones = await bookingZones(staffApi);
await releaseAsset(staffApi, 'desk', desk.id, from(), to());

const start = Math.floor(Date.now() / 1000) + 2 * DAY;
const end = start + 3600;

const first = await book(staffApi, desk.id, start, end, zones);
expect(first.ok(), `first booking: ${first.status()}`).toBeTruthy();
const booking = await first.json();

const blocked = await book(staffApi, desk.id, start, end, zones);
expect(blocked.status(), 'the slot is taken while the booking exists').toBeGreaterThanOrEqual(400);

await deleteBooking(staffApi, booking.id);

const after = await book(staffApi, desk.id, start, end, zones);
expect(
after.ok(),
`the same slot should be bookable again once freed: ${after.status()} ${await after.text()}`,
).toBeTruthy();
await deleteBooking(staffApi, (await after.json()).id);
});
});
Loading
Loading