From 1f681ac9ecf9b4e0d685c09920242b00461b9c3f Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Thu, 8 Oct 2026 10:50:43 +1100 Subject: [PATCH 1/2] ci(e2e): back the stack's calendar and directory with the Microsoft 365 sandbox tenant The tenant row carried placeholder credentials, so every calendar-backed route (the concierge day view, the staff directory, the rooms and attendance reports) died at Microsoft and their specs could only be guarded. With E2E_O365_TENANT, E2E_O365_CLIENT_ID and E2E_O365_CLIENT_SECRET set, the seed writes app-only credentials to the row, updating one left over from a placeholder run, and creates a local admin whose address is a mailbox in the tenant so staff-api can create events on its behalf. Without them nothing changes and the specs that need the tenant skip. CI takes the values from the repository variables and secret of the same names; they belong to the sandbox app "PlaceOS Bookings Visualiser". --- .github/workflows/e2e-advisory.yml | 6 +++ e2e/README.md | 10 ++++ e2e/stack/SELF_HOSTED_RUNNER.md | 6 ++- e2e/support/calendar/calendar.env.ts | 50 ++++++++++++++++++ e2e/support/env.ts | 18 ++++++- e2e/support/seed.ts | 79 ++++++++++++++++++++++++---- 6 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 e2e/support/calendar/calendar.env.ts diff --git a/.github/workflows/e2e-advisory.yml b/.github/workflows/e2e-advisory.yml index 4ed62770dc7..5f2271700a7 100644 --- a/.github/workflows/e2e-advisory.yml +++ b/.github/workflows/e2e-advisory.yml @@ -105,6 +105,12 @@ jobs: # matters more here than shaving a minute off the run. E2E_WORKERS: '2' TZ: 'Etc/UTC' + # The Microsoft 365 sandbox tenant behind the stack's calendar and + # directory (e2e/support/calendar/calendar.env.ts). Without these the + # calendar-backed concierge specs skip; nothing else changes. + E2E_O365_TENANT: ${{ vars.E2E_O365_TENANT }} + E2E_O365_CLIENT_ID: ${{ vars.E2E_O365_CLIENT_ID }} + E2E_O365_CLIENT_SECRET: ${{ secrets.E2E_O365_CLIENT_SECRET }} steps: # persist-credentials: false — checkout otherwise leaves the token in # .git/config. Nothing here pushes, and this runner is not a fresh VM. diff --git a/e2e/README.md b/e2e/README.md index 8b8e40fa2c0..b047f24820c 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -54,6 +54,16 @@ search-ingest, frontend-loader, auth, rest-api, staff-api, core, nginx, init. Dropped from PlaceOS/local: edge, triggers, dispatch, source, influx, chronograf, mosquitto, minio and the loki/grafana profile — roughly half the containers. +The calendar and directory can be backed by the PlaceOS Microsoft 365 sandbox +tenant. Set `E2E_O365_TENANT`, `E2E_O365_CLIENT_ID` and `E2E_O365_CLIENT_SECRET` +before `up.sh` (CI takes them from the repository variables and secret of the +same names) and the seed writes app-only credentials to the tenant row, creates +a local admin whose address is a mailbox there (`roleFor('calendar')`), and +`/api/staff/v1/events` and `/api/staff/v1/people` answer from the tenant. +Without them the tenant row carries placeholders, those routes answer 500 at +Microsoft, and the specs that need them skip. See +`e2e/support/calendar/calendar.env.ts`. + core runs real drivers from PlaceOS/drivers, pinned to a commit in `e2e/support/drivers/drivers.env.ts`. It compiles nothing itself: binaries come from the PlaceOS build farm (build.placeos.run), which builds a commit it has not diff --git a/e2e/stack/SELF_HOSTED_RUNNER.md b/e2e/stack/SELF_HOSTED_RUNNER.md index 2a52a1bd5f6..4fb112b8e4a 100644 --- a/e2e/stack/SELF_HOSTED_RUNNER.md +++ b/e2e/stack/SELF_HOSTED_RUNNER.md @@ -29,7 +29,8 @@ for host in github.com api.github.com codeload.github.com \ objects.githubusercontent.com release-assets.githubusercontent.com \ pipelines.actions.githubusercontent.com \ registry-1.docker.io auth.docker.io production.cloudflare.docker.com \ - build.placeos.run placeos-drivers.s3-ap-southeast-2.amazonaws.com; do + build.placeos.run placeos-drivers.s3-ap-southeast-2.amazonaws.com \ + login.microsoftonline.com graph.microsoft.com; do printf '%-48s ' "$host" curl -sS -o /dev/null -m 10 -w '%{http_code}\n' "https://$host" 2>&1 | tail -1 done @@ -43,6 +44,9 @@ timeouts, not 200s. - **registry-1.docker.io / auth.docker.io / production.cloudflare.docker.com** — pulling the PlaceOS images. The one people forget; without it the stack cannot start. Every run pulls (`up.sh --pull`), so this needs to stay open, not just work once. +- **login.microsoftonline.com / graph.microsoft.com** — the Microsoft 365 sandbox + tenant behind the stack's calendar and directory, reached by staff-api with the + app-only credentials the workflow passes in (`E2E_O365_*`). - **build.placeos.run / placeos-drivers.s3-ap-southeast-2.amazonaws.com** — the build farm core fetches driver binaries from, and the bucket its download links point at. The stack is torn down with its volumes after every run, so every run diff --git a/e2e/support/calendar/calendar.env.ts b/e2e/support/calendar/calendar.env.ts new file mode 100644 index 00000000000..13c729575a2 --- /dev/null +++ b/e2e/support/calendar/calendar.env.ts @@ -0,0 +1,50 @@ +/** + * The Microsoft 365 tenant the stack's calendar and directory can be backed by. + * + * Off unless all three `E2E_O365_*` variables are set. Off, `seed.ts` creates + * the tenant row with placeholder credentials and every calendar-backed route + * (`/api/staff/v1/events`, `/api/staff/v1/staff`) answers 500 at Microsoft; the + * PlaceOS-native surfaces (desks, lockers, parking, visitors, native room + * bookings) are unaffected. On, the row carries app-only credentials and those + * routes answer from the tenant. + * + * CI sets them from the repository variables `E2E_O365_TENANT` and + * `E2E_O365_CLIENT_ID` and the secret `E2E_O365_CLIENT_SECRET`; they belong to + * the PlaceOS sandbox tenant and the app "PlaceOS Bookings Visualiser", which + * holds Calendars.ReadWrite, User.Read.All, GroupMember.Read.All and + * Place.Read.All. + */ +export const O365 = { + tenant: process.env.E2E_O365_TENANT ?? '', + client_id: process.env.E2E_O365_CLIENT_ID ?? '', + client_secret: process.env.E2E_O365_CLIENT_SECRET ?? '', +}; + +export const CALENDAR_ENABLED = !!( + O365.tenant && + O365.client_id && + O365.client_secret +); + +/** + * A local admin whose email is a mailbox in that tenant. + * + * staff-api creates an event on the signed-in user's own calendar and only lets + * a user host for themselves (`can_create?` in events.cr), so the identity that + * books through the calendar has to exist in the tenant. The default is one of + * the tenant's demo users. The password is local: this is an ordinary PlaceOS + * user, created by `seed.ts`, that happens to share an address with a mailbox. + */ +export const CALENDAR_USER_EMAIL = + process.env.E2E_CALENDAR_USER ?? 'AdeleV@0cbfs.onmicrosoft.com'; +export const CALENDAR_USER_PASSWORD = + process.env.E2E_CALENDAR_PASSWORD ?? 'e2e-calendar-development'; + +/** + * A room mailbox in the tenant, for the one room system the calendar specs book. + * + * Shared with placeos-dev's "[PlaceOS Dev] Sydney Room 4": the sandbox has no + * spare room mailboxes. The specs book days out and delete what they create. + */ +export const CALENDAR_ROOM_EMAIL = + process.env.E2E_CALENDAR_ROOM ?? 'testroom4@0cbfs.onmicrosoft.com'; diff --git a/e2e/support/env.ts b/e2e/support/env.ts index 401b2228f6e..3f772142b2b 100644 --- a/e2e/support/env.ts +++ b/e2e/support/env.ts @@ -68,7 +68,7 @@ export function assertLocalOnly(...urls: string[]): void { } } -export type RoleName = 'admin' | 'staff'; +export type RoleName = 'admin' | 'staff' | 'calendar'; export interface Role { name: RoleName; @@ -83,6 +83,13 @@ export interface Role { const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'support@place.tech'; const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'development'; const STAFF_PASSWORD = process.env.E2E_STAFF_PASSWORD ?? 'e2e-staff-development'; +/** + * The admin whose address is a mailbox in the Microsoft 365 tenant, when the + * stack is backed by one (`e2e/support/calendar/calendar.env.ts`). Seeded only + * then; `roleFor('calendar')` on a stack without it has no user behind it. + */ +const CALENDAR_EMAIL = process.env.E2E_CALENDAR_USER ?? 'AdeleV@0cbfs.onmicrosoft.com'; +const CALENDAR_PASSWORD = process.env.E2E_CALENDAR_PASSWORD ?? 'e2e-calendar-development'; /** Per-worker non-admin address. Seeded by seed.ts for 0..WORKERS-1. */ export function staffEmail(workerIndex: number): string { @@ -117,6 +124,15 @@ export function roleFor(name: RoleName, workerIndex = 0): Role { tokenPath: path.join(AUTH_DIR, `admin-${workerIndex}.token.json`), }; } + if (name === 'calendar') { + return { + name, + email: CALENDAR_EMAIL, + password: CALENDAR_PASSWORD, + storagePath: path.join(AUTH_DIR, `calendar-${workerIndex}.json`), + tokenPath: path.join(AUTH_DIR, `calendar-${workerIndex}.token.json`), + }; + } return { name, email: staffEmail(workerIndex), diff --git a/e2e/support/seed.ts b/e2e/support/seed.ts index 2833212ed14..00a65cfe18a 100644 --- a/e2e/support/seed.ts +++ b/e2e/support/seed.ts @@ -27,6 +27,7 @@ import { import { mintToken, clientId, redirectUriFor } from './auth'; import { ENGINE_API, STAFF_API } from './api'; import { describeDrivers, ensureDrivers } from './drivers/drivers.seed'; +import { CALENDAR_ENABLED, CALENDAR_USER_EMAIL, O365 } from './calendar/calendar.env'; /** The app `init` guarantees exists — our way in before anything else is registered. */ const BOOTSTRAP_APP_URL = `${BACKEND_URL}/backoffice`; @@ -151,31 +152,80 @@ async function ensureAbsoluteLoginUrl(api: APIRequestContext) { * for the calendar-backed routes (/calendars, /events) and nothing else — so a * placeholder tenant unblocks the whole PlaceOS-native booking surface (desks, * lockers, parking, visitors) with ZERO external calls. That is what keeps this - * suite genuinely local. Real calendar credentials are opt-in; see e2e/README.md. + * suite genuinely local. Real credentials are opt-in through the `E2E_O365_*` + * variables (`e2e/support/calendar/calendar.env.ts`); with them the row is + * app-only (no delegated access, no service account) so staff-api acts as the + * signed-in user, and a row left over from a placeholder run is updated. */ async function ensureTenant(api: APIRequestContext) { const domain = new URL(BACKEND_URL).hostname; + const credentials = CALENDAR_ENABLED + ? { tenant: O365.tenant, client_id: O365.client_id, client_secret: O365.client_secret } + : { + tenant: 'e2e-local-placeholder', + client_id: 'e2e-local-placeholder', + client_secret: 'e2e-local-placeholder', + }; const existing = await json(api, `${STAFF_API}/tenants`); const list = Array.isArray(existing) ? existing : (existing.results ?? []); - if (list.some((t: { domain?: string }) => t.domain === domain)) { - return { domain, created: false }; + const found = list.find((t: { domain?: string }) => t.domain === domain); + if (found && !CALENDAR_ENABLED) return { domain, created: false, calendar: false }; + if (found) { + // Credentials are never returned, so the row is re-sent every time the + // calendar is on; staff-api encrypts them on save. + const res = await api.put(`${STAFF_API}/tenants/${found.id}`, { + data: { ...found, platform: 'office365', delegated: false, credentials }, + }); + if (!res.ok()) { + throw new Error(`update tenant failed: HTTP ${res.status()} ${await res.text()}`); + } + return { domain, created: false, calendar: true }; } const res = await api.post(`${STAFF_API}/tenants`, { data: { name: 'E2E Local', domain, platform: 'office365', - credentials: { - tenant: 'e2e-local-placeholder', - client_id: 'e2e-local-placeholder', - client_secret: 'e2e-local-placeholder', - }, + delegated: false, + credentials, }, }); if (!res.ok()) { throw new Error(`create tenant failed: HTTP ${res.status()} ${await res.text()}`); } - return { domain, created: true }; + return { domain, created: true, calendar: CALENDAR_ENABLED }; +} + +/** + * The admin whose address is a mailbox in the tenant (`roleFor('calendar')`). + * + * A sys_admin, because the concierge calendar specs sign in as this user and + * concierge admits admin and support users by default. + */ +async function ensureCalendarUser(api: APIRequestContext) { + const role = roleFor('calendar'); + const authority_id = (await authority(api)).id; + const found = await json(api, `${ENGINE_API}/users`, { q: role.email, limit: '50' }); + const list = Array.isArray(found) ? found : (found.results ?? []); + if (list.some((u: { email?: string }) => u.email?.toLowerCase() === role.email.toLowerCase())) { + return { email: role.email, created: false }; + } + const res = await api.post(`${ENGINE_API}/users`, { + data: { + name: 'E2E Calendar Admin', + email: role.email, + password: role.password, + authority_id, + sys_admin: true, + support: false, + }, + }); + if (!res.ok()) { + const body = await res.text(); + if (alreadyExists(body)) return { email: role.email, created: false }; + throw new Error(`create user ${role.email} failed: HTTP ${res.status()} ${body}`); + } + return { email: role.email, created: true }; } /** @@ -278,7 +328,16 @@ export async function seed(): Promise { const login = await ensureAbsoluteLoginUrl(api); console.log(` login_url ${login.changed ? 'patched' : 'ok'} ${login.login_url}`); const tenant = await ensureTenant(api); - console.log(` tenant ${tenant.created ? 'created' : 'present'} domain=${tenant.domain}`); + console.log( + ` tenant ${tenant.created ? 'created' : 'present'} domain=${tenant.domain} ` + + `calendar=${tenant.calendar ? `microsoft 365 (${O365.tenant})` : 'placeholder'}`, + ); + if (CALENDAR_ENABLED) { + const calendar_user = await ensureCalendarUser(api); + console.log( + ` calendar ${calendar_user.created ? 'created' : 'present'} admin ${CALENDAR_USER_EMAIL}`, + ); + } const desks = await ensureDesks(api); console.log(` desks ${desks.count} on ${desks.zone}`); const users = await ensureStaffUsers(api); From f2bc0b3bc36f0b3188329025a03fd9dc2a6fb023 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 8 Oct 2026 13:43:57 +1100 Subject: [PATCH 2/2] ci(e2e): let specs ask the stack whether the calendar is backed - The seed names the tenant row after what it holds, and specs read it with calendarBacked(), so the test step needs no O365 secret. - The O365 vars move from the job env to the bring-up step only. - ensureTenant always writes the row back, so a reused stack goes back to placeholders. - ensureUser upserts users for staff and calendar roles and updates the password and sys_admin of an existing user. - calendar.env.ts loads e2e/.env itself and is the one source for the calendar user. - Fix the directory route in the docs and list the new vars in .env.example. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/e2e-advisory.yml | 16 +-- e2e/.env.example | 13 +++ e2e/README.md | 5 +- e2e/support/calendar/calendar.api.ts | 19 ++++ e2e/support/calendar/calendar.env.ts | 28 +++-- e2e/support/env.ts | 15 ++- e2e/support/seed.ts | 153 +++++++++++++-------------- 7 files changed, 145 insertions(+), 104 deletions(-) create mode 100644 e2e/support/calendar/calendar.api.ts diff --git a/.github/workflows/e2e-advisory.yml b/.github/workflows/e2e-advisory.yml index 665e70965de..f469bdab45f 100644 --- a/.github/workflows/e2e-advisory.yml +++ b/.github/workflows/e2e-advisory.yml @@ -105,12 +105,6 @@ jobs: # matters more here than shaving a minute off the run. E2E_WORKERS: '2' TZ: 'Etc/UTC' - # The Microsoft 365 sandbox tenant behind the stack's calendar and - # directory (e2e/support/calendar/calendar.env.ts). Without these the - # calendar-backed concierge specs skip; nothing else changes. - E2E_O365_TENANT: ${{ vars.E2E_O365_TENANT }} - E2E_O365_CLIENT_ID: ${{ vars.E2E_O365_CLIENT_ID }} - E2E_O365_CLIENT_SECRET: ${{ secrets.E2E_O365_CLIENT_SECRET }} steps: # persist-credentials: false — checkout otherwise leaves the token in # .git/config. Nothing here pushes, and this runner is not a fresh VM. @@ -178,8 +172,18 @@ jobs: # GitHub once, to clone PlaceOS/www-core into the www volume — that is # where the platform /login page comes from — and to the build farm, # which core fetches the suite's driver binaries from. + # + # The Microsoft 365 sandbox tenant behind the stack's calendar and + # directory (e2e/support/calendar/calendar.env.ts) is set on this step + # only: the seed is the one consumer, and the job-level env would hand + # the secret to `bun install` scripts and the dev server too. Without + # these the calendar-backed specs skip; nothing else changes. - name: Bring up the local PlaceOS stack run: e2e/stack/up.sh + env: + E2E_O365_TENANT: ${{ vars.E2E_O365_TENANT }} + E2E_O365_CLIENT_ID: ${{ vars.E2E_O365_CLIENT_ID }} + E2E_O365_CLIENT_SECRET: ${{ secrets.E2E_O365_CLIENT_SECRET }} # What backend did this run actually test? # diff --git a/e2e/.env.example b/e2e/.env.example index 4dd83d851d2..e0a1c03acca 100644 --- a/e2e/.env.example +++ b/e2e/.env.example @@ -23,3 +23,16 @@ E2E_STAFF_PASSWORD=e2e-staff-development # How many parallel workers to provision for. Must match (or exceed) the config's # `workers`; re-run seed.ts after changing it. CI uses 2. E2E_WORKERS=4 + +# The Microsoft 365 sandbox tenant behind the stack's calendar and directory. +# Read by the seed only (`e2e/stack/up.sh`); leave unset to keep the stack fully +# local with placeholder credentials. See e2e/support/calendar/calendar.env.ts. +# E2E_O365_TENANT= +# E2E_O365_CLIENT_ID= +# E2E_O365_CLIENT_SECRET= + +# Overrides for the tenant mailboxes the calendar specs use. The defaults are the +# sandbox tenant's demo user and a shared room; the password is local to PlaceOS. +# E2E_CALENDAR_USER=AdeleV@0cbfs.onmicrosoft.com +# E2E_CALENDAR_PASSWORD=e2e-calendar-development +# E2E_CALENDAR_ROOM=testroom4@0cbfs.onmicrosoft.com diff --git a/e2e/README.md b/e2e/README.md index fd77f03c192..88cd06fe166 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -63,8 +63,9 @@ same names) and the seed writes app-only credentials to the tenant row, creates a local admin whose address is a mailbox there (`roleFor('calendar')`), and `/api/staff/v1/events` and `/api/staff/v1/people` answer from the tenant. Without them the tenant row carries placeholders, those routes answer 500 at -Microsoft, and the specs that need them skip. See -`e2e/support/calendar/calendar.env.ts`. +Microsoft, and the specs that need them skip. Only the seed reads the variables: +specs ask the stack (`calendarBacked` in `e2e/support/calendar/calendar.api.ts`). +See `e2e/support/calendar/calendar.env.ts`. core runs real drivers from PlaceOS/drivers, pinned to a commit in `e2e/support/drivers/drivers.env.ts`. It compiles nothing itself: binaries come diff --git a/e2e/support/calendar/calendar.api.ts b/e2e/support/calendar/calendar.api.ts new file mode 100644 index 00000000000..b99bc776780 --- /dev/null +++ b/e2e/support/calendar/calendar.api.ts @@ -0,0 +1,19 @@ +import { APIRequestContext } from '@playwright/test'; +import { STAFF_API, asList, getJson } from '../api'; +import { BACKEND_URL } from '../env'; +import { CALENDAR_TENANT_NAME } from './calendar.env'; + +/** + * Was the stack under test seeded with the Microsoft 365 tenant? + * + * Calendar-backed specs skip when this is false. Reads the tenant row the seed + * wrote, so it answers for the stack, not for this process's env. Needs an + * admin `api`: staff-api lists tenants to admins only. + */ +export async function calendarBacked(api: APIRequestContext): Promise { + const tenants = asList<{ domain?: string; name?: string }>( + await getJson(api, `${STAFF_API}/tenants`), + ); + const domain = new URL(BACKEND_URL).hostname; + return tenants.find((t) => t.domain === domain)?.name === CALENDAR_TENANT_NAME; +} diff --git a/e2e/support/calendar/calendar.env.ts b/e2e/support/calendar/calendar.env.ts index 13c729575a2..b49b1e77067 100644 --- a/e2e/support/calendar/calendar.env.ts +++ b/e2e/support/calendar/calendar.env.ts @@ -1,19 +1,26 @@ /** * The Microsoft 365 tenant the stack's calendar and directory can be backed by. * - * Off unless all three `E2E_O365_*` variables are set. Off, `seed.ts` creates - * the tenant row with placeholder credentials and every calendar-backed route - * (`/api/staff/v1/events`, `/api/staff/v1/staff`) answers 500 at Microsoft; the + * Off unless all three `E2E_O365_*` variables are set when `seed.ts` runs. Off, + * the tenant row carries placeholder credentials and every calendar-backed route + * (`/api/staff/v1/events`, `/api/staff/v1/people`) answers 500 at Microsoft; the * PlaceOS-native surfaces (desks, lockers, parking, visitors, native room * bookings) are unaffected. On, the row carries app-only credentials and those * routes answer from the tenant. * - * CI sets them from the repository variables `E2E_O365_TENANT` and - * `E2E_O365_CLIENT_ID` and the secret `E2E_O365_CLIENT_SECRET`; they belong to - * the PlaceOS sandbox tenant and the app "PlaceOS Bookings Visualiser", which - * holds Calendars.ReadWrite, User.Read.All, GroupMember.Read.All and - * Place.Read.All. + * The variables are for the seed only. Specs ask the stack what it was seeded + * with (`calendarBacked` in `calendar.api.ts`), so they need no secret and cannot + * disagree with the stack they run against. + * + * CI sets them on the bring-up step from the repository variables + * `E2E_O365_TENANT` and `E2E_O365_CLIENT_ID` and the secret + * `E2E_O365_CLIENT_SECRET`; they belong to the PlaceOS sandbox tenant and the app + * "PlaceOS Bookings Visualiser", which holds Calendars.ReadWrite, User.Read.All, + * GroupMember.Read.All and Place.Read.All. */ +// Must be first: loads .env into process.env before any var below is read. +import '../load-env'; + export const O365 = { tenant: process.env.E2E_O365_TENANT ?? '', client_id: process.env.E2E_O365_CLIENT_ID ?? '', @@ -26,8 +33,11 @@ export const CALENDAR_ENABLED = !!( O365.client_secret ); +/** The tenant row's name when the seed backed it with the tenant above. */ +export const CALENDAR_TENANT_NAME = 'E2E Microsoft 365'; + /** - * A local admin whose email is a mailbox in that tenant. + * A local admin whose email is a mailbox in that tenant (`roleFor('calendar')`). * * staff-api creates an event on the signed-in user's own calendar and only lets * a user host for themselves (`can_create?` in events.cr), so the identity that diff --git a/e2e/support/env.ts b/e2e/support/env.ts index 3f772142b2b..95f7a508e42 100644 --- a/e2e/support/env.ts +++ b/e2e/support/env.ts @@ -18,6 +18,7 @@ // Must be first: loads .env into process.env before any var below is read. import './load-env'; import * as path from 'path'; +import { CALENDAR_USER_EMAIL, CALENDAR_USER_PASSWORD } from './calendar/calendar.env'; /** * Auth artifacts are resolved against this file, not the cwd — Nx, Playwright @@ -83,13 +84,6 @@ export interface Role { const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'support@place.tech'; const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'development'; const STAFF_PASSWORD = process.env.E2E_STAFF_PASSWORD ?? 'e2e-staff-development'; -/** - * The admin whose address is a mailbox in the Microsoft 365 tenant, when the - * stack is backed by one (`e2e/support/calendar/calendar.env.ts`). Seeded only - * then; `roleFor('calendar')` on a stack without it has no user behind it. - */ -const CALENDAR_EMAIL = process.env.E2E_CALENDAR_USER ?? 'AdeleV@0cbfs.onmicrosoft.com'; -const CALENDAR_PASSWORD = process.env.E2E_CALENDAR_PASSWORD ?? 'e2e-calendar-development'; /** Per-worker non-admin address. Seeded by seed.ts for 0..WORKERS-1. */ export function staffEmail(workerIndex: number): string { @@ -124,11 +118,14 @@ export function roleFor(name: RoleName, workerIndex = 0): Role { tokenPath: path.join(AUTH_DIR, `admin-${workerIndex}.token.json`), }; } + // The admin whose address is a mailbox in the Microsoft 365 tenant. Seeded + // only on a stack backed by it (`calendarBacked`); elsewhere no user may be + // behind this role. if (name === 'calendar') { return { name, - email: CALENDAR_EMAIL, - password: CALENDAR_PASSWORD, + email: CALENDAR_USER_EMAIL, + password: CALENDAR_USER_PASSWORD, storagePath: path.join(AUTH_DIR, `calendar-${workerIndex}.json`), tokenPath: path.join(AUTH_DIR, `calendar-${workerIndex}.token.json`), }; diff --git a/e2e/support/seed.ts b/e2e/support/seed.ts index 2abb4044d20..5f7b5b6c769 100644 --- a/e2e/support/seed.ts +++ b/e2e/support/seed.ts @@ -19,6 +19,7 @@ import { APP_URL, BACKEND_URL, DESK_PREFIX, + Role, WORKERS, assertLocalOnly, roleFor, @@ -27,7 +28,7 @@ import { import { mintToken, clientId, redirectUriFor } from './auth'; import { ENGINE_API, STAFF_API, alreadyExists, getJson as json } from './api'; import { describeDrivers, ensureDrivers } from './drivers/drivers.seed'; -import { CALENDAR_ENABLED, CALENDAR_USER_EMAIL, O365 } from './calendar/calendar.env'; +import { CALENDAR_ENABLED, CALENDAR_TENANT_NAME, O365 } from './calendar/calendar.env'; /** The app `init` guarantees exists — our way in before anything else is registered. */ const BOOTSTRAP_APP_URL = `${BACKEND_URL}/backoffice`; @@ -142,45 +143,82 @@ async function ensureAbsoluteLoginUrl(api: APIRequestContext) { * suite genuinely local. Real credentials are opt-in through the `E2E_O365_*` * variables (`e2e/support/calendar/calendar.env.ts`); with them the row is * app-only (no delegated access, no service account) so staff-api acts as the - * signed-in user, and a row left over from a placeholder run is updated. + * signed-in user, and is named `CALENDAR_TENANT_NAME` so specs can tell. + * + * An existing row is always re-sent. Credentials are never returned, so there is + * no way to see what it holds, and a stack reused after a calendar run must go + * back to placeholders rather than keep calling Microsoft. */ async function ensureTenant(api: APIRequestContext) { const domain = new URL(BACKEND_URL).hostname; - const credentials = CALENDAR_ENABLED - ? { tenant: O365.tenant, client_id: O365.client_id, client_secret: O365.client_secret } - : { - tenant: 'e2e-local-placeholder', - client_id: 'e2e-local-placeholder', - client_secret: 'e2e-local-placeholder', - }; + const row = { + domain, + platform: 'office365', + delegated: false, + ...(CALENDAR_ENABLED + ? { name: CALENDAR_TENANT_NAME, credentials: O365 } + : { + name: 'E2E Local', + credentials: { + tenant: 'e2e-local-placeholder', + client_id: 'e2e-local-placeholder', + client_secret: 'e2e-local-placeholder', + }, + }), + }; const existing = await json(api, `${STAFF_API}/tenants`); const list = Array.isArray(existing) ? existing : (existing.results ?? []); const found = list.find((t: { domain?: string }) => t.domain === domain); - if (found && !CALENDAR_ENABLED) return { domain, created: false, calendar: false }; - if (found) { - // Credentials are never returned, so the row is re-sent every time the - // calendar is on; staff-api encrypts them on save. - const res = await api.put(`${STAFF_API}/tenants/${found.id}`, { - data: { ...found, platform: 'office365', delegated: false, credentials }, - }); + // staff-api encrypts the credentials on save. + const res = found + ? await api.put(`${STAFF_API}/tenants/${found.id}`, { data: { ...found, ...row } }) + : await api.post(`${STAFF_API}/tenants`, { data: row }); + if (!res.ok()) { + throw new Error( + `${found ? 'update' : 'create'} tenant failed: HTTP ${res.status()} ${await res.text()}`, + ); + } + return { domain, created: !found }; +} + +/** + * Create a local user, or bring an existing one back in line. Keyed on email. + * + * A reused stack keeps its users between seeds, so an existing user gets the + * role's password and `fields` again: a changed password or a missing sys_admin + * would otherwise leave a user the suite cannot sign in as. Returns whether the + * user was created. + */ +async function ensureUser( + api: APIRequestContext, + role: Role, + authority_id: string, + fields: { name: string; sys_admin: boolean }, +): Promise { + const data = { ...fields, password: role.password, support: false }; + const found = await json(api, `${ENGINE_API}/users`, { q: role.email, limit: '50' }); + const list = Array.isArray(found) ? found : (found.results ?? []); + const user = list.find( + (u: { email?: string }) => u.email?.toLowerCase() === role.email.toLowerCase(), + ); + if (user) { + const res = await api.patch(`${ENGINE_API}/users/${user.id}`, { data }); if (!res.ok()) { - throw new Error(`update tenant failed: HTTP ${res.status()} ${await res.text()}`); + const body = await res.text(); + throw new Error(`update user ${role.email} failed: HTTP ${res.status()} ${body}`); } - return { domain, created: false, calendar: true }; + return false; } - const res = await api.post(`${STAFF_API}/tenants`, { - data: { - name: 'E2E Local', - domain, - platform: 'office365', - delegated: false, - credentials, - }, + const res = await api.post(`${ENGINE_API}/users`, { + data: { ...data, email: role.email, authority_id }, }); if (!res.ok()) { - throw new Error(`create tenant failed: HTTP ${res.status()} ${await res.text()}`); + const body = await res.text(); + // The lookup above reads from Elasticsearch, which can lag a fresh row. + if (alreadyExists(body)) return false; + throw new Error(`create user ${role.email} failed: HTTP ${res.status()} ${body}`); } - return { domain, created: true, calendar: CALENDAR_ENABLED }; + return true; } /** @@ -191,28 +229,11 @@ async function ensureTenant(api: APIRequestContext) { */ async function ensureCalendarUser(api: APIRequestContext) { const role = roleFor('calendar'); - const authority_id = (await authority(api)).id; - const found = await json(api, `${ENGINE_API}/users`, { q: role.email, limit: '50' }); - const list = Array.isArray(found) ? found : (found.results ?? []); - if (list.some((u: { email?: string }) => u.email?.toLowerCase() === role.email.toLowerCase())) { - return { email: role.email, created: false }; - } - const res = await api.post(`${ENGINE_API}/users`, { - data: { - name: 'E2E Calendar Admin', - email: role.email, - password: role.password, - authority_id, - sys_admin: true, - support: false, - }, + const created = await ensureUser(api, role, (await authority(api)).id, { + name: 'E2E Calendar Admin', + sys_admin: true, }); - if (!res.ok()) { - const body = await res.text(); - if (alreadyExists(body)) return { email: role.email, created: false }; - throw new Error(`create user ${role.email} failed: HTTP ${res.status()} ${body}`); - } - return { email: role.email, created: true }; + return { email: role.email, created }; } /** @@ -273,33 +294,9 @@ async function ensureStaffUsers(api: APIRequestContext) { for (let i = 0; i < WORKERS; i++) { const role = roleFor('staff', i); - const found = await json(api, `${ENGINE_API}/users`, { q: role.email, limit: '50' }); - const list = Array.isArray(found) ? found : (found.results ?? []); - if ( - list.some((u: { email?: string }) => u.email?.toLowerCase() === role.email.toLowerCase()) - ) { - present.push(role.email); - continue; - } - const res = await api.post(`${ENGINE_API}/users`, { - data: { - name: `E2E Staff ${i} (non-admin)`, - email: role.email, - password: role.password, - authority_id, - sys_admin: false, - support: false, - }, - }); - if (!res.ok()) { - const body = await res.text(); - if (alreadyExists(body)) { - present.push(role.email); - continue; - } - throw new Error(`create user ${role.email} failed: HTTP ${res.status()} ${body}`); - } - created.push(role.email); + const fields = { name: `E2E Staff ${i} (non-admin)`, sys_admin: false }; + if (await ensureUser(api, role, authority_id, fields)) created.push(role.email); + else present.push(role.email); } return { created, present }; } @@ -317,12 +314,12 @@ export async function seed(): Promise { const tenant = await ensureTenant(api); console.log( ` tenant ${tenant.created ? 'created' : 'present'} domain=${tenant.domain} ` + - `calendar=${tenant.calendar ? `microsoft 365 (${O365.tenant})` : 'placeholder'}`, + `calendar=${CALENDAR_ENABLED ? `microsoft 365 (${O365.tenant})` : 'placeholder'}`, ); if (CALENDAR_ENABLED) { const calendar_user = await ensureCalendarUser(api); console.log( - ` calendar ${calendar_user.created ? 'created' : 'present'} admin ${CALENDAR_USER_EMAIL}`, + ` calendar ${calendar_user.created ? 'created' : 'present'} admin ${calendar_user.email}`, ); } const desks = await ensureDesks(api);