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:
2026-09-03 11:14:43 -03:00
parent c9ba96f61f
commit cdafb7f853
12 changed files with 1767 additions and 3 deletions

View 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();
});
});