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
4 changes: 4 additions & 0 deletions backend/security/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
"@types/pg": "^8.15.4",
"@types/supertest": "^2.0.15",
"@types/uuid": "^9.0.2",
"@types/js-yaml": "^4.0.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"js-yaml": "^4.1.0",
"jest": "^29.7.0",
"nodemon": "^3.0.1",
"supertest": "^6.3.3",
Expand Down
55 changes: 55 additions & 0 deletions backend/security/tests/security-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# AuthN contract-test suite (independent verification)

Independent, spec-first verification of the FuzeFront Security API **AuthN** slice
against the **frozen** contract `packages/security/openapi.yaml` (PR #243).

Authored by `test-engineer` — this suite verifies the implementation; it does not
implement it. A failing test against a real bug is a valid deliverable.

## What it asserts

- **Contract conformance** for every `/api/v1/security/*` AuthN endpoint:
session CRUD + `session/exchange`, social `start`/`callback` (302 semantics),
`signup`, `methods`, the `SessionResult` MFA-step-up discriminated union, the
MFA factor lifecycle (enroll → activate → remove, recovery codes), MFA login
step-up (challenge → verify), email/phone verification, and M2M tokens.
Response bodies are validated against the spec component schemas with Ajv
(OpenAPI 3.1 / JSON-Schema 2020-12).
- **Provider-swap proof** (`provider-swap.contract.test.ts`): the full path runs
through a second, independent `IdentityProvider` (`AltIdentityProvider`) with a
different token format/storage — proving no consumer-visible vendor coupling.
- **Boundary / neutrality** (`boundary.contract.test.ts`): no AuthN response,
redirect, or body references `auth.fuzefront.com` or names a vendor; social
`start` 302 targets a FuzeFront-owned (or Google) host only.
- **Fail-closed**: bad credentials/expired code/unknown token →
`401` / `{ active: false }`, never permissive.
- **Pagination gate** (`pagination.contract.test.ts`): every AuthN endpoint is
genuinely `x-pagination: exempt` (bounded/singleton); the spec's paginated
collections (AuthZ Phase 2) correctly encode the `{ items, page }` +
nullable-`nextCursor` envelope. The **runtime cursor-walk** targets those
AuthZ endpoints and is flagged `it.todo` — out of this AuthN suite's scope.

## Subject under test — mock now, real impl later

`harness.ts` resolves what the assertions run against:

- **`SECURITY_BASE_URL` set** → runs the identical assertions over HTTP against a
real running implementation (ephemeral, FuzeInfra-pinned base services +
mocked external SaaS — never prod). This is the objective backend gate.
- **unset** (default) → an in-process **contract-mock** reference app
(`referenceApp.ts`) driven by `MockIdentityProvider`. This keeps the suite
runnable and proves the contract is satisfiable through the neutral interface
before the backend lands.

`referenceApp.ts` + `mockIdentityProvider.ts` are **test fixtures**, not the
product.

## Run

```bash
# contract-mock (default)
npm test -w backend/security -- security-api

# against a live implementation
SECURITY_BASE_URL=https://<ephemeral-host> npm test -w backend/security -- security-api
```
67 changes: 67 additions & 0 deletions backend/security/tests/security-api/boundary.contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Boundary / vendor-neutrality assertions.
*
* No AuthN response, redirect, or body may reference the internal identity host
* (`auth.fuzefront.com`) or name a vendor. Social `start` 302 must target a
* FuzeFront-owned (or Google) host only. This is the acute-leak regression gate
* at the API layer (the browser-level version lives in frontend-test-engineer's
* Playwright suite — out of this suite's scope).
*/
import { agent, RUNNING_AGAINST } from './harness'
import {
spec,
FORBIDDEN_INTERNAL_HOST,
FORBIDDEN_VENDOR_TOKENS,
ALLOWED_SOCIAL_HOSTS,
} from './spec'
import { SEED } from './mockIdentityProvider'

function assertClean(label: string, text: string) {
const lower = text.toLowerCase()
expect(`${label}:${lower.includes(FORBIDDEN_INTERNAL_HOST)}`).toBe(`${label}:false`)
for (const vendor of FORBIDDEN_VENDOR_TOKENS) {
expect(`${label}/${vendor}:${lower.includes(vendor)}`).toBe(`${label}/${vendor}:false`)
}
}

describe(`boundary + neutrality (against ${RUNNING_AGAINST})`, () => {
it('the frozen spec itself names no vendor in any consumer-facing path/schema key', () => {
// Descriptions may cite Google (a genuine social provider) but never our IdP vendor.
const pathsAndSchemas = JSON.stringify({
paths: Object.keys(spec.paths),
schemas: Object.keys(spec.components.schemas),
}).toLowerCase()
assertClean('spec-names', pathsAndSchemas)
})

it('social start Location is a FuzeFront-owned/Google host, never the internal IdP', async () => {
const res = await agent().get('/api/v1/security/social/google/start').redirects(0)
const loc = res.headers['location'] || ''
assertClean('social-start-location', loc)
try {
const host = new URL(loc).host
expect(ALLOWED_SOCIAL_HOSTS.has(host)).toBe(true)
} catch {
/* relative Location = same-origin, inherently owned */
}
})

it('successful login response references no vendor/internal host', async () => {
const res = await agent()
.post('/api/v1/security/session')
.send({ email: SEED.email, password: SEED.password })
assertClean('login-body', JSON.stringify(res.body))
})

it('error bodies reference no vendor/internal host', async () => {
const res = await agent()
.post('/api/v1/security/session')
.send({ email: SEED.email, password: 'wrong' })
assertClean('error-body', JSON.stringify(res.body))
})

it('/methods descriptor references no vendor/internal host', async () => {
const res = await agent().get('/api/v1/security/methods')
assertClean('methods-body', JSON.stringify(res.body))
})
})
36 changes: 36 additions & 0 deletions backend/security/tests/security-api/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* harness.ts — resolves the SUBJECT UNDER TEST for the AuthN contract suite.
*
* - If SECURITY_BASE_URL is set → run the identical spec assertions against the
* REAL running implementation (e.g. an ephemeral stack in CI). This is how the
* suite becomes objective verification of the backend once it lands.
* - Otherwise → fall back to the in-process contract-mock reference app driven
* by MockIdentityProvider. This keeps the suite runnable (and proves the
* contract is satisfiable through the neutral interface) before the impl lands.
*
* Either way, `agent()` returns a supertest instance the tests use uniformly.
*/
import supertest from 'supertest'
import { createSecurityApp } from './referenceApp'
import { IdentityProvider } from '../../src/providers/IdentityProvider'

export const BASE_URL = process.env.SECURITY_BASE_URL

export const RUNNING_AGAINST: 'live-implementation' | 'contract-mock' = BASE_URL
? 'live-implementation'
: 'contract-mock'

// A single persistent contract-mock app+provider is the in-process stand-in for
// "the running server": session/token state minted by one request must be
// visible to the next, exactly as it would be against a live backend. So the
// no-arg `agent()` reuses ONE app (and one MockIdentityProvider) for the whole
// run. Passing an explicit `provider` opts out (fresh app) — used by suites that
// want an isolated provider instance (mfa lifecycle, provider-swap).
let sharedApp: ReturnType<typeof createSecurityApp> | undefined

export function agent(provider?: IdentityProvider) {
if (BASE_URL) return supertest(BASE_URL)
if (provider) return supertest(createSecurityApp(provider))
if (!sharedApp) sharedApp = createSecurityApp()
return supertest(sharedApp)
}
166 changes: 166 additions & 0 deletions backend/security/tests/security-api/mfa.contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* Contract tests: full MFA lifecycle — factor enrollment/activation/removal,
* recovery codes, and the login step-up (challenge → verify) path. Schemas +
* status codes + fail-closed on bad codes, all against the frozen spec.
*/
import { agent, RUNNING_AGAINST } from './harness'
import { assertSchema } from './spec'
import { MockIdentityProvider, SEED } from './mockIdentityProvider'

// The MFA lifecycle needs a stable provider instance across requests so an
// enrolled factor persists. Against a live impl (SECURITY_BASE_URL) state is
// held server-side, so a shared provider is only wired for the contract-mock.
function ctx() {
const provider = new MockIdentityProvider()
return { provider, api: () => agent(provider) }
}

async function loggedInToken(api: () => ReturnType<typeof agent>) {
const res = await api()
.post('/api/v1/security/session')
.send({ email: SEED.email, password: SEED.password })
return res.body.token as string
}

describe(`mfa lifecycle + step-up (against ${RUNNING_AGAINST})`, () => {
describe('factor management', () => {
it('GET /mfa/factors → { items: MfaFactor[] } (401 without token)', async () => {
const { api } = ctx()
const unauth = await api().get('/api/v1/security/mfa/factors')
expect(unauth.status).toBe(401)
assertSchema('ErrorBody', unauth.body)

const token = await loggedInToken(api)
const res = await api()
.get('/api/v1/security/mfa/factors')
.set('Authorization', `Bearer ${token}`)
expect(res.status).toBe(200)
expect(Array.isArray(res.body.items)).toBe(true)
for (const f of res.body.items) assertSchema('MfaFactor', f)
})

it('enroll TOTP → 201 MfaEnrollResult with secret + provisioningUri', async () => {
const { api } = ctx()
const token = await loggedInToken(api)
const res = await api()
.post('/api/v1/security/mfa/factors')
.set('Authorization', `Bearer ${token}`)
.send({ type: 'totp' })
expect(res.status).toBe(201)
assertSchema('MfaEnrollResult', res.body)
expect(res.body.type).toBe('totp')
expect(typeof res.body.secret).toBe('string')
expect(res.body.provisioningUri).toMatch(/^otpauth:\/\//)
})

it('enroll SMS without phone → 400 (fail-closed validation)', async () => {
const { api } = ctx()
const token = await loggedInToken(api)
const res = await api()
.post('/api/v1/security/mfa/factors')
.set('Authorization', `Bearer ${token}`)
.send({ type: 'sms' })
expect(res.status).toBe(400)
assertSchema('ErrorBody', res.body)
})

it('enroll → activate happy path (200 active) and bad code → 400', async () => {
const { api } = ctx()
const token = await loggedInToken(api)
const enroll = await api()
.post('/api/v1/security/mfa/factors')
.set('Authorization', `Bearer ${token}`)
.send({ type: 'totp' })
const factorId = enroll.body.factorId

const bad = await api()
.post(`/api/v1/security/mfa/factors/${factorId}/activate`)
.set('Authorization', `Bearer ${token}`)
.send({ code: '999999' })
expect(bad.status).toBe(400)
assertSchema('ErrorBody', bad.body)

const ok = await api()
.post(`/api/v1/security/mfa/factors/${factorId}/activate`)
.set('Authorization', `Bearer ${token}`)
.send({ code: '000000' })
expect(ok.status).toBe(200)
assertSchema('MfaFactor', ok.body)
expect(ok.body.status).toBe('active')
})

it('DELETE /mfa/factors/{id} → 204 (idempotent)', async () => {
const { api } = ctx()
const token = await loggedInToken(api)
const enroll = await api()
.post('/api/v1/security/mfa/factors')
.set('Authorization', `Bearer ${token}`)
.send({ type: 'totp' })
const factorId = enroll.body.factorId
const del = await api()
.delete(`/api/v1/security/mfa/factors/${factorId}`)
.set('Authorization', `Bearer ${token}`)
expect(del.status).toBe(204)
})

it('POST /mfa/recovery-codes → 200 RecoveryCodes', async () => {
const { api } = ctx()
const token = await loggedInToken(api)
const res = await api()
.post('/api/v1/security/mfa/recovery-codes')
.set('Authorization', `Bearer ${token}`)
expect(res.status).toBe(200)
assertSchema('RecoveryCodes', res.body)
expect(res.body.codes.length).toBeGreaterThan(0)
})
})

describe('login step-up (challenge → verify)', () => {
it('completes MFA and returns a LoginResponse', async () => {
const { api } = ctx()
// Trigger the mfa_required challenge.
const login = await api()
.post('/api/v1/security/session')
.send({ email: SEED.mfaEmail, password: SEED.mfaPassword })
expect(login.body.status).toBe('mfa_required')
const { challengeId, factors } = login.body
const factorId = factors[0].factorId

const challenge = await api()
.post('/api/v1/security/mfa/challenge')
.send({ challengeId, factorId })
expect(challenge.status).toBe(202)
assertSchema('MfaChallengeAck', challenge.body)

const verify = await api()
.post('/api/v1/security/mfa/verify')
.send({ challengeId, factorId, code: '000000' })
expect(verify.status).toBe(200)
assertSchema('LoginResponse', verify.body)
expect(typeof verify.body.token).toBe('string')
})

it('bad OTP on verify → 401 (fail-closed, no session)', async () => {
const { api } = ctx()
const login = await api()
.post('/api/v1/security/session')
.send({ email: SEED.mfaEmail, password: SEED.mfaPassword })
const { challengeId, factors } = login.body
const verify = await api()
.post('/api/v1/security/mfa/verify')
.send({ challengeId, factorId: factors[0].factorId, code: '111111' })
expect(verify.status).toBe(401)
assertSchema('ErrorBody', verify.body)
expect(verify.body).not.toHaveProperty('token')
})

it('unknown challengeId on verify → 401 (fail-closed)', async () => {
const { api } = ctx()
const verify = await api()
.post('/api/v1/security/mfa/verify')
.send({ challengeId: 'nope', factorId: 'factor-totp-1', code: '000000' })
expect(verify.status).toBe(401)
assertSchema('ErrorBody', verify.body)
})
})
})
Loading
Loading