fix(portal): add invitation links and correct customer login routing
This commit is contained in:
126
backend/tests/customerPortalLogin.test.ts
Normal file
126
backend/tests/customerPortalLogin.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import Fastify from "fastify"
|
||||
import cookie from "@fastify/cookie"
|
||||
import bcrypt from "bcrypt"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { drizzle } from "drizzle-orm/pg-proxy"
|
||||
import authRoutes from "../src/routes/auth/auth"
|
||||
import meRoutes from "../src/routes/auth/me"
|
||||
import { secrets } from "../src/utils/secrets"
|
||||
import { getPortalLoginUrl } from "../src/utils/portalUrl"
|
||||
|
||||
test("portal invitations always use a configured frontend login URL", () => {
|
||||
const defaults = { apiBaseUrl: "https://fedeo.example/backend" }
|
||||
assert.equal(getPortalLoginUrl(defaults), "https://fedeo.example/login")
|
||||
assert.equal(getPortalLoginUrl({ ...defaults, portalDomain: " kunden.example " }), "https://kunden.example/login")
|
||||
assert.equal(getPortalLoginUrl({ ...defaults, portalDomain: "https://kunden.example/" }), "https://kunden.example/login")
|
||||
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "https://web.example/fedeo/" }), "https://web.example/fedeo/login")
|
||||
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "http://localhost:3000/" }), "http://localhost:3000/login")
|
||||
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "javascript:alert(1)" }), "https://fedeo.example/login")
|
||||
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "https://user:password@web.example/" }), "https://fedeo.example/login")
|
||||
assert.throws(() => getPortalLoginUrl({}), /öffentliche FEDEO-Adresse/)
|
||||
})
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000000001"
|
||||
const profileId = "00000000-0000-4000-8000-000000000002"
|
||||
const passwordHash = bcrypt.hashSync("portal-password", 4)
|
||||
|
||||
function selectedRow(sql: string, values: Record<string, any>) {
|
||||
return sql.slice(7, sql.indexOf(" from ")).split(", ").map(column => {
|
||||
const name = [...column.matchAll(/"([^"]+)"/g)].at(-1)?.[1]
|
||||
return values[name!] ?? null
|
||||
})
|
||||
}
|
||||
|
||||
async function fixture(t: any, memberships: Array<[number, number | null]>, domainTenantId?: number) {
|
||||
const refreshTenants: Array<number | null> = []
|
||||
const db = drizzle(async (sql, params) => {
|
||||
if (sql.includes('from "auth_users"')) {
|
||||
return { rows: [selectedRow(sql, {
|
||||
id: userId, email: "customer@example.com", password_hash: passwordHash,
|
||||
must_change_password: true, is_admin: false,
|
||||
})] }
|
||||
}
|
||||
if (sql.includes('from "auth_tenant_users"') && sql.includes('"auth_profiles"')) {
|
||||
assert.ok(params.includes(userId))
|
||||
assert.match(sql, /"auth_profiles"\."tenant_id" = "auth_tenant_users"\."tenant_id"/)
|
||||
assert.match(sql, /"auth_profiles"\."active" =/)
|
||||
assert.ok(params.includes(true))
|
||||
return { rows: memberships }
|
||||
}
|
||||
if (sql.startsWith('insert into "auth_refresh_tokens"')) {
|
||||
refreshTenants.push(params[1] as number | null)
|
||||
return { rows: [] }
|
||||
}
|
||||
if (sql.includes('from "auth_tenant_users"')) {
|
||||
return { rows: memberships.map(([id]) => selectedRow(sql, { id, name: "Test tenant" })) }
|
||||
}
|
||||
if (sql.includes('from "auth_profiles"')) {
|
||||
const membership = memberships.find(([id]) => params.includes(id))
|
||||
return { rows: membership ? [selectedRow(sql, {
|
||||
id: profileId, user_id: userId, tenant_id: membership[0],
|
||||
customer_for_portal: membership[1], active: true,
|
||||
})] : [] }
|
||||
}
|
||||
if (sql.includes('from "auth_profile_branches"') || sql.includes('from "auth_user_roles"')) return { rows: [] }
|
||||
throw new Error(`Unexpected query: ${sql}`)
|
||||
})
|
||||
secrets.JWT_SECRET = "customer-portal-test-secret"
|
||||
const app = Fastify()
|
||||
app.decorate("db", db as any)
|
||||
app.addHook("preHandler", async (req: any) => {
|
||||
if (domainTenantId) req.tenant = { id: String(domainTenantId) }
|
||||
if (req.headers.authorization) req.user = jwt.verify(req.headers.authorization.slice(7), secrets.JWT_SECRET)
|
||||
})
|
||||
await app.register(cookie)
|
||||
await app.register(authRoutes)
|
||||
await app.register(meRoutes)
|
||||
t.after(() => app.close())
|
||||
const login = (password = "portal-password") => app.inject({
|
||||
method: "POST", url: "/auth/login", payload: { email: "customer@example.com", password },
|
||||
})
|
||||
return { app, login, refreshTenants }
|
||||
}
|
||||
|
||||
test("shared-address login scopes both tokens and loads the customer's portal profile", async t => {
|
||||
const f = await fixture(t, [[7, 123]])
|
||||
const response = await f.login()
|
||||
assert.equal(response.statusCode, 200, response.body)
|
||||
const session = response.json()
|
||||
assert.equal((jwt.verify(session.token, secrets.JWT_SECRET) as any).tenant_id, 7)
|
||||
assert.deepEqual(f.refreshTenants, [7])
|
||||
assert.ok(session.refreshToken)
|
||||
const me = await f.app.inject({ url: "/me", headers: { authorization: `Bearer ${session.token}` } })
|
||||
assert.equal(me.statusCode, 200, me.body)
|
||||
assert.equal(me.json().activeTenant, 7)
|
||||
assert.equal(me.json().profile.customer_for_portal, 123)
|
||||
assert.equal(me.json().user.must_change_password, true)
|
||||
})
|
||||
|
||||
test("internal and ambiguous accounts retain tenant selection", async t => {
|
||||
for (const memberships of [[], [[7, null]], [[7, 123], [8, 456]]] as Array<Array<[number, number | null]>>) {
|
||||
const f = await fixture(t, memberships)
|
||||
const response = await f.login()
|
||||
assert.equal(response.statusCode, 200, response.body)
|
||||
assert.equal((jwt.verify(response.json().token, secrets.JWT_SECRET) as any).tenant_id, null)
|
||||
assert.deepEqual(f.refreshTenants, [null])
|
||||
}
|
||||
})
|
||||
|
||||
test("a portal domain selects only a tenant the user belongs to", async t => {
|
||||
const f = await fixture(t, [[7, 123], [8, 456]], 8)
|
||||
const response = await f.login()
|
||||
assert.equal(response.statusCode, 200, response.body)
|
||||
assert.equal((jwt.verify(response.json().token, secrets.JWT_SECRET) as any).tenant_id, 8)
|
||||
assert.deepEqual(f.refreshTenants, [8])
|
||||
const other = await fixture(t, [[7, 123]], 99)
|
||||
assert.equal((await other.login()).statusCode, 403)
|
||||
assert.deepEqual(other.refreshTenants, [])
|
||||
})
|
||||
|
||||
test("invalid credentials never create a portal session", async t => {
|
||||
const f = await fixture(t, [[7, 123]])
|
||||
assert.equal((await f.login("wrong-password")).statusCode, 401)
|
||||
assert.deepEqual(f.refreshTenants, [])
|
||||
})
|
||||
Reference in New Issue
Block a user