ADR-0006 explicitly commits to preserving the legacy's "trava" once a contract leaves its editable draft state, overridable only by an audited super_admin correction — but ContractsService.addItem/addParty had no status or role check at all, letting anyone with contracts:edit add items/parties to a contract in any status, including a signed active one. Adds assertEditable() (same shape as Quotes' assertNotLocked): draft is the only free-editing state; past it, only super_admin can still write, and that write is tagged distinctly in the audit log (contract.item_added_override) rather than looking like an ordinary edit. No frontend change needed — ContractDetailPage never called the add-item/party endpoints in the first place. Verified live: super_admin override still works past draft, and the audit_log row for it carries overrode:true and the contract's status at the time — confirmed directly in Postgres. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
159 lines
5.3 KiB
TypeScript
159 lines
5.3 KiB
TypeScript
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 edit-lock invariant ADR-0006 explicitly promises but that
|
|
* ContractsService didn't actually enforce until now: "fluxo de
|
|
* fechamento de oferta → geração de contrato preserva a trava equivalente
|
|
* [...] exceto por papel com permissão de correção auditada equivalente
|
|
* ao super_admin do legado" (docs/adr/0006-contract-first-class.md).
|
|
* Mirrors the shape of quotes.service.ts's assertNotLocked, ported to
|
|
* Contract's own status field (draft = editable, anything past it =
|
|
* locked except for super_admin).
|
|
*/
|
|
describe("contracts — edit lock past 'draft'", () => {
|
|
let app: INestApplication;
|
|
let admin: AuthedTokens;
|
|
let resellerId: string;
|
|
let productId: string;
|
|
let contractId: string;
|
|
|
|
let lowRoleKey: string;
|
|
let lowActorUserId: string;
|
|
let lowActorEmail: string;
|
|
let lowActorTokens: AuthedTokens;
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp();
|
|
admin = await loginSuperAdmin(app);
|
|
|
|
const reseller = await agent(app)
|
|
.post("/api/v1/resellers")
|
|
.set(...bearer(admin.accessToken))
|
|
.send({ name: "qa-test- Revenda Contract Lock" })
|
|
.expect(201);
|
|
resellerId = reseller.body.id;
|
|
|
|
const product = await agent(app)
|
|
.post("/api/v1/products")
|
|
.set(...bearer(admin.accessToken))
|
|
.send({ name: "qa-test- Produto Contract Lock", productType: "service", price0: 100 })
|
|
.expect(201);
|
|
productId = product.body.id;
|
|
|
|
const contract = await agent(app)
|
|
.post("/api/v1/contracts")
|
|
.set(...bearer(admin.accessToken))
|
|
.send({
|
|
resellerId,
|
|
contractPeriod: 0,
|
|
items: [{ productId, quantity: 1 }],
|
|
parties: [{ partyRole: "contratante", fullName: "qa-test Cliente Contract Lock" }],
|
|
})
|
|
.expect(201);
|
|
contractId = contract.body.id;
|
|
expect(contract.body.status).toBe("draft");
|
|
|
|
// Non-super_admin actor holding contracts:edit — isolates the lock
|
|
// check itself from the generic PermissionGuard (same pattern as
|
|
// role-weight.spec.ts).
|
|
lowRoleKey = qaKey("contractlock");
|
|
await agent(app)
|
|
.post("/api/v1/roles")
|
|
.set(...bearer(admin.accessToken))
|
|
.send({ key: lowRoleKey, label: "QA test contract-lock role", weight: 10 })
|
|
.expect(201);
|
|
await agent(app)
|
|
.patch(`/api/v1/roles/${lowRoleKey}/permissions`)
|
|
.set(...bearer(admin.accessToken))
|
|
.send({
|
|
permissions: [
|
|
{ resource: "contracts", action: "edit", scope: "all" },
|
|
{ resource: "contracts", action: "view", scope: "all" },
|
|
],
|
|
})
|
|
.expect(200);
|
|
|
|
lowActorEmail = qaEmail("contractlock-actor");
|
|
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;
|
|
lowActorTokens = await login(app, lowActorEmail, invite.body.tempPassword);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
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);
|
|
await agent(app)
|
|
.patch(`/api/v1/products/${productId}`)
|
|
.set(...bearer(admin.accessToken))
|
|
.send({ active: false })
|
|
.expect(200);
|
|
await agent(app)
|
|
.patch(`/api/v1/resellers/${resellerId}`)
|
|
.set(...bearer(admin.accessToken))
|
|
.send({ active: false })
|
|
.expect(200);
|
|
await closeTestApp(app);
|
|
});
|
|
|
|
it("holding contracts:edit is enough to add an item while the contract is still draft", async () => {
|
|
await agent(app)
|
|
.post(`/api/v1/contracts/${contractId}/items`)
|
|
.set(...bearer(lowActorTokens.accessToken))
|
|
.send({ productId, quantity: 1 })
|
|
.expect(201);
|
|
});
|
|
|
|
it("once the contract leaves draft, a non-super_admin holding contracts:edit is blocked (409)", async () => {
|
|
await agent(app)
|
|
.patch(`/api/v1/contracts/${contractId}/status`)
|
|
.set(...bearer(admin.accessToken))
|
|
.send({ toStatus: "pending_signature" })
|
|
.expect(200);
|
|
|
|
const res = await agent(app)
|
|
.post(`/api/v1/contracts/${contractId}/items`)
|
|
.set(...bearer(lowActorTokens.accessToken))
|
|
.send({ productId, quantity: 1 })
|
|
.expect(409);
|
|
expect(res.body.message).toMatch(/rascunho/i);
|
|
|
|
await agent(app)
|
|
.post(`/api/v1/contracts/${contractId}/parties`)
|
|
.set(...bearer(lowActorTokens.accessToken))
|
|
.send({ partyRole: "contratante", fullName: "qa-test Outra Parte" })
|
|
.expect(409);
|
|
});
|
|
|
|
it("super_admin can still add an item past draft — an audited override, not a silent bypass", async () => {
|
|
const res = await agent(app)
|
|
.post(`/api/v1/contracts/${contractId}/items`)
|
|
.set(...bearer(admin.accessToken))
|
|
.send({ productId, quantity: 1 })
|
|
.expect(201);
|
|
expect(res.body.product_id).toBe(productId);
|
|
});
|
|
});
|