/** * Auth model (see apps/edr-freight-api + freight web apps): * - POST {api}/api/auth/login { email, password } * → flattened body { success, token, refreshToken } (response interceptor * flattens /api/auth responses — no .data nesting). * - Both web apps read cookies `auth-token` / `refresh-token` and attach * `Authorization: Bearer `. * - Cookies are port-agnostic on localhost, so portal and backoffice share * one cookie jar. cy.session snapshots/restores cookies per session id, * which keeps staff and customer sessions from clobbering each other — * but inside a single test, switching apps requires re-invoking the * matching login command first (see flows specs). */ export interface LoginBody { success: boolean; token: string; refreshToken: string; } const apiUrl = () => Cypress.env("apiUrl") as string; const password = () => Cypress.env("defaultPassword") as string; function apiLogin(email: string, pass?: string): Cypress.Chainable { return cy .request("POST", `${apiUrl()}/api/auth/login`, { email, password: pass ?? password(), }) .then((response) => { expect(response.status).to.eq(201); expect(response.body.token, "login token").to.be.a("string"); return cy.wrap(response.body, { log: false }); }); } function sessionFor(app: "backoffice" | "portal", email: string, pass?: string) { cy.session( [app, email], () => { apiLogin(email, pass).then(({ token, refreshToken }) => { cy.setCookie("auth-token", token); cy.setCookie("refresh-token", refreshToken); }); }, { cacheAcrossSpecs: true, validate() { cy.getCookie("auth-token").then((cookie) => { expect(cookie, "auth-token cookie").to.exist; cy.request({ url: `${apiUrl()}/api/me`, headers: { Authorization: `Bearer ${cookie!.value}` }, }) .its("status") .should("eq", 200); }); }, }, ); } Cypress.Commands.add("apiLogin", (email: string, pass?: string) => apiLogin(email, pass)); Cypress.Commands.add("loginBackoffice", (email = "ceo@edr.local", pass?: string) => { sessionFor("backoffice", email, pass); }); Cypress.Commands.add("loginPortal", (email = "user@gmail.com", pass?: string) => { // Demo portal users are seeded with a hardcoded password (DemoUsersSeeder), // unlike staff users which use DEFAULT_PASSWORD. sessionFor("portal", email, pass ?? (Cypress.env("demoPassword") as string)); }); Cypress.Commands.add("visitPortal", (path = "/") => { cy.visit(`${Cypress.env("portalUrl")}${path}`); }); /** * Read the latest OTP the API generated for a contact. SMS/email delivery is * disabled in e2e (RABBITMQ_ENABLED=false) but the code is still stored in * freight.otp_verifications — keyed by normalized email (lowercased) or E.164 * phone. Polls because the row is written async to the UI action. */ Cypress.Commands.add("getOtp", (target: string) => { const read = (attempt: number): Cypress.Chainable => cy .task<{ rows: Array<{ otp: string }> }>( "db:query", { sql: `SELECT otp FROM freight.otp_verifications WHERE email = $1 OR phone = $1 ORDER BY updated_at DESC LIMIT 1`, params: [target], }, { log: false }, ) .then((res) => { if (res.rows.length > 0) return cy.wrap(res.rows[0].otp, { log: false }); expect(attempt, `OTP row for ${target}`).to.be.lessThan(20); return cy.wait(500, { log: false }).then(() => read(attempt + 1)); }); return read(0); }); /** Open a Mantine