diff --git a/.claude/agent-memory/lp-front-dev/MEMORY.md b/.claude/agent-memory/lp-front-dev/MEMORY.md index 3323f9b..2b66e69 100644 --- a/.claude/agent-memory/lp-front-dev/MEMORY.md +++ b/.claude/agent-memory/lp-front-dev/MEMORY.md @@ -34,6 +34,8 @@ TCL/XSL/XSLT editáveis, Sysmiddle read-only. - [Mapping Studio — Slices 3 a 5](project_mapping_studio_slices_3_5_2026_08_31.md) — revisão humana, explicação canônica, compilação/Test Lab e gaps contratuais preservados de forma fail-closed. +- [Mapping Studio — Slices 6 e 7](project_mapping_studio_slices_6_7_2026_09_01.md) — gate Sysmiddle, + governança approve/publish/rollback com RBAC e bloqueios remanescentes do piloto FIAT real. Regras duráveis: HTTP só em `services/`; tipos em `src/types`; sem `any` novo; preserve `X-Correlation-ID`; payload TXT/XML não vai para logs/cache; produção nunca usa API absoluta. diff --git a/.claude/agent-memory/lp-front-dev/project_mapping_studio_slices_6_7_2026_09_01.md b/.claude/agent-memory/lp-front-dev/project_mapping_studio_slices_6_7_2026_09_01.md new file mode 100644 index 0000000..75841ff --- /dev/null +++ b/.claude/agent-memory/lp-front-dev/project_mapping_studio_slices_6_7_2026_09_01.md @@ -0,0 +1,28 @@ +# Mapping Studio — Slices 6 e 7 (01/09/2026) + +## Slice 6 + +- API #247 fecha bypasses do gate Sysmiddle por array, objeto, tipo inesperado e espaços. +- O front rejeita `MappingExplanation` Sysmiddle com `author|compile|publish=true` e rejeita Draft/ + Release cujo engine não seja exatamente `tcl|xslt`. +- O componente continua sem controles mesmo se o service for adulterado em memória; E2E cobre a + resposta maliciosa no navegador. + +## Slice 7 + +- API PR #248 publicou `approve`, `publish` e `rollback`, RBAC por papel do workspace e os estados + `in_review`, `approved`, `published`, `deprecated` e `archived`. +- O front usa o papel de `/api/workspaces/me` apenas para orientar controles; a API permanece + autoritativa e erros 403/404/422 são exibidos sem bypass. +- `capabilities.publish` não é permissão da identidade. Gates e status contraditórios, troca de + identidade da release e engine Sysmiddle falham fechado. +- O `GET` completo da release ainda omite metadados novos; as mutações devolvem resposta parcial. + Não há `If-Match`, leitura de `MappingTransition` nem `allowedTransitions`. +- E2E sintético cobre `test_passed → approved → published → deprecated`. Piloto FIAT real segue + bloqueado por API #219/#221 e pelo pacote/gabarito homologado. + +## Regra durável + +Nunca promover automaticamente uma release apenas porque `RequiredGatesPassed=true`; cada ação é +explícita e revalidada pelo RBAC da API. Sysmiddle permanece explicável/executável e não-autoral em +todas as camadas. diff --git a/README.md b/README.md index e836a31..3171b54 100644 --- a/README.md +++ b/README.md @@ -187,10 +187,21 @@ O conteúdo das fixtures é limpo após o envio e nunca vai para `localStorage`. `capabilities` de forma fail-closed: enquanto a API declarar `compile=false`, nenhum botão de compilação é exposto. TCL pode ser compilado, mas ainda não possui runner determinístico na API. +Quando uma release XSLT chega a `test_passed`, o Studio consome a governança do Slice 7: Revisor ou +Administrador fiscal pode aprovar com justificativa; Owner ou Administrador fiscal pode publicar +em desenvolvimento, validação ou produção e executar rollback para a publicação anterior. A API +revalida o papel do workspace e registra as transições; o React nunca promove automaticamente pela +simples presença de `RequiredGatesPassed=true`. Releases publicadas não reabrem o formulário do Test +Lab e seus artefatos permanecem imutáveis. O `GET` completo da release ainda não devolve todos os +metadados de governança, e a API ainda não expõe leitura de transições nem concorrência `If-Match` +nessas três operações. + > **EN:** Mapping Studio reviews TCL/XSLT proposals with optimistic concurrency and renders > Sysmiddle as strictly read-only. Fiscal Test Lab observes asynchronous compilation, exposes the > immutable snapshot and runs individual XSLT fixtures with XSD, canonical diff, coverage and > provenance. Fixtures are not persisted in the browser, and capabilities are enforced fail-closed. +> Slice 7 adds explicit RBAC-protected approval, publication and rollback actions; technical gates +> never trigger an automatic promotion, and published releases cannot be retested or edited. A rota `/admin` consulta a sessão do gateway e exige autorização administrativa. A proteção no React melhora a experiência, mas não substitui a autorização aplicada no servidor. diff --git a/contracts/api-endpoints.json b/contracts/api-endpoints.json index cbef496..8db583a 100644 --- a/contracts/api-endpoints.json +++ b/contracts/api-endpoints.json @@ -1,5 +1,5 @@ { - "version": 4, + "version": 5, "sourceOfTruth": "LayoutParserApi", "endpoints": [ { "method": "GET", "path": "/api/session", "owner": "LayoutParserNodeGateway" }, @@ -123,6 +123,24 @@ "path": "/api/workspaces/:param/mapping-drafts/:param/test-runs/:param", "owner": "LayoutParserApi", "deliveredBy": "LayoutParserApi#243" + }, + { + "method": "POST", + "path": "/api/workspaces/:param/mapping-releases/:param/approve", + "owner": "LayoutParserApi", + "deliveredBy": "LayoutParserApi#248" + }, + { + "method": "POST", + "path": "/api/workspaces/:param/mapping-releases/:param/publish", + "owner": "LayoutParserApi", + "deliveredBy": "LayoutParserApi#248" + }, + { + "method": "POST", + "path": "/api/workspaces/:param/mapping-releases/:param/rollback", + "owner": "LayoutParserApi", + "deliveredBy": "LayoutParserApi#248" } ] } diff --git a/docs/contracts/fiscal-workspace-and-mapping-explanation-api.md b/docs/contracts/fiscal-workspace-and-mapping-explanation-api.md index 9b710a5..24b43dc 100644 --- a/docs/contracts/fiscal-workspace-and-mapping-explanation-api.md +++ b/docs/contracts/fiscal-workspace-and-mapping-explanation-api.md @@ -200,6 +200,47 @@ Criação de pacote usa multipart com metadados JSON e artefatos classificados. conteúdo e extensão, calcula hash, aplica limite e antivírus/política equivalente e nunca confia no MIME informado pelo navegador. +### Slices 6 e 7 — segurança e governança + +O Slice 6 não adiciona operação ao consumidor: ele torna categórica a fronteira existente. O front +também falha fechado nas respostas: + +- `MappingExplanation.engine=sysmiddle` com `author`, `compile` ou `publish=true` é inválido; +- Draft e Release aceitam somente `tcl` ou `xslt`, sem normalizar arrays, objetos ou strings + adulteradas para um valor permitido; +- ausência de controle visual não substitui a rejeição autoritativa da API. + +O Slice 7 foi entregue pela API no PR +[#248](https://github.com/LayoutParser/LayoutParserApi/pull/248). Ele estende +`MappingRelease.status` com `in_review`, `approved`, `published`, `deprecated` e `archived` e expõe: + +| Operação | Papel de workspace | Corpo | Transição | +| -------------------------------------------------------------------------- | ---------------------------- | --------------------------------- | ----------------------------------------------------------- | +| `POST /api/workspaces/{workspaceId}/mapping-releases/{releaseId}/approve` | `reviewer` ou `fiscal_admin` | `{ "justification": "..." }` | `test_passed → in_review → approved` | +| `POST /api/workspaces/{workspaceId}/mapping-releases/{releaseId}/publish` | `fiscal_admin` ou `owner` | `{ "environment": "production" }` | `approved → published` | +| `POST /api/workspaces/{workspaceId}/mapping-releases/{releaseId}/rollback` | `fiscal_admin` ou `owner` | sem corpo | `published → deprecated`, restaurando a publicação anterior | + +O front oferece como alvos `development`, `validation` e `production`, exige justificativa para +aprovação, atualiza a máquina de estados com a resposta autoritativa e trata `403`, `404` e `422` +sem tentar contornar o RBAC. `capabilities.publish` pertence ao motor/adaptador de explicação e +**não** representa permissão do usuário; a orientação visual usa o papel devolvido por +`GET /api/workspaces/me`, e a API sempre revalida o acesso. + +Limitações contratuais preservadas: + +- as respostas das mutações são parciais; o `GET` completo do Slice 5 ainda omite os novos campos + de aprovação/publicação, então o parser não inventa valores ausentes; +- a API devolve `eTag`, mas os endpoints ainda não recebem `If-Match`; +- não existe endpoint de leitura de `MappingTransition` nem `allowedTransitions`; +- o rollback é idempotente e nunca edita os artefatos publicados; +- o piloto FIAT automatizado da API usa fixture sintética; a execução com artefatos reais continua + pendente e não pode ser declarada validada em produção. + +**EN summary:** Slice 7 now exposes RBAC-protected approve, publish and rollback operations. The +frontend follows the authoritative release status, never treats engine capabilities as user +authorization, and keeps API gaps (partial governance responses, no `If-Match`, no transition +query, real FIAT pilot pending) explicit. + Resposta atual de um Draft: ```json diff --git a/docs/product/fiscal-platform-roadmap.md b/docs/product/fiscal-platform-roadmap.md index bc48578..3edb706 100644 --- a/docs/product/fiscal-platform-roadmap.md +++ b/docs/product/fiscal-platform-roadmap.md @@ -190,18 +190,52 @@ Draft mutável. - **Gate:** o front não habilita compilação contra capability falsa, não guarda fixtures no `localStorage` e nunca expõe mutação Sysmiddle. -### Slice 6 — Histórico e catálogo navegável +### Slice 6 — Gate transversal Sysmiddle + +- **Entrega:** a API fecha mutação por payload adulterado, inclusive arrays, objetos, tipos + inesperados e espaços ao redor de `sysmiddle`; o front rejeita `MappingExplanation`, Draft e + Release que tentem anunciar ou transportar autoria, compilação ou publicação Sysmiddle. +- **Status:** API [#247](https://github.com/LayoutParser/LayoutParserApi/pull/247) mesclada em + `develop`; o front possui validação runtime, teste de estado adulterado e E2E desktop/mobile + cobrindo ausência de controles. A auditoria explícita de tentativas bloqueadas permanece como + follow-up adicional, apesar de a proteção autoritativa da API #232 estar concluída. +- **Gate:** qualquer capacidade mutável Sysmiddle falha fechado antes de montar o Studio. O servidor + continua sendo a proteção autoritativa para clientes fora deste front. + +### Slice 7 — Governança e piloto FIAT + +- **Entrega integrada:** o front consome `approve`, `publish` e `rollback` do PR API + [#248](https://github.com/LayoutParser/LayoutParserApi/pull/248), acompanha os estados + `draft_compiled → test_passed → approved → published → deprecated`, orienta ações pelo papel do + workspace e deixa a API revalidar o RBAC. A aprovação exige justificativa; a publicação congela + os artefatos; rollback restaura a publicação anterior sem editar a release. +- **Defesa:** status e resumo de gates contraditórios são rejeitados, resposta de mutação não pode + trocar workspace/release, Sysmiddle nunca entra no fluxo e `capabilities.publish` não é usado como + autorização da identidade. +- **Dependências restantes:** a API + [#94](https://github.com/LayoutParser/LayoutParserApi/issues/94) foi encerrada com os três + endpoints entregues; leitura da trilha de transições, `If-Match` nas mutações e metadados de + governança no `GET` completo devem ser acompanhados em follow-ups. O piloto FIAT real do front + [#206](https://github.com/LayoutParser/LayoutParserReact/issues/206) continua bloqueado por + [API #219](https://github.com/LayoutParser/LayoutParserApi/issues/219) e + [API #221](https://github.com/LayoutParser/LayoutParserApi/issues/221). +- **Evidência já disponível:** a suíte opt-in `npm run test:e2e:real` usa navegador, BFF e API reais, + exige o par privado homologado e valida correlation IDs, detecção ambígua Top 5, escolha explícita, + parse posicional, transformação, edição e reparse. A suíte E2E isolada cobre a governança com + respostas sintéticas; a suíte real ainda não publica uma release contra o backend. +- **Gate:** saída válida no XSD, provenance navegável, regressão aprovada, transições auditáveis e + zero mutação Sysmiddle. A integração do contrato está concluída; o piloto FIAT real permanece + parcial até receber e executar o pacote fiscal homologado. + +**EN summary:** the Slice 7 governance contract is integrated end to end in the frontend. Real FIAT +validation, transition-history retrieval and optimistic concurrency remain explicit follow-ups. + +### Próximo slice — Histórico e catálogo navegável - **Entrega esperada:** listar projetos, pacotes, análises e mappings sem exigir GUID manual; políticas de retenção e versionamento continuam autoritativas na API. - **Gate:** conteúdo fiscal não é persistido no navegador e versão publicada é imutável. -### Slice 7 — Primeiro pacote fiscal FIAT - -- **Entrega:** amostra MQSeries/IDoc + layout + planilha + XSD NF-e 4.00 alimentam um MappingDraft; - usuário revisa propostas e gera TCL/XSL/XSLT. -- **Gate:** saída válida no XSD, provenance navegável, regressão aprovada e zero mutação Sysmiddle. - ## Matriz cross-repo | Capacidade | React/BFF | API | QA/Contrato | diff --git a/e2e/layout-parser.spec.ts b/e2e/layout-parser.spec.ts index 1112c1f..82bf0e4 100644 --- a/e2e/layout-parser.spec.ts +++ b/e2e/layout-parser.spec.ts @@ -4,7 +4,11 @@ const layoutGuid = '11111111-1111-1111-1111-111111111111'; const syntheticTxt = `000001001ABC${' '.repeat(588)}`; const sapLayoutName = 'LAY_MARELLI_TXT_SAP_ENVNFE_4.00_NFe'; -const mockAuthenticatedGateway = async (page: Page, isAdmin = true) => { +const mockAuthenticatedGateway = async ( + page: Page, + isAdmin = true, + workspaceRole: 'owner' | 'fiscal_admin' = 'owner' +) => { await page.route('**/api/session', route => route.fulfill({ json: { @@ -25,7 +29,7 @@ const mockAuthenticatedGateway = async (page: Page, isAdmin = true) => { workspaceId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', name: 'Workspace fiscal E2E', kind: 'personal', - role: 'owner', + role: workspaceRole, createdAt: '2026-08-31T12:00:00Z', }, ], @@ -41,7 +45,42 @@ const mappingRuleId = 'cccccccc-cccc-cccc-cccc-cccccccccccc'; const mappingReleaseId = 'dddddddd-dddd-dddd-dddd-dddddddddddd'; const mockMappingStudioApis = async (page: Page) => { - let testCompleted = false; + let releaseStatus: 'draft_compiled' | 'test_passed' | 'approved' | 'published' | 'deprecated' = + 'draft_compiled'; + + const governanceResponse = () => ({ + releaseId: mappingReleaseId, + workspaceId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + draftId: mappingDraftId, + engine: 'xslt', + status: releaseStatus, + environment: + releaseStatus === 'published' || releaseStatus === 'deprecated' + ? 'production' + : 'development', + approvedByUserId: + releaseStatus === 'draft_compiled' || releaseStatus === 'test_passed' ? null : 'reviewer-e2e', + approvedAt: + releaseStatus === 'draft_compiled' || releaseStatus === 'test_passed' + ? null + : '2026-09-01T10:00:00Z', + approvalJustification: + releaseStatus === 'draft_compiled' || releaseStatus === 'test_passed' + ? null + : 'Aprovada pelo cenário E2E.', + publishedByUserId: + releaseStatus === 'published' || releaseStatus === 'deprecated' ? 'admin-e2e' : null, + publishedAt: + releaseStatus === 'published' || releaseStatus === 'deprecated' + ? '2026-09-01T10:05:00Z' + : null, + previousPublishedReleaseId: + releaseStatus === 'published' || releaseStatus === 'deprecated' + ? 'release-anterior-e2e' + : null, + correlationId: 'mapping-e2e-correlation', + eTag: 'AAAAAAAAAAM=', + }); await page.route('**/api/workspaces/*/mappings/*/versions/draft/explanation', route => route.fulfill({ @@ -147,18 +186,19 @@ const mockMappingStudioApis = async (page: Page) => { sourceRuleIds: [mappingRuleId], compileDiagnostics: [], rulesSnapshotHash: 'rules-e2e-hash', - testRunSummary: testCompleted - ? { - passed: 1, - failed: 0, - coveragePercent: 100, - requiredGatesPassed: true, - xsdValid: true, - xsdErrors: [], - divergences: [], - } - : null, - status: testCompleted ? 'test_passed' : 'draft_compiled', + testRunSummary: + releaseStatus === 'draft_compiled' + ? null + : { + passed: 1, + failed: 0, + coveragePercent: 100, + requiredGatesPassed: true, + xsdValid: true, + xsdErrors: [], + divergences: [], + }, + status: releaseStatus, correlationId: 'mapping-e2e-correlation', createdAt: '2026-08-31T20:02:00Z', eTag: 'AAAAAAAAAAI=', @@ -169,7 +209,7 @@ const mockMappingStudioApis = async (page: Page) => { route.fulfill({ json: { jobId: 'test-job-e2e', status: 'queued' } }) ); await page.route('**/api/workspaces/*/mapping-drafts/*/test-runs/*', route => { - testCompleted = true; + releaseStatus = 'test_passed'; return route.fulfill({ json: { jobId: 'test-job-e2e', @@ -181,6 +221,22 @@ const mockMappingStudioApis = async (page: Page) => { }, }); }); + await page.route('**/api/workspaces/*/mapping-releases/*/approve', async route => { + expect(route.request().postDataJSON()).toEqual({ + justification: 'Revisão fiscal aprovada no E2E.', + }); + releaseStatus = 'approved'; + return route.fulfill({ json: governanceResponse() }); + }); + await page.route('**/api/workspaces/*/mapping-releases/*/publish', async route => { + expect(route.request().postDataJSON()).toEqual({ environment: 'production' }); + releaseStatus = 'published'; + return route.fulfill({ json: governanceResponse() }); + }); + await page.route('**/api/workspaces/*/mapping-releases/*/rollback', route => { + releaseStatus = 'deprecated'; + return route.fulfill({ json: governanceResponse() }); + }); }; const mockProcessingApis = async (page: Page, withoutCandidates = false) => { @@ -469,7 +525,7 @@ test('vincula a sessão autenticada ao workspace fiscal', async ({ page }) => { }); test('compila o snapshot revisado e executa uma fixture no Fiscal Test Lab', async ({ page }) => { - await mockAuthenticatedGateway(page); + await mockAuthenticatedGateway(page, true, 'fiscal_admin'); await mockMappingStudioApis(page); await page.goto(`/workspace/mapping-studio/${mappingDraftId}/draft`); @@ -478,15 +534,68 @@ test('compila o snapshot revisado e executa uma fixture no Fiscal Test Lab', asy await page.getByRole('button', { name: 'Compilar snapshot' }).click(); await expect(page.getByRole('heading', { name: 'Compilada, aguardando testes' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Revisão, publicação e rollback' })).toBeVisible(); + await expect(page.getByText('Aguardando Test Lab')).toBeVisible(); await page.getByLabel('XML de entrada').fill('12345678000199'); await page.getByLabel('XML esperado').fill('12345678000199'); await page.getByRole('button', { name: 'Executar Test Lab' }).click(); await expect(page.getByRole('heading', { name: 'Gates aprovados' })).toBeVisible(); await expect(page.getByText('100.0% de cobertura')).toBeVisible(); + await expect(page.getByText('Gates técnicos aprovados', { exact: true })).toBeVisible(); + await page.getByLabel('Justificativa da revisão').fill('Revisão fiscal aprovada no E2E.'); + await page.getByRole('button', { name: 'Aprovar release' }).click(); + await expect(page.getByRole('heading', { name: 'Aprovada para publicação' })).toBeVisible(); + + await page.getByLabel('Ambiente de ativação').selectOption('production'); + await page.getByRole('button', { name: 'Publicar release' }).click(); + await expect(page.getByRole('heading', { name: 'Publicada' })).toBeVisible(); + await expect(page.getByText('release-anterior-e2e')).toBeVisible(); + + await page.getByRole('button', { name: 'Reverter para a versão anterior' }).click(); + await expect(page.getByRole('heading', { name: 'Descontinuada' })).toBeVisible(); + await expect(page.getByText(/Rollback concluído/i)).toBeVisible(); await expect(page.getByText('mapping-e2e-correlation')).toBeVisible(); }); +test('falha fechado quando Sysmiddle anuncia capabilities de mutação adulteradas', async ({ + page, +}) => { + await mockAuthenticatedGateway(page); + await page.route('**/api/workspaces/*/mappings/*/versions/current/explanation', route => + route.fulfill({ + json: { + mappingId: 'mapper-sysmiddle-adulterado', + version: 'current', + engine: 'sysmiddle', + capabilities: { + execute: true, + explain: true, + author: true, + compile: true, + publish: true, + }, + sourceSchema: null, + targetSchema: null, + rules: [], + description: 'Payload que deve ser recusado', + limitations: [], + opaqueRuleCount: 0, + }, + }) + ); + + await page.goto('/workspace/mapping-studio/mapper-sysmiddle-adulterado/current'); + + await expect( + page.getByRole('heading', { name: 'Não foi possível abrir esta transformação' }) + ).toBeVisible(); + await expect(page.getByText(/explicação de mapping inválida/i)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Gerar sugestões' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Compilar snapshot' })).toHaveCount(0); + await expect(page.getByRole('button', { name: /publicar/i })).toHaveCount(0); +}); + test('processa TXT e entrega o XML transformado para download', async ({ page }) => { await mockAuthenticatedGateway(page); await mockProcessingApis(page); diff --git a/src/components/mapping-studio/MappingGovernanceReadiness.test.tsx b/src/components/mapping-studio/MappingGovernanceReadiness.test.tsx new file mode 100644 index 0000000..484e077 --- /dev/null +++ b/src/components/mapping-studio/MappingGovernanceReadiness.test.tsx @@ -0,0 +1,186 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mappingReleaseService } from '../../services/api/mappingReleaseService'; +import type { MappingGovernanceSnapshot, MappingRelease } from '../../types/mappingRelease'; +import MappingGovernanceReadiness from './MappingGovernanceReadiness'; + +vi.mock('../../services/api/mappingReleaseService', () => ({ + mappingReleaseService: { + approveRelease: vi.fn(), + publishRelease: vi.fn(), + rollbackRelease: vi.fn(), + }, +})); + +const passedSummary = { + passed: 2, + failed: 0, + coveragePercent: 100, + requiredGatesPassed: true, + xsdValid: true, + xsdErrors: [], + divergences: [], +}; + +const release: MappingRelease = { + releaseId: 'release-1', + workspaceId: 'workspace-1', + draftId: 'draft-1', + engine: 'xslt', + artifacts: [], + sourceRuleIds: ['rule-1'], + compileDiagnostics: [], + rulesSnapshotHash: 'snapshot-hash', + testRunSummary: null, + status: 'draft_compiled', + correlationId: 'correlation-1', + createdAt: '2026-09-01T01:00:00Z', + eTag: 'AAAAAAAAAAE=', + environment: null, + approvedByUserId: null, + approvedAt: null, + approvalJustification: null, + publishedByUserId: null, + publishedAt: null, + previousPublishedReleaseId: null, +}; + +const snapshot = (status: MappingGovernanceSnapshot['status']): MappingGovernanceSnapshot => ({ + releaseId: 'release-1', + workspaceId: 'workspace-1', + draftId: 'draft-1', + engine: 'xslt', + status, + environment: status === 'published' ? 'production' : 'development', + approvedByUserId: 'reviewer-1', + approvedAt: '2026-09-01T10:00:00Z', + approvalJustification: 'Revisão fiscal concluída.', + publishedByUserId: ['published', 'deprecated'].includes(status) ? 'admin-1' : null, + publishedAt: ['published', 'deprecated'].includes(status) ? '2026-09-01T10:05:00Z' : null, + previousPublishedReleaseId: ['published', 'deprecated'].includes(status) ? 'release-0' : null, + correlationId: 'correlation-1', + eTag: 'AAAAAAAAAAI=', +}); + +describe('MappingGovernanceReadiness', () => { + beforeEach(() => vi.clearAllMocks()); + + it('representa a etapa atual de forma visual e acessível', () => { + render( + + ); + + expect(screen.getByRole('heading', { name: 'Revisão, publicação e rollback' })).toBeVisible(); + expect(screen.getByText('Aguardando Test Lab')).toBeVisible(); + expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Draft técnico'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('aprova uma release testada com justificativa obrigatória', async () => { + const onReleaseChange = vi.fn(); + vi.mocked(mappingReleaseService.approveRelease).mockResolvedValue(snapshot('approved')); + render( + + ); + + const approveButton = screen.getByRole('button', { name: 'Aprovar release' }); + expect(approveButton).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Justificativa da revisão'), { + target: { value: 'Revisão fiscal concluída.' }, + }); + fireEvent.click(approveButton); + + await waitFor(() => + expect(mappingReleaseService.approveRelease).toHaveBeenCalledWith( + 'workspace-1', + 'release-1', + 'Revisão fiscal concluída.' + ) + ); + expect(onReleaseChange).toHaveBeenCalledWith( + expect.objectContaining({ status: 'approved', approvedByUserId: 'reviewer-1' }) + ); + }); + + it('não oferece aprovação para papel sem autorização', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: 'Aprovar release' })).not.toBeInTheDocument(); + expect(screen.getByText(/Seu papel atual \(Owner\) não aprova releases/i)).toBeVisible(); + }); + + it('publica uma release aprovada no ambiente escolhido', async () => { + vi.mocked(mappingReleaseService.publishRelease).mockResolvedValue(snapshot('published')); + const onReleaseChange = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByLabelText('Ambiente de ativação'), { + target: { value: 'production' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Publicar release' })); + + await waitFor(() => + expect(mappingReleaseService.publishRelease).toHaveBeenCalledWith( + 'workspace-1', + 'release-1', + 'production' + ) + ); + expect(onReleaseChange).toHaveBeenCalledWith(expect.objectContaining({ status: 'published' })); + }); + + it('executa rollback da publicação e preserva a resposta idempotente da API', async () => { + vi.mocked(mappingReleaseService.rollbackRelease).mockResolvedValue(snapshot('deprecated')); + const onReleaseChange = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Reverter para a versão anterior' })); + + await waitFor(() => + expect(mappingReleaseService.rollbackRelease).toHaveBeenCalledWith('workspace-1', 'release-1') + ); + expect(onReleaseChange).toHaveBeenCalledWith(expect.objectContaining({ status: 'deprecated' })); + }); +}); diff --git a/src/components/mapping-studio/MappingGovernanceReadiness.tsx b/src/components/mapping-studio/MappingGovernanceReadiness.tsx new file mode 100644 index 0000000..601dd57 --- /dev/null +++ b/src/components/mapping-studio/MappingGovernanceReadiness.tsx @@ -0,0 +1,337 @@ +import { useState } from 'react'; +import { mappingReleaseService } from '../../services/api/mappingReleaseService'; +import type { + MappingGovernanceEnvironment, + MappingGovernanceSnapshot, + MappingRelease, + MappingReleaseStatus, +} from '../../types/mappingRelease'; +import type { WorkspaceRole } from '../../types/workspace'; + +interface MappingGovernanceReadinessProps { + workspaceId: string; + workspaceRole: WorkspaceRole; + release: MappingRelease; + onReleaseChange: (release: MappingRelease) => void; +} + +type GovernanceAction = 'approve' | 'publish' | 'rollback'; + +const lifecycle = [ + { id: 'draft', label: 'Draft técnico', description: 'Compilação e gates fiscais' }, + { id: 'in_review', label: 'Em revisão', description: 'Revisão humana auditável' }, + { id: 'approved', label: 'Aprovado', description: 'Pronto para publicação' }, + { id: 'published', label: 'Publicado', description: 'Artefato ativo e imutável' }, + { id: 'deprecated', label: 'Descontinuado', description: 'Substituído ou revertido' }, + { id: 'archived', label: 'Arquivado', description: 'Retido fora de uso' }, +] as const; + +const lifecycleIndex: Record = { + draft_compiled: 0, + test_passed: 0, + test_failed: 0, + in_review: 1, + approved: 2, + published: 3, + deprecated: 4, + archived: 5, +}; + +const statusLabels: Record = { + draft_compiled: 'Aguardando Test Lab', + test_passed: 'Gates técnicos aprovados', + test_failed: 'Promoção bloqueada', + in_review: 'Em revisão', + approved: 'Aprovada', + published: 'Publicada', + deprecated: 'Descontinuada', + archived: 'Arquivada', +}; + +const roleLabels: Record = { + owner: 'Owner', + fiscal_admin: 'Administrador fiscal', + mapper: 'Mapeador', + reviewer: 'Revisor', + operator: 'Operador', + viewer: 'Visualizador', +}; + +const environments: Array<{ value: MappingGovernanceEnvironment; label: string }> = [ + { value: 'development', label: 'Desenvolvimento' }, + { value: 'validation', label: 'Validação' }, + { value: 'production', label: 'Produção' }, +]; + +const canApprove = (role: WorkspaceRole) => ['reviewer', 'fiscal_admin'].includes(role); +const canPublish = (role: WorkspaceRole) => ['fiscal_admin', 'owner'].includes(role); + +const mergeGovernanceSnapshot = ( + release: MappingRelease, + snapshot: MappingGovernanceSnapshot +): MappingRelease => ({ ...release, ...snapshot }); + +const formatDate = (value: string | null) => + value + ? new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format( + new Date(value) + ) + : 'Não informado pelo contrato de leitura'; + +/** + * Opera apenas as transições autoritativas do Slice 7. O papel vindo de /workspaces/me orienta a + * interface, mas a API repete o RBAC e permanece responsável por aceitar ou rejeitar a ação. + */ +const MappingGovernanceReadiness = ({ + workspaceId, + workspaceRole, + release, + onReleaseChange, +}: MappingGovernanceReadinessProps) => { + const [justification, setJustification] = useState(''); + const [environment, setEnvironment] = useState('production'); + const [busyAction, setBusyAction] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const gatesPassed = release.testRunSummary?.requiredGatesPassed === true; + const gatesFailed = release.testRunSummary?.requiredGatesPassed === false; + const readiness = gatesFailed + ? 'failed' + : gatesPassed || lifecycleIndex[release.status] > 0 + ? 'ready' + : 'pending'; + const currentIndex = lifecycleIndex[release.status]; + const approvalAllowed = canApprove(workspaceRole); + const publicationAllowed = canPublish(workspaceRole); + + const runAction = async (action: GovernanceAction) => { + setBusyAction(action); + setError(null); + setSuccess(null); + try { + const snapshot = + action === 'approve' + ? await mappingReleaseService.approveRelease( + workspaceId, + release.releaseId, + justification + ) + : action === 'publish' + ? await mappingReleaseService.publishRelease( + workspaceId, + release.releaseId, + environment + ) + : await mappingReleaseService.rollbackRelease(workspaceId, release.releaseId); + onReleaseChange(mergeGovernanceSnapshot(release, snapshot)); + if (action === 'approve') setJustification(''); + setSuccess( + action === 'approve' + ? 'Release aprovada e registrada na trilha de auditoria.' + : action === 'publish' + ? `Release publicada em ${environment}.` + : snapshot.status === 'deprecated' + ? 'Rollback concluído; esta release foi descontinuada.' + : 'Rollback já havia sido processado; nenhuma nova transição foi criada.' + ); + } catch (actionError) { + setError( + actionError instanceof Error + ? actionError.message + : 'Não foi possível concluir a transição de governança.' + ); + } finally { + setBusyAction(null); + } + }; + + return ( +
+
+
+

Slice 7 · governança fiscal

+

Revisão, publicação e rollback

+
+ + {statusLabels[release.status]} + +
+ +
    + {lifecycle.map((stage, index) => { + const state = + index < currentIndex ? 'completed' : index === currentIndex ? 'current' : 'locked'; + return ( +
  1. + +
    + {stage.label} + {stage.description} +
    +
  2. + ); + })} +
+ + + + {(release.approvedAt || release.publishedAt || release.environment) && ( +
+
+
Ambiente ativo
+
{release.environment ?? 'Não informado'}
+
+
+
Aprovada em
+
{formatDate(release.approvedAt)}
+
+
+
Publicada em
+
{formatDate(release.publishedAt)}
+
+
+
Release anterior
+
{release.previousPublishedReleaseId ?? 'Nenhuma informada'}
+
+
+ )} + + {release.status === 'test_passed' && + (approvalAllowed ? ( +
{ + event.preventDefault(); + void runAction('approve'); + }} + > +

Aprovar para publicação

+