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