test(api): add Vitest+supertest integration test suite (eden-qa)
Boots the real Nest app (Test.createTestingModule + AppModule) against a
real Postgres — no mocked DB or guards anywhere in this suite. Uses SWC
(unplugin-swc) instead of Vite's default esbuild transform because
esbuild doesn't implement emitDecoratorMetadata, which silently breaks
NestJS's constructor DI (every provider resolves to undefined).
Covers:
- auth.spec.ts: anti-enumeration login message, refresh-token rotation,
replay-detection revoking the whole session chain, idempotent logout
(ADR-0003).
- role-weight.spec.ts: invalid role_key on invite returns 400 not 500
(regression guard), lower-weight actor forbidden from assigning a
heavier role even when holding users:edit (ADR-0004).
- companies.spec.ts: CNPJ digit-check validation (reject/accept).
- mass-assignment.spec.ts: forbidNonWhitelisted actually rejects
unexpected fields on companies/products.
- reseller-assignment.spec.ts: assign-user always targets the URL's
:id, never a body resellerId (regression guard for commit 8d108e0);
unassign-user clears the assignment.
All fixtures are qa-test-/qa_ tagged and cleaned up (deactivated where
no DELETE endpoint exists, deleted where one does) — verified no
pre-existing seed data (super_admin, Handix Telecom LTDA, Handix revenda
própria) was touched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
143
apps/api/test/auth.spec.ts
Normal file
143
apps/api/test/auth.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agent,
|
||||
bearer,
|
||||
closeTestApp,
|
||||
createTestApp,
|
||||
loginSuperAdmin,
|
||||
qaEmail,
|
||||
type AuthedTokens,
|
||||
} from "./setup.js";
|
||||
|
||||
/**
|
||||
* Protects the session/refresh-token invariants of ADR-0003
|
||||
* (docs/adr/0003-auth-sessions.md) and apps/api/src/auth/auth.service.ts.
|
||||
*
|
||||
* Fixture: one throwaway user (role_key='user', a pre-existing system
|
||||
* role — no new role is created here), invited via the real
|
||||
* POST /v1/users/invite flow as super_admin. Every session created for it
|
||||
* is a byproduct of real login/refresh/logout calls, never a hand-inserted
|
||||
* row — replay detection specifically must be proven end-to-end through
|
||||
* the actual rotation chain, not simulated.
|
||||
*
|
||||
* Cleanup: the fixture user is deactivated (PATCH isActive:false) in
|
||||
* afterAll, which also revokes any sessions still active — there is no
|
||||
* DELETE /v1/users endpoint. Session rows themselves are left in place
|
||||
* (there's no session-deletion endpoint either, and they're an expected
|
||||
* append-only-ish audit trail of real auth activity, not "dirty" state);
|
||||
* they all point at a deactivated, obviously-tagged (qa-test-) fixture
|
||||
* user.
|
||||
*/
|
||||
describe("auth — session lifecycle invariants", () => {
|
||||
let app: INestApplication;
|
||||
let admin: AuthedTokens;
|
||||
let fixtureEmail: string;
|
||||
let fixtureUserId: string;
|
||||
let fixturePassword: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
admin = await loginSuperAdmin(app);
|
||||
|
||||
fixtureEmail = qaEmail("auth");
|
||||
const invite = await agent(app)
|
||||
.post("/api/v1/users/invite")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ email: fixtureEmail, roleKey: "user", applications: ["core"] })
|
||||
.expect(201);
|
||||
fixtureUserId = invite.body.user.id;
|
||||
fixturePassword = invite.body.tempPassword;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await agent(app)
|
||||
.patch(`/api/v1/users/${fixtureUserId}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ isActive: false })
|
||||
.expect(200);
|
||||
await closeTestApp(app);
|
||||
});
|
||||
|
||||
describe("login — anti-enumeration (eden.md §4.1)", () => {
|
||||
it("wrong password for an existing account returns a generic 401", async () => {
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: "definitely-the-wrong-password", applicationKey: "core" })
|
||||
.expect(401);
|
||||
expect(res.body.message).toBe("Credenciais inválidas");
|
||||
});
|
||||
|
||||
it("a non-existent account returns the exact same 401 message as a wrong password", async () => {
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: qaEmail("nobody"), password: "whatever-12345", applicationKey: "core" })
|
||||
.expect(401);
|
||||
// Invariant: the response must not let a caller distinguish
|
||||
// "account doesn't exist" from "account exists, wrong password" —
|
||||
// if this message ever diverges from the wrong-password case above,
|
||||
// the API becomes an account-enumeration oracle.
|
||||
expect(res.body.message).toBe("Credenciais inválidas");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh — rotation (ADR-0003)", () => {
|
||||
it("a successful refresh returns a different refresh token than the one submitted", async () => {
|
||||
const session = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
|
||||
.expect(200);
|
||||
const originalRefreshToken = session.body.refreshToken as string;
|
||||
|
||||
const refreshed = await agent(app)
|
||||
.post("/api/v1/auth/refresh")
|
||||
.send({ refreshToken: originalRefreshToken })
|
||||
.expect(200);
|
||||
|
||||
expect(refreshed.body.refreshToken).toBeTypeOf("string");
|
||||
expect(refreshed.body.refreshToken).not.toBe(originalRefreshToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh — replay detection revokes the whole session chain (ADR-0003)", () => {
|
||||
it("reusing an already-rotated refresh token is rejected AND invalidates the token that replaced it", async () => {
|
||||
const session = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
|
||||
.expect(200);
|
||||
const firstToken = session.body.refreshToken as string;
|
||||
|
||||
const rotated = await agent(app)
|
||||
.post("/api/v1/auth/refresh")
|
||||
.send({ refreshToken: firstToken })
|
||||
.expect(200);
|
||||
const secondToken = rotated.body.refreshToken as string;
|
||||
expect(secondToken).not.toBe(firstToken);
|
||||
|
||||
// Replay: reuse the now-rotated-away first token.
|
||||
await agent(app).post("/api/v1/auth/refresh").send({ refreshToken: firstToken }).expect(401);
|
||||
|
||||
// The specific invariant under test: replay of a revoked token must
|
||||
// revoke the ENTIRE session chain defensively (auth.service.ts
|
||||
// refresh() -> revokeAllUserSessions), not just reject the replayed
|
||||
// token. So the token that legitimately replaced it must now also
|
||||
// be dead, even though it was never itself reused or leaked.
|
||||
await agent(app).post("/api/v1/auth/refresh").send({ refreshToken: secondToken }).expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout — idempotent", () => {
|
||||
it("calling logout twice with the same refresh token succeeds both times", async () => {
|
||||
const session = await agent(app)
|
||||
.post("/api/v1/auth/login")
|
||||
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
|
||||
.expect(200);
|
||||
const refreshToken = session.body.refreshToken as string;
|
||||
|
||||
await agent(app).post("/api/v1/auth/logout").send({ refreshToken }).expect(204);
|
||||
// Second call: session is already revoked/gone — auth.service.ts's
|
||||
// logout() treats "no active session found" as success, not 404/500.
|
||||
await agent(app).post("/api/v1/auth/logout").send({ refreshToken }).expect(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user