feat(auth): add login audience middleware (EDRFREIGHT-415)

This commit is contained in:
Nathnael
2026-07-28 10:46:55 +00:00
parent 2d8b9203d5
commit e823874d39
12 changed files with 216 additions and 26 deletions

View File

@@ -15,6 +15,7 @@ describe("freight-api: health + auth contract", () => {
method: "POST",
url: `${api()}/api/auth/login`,
body: { email: "nobody@edr.local", password: "wrong-password" },
headers: { "x-client-app": "backoffice" },
failOnStatusCode: false,
})
.its("status")
@@ -50,8 +51,8 @@ describe("freight-api: health + auth contract", () => {
it("demo portal users are seeded", () => {
// DemoUsersSeeder hardcodes this password (staff users use DEFAULT_PASSWORD)
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword"));
cy.apiLogin("user2@gmail.com", Cypress.env("demoPassword"));
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword"), "portal");
cy.apiLogin("user2@gmail.com", Cypress.env("demoPassword"), "portal");
});
});

View File

@@ -42,7 +42,7 @@ describe("flow: customer and staff see the same world", () => {
.its("status")
.should("eq", 200);
});
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword")).then(({ token }) => {
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword"), "portal").then(({ token }) => {
cy.request({
url: `${api()}/api/me`,
headers: { Authorization: `Bearer ${token}` },

View File

@@ -45,9 +45,11 @@ export function db<T = Row>(sql: string, params: unknown[] = []) {
/** Bearer token for a staff/customer account (portal users use the demo pwd). */
export function tokenFor(email: string): Cypress.Chainable<string> {
const pass =
email.endsWith("@gmail.com") ? (Cypress.env("demoPassword") as string) : undefined;
return cy.apiLogin(email, pass).then(({ token }) => cy.wrap(token, { log: false }));
const isPortalUser = email.endsWith("@gmail.com");
const pass = isPortalUser ? (Cypress.env("demoPassword") as string) : undefined;
return cy
.apiLogin(email, pass, isPortalUser ? "portal" : "backoffice")
.then(({ token }) => cy.wrap(token, { log: false }));
}
export function apiPost(

View File

@@ -55,26 +55,29 @@ where not exists (
);
-- ── Users ────────────────────────────────────────────────────────────────────
-- user_type matters now: EDRFREIGHT-415's /auth/login audience gate rejects an
-- employee-typed account on the portal client and vice versa, so the two demo
-- "customer" accounts must actually be typed 'individual', not 'employee'.
insert into iam.users (id, email, username, name, status, is_active, has_set_password, user_type)
select gen_random_uuid(), v.email, v.username, jsonb_build_object('en', v.display),
'accepted', true, true, 'employee'
'accepted', true, true, v.user_type
from (values
('linestaff@edr.local', 'linestaff', 'linestaff'),
('chief@edr.local', 'chief', 'chief'),
('director@edr.local', 'director', 'director'),
('ceo@edr.local', 'ceo', 'ceo'),
('marketer@edr.local', 'marketer', 'marketer'),
('operation@edr.local', 'operation', 'operation'),
('gl-et@edr.local', 'gl_et', 'gl_et'),
('gl-dj@edr.local', 'gl_dj', 'gl_dj'),
('user@gmail.com', 'user', 'Demo User 1'),
('user2@gmail.com', 'user2', 'Demo User 2'),
('linestaff@edr.local', 'linestaff', 'linestaff', 'employee'),
('chief@edr.local', 'chief', 'chief', 'employee'),
('director@edr.local', 'director', 'director', 'employee'),
('ceo@edr.local', 'ceo', 'ceo', 'employee'),
('marketer@edr.local', 'marketer', 'marketer', 'employee'),
('operation@edr.local', 'operation', 'operation', 'employee'),
('gl-et@edr.local', 'gl_et', 'gl_et', 'employee'),
('gl-dj@edr.local', 'gl_dj', 'gl_dj', 'employee'),
('user@gmail.com', 'user', 'Demo User 1', 'individual'),
('user2@gmail.com', 'user2', 'Demo User 2', 'individual'),
-- Full-authority operator the corridor specs drive the customs/GL steps with
-- (t1-close, risk, gate pass, second duty, import release). Nothing in the
-- repo seeded it before — the suite silently relied on a hand-made row that
-- only existed in a long-lived dev database, so a fresh stack failed 7 specs.
('superadmin@tria.com', 'superadmin', 'Super Admin')
) v(email, username, display)
('superadmin@tria.com', 'superadmin', 'Super Admin', 'employee')
) v(email, username, display, user_type)
where not exists (select 1 from iam.users u where u.email = v.email);
-- ── Credentials ──────────────────────────────────────────────────────────────

View File

@@ -21,11 +21,17 @@ export interface LoginBody {
const apiUrl = () => Cypress.env("apiUrl") as string;
const password = () => Cypress.env("defaultPassword") as string;
function apiLogin(email: string, pass?: string): Cypress.Chainable<LoginBody> {
function apiLogin(
email: string,
pass?: string,
app: "backoffice" | "portal" = "backoffice",
): Cypress.Chainable<LoginBody> {
return cy
.request<LoginBody>("POST", `${apiUrl()}/api/auth/login`, {
email,
password: pass ?? password(),
.request<LoginBody>({
method: "POST",
url: `${apiUrl()}/api/auth/login`,
body: { email, password: pass ?? password() },
headers: { "x-client-app": app },
})
.then((response) => {
expect(response.status).to.eq(201);
@@ -38,7 +44,7 @@ function sessionFor(app: "backoffice" | "portal", email: string, pass?: string)
cy.session(
[app, email],
() => {
apiLogin(email, pass).then(({ token, refreshToken }) => {
apiLogin(email, pass, app).then(({ token, refreshToken }) => {
cy.setCookie("auth-token", token);
cy.setCookie("refresh-token", refreshToken);
});
@@ -60,7 +66,11 @@ function sessionFor(app: "backoffice" | "portal", email: string, pass?: string)
);
}
Cypress.Commands.add("apiLogin", (email: string, pass?: string) => apiLogin(email, pass));
Cypress.Commands.add(
"apiLogin",
(email: string, pass?: string, app: "backoffice" | "portal" = "backoffice") =>
apiLogin(email, pass, app),
);
Cypress.Commands.add("loginBackoffice", (email = "ceo@edr.local", pass?: string) => {
sessionFor("backoffice", email, pass);
@@ -260,7 +270,11 @@ declare global {
namespace Cypress {
interface Chainable {
/** POST /api/auth/login, returns the flattened token body. */
apiLogin(email: string, pass?: string): Chainable<LoginBody>;
apiLogin(
email: string,
pass?: string,
app?: "backoffice" | "portal",
): Chainable<LoginBody>;
/** Cached programmatic staff session (default ceo@edr.local). */
loginBackoffice(email?: string, pass?: string): Chainable<void>;
/** Cached programmatic customer session (default user@gmail.com). */