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);
|
||||
});
|
||||
});
|
||||
});
|
||||
65
apps/api/test/companies.spec.ts
Normal file
65
apps/api/test/companies.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
|
||||
|
||||
/**
|
||||
* Protects the CNPJ digit-check invariant in
|
||||
* apps/api/src/companies/companies.service.ts (assertValidCnpjIfPresent ->
|
||||
* @eden/domain-shared's isValidCNPJ) — format validation only, per that
|
||||
* function's own doc comment ("never confirms a document actually exists
|
||||
* or belongs to whoever claims it").
|
||||
*
|
||||
* The valid-CNPJ fixture uses 11.444.777/0001-61 — verified against the
|
||||
* same digit-check algorithm (packages/domain-shared/src/validators/
|
||||
* br-documents.ts) before writing this test, and confirmed (by querying
|
||||
* the shared dev Postgres directly) NOT already used by any company row,
|
||||
* unlike 11.222.333/0001-81 which the seeded "Handix Telecom LTDA" company
|
||||
* already holds.
|
||||
*
|
||||
* Cleanup: there is no DELETE /v1/companies endpoint, so the company
|
||||
* created by the "valid CNPJ succeeds" test is deactivated
|
||||
* (PATCH isActive:false) in afterAll rather than deleted.
|
||||
*/
|
||||
describe("companies — CNPJ validation invariant", () => {
|
||||
let app: INestApplication;
|
||||
let admin: AuthedTokens;
|
||||
let createdCompanyId: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
admin = await loginSuperAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdCompanyId) {
|
||||
await agent(app)
|
||||
.patch(`/api/v1/companies/${createdCompanyId}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ isActive: false })
|
||||
.expect(200);
|
||||
}
|
||||
await closeTestApp(app);
|
||||
});
|
||||
|
||||
it("rejects a CNPJ that fails the check-digit algorithm with 400", async () => {
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/companies")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ companyName: "qa-test- Empresa CNPJ Inválido", cnpj: "11.222.333/0001-00" })
|
||||
.expect(400);
|
||||
expect(res.body.message).toContain("CNPJ inválido");
|
||||
});
|
||||
|
||||
it("accepts a company with a valid, not-already-used CNPJ", async () => {
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/companies")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ companyName: "qa-test- Empresa CNPJ Válido", cnpj: "11.444.777/0001-61" })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.id).toBeTypeOf("string");
|
||||
// Persisted digits-only, per companies.service.ts's onlyDigits(dto.cnpj).
|
||||
expect(res.body.cnpj).toBe("11444777000161");
|
||||
createdCompanyId = res.body.id;
|
||||
});
|
||||
});
|
||||
58
apps/api/test/mass-assignment.spec.ts
Normal file
58
apps/api/test/mass-assignment.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
|
||||
|
||||
/**
|
||||
* Protects the global ValidationPipe wiring itself
|
||||
* (whitelist: true, forbidNonWhitelisted: true — apps/api/src/main.ts,
|
||||
* mirrored in test/setup.ts's createTestApp). Master Prompt §12: the
|
||||
* server never trusts a client-supplied field it didn't ask for. This
|
||||
* doesn't test any one DTO's specific fields so much as prove the pipe
|
||||
* is actually reaching these routes and actually configured to reject
|
||||
* (not silently strip) unknown fields.
|
||||
*
|
||||
* No fixtures are created — every request here is expected to be
|
||||
* rejected by the pipe *before* the controller/service runs, so nothing
|
||||
* ever reaches the database. No cleanup needed.
|
||||
*/
|
||||
describe("mass-assignment — unexpected fields are rejected, not silently dropped", () => {
|
||||
let app: INestApplication;
|
||||
let admin: AuthedTokens;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
admin = await loginSuperAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(app);
|
||||
});
|
||||
|
||||
it("POST /v1/companies rejects an unexpected 'id' field", async () => {
|
||||
await agent(app)
|
||||
.post("/api/v1/companies")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ companyName: "qa-test- mass assignment id", id: "11111111-1111-1111-1111-111111111111" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("POST /v1/companies rejects an unexpected 'isActive' field (not part of CreateCompanyDto)", async () => {
|
||||
await agent(app)
|
||||
.post("/api/v1/companies")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ companyName: "qa-test- mass assignment isActive", isActive: false })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("POST /v1/products rejects an unexpected 'createdBy' field", async () => {
|
||||
await agent(app)
|
||||
.post("/api/v1/products")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({
|
||||
name: "qa-test- mass assignment product",
|
||||
productType: "service",
|
||||
createdBy: "11111111-1111-1111-1111-111111111111",
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
120
apps/api/test/reseller-assignment.spec.ts
Normal file
120
apps/api/test/reseller-assignment.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { query } from "@eden/database";
|
||||
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaEmail, type AuthedTokens } from "./setup.js";
|
||||
|
||||
/**
|
||||
* Protects the fix in commit 8d108e0 ("fix: assign-user de reseller usava
|
||||
* resellerId do body, não o :id da URL"): the reseller a user is assigned
|
||||
* to must always be the `:id` in the URL, never a value read from the
|
||||
* request body. AssignUserToResellerDto (apps/api/src/resellers/dto/
|
||||
* assign-user-to-reseller.dto.ts) now only declares `userId` — it doesn't
|
||||
* accept `resellerId` at all — so the strongest proof available at the
|
||||
* HTTP layer is that a body containing `resellerId` is rejected outright
|
||||
* by the mass-assignment whitelist (a body that could "win" over the URL
|
||||
* literally cannot reach the service layer any more). This is backed up
|
||||
* with a direct read of `users.reseller_id` after a legitimate call,
|
||||
* confirming the URL's reseller is the one actually persisted.
|
||||
*
|
||||
* Fixtures: two throwaway resellers + one throwaway user (all
|
||||
* `qa-test-`/`qa_` tagged). Cleanup: unassign the user (reseller_id back
|
||||
* to null, exercised by test (b) itself), then deactivate the user and
|
||||
* both resellers — no DELETE endpoint exists for either resource.
|
||||
*/
|
||||
describe("resellers — assign-user URL-wins-over-body invariant", () => {
|
||||
let app: INestApplication;
|
||||
let admin: AuthedTokens;
|
||||
let resellerAId: string;
|
||||
let resellerBId: string;
|
||||
let userId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
admin = await loginSuperAdmin(app);
|
||||
|
||||
const resellerA = await agent(app)
|
||||
.post("/api/v1/resellers")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ name: "qa-test- Reseller A (target)" })
|
||||
.expect(201);
|
||||
resellerAId = resellerA.body.id;
|
||||
|
||||
const resellerB = await agent(app)
|
||||
.post("/api/v1/resellers")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ name: "qa-test- Reseller B (decoy in body)" })
|
||||
.expect(201);
|
||||
resellerBId = resellerB.body.id;
|
||||
|
||||
const invite = await agent(app)
|
||||
.post("/api/v1/users/invite")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ email: qaEmail("reseller-assignee"), roleKey: "user", applications: ["core"] })
|
||||
.expect(201);
|
||||
userId = invite.body.user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await agent(app)
|
||||
.patch(`/api/v1/users/${userId}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ isActive: false })
|
||||
.expect(200);
|
||||
await agent(app)
|
||||
.patch(`/api/v1/resellers/${resellerAId}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ active: false })
|
||||
.expect(200);
|
||||
await agent(app)
|
||||
.patch(`/api/v1/resellers/${resellerBId}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ active: false })
|
||||
.expect(200);
|
||||
await closeTestApp(app);
|
||||
});
|
||||
|
||||
it("a 'resellerId' in the body is rejected by the whitelist — there is no way to redirect the assignment via the body", async () => {
|
||||
await agent(app)
|
||||
.patch(`/api/v1/resellers/${resellerAId}/assign-user`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ userId, resellerId: resellerBId })
|
||||
.expect(400);
|
||||
|
||||
// Confirm the rejected request didn't partially apply anything.
|
||||
const { rows } = await query<{ reseller_id: string | null }>(
|
||||
"SELECT reseller_id FROM users WHERE id = $1",
|
||||
[userId],
|
||||
);
|
||||
expect(rows[0]?.reseller_id).toBeNull();
|
||||
});
|
||||
|
||||
it("assigning via the URL's :id actually persists that reseller (not the decoy)", async () => {
|
||||
const res = await agent(app)
|
||||
.patch(`/api/v1/resellers/${resellerAId}/assign-user`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ userId })
|
||||
.expect(200);
|
||||
expect(res.body.resellerId).toBe(resellerAId);
|
||||
|
||||
const { rows } = await query<{ reseller_id: string | null }>(
|
||||
"SELECT reseller_id FROM users WHERE id = $1",
|
||||
[userId],
|
||||
);
|
||||
expect(rows[0]?.reseller_id).toBe(resellerAId);
|
||||
});
|
||||
|
||||
it("POST /v1/resellers/unassign-user clears the assignment", async () => {
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/resellers/unassign-user")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ userId })
|
||||
.expect(201);
|
||||
expect(res.body.resellerId).toBeNull();
|
||||
|
||||
const { rows } = await query<{ reseller_id: string | null }>(
|
||||
"SELECT reseller_id FROM users WHERE id = $1",
|
||||
[userId],
|
||||
);
|
||||
expect(rows[0]?.reseller_id).toBeNull();
|
||||
});
|
||||
});
|
||||
124
apps/api/test/role-weight.spec.ts
Normal file
124
apps/api/test/role-weight.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agent,
|
||||
bearer,
|
||||
closeTestApp,
|
||||
createTestApp,
|
||||
login,
|
||||
loginSuperAdmin,
|
||||
qaEmail,
|
||||
qaKey,
|
||||
type AuthedTokens,
|
||||
} from "./setup.js";
|
||||
|
||||
/**
|
||||
* Protects the role-weight invariant of apps/api/src/auth/role-weight.ts
|
||||
* (docs/adr/0004-permission-model.md, eden.md §1.6): an actor can never
|
||||
* assign/act on a role heavier than their own. Two distinct regressions
|
||||
* are guarded here:
|
||||
*
|
||||
* (a) an unknown role_key must fail as a clean 400, not an unhandled
|
||||
* DB FK-constraint 500 — see role-weight.ts's own comment on this
|
||||
* exact bug ("this function must be the single place that turns
|
||||
* 'role doesn't exist' into a clean 400, not the DB constraint").
|
||||
* (b) the weight comparison itself: a low-weight actor must be
|
||||
* forbidden (403) from assigning a heavier role than their own,
|
||||
* even when they otherwise hold the users:edit permission needed to
|
||||
* reach the check at all.
|
||||
*/
|
||||
describe("role-weight — assignment invariant", () => {
|
||||
let app: INestApplication;
|
||||
let admin: AuthedTokens;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
admin = await loginSuperAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(app);
|
||||
});
|
||||
|
||||
it("inviting a user with a role_key that doesn't exist returns 400, not 500 (regression guard)", async () => {
|
||||
const email = qaEmail("role-badkey");
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/users/invite")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ email, roleKey: "does_not_exist_role_xyz" })
|
||||
.expect(400);
|
||||
expect(res.body.message).toContain("does_not_exist_role_xyz");
|
||||
|
||||
// The invite must have been rejected before any row was written —
|
||||
// confirm no user with this email exists.
|
||||
const list = await agent(app)
|
||||
.get("/api/v1/users")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(list.body.some((u: { email: string }) => u.email === email.toLowerCase())).toBe(false);
|
||||
});
|
||||
|
||||
describe("a lower-weight actor cannot assign a heavier role", () => {
|
||||
let lowRoleKey: string;
|
||||
let lowActorUserId: string;
|
||||
let lowActorEmail: string;
|
||||
let lowActorPassword: string;
|
||||
let lowActorTokens: AuthedTokens;
|
||||
|
||||
beforeAll(async () => {
|
||||
// 1. A fresh, low-weight role (weight 5 — lighter than every
|
||||
// system role except none; lighter than 'admin' at 80), granted
|
||||
// users:edit/all so the actor reaches the role-weight check itself
|
||||
// rather than being stopped earlier by PermissionGuard — isolating
|
||||
// the specific invariant under test.
|
||||
lowRoleKey = qaKey("lowrole");
|
||||
await agent(app)
|
||||
.post("/api/v1/roles")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ key: lowRoleKey, label: "QA test low-weight role", weight: 5 })
|
||||
.expect(201);
|
||||
await agent(app)
|
||||
.patch(`/api/v1/roles/${lowRoleKey}/permissions`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ permissions: [{ resource: "users", action: "edit", scope: "all" }] })
|
||||
.expect(200);
|
||||
|
||||
// 2. A throwaway user in that role, logged in for real.
|
||||
lowActorEmail = qaEmail("role-lowactor");
|
||||
const invite = await agent(app)
|
||||
.post("/api/v1/users/invite")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ email: lowActorEmail, roleKey: lowRoleKey, applications: ["core"] })
|
||||
.expect(201);
|
||||
lowActorUserId = invite.body.user.id;
|
||||
lowActorPassword = invite.body.tempPassword;
|
||||
lowActorTokens = await login(app, lowActorEmail, lowActorPassword);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Reassign the fixture user back to a system role and deactivate it
|
||||
// so the throwaway role has zero referencing users left, then
|
||||
// delete the throwaway role (RolesService.delete refuses to delete
|
||||
// a role that still has users on it) — full cleanup, not just a
|
||||
// tag, since both a DELETE-role and a PATCH-user endpoint exist.
|
||||
await agent(app)
|
||||
.patch(`/api/v1/users/${lowActorUserId}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ roleKey: "user", isActive: false })
|
||||
.expect(200);
|
||||
await agent(app)
|
||||
.delete(`/api/v1/roles/${lowRoleKey}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it("holding users:edit is not enough — assigning role 'admin' (weight 80 > actor's 5) is forbidden", async () => {
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/users/invite")
|
||||
.set(...bearer(lowActorTokens.accessToken))
|
||||
.send({ email: qaEmail("role-target"), roleKey: "admin" })
|
||||
.expect(403);
|
||||
expect(res.body.message).toMatch(/peso maior/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
120
apps/api/test/setup.ts
Normal file
120
apps/api/test/setup.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import "reflect-metadata";
|
||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { closePool } from "@eden/database";
|
||||
import { AppModule } from "../src/app.module.js";
|
||||
|
||||
/**
|
||||
* Shared integration-test bootstrap (eden-qa). Boots the REAL Nest app —
|
||||
* every module, guard, pipe and repository exactly as main.ts wires them —
|
||||
* against the REAL Postgres pointed to by DATABASE_URL. The only thing
|
||||
* skipped versus a full deployment is the separate OS process/container:
|
||||
* HTTP goes through supertest -> app.getHttpServer() (real Express, real
|
||||
* routing, real guards), and the DB goes through the real `pg` Pool (no
|
||||
* mock/in-memory substitute anywhere in this stack).
|
||||
*
|
||||
* Mirrors main.ts's ValidationPipe + global prefix exactly (see
|
||||
* apps/api/src/main.ts) without importing/touching main.ts itself, so
|
||||
* this file can't conflict with other work landing there.
|
||||
*/
|
||||
export async function createTestApp(): Promise<INestApplication> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
const app = moduleRef.createNestApplication();
|
||||
|
||||
app.setGlobalPrefix("api", { exclude: ["health/live", "health/ready"] });
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function closeTestApp(app: INestApplication): Promise<void> {
|
||||
await app.close();
|
||||
// packages/database's pg Pool is a module-level singleton, not a Nest
|
||||
// provider tied to app.close() — close it explicitly so vitest's process
|
||||
// can exit instead of hanging on an idle DB connection.
|
||||
await closePool();
|
||||
}
|
||||
|
||||
/** Base path every controller in this API is mounted under (see main.ts's setGlobalPrefix). */
|
||||
export const API = "/api";
|
||||
|
||||
export function agent(app: INestApplication) {
|
||||
return request(app.getHttpServer());
|
||||
}
|
||||
|
||||
export interface AuthedTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in for real via POST /api/v1/auth/login (no guard/token mocking
|
||||
* anywhere in this suite). Requires EDEN_SUPERADMIN_EMAIL/PASSWORD to be
|
||||
* set in the environment (apps/api/src/bootstrap/seed.service.ts seeds
|
||||
* this account once at boot, only if `users` was empty when the API first
|
||||
* started) — the same account docker-compose's eden-api container seeds
|
||||
* against the shared dev Postgres.
|
||||
*/
|
||||
export async function loginSuperAdmin(app: INestApplication): Promise<AuthedTokens> {
|
||||
const email = process.env.EDEN_SUPERADMIN_EMAIL;
|
||||
const password = process.env.EDEN_SUPERADMIN_PASSWORD;
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
"EDEN_SUPERADMIN_EMAIL/EDEN_SUPERADMIN_PASSWORD must be set (see .env) to run these integration tests — they authenticate for real, no mocked guard.",
|
||||
);
|
||||
}
|
||||
return login(app, email, password);
|
||||
}
|
||||
|
||||
export async function login(
|
||||
app: INestApplication,
|
||||
email: string,
|
||||
password: string,
|
||||
applicationKey: "core" | "reseller" | "subscriber" = "core",
|
||||
): Promise<AuthedTokens> {
|
||||
const res = await agent(app)
|
||||
.post(`${API}/v1/auth/login`)
|
||||
.send({ email, password, applicationKey })
|
||||
.expect(200);
|
||||
return {
|
||||
accessToken: res.body.accessToken as string,
|
||||
refreshToken: res.body.refreshToken as string,
|
||||
userId: res.body.user.id as string,
|
||||
};
|
||||
}
|
||||
|
||||
export function bearer(token: string): [string, string] {
|
||||
return ["Authorization", `Bearer ${token}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every fixture this suite creates is tagged with this prefix so it's
|
||||
* trivially identifiable (and greppable) as QA-test-owned data in the
|
||||
* shared dev database, even if a cleanup step is ever missed.
|
||||
*/
|
||||
export const QA_PREFIX = "qa-test-";
|
||||
|
||||
export function qaEmail(label: string): string {
|
||||
const rand = Math.random().toString(36).slice(2, 10);
|
||||
return `${QA_PREFIX}${label}-${rand}@example.com`;
|
||||
}
|
||||
|
||||
export function qaKey(label: string): string {
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
// Roles: lowercase, starts with a letter, a-z0-9_, 2-30 chars (see
|
||||
// apps/api/src/roles/dto/create-role.dto.ts).
|
||||
return `qa_${label}_${rand}`.slice(0, 30);
|
||||
}
|
||||
Reference in New Issue
Block a user