diff --git a/E2E_USER_STORIES.md b/E2E_USER_STORIES.md index 93e5ec1f16..65efa7c662 100644 --- a/E2E_USER_STORIES.md +++ b/E2E_USER_STORIES.md @@ -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). | todo — see 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 | @@ -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). | @@ -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 | diff --git a/apps/concierge/src/app/email-templates/broadcast-email-modal.component.ts b/apps/concierge/src/app/email-templates/broadcast-email-modal.component.ts index 6525056592..cfa51d28af 100644 --- a/apps/concierge/src/app/email-templates/broadcast-email-modal.component.ts +++ b/apps/concierge/src/app/email-templates/broadcast-email-modal.component.ts @@ -1,4 +1,11 @@ -import { Component, computed, effect, inject, signal } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; import { form, FormField, required, validate } from '@angular/forms/signals'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -231,7 +238,7 @@ export class BroadcastEmailModalComponent { effect(() => { this.form.recipient_group().value(); this.form.recipients().value(); - this.updateRecipients(); + untracked(() => this.updateRecipients()); }); } diff --git a/apps/concierge/src/app/parking/parking-bookings-list.component.ts b/apps/concierge/src/app/parking/parking-bookings-list.component.ts index cdc9fd8982..f13ac2d1d5 100644 --- a/apps/concierge/src/app/parking/parking-bookings-list.component.ts +++ b/apps/concierge/src/app/parking/parking-bookings-list.component.ts @@ -24,6 +24,8 @@ import { TableColumn, TranslatePipe, } from '@placeos/components'; +import { isSameDay } from 'date-fns'; +import { toZonedTime } from 'date-fns-tz'; import { ParkingBookingsWeekViewComponent } from './parking-bookings-week-view.component'; import { ParkingStateService } from './parking-state.service'; import { @@ -147,6 +149,9 @@ interface ParkingBookingColumnTemplates { } @else { {{ row.date | date: time_format : timezone }} - {{ row.date_end | date: time_format : timezone }} + @if (isNextDay(row)) { + +1 + } } @@ -593,7 +598,12 @@ export class ParkingBookingsListComponent private _state = inject(ParkingStateService); private _settings = inject(SettingsService); - public readonly bookings = this._state.bookings; + public readonly bookings = computed(() => { + const selected_date = this._state.options().date; + return this._state + .bookings() + .filter((booking) => this._isSameDay(booking.date, selected_date)); + }); public readonly options = this._state.options; public readonly loading = this._state.loading; public readonly period = this._state.period; @@ -774,6 +784,18 @@ export class ParkingBookingsListComponent ); } + public isNextDay(booking: Booking) { + return !this._isSameDay(booking.date, booking.date_end); + } + + private _isSameDay(first: number, second: number) { + const timezone = this.timezone; + return isSameDay( + timezone ? toZonedTime(first, timezone) : first, + timezone ? toZonedTime(second, timezone) : second, + ); + } + public statusLabel(booking: Booking) { return this.isAssignedBooking(booking) ? 'APP.CONCIERGE.BOOKING_STATUS_ASSIGNED' diff --git a/apps/concierge/src/tests/email-templates/broadcast-email-modal.component.spec.ts b/apps/concierge/src/tests/email-templates/broadcast-email-modal.component.spec.ts index 9571c12e9c..d84b832023 100644 --- a/apps/concierge/src/tests/email-templates/broadcast-email-modal.component.spec.ts +++ b/apps/concierge/src/tests/email-templates/broadcast-email-modal.component.spec.ts @@ -1,5 +1,8 @@ -import { ComponentFixtureAutoDetect } from '@angular/core/testing'; +import { ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; import { MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { OrganisationService, @@ -31,6 +34,7 @@ describe('BroadcastEmailModalComponent', () => { MockComponent(FullscreenModalShellComponent), MockComponent(UserListFieldComponent), ], + imports: [MatFormFieldModule, MatInputModule, MatSelectModule], providers: [ { provide: ComponentFixtureAutoDetect, useValue: false }, MockProvider(MatDialogRef, { close: dialog_close } as any), @@ -103,6 +107,22 @@ describe('BroadcastEmailModalComponent', () => { ]); }); + it('should not re-resolve recipients when the subject changes', () => { + TestBed.flushEffects(); + const update_recipients = vi.spyOn( + spectator.component, + 'updateRecipients', + ); + + spectator.component.model.update((m) => ({ + ...m, + subject: 'Emergency notice', + })); + TestBed.flushEffects(); + + expect(update_recipients).not.toHaveBeenCalled(); + }); + it('should notify an error and skip sending when the mailer is missing', async () => { smtp_module = null; spectator.component.model.update((m) => ({ diff --git a/apps/concierge/src/tests/parking/parking-bookings-list.component.spec.ts b/apps/concierge/src/tests/parking/parking-bookings-list.component.spec.ts index 11f0ea2025..c09a520e06 100644 --- a/apps/concierge/src/tests/parking/parking-bookings-list.component.spec.ts +++ b/apps/concierge/src/tests/parking/parking-bookings-list.component.spec.ts @@ -18,6 +18,7 @@ describe('ParkingBookingsListComponent', () => { let custom_booking_columns: any[] = []; let bookable_hours: { start: number; end: number } | undefined; let timezone = 'Australia/Perth'; + let selected_date = Date.now(); let request_filter: 'all' | 'bookings' | 'requests' | 'waitlist' = 'all'; const createComponent = createComponentFactory({ @@ -26,7 +27,7 @@ describe('ParkingBookingsListComponent', () => { MockProvider(ParkingStateService, { bookings: (() => bookings) as any, options: (() => ({ - date: Date.now(), + date: selected_date, search: '', zones: [], period: 'day', @@ -93,6 +94,7 @@ describe('ParkingBookingsListComponent', () => { custom_booking_columns = []; bookable_hours = undefined; timezone = 'Australia/Perth'; + selected_date = Date.now(); request_filter = 'all'; settingSignal('parking.allow_editing', true).set(true); settingSignal('parking.allow_deleting', false).set(false); @@ -154,6 +156,7 @@ describe('ParkingBookingsListComponent', () => { }); it('should show start and end times for all-day bookings', () => { + selected_date = new Date(2026, 6, 21, 8).valueOf(); bookings = [ { id: 'booking-1', @@ -174,6 +177,7 @@ describe('ParkingBookingsListComponent', () => { it('should show all day when the booking matches the bookable period', () => { bookable_hours = { start: 8, end: 17 }; + selected_date = new Date(2026, 6, 21, 8).valueOf(); bookings = [ { id: 'booking-1', @@ -193,6 +197,50 @@ describe('ParkingBookingsListComponent', () => { ).not.toHaveText(':'); }); + it('should only show bookings that start on the selected day', () => { + selected_date = new Date('2026-08-03T12:00:00+08:00').valueOf(); + bookings = [ + { + id: 'previous-day', + asset_id: 'bay-1', + status: 'approved', + date: new Date('2026-08-02T17:30:00+08:00').valueOf(), + date_end: new Date('2026-08-03T06:30:00+08:00').valueOf(), + duration: 13 * 60, + }, + { + id: 'selected-day', + asset_id: 'bay-2', + status: 'approved', + date: new Date('2026-08-03T17:30:00+08:00').valueOf(), + date_end: new Date('2026-08-04T06:30:00+08:00').valueOf(), + duration: 13 * 60, + }, + ] as Booking[]; + spectator = createComponent(); + + expect( + spectator.component.filtered_events().map(({ id }) => id), + ).toEqual(['selected-day']); + }); + + it('should mark overnight booking end times as the next day', () => { + selected_date = new Date('2026-08-03T12:00:00+08:00').valueOf(); + bookings = [ + { + id: 'overnight', + asset_id: 'bay-1', + status: 'approved', + date: new Date('2026-08-03T17:30:00+08:00').valueOf(), + date_end: new Date('2026-08-04T06:30:00+08:00').valueOf(), + duration: 13 * 60, + } as Booking, + ]; + spectator = createComponent(); + + expect(spectator.query('sup')).toHaveText('+1'); + }); + it('should add custom extension data columns', () => { custom_booking_columns = [ { field: 'cost_code', name: 'Cost Code' }, @@ -456,6 +504,8 @@ describe('ParkingBookingsListComponent', () => { bookings = [ { asset_id: 'bay-1', + date: selected_date, + date_end: selected_date + 60 * 60 * 1000, extension_data: { vehicle_type: 'truck' }, } as unknown as Booking, ]; diff --git a/apps/workplace/e2e/local/booking-scoping.spec.ts b/apps/workplace/e2e/local/booking-scoping.spec.ts new file mode 100644 index 0000000000..6d3256138e --- /dev/null +++ b/apps/workplace/e2e/local/booking-scoping.spec.ts @@ -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 { + 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); + } + }); +}); diff --git a/apps/workplace/e2e/local/desk-clash.spec.ts b/apps/workplace/e2e/local/desk-clash.spec.ts new file mode 100644 index 0000000000..9ae90ef48b --- /dev/null +++ b/apps/workplace/e2e/local/desk-clash.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/workplace/e2e/local/pkce.spec.ts b/apps/workplace/e2e/local/pkce.spec.ts new file mode 100644 index 0000000000..467a2808f0 --- /dev/null +++ b/apps/workplace/e2e/local/pkce.spec.ts @@ -0,0 +1,94 @@ +/** + * AUTH-E2E-01 — the PKCE handshake, asserted on the wire. + * + * `login.spec.ts` proves a login works and that the resulting token is usable. + * This proves the handshake that produced it was actually safe, which is a + * different question: a client that leaked a secret, or quietly dropped PKCE, + * would still end up with a perfectly valid token and a perfectly working app. + * Nothing about the response tells you the exchange was sound. You have to look + * at what the browser sent. + * + * These assertions previously lived in tasks/PPT-2536/e2e/backoffice-login.spec.js, + * written during the auth.cr migration. They belong with the suite rather than in + * a task folder, where nothing runs them. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { loginViaUI } from '../../../../e2e/support/login'; +import { APP_URL, roleFor } from '../../../../e2e/support/env'; +import { clientId, redirectUriFor } from '../../../../e2e/support/auth'; + +// The subject is the handshake itself, so start with no credentials. +test.use({ storageState: undefined }); + +test.describe('PKCE handshake', () => { + test('the browser performs a public-client PKCE exchange with no secret', async ({ page }) => { + const { requests, token } = await loginViaUI(page, roleFor('admin')); + + const authorize = requests.find((r) => r.url.includes('/oauth/authorize')); + const exchange = requests.find( + (r) => r.url.includes('/oauth/token') && !r.url.includes('refresh_token'), + ); + + expect(authorize, 'an /oauth/authorize request should have been made').toBeTruthy(); + expect(exchange, 'an /oauth/token request should have been made').toBeTruthy(); + + // --- the authorize leg ------------------------------------------------- + // 302 is the redirect back to the app carrying ?code=. A 200 here would + // mean we were served a page instead, i.e. the handshake never completed. + expect(authorize!.status, 'authorize redirects back with a code').toBe(302); + + expect(authorize!.url, 'PKCE must use S256, never "plain"').toContain( + 'code_challenge_method=S256', + ); + + const challenge = new URL(authorize!.url).searchParams.get('code_challenge') ?? ''; + expect(challenge.length, 'a real code_challenge was sent').toBeGreaterThan(20); + // base64url, so it must not contain the standard-base64-only characters. + expect(challenge, 'the challenge is base64url encoded').not.toMatch(/[+/=]/); + + // --- the token leg ----------------------------------------------------- + expect(exchange!.status, 'token exchange succeeds').toBe(200); + + // The whole point of a public client: the browser holds no secret, so it + // must never send one. If this ever fails, a credential is sitting in a + // shipped bundle where anyone can read it. + const wire = `${exchange!.url} ${exchange!.postData ?? ''}`; + expect(wire, 'no client_secret anywhere in the token request').not.toContain( + 'client_secret', + ); + + // The verifier is what proves this client started the flow. Without it, + // an intercepted code could be redeemed by anyone. + expect(wire, 'the code_verifier was sent').toContain('code_verifier'); + + // And it is the client we registered, not something else. + expect(wire, 'the expected client_id was used').toContain( + clientId(redirectUriFor(APP_URL)), + ); + + expect(token.access_token, 'the exchange produced a token').toBeTruthy(); + }); + + test('no credentials are exposed in any /auth request', async ({ page }) => { + const { requests } = await loginViaUI(page, roleFor('admin')); + const password = roleFor('admin').password; + + // The password legitimately appears once, in the sign-in POST body. It must + // not turn up anywhere else, and above all never in a URL, where it would be + // captured by browser history, proxies and server logs. + for (const r of requests) { + expect(r.url, `password must never appear in a URL (${r.method} ${r.url})`).not.toContain( + password, + ); + expect(r.url, `no client_secret in a URL (${r.method} ${r.url})`).not.toContain( + 'client_secret', + ); + } + + const bodies_with_password = requests.filter((r) => (r.postData ?? '').includes(password)); + expect( + bodies_with_password.every((r) => r.url.includes('/auth/signin')), + 'the password should only ever be sent to /auth/signin', + ).toBeTruthy(); + }); +}); diff --git a/e2e/support/login.ts b/e2e/support/login.ts index e6f3a35bf9..82449ee9d5 100644 --- a/e2e/support/login.ts +++ b/e2e/support/login.ts @@ -17,9 +17,26 @@ import { Page, expect } from '@playwright/test'; import { APP_URL, Role } from './env'; +/** One request the browser made to /auth/*, as observed on the wire. */ +export interface AuthRequest { + url: string; + method: string; + status: number; + postData: string | null; +} + export interface LoginResult { /** The token-endpoint response the browser actually received. */ token: { access_token?: string; refresh_token?: string; scope?: string; expires_in?: number }; + /** + * Every `/auth/*` request the browser made during the login, in order. + * + * Captured so specs can assert on what was actually sent rather than on what + * the SDK claims it sent. The distinction matters: a client that leaks a + * secret, or silently drops PKCE, still returns a perfectly valid-looking + * token, so the response alone cannot tell you the handshake was safe. + */ + requests: AuthRequest[]; } /** @@ -36,8 +53,18 @@ export interface LoginResult { */ export async function loginViaUI(page: Page, role: Role): Promise { let token: LoginResult['token'] = {}; + const requests: AuthRequest[] = []; page.on('response', async (res) => { - if (res.url().includes('/oauth/token') && res.status() === 200) { + const url = res.url(); + if (!url.includes('/auth/')) return; + const req = res.request(); + requests.push({ + url, + method: req.method(), + status: res.status(), + postData: req.postData(), + }); + if (url.includes('/oauth/token') && res.status() === 200) { try { token = await res.json(); } catch { @@ -64,7 +91,7 @@ export async function loginViaUI(page: Page, role: Role): Promise { await page.waitForURL(new RegExp(escapeRe(new URL(APP_URL).host)), { timeout: 30_000 }); await expect(page.locator('topbar')).toBeVisible({ timeout: 30_000 }); - return { token }; + return { token, requests }; } function escapeRe(s: string): string { diff --git a/e2e/support/repro/reg09-client-abort.ts b/e2e/support/repro/reg09-client-abort.ts new file mode 100644 index 0000000000..f44243eb7b --- /dev/null +++ b/e2e/support/repro/reg09-client-abort.ts @@ -0,0 +1,89 @@ +/** + * PPT-2642 diagnostic — does a client that disconnects mid-request leave a + * transaction open on the server? + * + * The burst reproducer leaves a backend `idle in transaction` for as long as the + * process lives, with pg-orm 2.2.3's discard handling compiled in. That means a + * `BEGIN` was issued and neither committed nor rolled back — which is what you + * would expect if the request fiber died between the two, rather than unwinding + * through the `ensure` that returns the connection. + * + * The burst always aborted a couple of requests client-side, so this isolates + * that one variable: no concurrency, no serialization failure, just a POST that + * is cut off after the transaction has certainly started. + * + * e2e/stack/up.sh + * docker compose -p placeos-e2e restart staff-api # clean pool + * ABORT_MS=60 N=5 bunx tsx e2e/support/repro/reg09-client-abort.ts + * + * Then, with no other traffic: + * docker exec placeos-e2e-postgres-1 psql -U placeos -d placeos \ + * -c "SELECT pid, state, now()-state_change AS idle_for FROM pg_stat_activity + * WHERE state = 'idle in transaction';" + * + * A backend still `idle in transaction` seconds later means the abort is the + * trigger, and the fix belongs where the request fiber is torn down. + */ +import { mintToken } from '../auth'; + +const B = 'https://localhost:9443'; +const ABORT_MS = Number(process.env.ABORT_MS ?? 60); +const N = Number(process.env.N ?? 5); + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +(async () => { + const m = await mintToken( + B, + `${B}/backoffice`, + 'support@place.tech', + 'development', + ); + const auth = { Authorization: `Bearer ${m.accessToken}` }; + + const zones = ( + await (await fetch(`${B}/api/engine/v2/zones?limit=100`, { headers: auth })).json() + ).map((z: any) => z.id); + const me = await ( + await fetch(`${B}/api/engine/v2/users/current`, { headers: auth }) + ).json(); + + const base = Math.floor(Date.now() / 1000) + 86400 * 30; + let aborted = 0; + let completed = 0; + + for (let i = 0; i < N; i++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ABORT_MS); + try { + await fetch(`${B}/api/staff/v1/bookings`, { + method: 'POST', + signal: controller.signal, + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + booking_type: 'desk', + asset_id: `e2e-desk-${i % 5}`, + booking_start: base + i * 7200, + booking_end: base + i * 7200 + 3600, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: `REG09-abort ${i}`, + zones, + }), + }); + completed += 1; + } catch { + aborted += 1; + } finally { + clearTimeout(timer); + } + // let the server finish whatever it is doing before the next one + await new Promise((r) => setTimeout(r, 300)); + } + + console.log(`\n ${N} POSTs, aborted after ${ABORT_MS}ms`); + console.log(` aborted client-side: ${aborted}, completed: ${completed}`); + console.log(' now check pg_stat_activity for `idle in transaction`'); +})(); diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 7861525673..18b27aa541 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -352,6 +352,22 @@ export class BookingFormService extends AsyncHandler { return edits; } + /** + * Stash the user's in-progress edits for the reset that is about to run. + * + * Merges rather than replaces. A flow resets twice in a row — `loadForm` + * then `newForm` — and the first `form().reset()` clears the dirty flags + * `_userEditedValues` reads, so a plain assignment would overwrite a real + * capture with an empty one on the second call. + */ + private _captureUserEdits() { + const edits = { + ...(this._pending_user_edits || {}), + ...this._userEditedValues(), + }; + this._pending_user_edits = Object.keys(edits).length ? edits : null; + } + private _syncAssetOptions() { const { date, duration } = untracked(this.model); const next_asset_window = assetWindowKey(date, duration); @@ -821,7 +837,7 @@ export class BookingFormService extends AsyncHandler { // destroyed by the reset below. Capture it on the way back in — // as late as possible, so we take the user's final state. currentUserLoaded().then(() => { - this._pending_user_edits = this._userEditedValues(); + this._captureUserEdits(); this.newForm(type, booking); }); return; @@ -1073,6 +1089,19 @@ export class BookingFormService extends AsyncHandler { currentUserLoaded().then(() => this.loadForm(expected_type)); return; } + // Same hazard as `newForm`, and the one the flows actually hit: the form + // is rendered from first paint, but every flow calls this only after org + // data lands, so the reset below arrives on top of whatever the user has + // already entered. Capture before `form().reset()` clears the dirty + // flags `_userEditedValues` reads. + this._captureUserEdits(); + const user_edits = this._pending_user_edits; + // Flows call `loadForm(type)` and then `newForm(type)` in the same tick + // (desk-flow.component.ts:62 and :65, and the locker/parking + // equivalents). Leave the capture in place so that second reset replays + // it too, and release it at the end of the tick, where it can no longer + // reach an unrelated form. + queueMicrotask(() => (this._pending_user_edits = null)); this._startNetwork(); this._calendar.loadCalendars(); const data = JSON.parse( @@ -1111,6 +1140,12 @@ export class BookingFormService extends AsyncHandler { [null, undefined, ''], ); this._patch(booking_data, { emitEvent: false }); + // Re-apply the user's own edits over the loaded booking, before + // `applyDurationSettings` so a restored `all_day` still drives the + // time-sync window — same ordering as `newForm`. + if (user_edits && Object.keys(user_edits).length) { + this._patch(user_edits, { emitEvent: false }); + } this.applyDurationSettings(); this._form_value.set(this.model()); this._syncAssetOptions(); diff --git a/libs/bookings/src/test/booking-form.service.spec.ts b/libs/bookings/src/test/booking-form.service.spec.ts index d24a803df8..02221fc67b 100644 --- a/libs/bookings/src/test/booking-form.service.spec.ts +++ b/libs/bookings/src/test/booking-form.service.spec.ts @@ -2884,6 +2884,62 @@ describe('BookingFormService', () => { expect((savedBookings()[0] as Booking).asset_ids).toEqual(['desk-2']); }); + describe('initialisation after the user has already loaded', () => { + /** + * The case the flows actually hit. Every booking flow renders its form + * on first paint but initialises it late: `NewDeskFlowComponent.ngOnInit` + * awaits org initialisation plus a 300ms settle, then calls `loadForm` + * and — for a fresh booking — `newForm`, back to back. + * + * The current user is restored from the localStorage cache within about + * 50ms of bootstrap, long before org data arrives, so `newForm` never + * takes its deferred branch here. Nothing is mocked and no runtime probe + * is neutralised: this is the ordinary path. + */ + function userEdits(field: string, value: any) { + const node = (spectator.service.form as any)[field](); + node.value.set(value); + node.markAsDirty(); + } + + it('keeps input entered before the flow initialises the form', () => { + userEdits('title', 'Quiet corner desk'); + userEdits('all_day', true); + + // exactly what desk-flow.component.ts does once org data lands + spectator.service.loadForm('desk'); + spectator.service.newForm('desk'); + + expect(spectator.service.model().title).toBe('Quiet corner desk'); + expect(spectator.service.model().all_day).toBe(true); + }); + + it('carries a typed title between booking forms, deliberately', () => { + // Switching desk -> parking without leaving the booking area does not + // reset the form, so the user's own typing follows them. Pinned + // rather than left to chance: only fields they actually edited move, + // `isCrossTypeEdit` still discards the previous booking's identity, + // and leaving the booking section entirely calls `clearForm()`. + userEdits('title', 'Desk title'); + spectator.service.loadForm('parking'); + spectator.service.newForm('parking'); + expect(spectator.service.model().title).toBe('Desk title'); + }); + + it('does not carry those edits into a later unrelated form', () => { + userEdits('title', 'Quiet corner desk'); + spectator.service.loadForm('desk'); + spectator.service.newForm('desk'); + expect(spectator.service.model().title).toBe('Quiet corner desk'); + + // A form opened later must start clean, not inherit the last one. + spectator.service.newForm('desk'); + expect(spectator.service.model().title).not.toBe( + 'Quiet corner desk', + ); + }); + }); + describe('initialisation while the user is still loading', () => { /** * Put the service into the state `newForm` sees on a slow load: no diff --git a/libs/common/src/lib/general.ts b/libs/common/src/lib/general.ts index 76b6f93cbb..58f6d0cd04 100644 --- a/libs/common/src/lib/general.ts +++ b/libs/common/src/lib/general.ts @@ -1168,6 +1168,9 @@ export function setupFormTimeSync( date_end: finiteNumber(snap().date_end), all_day: snap().all_day, }; + let timed_window: + | { date: number; duration: number; date_end: number } + | undefined; const refreshPrev = () => { const s = snap(); prev.date = finiteNumber(s.date); @@ -1585,6 +1588,11 @@ export function setupFormTimeSync( () => { const all_day = snap().all_day; if (all_day) { + timed_window = { + date: normaliseTimeValue(snap().date), + duration: normaliseTimeValue(snap().duration), + date_end: normaliseTimeValue(snap().date_end), + }; applyPatch( getAllDayTimeRange( normaliseTimeValue(snap().date), @@ -1593,7 +1601,14 @@ export function setupFormTimeSync( all_day_end, ) as Partial, ); + } else if ( + timed_window && + !isMultiday(timed_window.date, normaliseTimeValue(snap().date)) + ) { + applyPatch(timed_window as Partial); + timed_window = undefined; } else { + timed_window = undefined; const date = normaliseTimeValue(snap().date); const duration = normaliseTimeValue(snap().duration); const date_end = normaliseTimeValue(snap().date_end); diff --git a/libs/common/src/tests/general.spec.ts b/libs/common/src/tests/general.spec.ts index 8eefc24b4d..ee4bed2bb8 100644 --- a/libs/common/src/tests/general.spec.ts +++ b/libs/common/src/tests/general.spec.ts @@ -659,6 +659,28 @@ describe('General Methods', () => { // --- all_day --- + it('should restore the timed window after toggling all_day on and off', () => { + const date_end = addMinutes(BASE, 90).valueOf(); + const form = createForm({ + date: BASE, + duration: 90, + date_end, + }); + setupFormTimeSync(form, {}, injector); + + setField(form, { all_day: true }); + setField(form, { all_day: false }); + + expect(form()).toEqual( + expect.objectContaining({ + date: BASE, + duration: 90, + date_end, + all_day: false, + }), + ); + }); + it('should reset duration to default_duration when all_day is toggled off', () => { const form = createForm({ date: BASE, diff --git a/libs/events/src/lib/event-form.service.ts b/libs/events/src/lib/event-form.service.ts index b6479ee284..c7d601b759 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -75,22 +75,46 @@ const BOOKING_URLS = [ 'upcoming', ]; -/** - * Form fields that change without the user editing them, so they can't be used - * to work out whether a booking has details other than its attendees changed. - * `attendees` is compared on its own, and the remaining fields are either - * derived (`date_end`), re-normalised on load (`system`) or re-hydrated with - * extra detail from the API (`organiser`, `resources`) — the identity of those - * last two is compared separately. - */ +/** Form fields that are derived or need semantic comparison below. */ const IGNORED_DETAIL_FIELDS = [ 'attendees', + 'body', 'system', 'date_end', 'organiser', + 'recurrence', 'resources', ]; +function normaliseEventBody(body: string) { + const template = document.createElement('template'); + template.innerHTML = body || ''; + const serialise = (node: Node): string => { + if (node.nodeType === Node.TEXT_NODE) { + return (node.textContent || '').replace(/\u200b/g, ''); + } + if (node.nodeType !== Node.ELEMENT_NODE) return ''; + const element = node as Element; + if (element.tagName === 'BR') return '\n'; + const content = [...element.childNodes].map(serialise).join(''); + if (element.tagName === 'DIV' || element.tagName === 'P') { + return `\n${content}\n`; + } + const tag = element.tagName.toLowerCase(); + const attributes = [...element.attributes] + .sort((a, b) => a.name.localeCompare(b.name)) + .map(({ name, value }) => ` ${name}="${value}"`) + .join(''); + return `<${tag}${attributes}>${content}`; + }; + return [...template.content.childNodes] + .map(serialise) + .join('') + .replace(/[ \t]+\n|\n[ \t]+/g, '\n') + .replace(/\n+/g, '\n') + .trim(); +} + enum Tags { Availability = 'AVAILABILITY', BookingRules = 'BOOKING_RULES', @@ -1318,10 +1342,28 @@ export class EventFormService extends AsyncHandler { const details = Object.entries(value).filter( ([key]) => !IGNORED_DETAIL_FIELDS.includes(key), ); + const recurrence = value.recurrence; + details.push(['body', normaliseEventBody(value.body)]); details.push(['host_email', (value.organiser as any)?.email || '']); + details.push([ + 'recurrence', + recurrence?.pattern && recurrence?._pattern !== 'none' + ? [ + recurrence.pattern, + recurrence.interval || 1, + [...(recurrence.days_of_week || [])].sort(), + recurrence.nth_of_month || null, + recurrence.start || null, + recurrence.end || null, + recurrence.occurrences || null, + ] + : null, + ]); details.push([ 'space_ids', - (value.resources || []).map((_: any) => _.id || _.email || ''), + (value.resources || []) + .map((_: any) => (_.email || _.id || '').toLowerCase()) + .sort(), ]); details.sort(([a], [b]) => (a > b ? 1 : -1)); return JSON.stringify(details); diff --git a/libs/events/src/tests/event-form.service.spec.ts b/libs/events/src/tests/event-form.service.spec.ts index 5b771f8c1e..250572f0a2 100644 --- a/libs/events/src/tests/event-form.service.spec.ts +++ b/libs/events/src/tests/event-form.service.spec.ts @@ -330,6 +330,103 @@ describe('EventFormService', () => { expect(service.can_notify_new_attendees_only()).toBe(false); }); + it('should restore attendee-only notification eligibility for equivalent form values', () => { + const date = new Date(2028, 5, 15, 10).valueOf(); + const event = new CalendarEvent({ + id: 'event-1', + host: 'host@test.com', + title: 'Team meeting', + body: 'Original notes', + date, + duration: 90, + attendees: [{ email: 'existing@test.com' } as any], + resources: [ + { + id: 'calendar-resource-1', + email: 'space-1@test.com', + zones: [], + } as any, + ], + }); + service.newForm(event); + TestBed.tick(); + service.model.update((model) => ({ + ...model, + attendees: [...model.attendees, { email: 'new@test.com' }], + })); + + service.model.update((model) => ({ ...model, all_day: true })); + TestBed.tick(); + expect(service.can_notify_new_attendees_only()).toBe(false); + service.model.update((model) => ({ ...model, all_day: false })); + TestBed.tick(); + expect(service.model()).toEqual( + expect.objectContaining({ + date, + duration: 90, + date_end: date + 90 * 60 * 1000, + all_day: false, + }), + ); + expect(service.can_notify_new_attendees_only()).toBe(true); + + service.model.update((model) => ({ + ...model, + recurrence: { + pattern: 'daily', + _pattern: 'daily', + interval: 1, + start: date, + end: date + 24 * 60 * 60 * 1000, + }, + })); + expect(service.can_notify_new_attendees_only()).toBe(false); + service.model.update((model) => ({ + ...model, + recurrence: { + pattern: 'daily', + _pattern: 'none', + interval: 1, + days_of_week: [], + start: date, + end: date, + }, + })); + expect(service.can_notify_new_attendees_only()).toBe(true); + + service.model.update((model) => ({ + ...model, + resources: [ + { + id: 'placeos-system-2', + email: 'space-2@test.com', + } as any, + ], + })); + expect(service.can_notify_new_attendees_only()).toBe(false); + service.model.update((model) => ({ + ...model, + resources: [ + { + id: 'placeos-system-1', + email: 'space-1@test.com', + } as any, + ], + })); + expect(service.can_notify_new_attendees_only()).toBe(true); + + service.model.update((model) => ({ + ...model, + body: '
Changed notes
', + })); + expect(service.can_notify_new_attendees_only()).toBe(false); + service.model.update((model) => ({ + ...model, + body: '
Original notes
', + })); + expect(service.can_notify_new_attendees_only()).toBe(true); + }); + it('should suppress existing attendee notifications for attendee-only edits', async () => { const event = new CalendarEvent({ id: 'event-1',