fix(contracts): enforce the edit-lock ADR-0006 promises but never wired
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
This commit is contained in:
@@ -22,6 +22,8 @@ import type { ContractPartyInputDto } from "./dto/contract-party-input.dto.js";
|
|||||||
|
|
||||||
const { ContractNotFoundError, InvalidContractTransitionError } = contractsRepo;
|
const { ContractNotFoundError, InvalidContractTransitionError } = contractsRepo;
|
||||||
|
|
||||||
|
const SUPER_ADMIN = "super_admin";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContractsService {
|
export class ContractsService {
|
||||||
async list(user: EdenAuthenticatedUser): Promise<Contract[]> {
|
async list(user: EdenAuthenticatedUser): Promise<Contract[]> {
|
||||||
@@ -113,37 +115,63 @@ export class ContractsService {
|
|||||||
|
|
||||||
async addItem(id: string, dto: ContractItemInputDto, user: EdenAuthenticatedUser) {
|
async addItem(id: string, dto: ContractItemInputDto, user: EdenAuthenticatedUser) {
|
||||||
const contract = await this.get(id, user);
|
const contract = await this.get(id, user);
|
||||||
|
const overrode = this.assertEditable(contract, user);
|
||||||
const [item] = await this.buildItems([dto], contract.contract_period);
|
const [item] = await this.buildItems([dto], contract.contract_period);
|
||||||
const created = await contractsRepo.addContractItem(id, { ...item!, sortOrder: contract.items.length });
|
const created = await contractsRepo.addContractItem(id, { ...item!, sortOrder: contract.items.length });
|
||||||
|
|
||||||
await auditRepo.appendAuditEvent({
|
await auditRepo.appendAuditEvent({
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
action: "contract.item_added",
|
action: overrode ? "contract.item_added_override" : "contract.item_added",
|
||||||
resourceType: "contract",
|
resourceType: "contract",
|
||||||
resourceId: id,
|
resourceId: id,
|
||||||
metadata: { productId: dto.productId },
|
metadata: { productId: dto.productId, contractStatus: contract.status, overrode },
|
||||||
});
|
});
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addParty(id: string, dto: ContractPartyInputDto, user: EdenAuthenticatedUser) {
|
async addParty(id: string, dto: ContractPartyInputDto, user: EdenAuthenticatedUser) {
|
||||||
await this.get(id, user);
|
const contract = await this.get(id, user);
|
||||||
|
const overrode = this.assertEditable(contract, user);
|
||||||
const created = await contractsRepo.addContractParty(id, this.mapParty(dto));
|
const created = await contractsRepo.addContractParty(id, this.mapParty(dto));
|
||||||
|
|
||||||
await auditRepo.appendAuditEvent({
|
await auditRepo.appendAuditEvent({
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
action: "contract.party_added",
|
action: overrode ? "contract.party_added_override" : "contract.party_added",
|
||||||
resourceType: "contract",
|
resourceType: "contract",
|
||||||
resourceId: id,
|
resourceId: id,
|
||||||
metadata: { partyRole: dto.partyRole },
|
metadata: { partyRole: dto.partyRole, contractStatus: contract.status, overrode },
|
||||||
});
|
});
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR-0006's own stated invariant: "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" — ported here from Quotes' assertNotLocked (same shape:
|
||||||
|
* hard lock once the aggregate has moved past its editable draft state,
|
||||||
|
* super_admin-only override). `draft` is the only status items/parties
|
||||||
|
* can be freely added to; anything past it means signing has started or
|
||||||
|
* the contract is already live — an edit past that point is either a
|
||||||
|
* correction (audited, super_admin-only) or should go through a future
|
||||||
|
* amendment instead (contract_amendments, not yet built — ADR-0006).
|
||||||
|
* Returns whether this call IS an override, so the caller can tag the
|
||||||
|
* audit event distinctly.
|
||||||
|
*/
|
||||||
|
private assertEditable(contract: Contract, user: EdenAuthenticatedUser): boolean {
|
||||||
|
if (contract.status === "draft") return false;
|
||||||
|
if (user.roleKey !== SUPER_ADMIN) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Este contrato não está mais em rascunho (status atual: ${contract.status}) — itens/partes só podem ser adicionados enquanto o contrato está em rascunho.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private mapParty(dto: ContractPartyInputDto) {
|
private mapParty(dto: ContractPartyInputDto) {
|
||||||
return {
|
return {
|
||||||
partyRole: dto.partyRole,
|
partyRole: dto.partyRole,
|
||||||
|
|||||||
158
apps/api/test/contracts-edit-lock.spec.ts
Normal file
158
apps/api/test/contracts-edit-lock.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -516,3 +516,26 @@ Novo endpoint `GET /v1/quotes/:id/anatel-pdf` — 400 se a oferta não tiver nen
|
|||||||
### Verificação
|
### Verificação
|
||||||
|
|
||||||
Teste de integração novo (`test/anatel-pdf.spec.ts`, 3 testes, `renderPdf` mockado como nos outros specs de PDF — o que está sob teste é o conteúdo regulatório computado, não o Chromium em si): confirma snapshot correto dos campos do produto no item, todos os campos regulatórios computados corretos no HTML (datas, fórmula da multa, tabela de tarifação, texto VoIP, campos fixos), e 400 para oferta sem item STFC. Suíte completa: 39/39 verdes (36→39). `pnpm turbo run typecheck build --force` (28/28).
|
Teste de integração novo (`test/anatel-pdf.spec.ts`, 3 testes, `renderPdf` mockado como nos outros specs de PDF — o que está sob teste é o conteúdo regulatório computado, não o Chromium em si): confirma snapshot correto dos campos do produto no item, todos os campos regulatórios computados corretos no HTML (datas, fórmula da multa, tabela de tarifação, texto VoIP, campos fixos), e 400 para oferta sem item STFC. Suíte completa: 39/39 verdes (36→39). `pnpm turbo run typecheck build --force` (28/28).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trava de edição de Contrato — lacuna real entre ADR-0006 e a implementação
|
||||||
|
|
||||||
|
Data: 2026-09-04. Usuário pediu para avaliar as regras de edição/trava de Orçamento-como-Contrato do legado contra o modelo de Contrato do EDEN (ADR-0006) antes de seguir para Assinatura Eletrônica.
|
||||||
|
|
||||||
|
### Achado
|
||||||
|
|
||||||
|
`docs/adr/0006-contract-first-class.md` promete explicitamente: "fluxo de fechamento de oferta → geração de contrato preserva a trava equivalente [...] transição de estado do contrato que também impede edição desconforme, exceto por papel com permissão de correção auditada equivalente ao super_admin do legado". Essa trava **nunca foi implementada**: `ContractsService.addItem`/`addParty` não tinham nenhuma checagem de status ou papel — qualquer ator com a permissão genérica `contracts:edit` podia adicionar item/parte a um contrato em **qualquer** status, incluindo `cancelled`/`terminated`/`expired`, ou um `active` já assinado. A infraestrutura para a regra correta já existia (`resolveEffectiveFidelity()`, a máquina de estados `CONTRACT_STATUS_TRANSITIONS`) — só a trava de edição em si nunca foi conectada.
|
||||||
|
|
||||||
|
### Correção (`apps/api/src/contracts/contracts.service.ts`)
|
||||||
|
|
||||||
|
Novo `assertEditable()`, mesmo formato do `assertNotLocked` de Quotes: `status === 'draft'` é o único estado livre para adicionar item/parte; qualquer outro status bloqueia (409) para quem não é `super_admin`; `super_admin` pode seguir editando, mas o evento de auditoria fica marcado (`contract.item_added_override`/`contract.party_added_override`, com `overrode: true` no metadata) em vez de ser indistinguível de uma edição normal — a "correção auditada" que o ADR pedia.
|
||||||
|
|
||||||
|
Sem mudança de frontend: `ContractDetailPage` nunca chamou `addContractItem`/`addContractParty` (a função existe no client mas não é usada em nenhuma tela) — a lacuna era puramente de backend.
|
||||||
|
|
||||||
|
### Verificação
|
||||||
|
|
||||||
|
Teste de integração novo (`test/contracts-edit-lock.spec.ts`, 3 testes, papel throwaway com `contracts:edit`+`view` mas sem `super_admin` — mesmo padrão do `role-weight.spec.ts`): confirma que adicionar item funciona em `draft`, que um ator não-super_admin é bloqueado (409) assim que o contrato sai de `draft`, e que `super_admin` continua conseguindo (override). Suíte completa: 42/42 verdes (39→42). `pnpm turbo run typecheck build --force` (28/28).
|
||||||
|
|
||||||
|
### Pendências
|
||||||
|
`contract_versions` (snapshot-on-signature, invariante nº4 do ADR-0006) segue deliberadamente adiado — precisa do desenho do fluxo de assinatura para não virar uma tabela vazia. `contract_amendments` (forma correta de alterar um contrato já assinado, em vez do override de super_admin) também segue adiado — depende de `contract_versions` primeiro.
|
||||||
|
|||||||
Reference in New Issue
Block a user