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
10 changes: 10 additions & 0 deletions .github/workflows/e2e-advisory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,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?
#
Expand Down
13 changes: 13 additions & 0 deletions e2e/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ 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. 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
from the PlaceOS build farm (build.placeos.run), which builds a commit it has not
Expand Down
6 changes: 5 additions & 1 deletion e2e/stack/SELF_HOSTED_RUNNER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-only`, its own bounded step), 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
Expand Down
19 changes: 19 additions & 0 deletions e2e/support/calendar/calendar.api.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
60 changes: 60 additions & 0 deletions e2e/support/calendar/calendar.env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* The Microsoft 365 tenant the stack's calendar and directory can be backed by.
*
* 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.
*
* 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 ?? '',
client_secret: process.env.E2E_O365_CLIENT_SECRET ?? '',
};

export const CALENDAR_ENABLED = !!(
O365.tenant &&
O365.client_id &&
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 (`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
* 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';
15 changes: 14 additions & 1 deletion e2e/support/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,7 +69,7 @@ export function assertLocalOnly(...urls: string[]): void {
}
}

export type RoleName = 'admin' | 'staff';
export type RoleName = 'admin' | 'staff' | 'calendar';

export interface Role {
name: RoleName;
Expand Down Expand Up @@ -117,6 +118,18 @@ 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_USER_EMAIL,
password: CALENDAR_USER_PASSWORD,
storagePath: path.join(AUTH_DIR, `calendar-${workerIndex}.json`),
tokenPath: path.join(AUTH_DIR, `calendar-${workerIndex}.token.json`),
};
}
return {
name,
email: staffEmail(workerIndex),
Expand Down
144 changes: 100 additions & 44 deletions e2e/support/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
APP_URL,
BACKEND_URL,
DESK_PREFIX,
Role,
WORKERS,
assertLocalOnly,
roleFor,
Expand All @@ -27,6 +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_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`;
Expand Down Expand Up @@ -138,31 +140,100 @@ 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 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 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 ?? []);
if (list.some((t: { domain?: string }) => t.domain === domain)) {
return { domain, created: false };
const found = list.find((t: { domain?: string }) => t.domain === domain);
// 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<boolean> {
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()) {
const body = await res.text();
throw new Error(`update user ${role.email} failed: HTTP ${res.status()} ${body}`);
}
return false;
}
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',
},
},
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 };
return true;
}

/**
* 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 created = await ensureUser(api, role, (await authority(api)).id, {
name: 'E2E Calendar Admin',
sys_admin: true,
});
return { email: role.email, created };
}

/**
Expand Down Expand Up @@ -223,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 };
}
Expand All @@ -265,7 +312,16 @@ export async function seed(): Promise<void> {
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=${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}`,
);
}
const desks = await ensureDesks(api);
console.log(` desks ${desks.count} on ${desks.zone}`);
const users = await ensureStaffUsers(api);
Expand Down
Loading