@@ -490,12 +502,13 @@ const MappingStudioDetail = ({ mappingId, version }: { mappingId: string; versio
)}
- {draft && explanation.capabilities.author && activeWorkspaceId && (
+ {draft && effectiveCapabilities.author && activeWorkspaceId && activeWorkspaceRole && (
)}
diff --git a/src/components/mapping-studio/MappingTestLabPanel.test.tsx b/src/components/mapping-studio/MappingTestLabPanel.test.tsx
index 80606f5..507aa3f 100644
--- a/src/components/mapping-studio/MappingTestLabPanel.test.tsx
+++ b/src/components/mapping-studio/MappingTestLabPanel.test.tsx
@@ -63,12 +63,25 @@ const release = {
correlationId: 'correlation-1',
createdAt: '2026-08-31T22:00:00Z',
eTag: 'AAAAAAAAAAE=',
+ environment: null,
+ approvedByUserId: null,
+ approvedAt: null,
+ approvalJustification: null,
+ publishedByUserId: null,
+ publishedAt: null,
+ previousPublishedReleaseId: null,
};
function renderPanel(path = '/workspace/mapping-studio/draft-1/draft') {
return render(
-
+
);
}
@@ -87,6 +100,7 @@ describe('MappingTestLabPanel', () => {
draft={draft}
compileEnabled={false}
executeEnabled
+ workspaceRole="fiscal_admin"
/>
);
@@ -148,4 +162,32 @@ describe('MappingTestLabPanel', () => {
expect(expected).toHaveValue('');
expect(window.localStorage).toHaveLength(0);
});
+
+ it('não permite executar nova fixture sobre release publicada e imutável', async () => {
+ vi.mocked(mappingReleaseService.getRelease).mockResolvedValue({
+ ...release,
+ status: 'published',
+ testRunSummary: {
+ passed: 1,
+ failed: 0,
+ coveragePercent: 100,
+ requiredGatesPassed: true,
+ xsdValid: true,
+ xsdErrors: [],
+ divergences: [],
+ },
+ environment: 'production',
+ approvedByUserId: 'reviewer-1',
+ approvedAt: '2026-09-01T10:00:00Z',
+ approvalJustification: 'Revisão fiscal concluída.',
+ publishedByUserId: 'admin-1',
+ publishedAt: '2026-09-01T10:05:00Z',
+ previousPublishedReleaseId: 'release-0',
+ });
+ renderPanel('/workspace/mapping-studio/draft-1/draft?releaseId=release-1');
+
+ expect(await screen.findByRole('heading', { name: 'Publicada' })).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Executar Test Lab' })).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('XML de entrada')).not.toBeInTheDocument();
+ });
});
diff --git a/src/components/mapping-studio/MappingTestLabPanel.tsx b/src/components/mapping-studio/MappingTestLabPanel.tsx
index 7c6970f..5f09d39 100644
--- a/src/components/mapping-studio/MappingTestLabPanel.tsx
+++ b/src/components/mapping-studio/MappingTestLabPanel.tsx
@@ -8,20 +8,33 @@ import type {
MappingReleaseArtifact,
MappingTestRunJob,
} from '../../types/mappingRelease';
+import type { WorkspaceRole } from '../../types/workspace';
+import MappingGovernanceReadiness from './MappingGovernanceReadiness';
interface MappingTestLabPanelProps {
workspaceId: string;
draft: MappingDraft;
compileEnabled: boolean;
executeEnabled: boolean;
+ workspaceRole: WorkspaceRole;
}
const activeStatuses = new Set(['queued', 'running']);
+const testableReleaseStatuses = new Set
([
+ 'draft_compiled',
+ 'test_passed',
+ 'test_failed',
+]);
const releaseStatusLabels: Record = {
draft_compiled: 'Compilada, aguardando testes',
test_passed: 'Gates aprovados',
test_failed: 'Gates reprovados',
+ in_review: 'Em revisão',
+ approved: 'Aprovada para publicação',
+ published: 'Publicada',
+ deprecated: 'Descontinuada',
+ archived: 'Arquivada',
};
const downloadArtifact = (artifact: MappingReleaseArtifact, releaseId: string) => {
@@ -42,6 +55,7 @@ const MappingTestLabPanel = ({
draft,
compileEnabled,
executeEnabled,
+ workspaceRole,
}: MappingTestLabPanelProps) => {
const [searchParams, setSearchParams] = useSearchParams();
const releaseIdFromUrl = searchParams.get('releaseId');
@@ -307,7 +321,7 @@ const MappingTestLabPanel = ({
className="mapping-button"
onClick={() => downloadArtifact(artifact, release.releaseId)}
>
- Baixar artefato
+ Baixar artefato {artifact.kind.toUpperCase()}
@@ -328,50 +342,52 @@ const MappingTestLabPanel = ({
)}
- {release.engine === 'xslt' && executeEnabled && (
-
- )}
+ Executar fixture individual
+
+ A API aplica o XSLT, valida o XSD quando reconhecido e compara o XML canônico com
+ o gabarito.
+
+
+ XML de entrada
+ setInputXml(event.target.value)}
+ rows={8}
+ required
+ />
+
+
+ XML esperado
+ setExpectedXml(event.target.value)}
+ rows={8}
+ required
+ />
+
+
+ Versão do XSD (opcional)
+ setXsdVersion(event.target.value)} />
+
+
+ {testActive ? 'Executando gates…' : 'Executar Test Lab'}
+
+
+ )}
{testJob && (
@@ -388,6 +404,8 @@ const MappingTestLabPanel = ({
@@ -428,6 +446,15 @@ const MappingTestLabPanel = ({
))}
)}
+
+
+ setReleaseResult({ releaseId: nextRelease.releaseId, value: nextRelease })
+ }
+ />
)}
diff --git a/src/services/api/mappingDraftService.test.ts b/src/services/api/mappingDraftService.test.ts
index 029f09d..a5e61ee 100644
--- a/src/services/api/mappingDraftService.test.ts
+++ b/src/services/api/mappingDraftService.test.ts
@@ -62,6 +62,22 @@ describe('mappingDraftService', () => {
);
});
+ it.each(['sysmiddle', ' sysmiddle ', ['xslt', 'sysmiddle'], { engine: 'xslt' }])(
+ 'não envia engine de autoria adulterado: %o',
+ async engine => {
+ await expect(
+ mappingDraftService.createDraft({
+ workspaceId: 'workspace-1',
+ packageId: 'package-1',
+ revisionId: 'revision-1',
+ engine: engine as never,
+ })
+ ).rejects.toMatchObject({ kind: 'invalid_input' });
+
+ expect(apiClient.post).not.toHaveBeenCalled();
+ }
+ );
+
it('consulta o draft e valida que todas as regras pertencem a ele', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: draft });
@@ -162,4 +178,15 @@ describe('mappingDraftService', () => {
kind: 'invalid_input',
});
});
+
+ it.each(['sysmiddle', ' sysmiddle ', ['tcl', 'sysmiddle'], { value: 'tcl' }])(
+ 'recusa Draft devolvido com engine adulterado: %o',
+ async engine => {
+ vi.mocked(apiClient.get).mockResolvedValue({ data: { ...draft, engine } });
+
+ await expect(mappingDraftService.getDraft('workspace-1', 'draft-1')).rejects.toMatchObject({
+ kind: 'invalid_response',
+ });
+ }
+ );
});
diff --git a/src/services/api/mappingReleaseService.test.ts b/src/services/api/mappingReleaseService.test.ts
index f10a58f..65d5d1a 100644
--- a/src/services/api/mappingReleaseService.test.ts
+++ b/src/services/api/mappingReleaseService.test.ts
@@ -29,6 +29,23 @@ const release = {
eTag: 'AAAAAAAAAAE=',
};
+const governanceSnapshot = {
+ releaseId: 'release-1',
+ workspaceId: 'workspace-1',
+ draftId: 'draft-1',
+ engine: 'xslt',
+ status: 'approved',
+ environment: 'development',
+ approvedByUserId: 'reviewer-1',
+ approvedAt: '2026-09-01T10:00:00Z',
+ approvalJustification: 'Revisão fiscal concluída.',
+ publishedByUserId: null,
+ publishedAt: null,
+ previousPublishedReleaseId: null,
+ correlationId: 'correlation-1',
+ eTag: 'AAAAAAAAAAI=',
+};
+
describe('mappingReleaseService', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -66,7 +83,16 @@ describe('mappingReleaseService', () => {
await expect(
mappingReleaseService.getRelease('workspace-1', 'draft-1', 'release-1')
- ).resolves.toEqual(release);
+ ).resolves.toEqual({
+ ...release,
+ environment: null,
+ approvedByUserId: null,
+ approvedAt: null,
+ approvalJustification: null,
+ publishedByUserId: null,
+ publishedAt: null,
+ previousPublishedReleaseId: null,
+ });
expect(apiClient.get).toHaveBeenCalledWith(
'/api/workspaces/workspace-1/mapping-drafts/draft-1/releases/release-1'
);
@@ -141,4 +167,128 @@ describe('mappingReleaseService', () => {
mappingReleaseService.getRelease('workspace-1', 'draft-1', 'release-1')
).rejects.toMatchObject({ kind: 'invalid_response' });
});
+
+ it.each(['sysmiddle', ' sysmiddle ', ['xslt', 'sysmiddle'], { value: 'xslt' }])(
+ 'recusa release devolvida com engine adulterado: %o',
+ async engine => {
+ vi.mocked(apiClient.get).mockResolvedValue({ data: { ...release, engine } });
+
+ await expect(
+ mappingReleaseService.getRelease('workspace-1', 'draft-1', 'release-1')
+ ).rejects.toMatchObject({ kind: 'invalid_response' });
+ }
+ );
+
+ it('recusa estado test_passed contraditório com gates reprovados', async () => {
+ vi.mocked(apiClient.get).mockResolvedValue({
+ data: {
+ ...release,
+ status: 'test_passed',
+ testRunSummary: {
+ passed: 0,
+ failed: 1,
+ coveragePercent: 50,
+ requiredGatesPassed: false,
+ xsdValid: false,
+ xsdErrors: ['XML inválido.'],
+ divergences: [],
+ },
+ },
+ });
+
+ await expect(
+ mappingReleaseService.getRelease('workspace-1', 'draft-1', 'release-1')
+ ).rejects.toMatchObject({ kind: 'invalid_response' });
+ });
+
+ it('aceita status governado no GET completo sem inventar metadados omitidos pela API', async () => {
+ vi.mocked(apiClient.get).mockResolvedValue({
+ data: {
+ ...release,
+ status: 'published',
+ testRunSummary: {
+ passed: 1,
+ failed: 0,
+ coveragePercent: 100,
+ requiredGatesPassed: true,
+ xsdValid: true,
+ xsdErrors: ['Validação XSD indisponível — não considerada no gate.'],
+ divergences: [],
+ },
+ },
+ });
+
+ await expect(
+ mappingReleaseService.getRelease('workspace-1', 'draft-1', 'release-1')
+ ).resolves.toMatchObject({ status: 'published', environment: null });
+ });
+
+ it('aprova somente com justificativa e valida a resposta parcial', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({ data: governanceSnapshot });
+
+ await expect(
+ mappingReleaseService.approveRelease(
+ 'workspace-1',
+ 'release-1',
+ ' Revisão fiscal concluída. '
+ )
+ ).resolves.toEqual(governanceSnapshot);
+ expect(apiClient.post).toHaveBeenCalledWith(
+ '/api/workspaces/workspace-1/mapping-releases/release-1/approve',
+ { justification: 'Revisão fiscal concluída.' }
+ );
+ await expect(
+ mappingReleaseService.approveRelease('workspace-1', 'release-1', ' ')
+ ).rejects.toMatchObject({ kind: 'invalid_input' });
+ });
+
+ it('publica apenas nos ambientes suportados pela interface', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({
+ data: {
+ ...governanceSnapshot,
+ status: 'published',
+ environment: 'production',
+ publishedByUserId: 'admin-1',
+ publishedAt: '2026-09-01T10:05:00Z',
+ },
+ });
+
+ await expect(
+ mappingReleaseService.publishRelease('workspace-1', 'release-1', 'production')
+ ).resolves.toMatchObject({ status: 'published', environment: 'production' });
+ expect(apiClient.post).toHaveBeenCalledWith(
+ '/api/workspaces/workspace-1/mapping-releases/release-1/publish',
+ { environment: 'production' }
+ );
+ });
+
+ it('executa rollback sem corpo e mantém o retorno idempotente autoritativo', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({
+ data: {
+ ...governanceSnapshot,
+ status: 'deprecated',
+ environment: 'production',
+ publishedByUserId: 'admin-1',
+ publishedAt: '2026-09-01T10:05:00Z',
+ previousPublishedReleaseId: 'release-0',
+ },
+ });
+
+ await expect(
+ mappingReleaseService.rollbackRelease('workspace-1', 'release-1')
+ ).resolves.toMatchObject({ status: 'deprecated' });
+ expect(apiClient.post).toHaveBeenCalledWith(
+ '/api/workspaces/workspace-1/mapping-releases/release-1/rollback'
+ );
+ });
+
+ it('recusa mutação que tenta trocar a identidade do recurso', async () => {
+ vi.mocked(apiClient.post).mockResolvedValue({
+ data: { ...governanceSnapshot, releaseId: 'release-alheia' },
+ });
+
+ await expect(
+ mappingReleaseService.approveRelease('workspace-1', 'release-1', 'Revisão concluída.')
+ ).rejects.toMatchObject({ kind: 'invalid_response' });
+ });
});
diff --git a/src/services/api/mappingReleaseService.ts b/src/services/api/mappingReleaseService.ts
index c245a21..e7b1eb7 100644
--- a/src/services/api/mappingReleaseService.ts
+++ b/src/services/api/mappingReleaseService.ts
@@ -5,6 +5,8 @@ import type {
MappingAsyncJobStatus,
MappingCompileDiagnostic,
MappingCompileJob,
+ MappingGovernanceEnvironment,
+ MappingGovernanceSnapshot,
MappingRelease,
MappingReleaseArtifact,
MappingReleaseStatus,
@@ -19,6 +21,16 @@ const releaseStatuses = new Set
([
'draft_compiled',
'test_passed',
'test_failed',
+ 'in_review',
+ 'approved',
+ 'published',
+ 'deprecated',
+ 'archived',
+]);
+const governanceEnvironments = new Set([
+ 'development',
+ 'validation',
+ 'production',
]);
const jobStatuses = new Set(['queued', 'running', 'completed', 'failed']);
@@ -61,6 +73,22 @@ function isValidDate(value: unknown): value is string {
return isNonEmptyString(value) && !Number.isNaN(Date.parse(value));
}
+function isNullableValidDate(value: unknown): value is string | null {
+ return value === null || isValidDate(value);
+}
+
+function parseOptionalString(value: unknown): string | null {
+ if (value === undefined || value === null) return null;
+ if (!isNonEmptyString(value)) throw invalidResponse();
+ return value;
+}
+
+function parseOptionalDate(value: unknown): string | null {
+ if (value === undefined || value === null) return null;
+ if (!isValidDate(value)) throw invalidResponse();
+ return value;
+}
+
function isNullableFiniteNumber(value: unknown): value is number | null {
return value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
}
@@ -158,7 +186,7 @@ function parseTestSummary(value: unknown): MappingTestRunSummary {
throw invalidResponse();
}
- return {
+ const summary: MappingTestRunSummary = {
passed: value.passed,
failed: value.failed,
coveragePercent: value.coveragePercent,
@@ -167,6 +195,31 @@ function parseTestSummary(value: unknown): MappingTestRunSummary {
xsdErrors: value.xsdErrors,
divergences: value.divergences.map(parseDivergence),
};
+
+ if (
+ summary.requiredGatesPassed &&
+ (!summary.xsdValid || summary.failed !== 0 || summary.divergences.length !== 0)
+ ) {
+ throw invalidResponse();
+ }
+
+ return summary;
+}
+
+function assertReleaseStateConsistency(
+ status: MappingReleaseStatus,
+ summary: MappingTestRunSummary | null
+): void {
+ if (status === 'draft_compiled' && summary !== null) throw invalidResponse();
+ if (status === 'test_failed' && summary?.requiredGatesPassed !== false) throw invalidResponse();
+ if (
+ ['test_passed', 'in_review', 'approved', 'published', 'deprecated', 'archived'].includes(
+ status
+ ) &&
+ summary?.requiredGatesPassed !== true
+ ) {
+ throw invalidResponse();
+ }
}
function parseRelease(value: unknown): MappingRelease {
@@ -191,6 +244,11 @@ function parseRelease(value: unknown): MappingRelease {
throw invalidResponse();
}
+ const status = value.status as MappingReleaseStatus;
+ const testRunSummary =
+ value.testRunSummary === null ? null : parseTestSummary(value.testRunSummary);
+ assertReleaseStateConsistency(status, testRunSummary);
+
return {
releaseId: value.releaseId,
workspaceId: value.workspaceId,
@@ -200,14 +258,75 @@ function parseRelease(value: unknown): MappingRelease {
sourceRuleIds: value.sourceRuleIds,
compileDiagnostics: value.compileDiagnostics.map(parseDiagnostic),
rulesSnapshotHash: value.rulesSnapshotHash,
- testRunSummary: value.testRunSummary === null ? null : parseTestSummary(value.testRunSummary),
- status: value.status as MappingReleaseStatus,
+ testRunSummary,
+ status,
correlationId: value.correlationId,
createdAt: value.createdAt,
eTag: value.eTag,
+ environment: parseOptionalString(value.environment),
+ approvedByUserId: parseOptionalString(value.approvedByUserId),
+ approvedAt: parseOptionalDate(value.approvedAt),
+ approvalJustification: parseOptionalString(value.approvalJustification),
+ publishedByUserId: parseOptionalString(value.publishedByUserId),
+ publishedAt: parseOptionalDate(value.publishedAt),
+ previousPublishedReleaseId: parseOptionalString(value.previousPublishedReleaseId),
};
}
+function parseGovernanceSnapshot(
+ value: unknown,
+ expectedStatus: MappingReleaseStatus
+): MappingGovernanceSnapshot {
+ if (
+ !isRecord(value) ||
+ !isNonEmptyString(value.releaseId) ||
+ !isNonEmptyString(value.workspaceId) ||
+ !isNonEmptyString(value.draftId) ||
+ !isNonEmptyString(value.engine) ||
+ !engines.has(value.engine as MappingAuthoringEngine) ||
+ !isNonEmptyString(value.status) ||
+ !releaseStatuses.has(value.status as MappingReleaseStatus) ||
+ !isNonEmptyString(value.environment) ||
+ !isNullableString(value.approvedByUserId) ||
+ !isNullableValidDate(value.approvedAt) ||
+ !isNullableString(value.approvalJustification) ||
+ !isNullableString(value.publishedByUserId) ||
+ !isNullableValidDate(value.publishedAt) ||
+ !isNullableString(value.previousPublishedReleaseId) ||
+ !isNonEmptyString(value.correlationId) ||
+ !isNonEmptyString(value.eTag)
+ ) {
+ throw invalidResponse();
+ }
+
+ const snapshot = value as unknown as MappingGovernanceSnapshot;
+ if (snapshot.status !== expectedStatus) throw invalidResponse();
+ if (
+ ['approved', 'published', 'deprecated'].includes(snapshot.status) &&
+ (!snapshot.approvedByUserId || !snapshot.approvedAt || !snapshot.approvalJustification)
+ ) {
+ throw invalidResponse();
+ }
+ if (
+ ['published', 'deprecated'].includes(snapshot.status) &&
+ (!snapshot.publishedByUserId || !snapshot.publishedAt)
+ ) {
+ throw invalidResponse();
+ }
+ return snapshot;
+}
+
+function assertGovernanceResource(
+ snapshot: MappingGovernanceSnapshot,
+ workspaceId: string,
+ releaseId: string
+): MappingGovernanceSnapshot {
+ if (snapshot.workspaceId !== workspaceId.trim() || snapshot.releaseId !== releaseId.trim()) {
+ throw invalidResponse();
+ }
+ return snapshot;
+}
+
function parseJob(value: unknown): MappingCompileJob {
if (
!isRecord(value) ||
@@ -252,31 +371,33 @@ function mapRequestError(error: unknown): never {
if (status === 401 || status === 403) {
throw new MappingReleaseRequestError(
'unauthorized',
- 'Sua sessão não permite compilar ou testar este mapping.'
+ status === 403
+ ? 'Seu papel no workspace não permite esta operação de governança.'
+ : 'Sua sessão não permite concluir esta operação fiscal.'
);
}
if (status === 404) {
throw new MappingReleaseRequestError(
'not_found',
- 'O draft, job ou release não foi encontrado para esta identidade.'
+ 'O draft, job, workspace ou release não foi encontrado para esta identidade.'
);
}
if (status === 400 || status === 422) {
throw new MappingReleaseRequestError(
'rejected',
- message ?? 'A API recusou a compilação ou fixture fiscal.'
+ message ?? 'A API recusou a operação fiscal.'
);
}
if (!error.response || status === 503 || (status !== undefined && status >= 500)) {
throw new MappingReleaseRequestError(
'unavailable',
- 'O serviço de compilação fiscal está temporariamente indisponível.'
+ 'O serviço fiscal está temporariamente indisponível.'
);
}
}
throw new MappingReleaseRequestError(
'request_failed',
- 'Não foi possível concluir a operação de compilação ou teste.'
+ 'Não foi possível concluir a operação fiscal.'
);
}
@@ -385,4 +506,81 @@ export const mappingReleaseService = {
return mapRequestError(error);
}
},
+
+ async approveRelease(
+ workspaceId: string,
+ releaseId: string,
+ justification: string
+ ): Promise {
+ const workspace = resourceSegment(workspaceId, 'Workspace');
+ const release = resourceSegment(releaseId, 'Release');
+ const normalizedJustification = justification.trim();
+ if (!normalizedJustification) {
+ throw new MappingReleaseRequestError(
+ 'invalid_input',
+ 'A justificativa é obrigatória para aprovar a release.'
+ );
+ }
+ try {
+ const response = await apiClient.post(
+ `/api/workspaces/${workspace}/mapping-releases/${release}/approve`,
+ { justification: normalizedJustification }
+ );
+ return assertGovernanceResource(
+ parseGovernanceSnapshot(response.data, 'approved'),
+ workspaceId,
+ releaseId
+ );
+ } catch (error) {
+ return mapRequestError(error);
+ }
+ },
+
+ async publishRelease(
+ workspaceId: string,
+ releaseId: string,
+ environment: MappingGovernanceEnvironment
+ ): Promise {
+ const workspace = resourceSegment(workspaceId, 'Workspace');
+ const release = resourceSegment(releaseId, 'Release');
+ if (!governanceEnvironments.has(environment)) {
+ throw new MappingReleaseRequestError(
+ 'invalid_input',
+ 'Selecione um ambiente de publicação suportado.'
+ );
+ }
+ try {
+ const response = await apiClient.post(
+ `/api/workspaces/${workspace}/mapping-releases/${release}/publish`,
+ { environment }
+ );
+ return assertGovernanceResource(
+ parseGovernanceSnapshot(response.data, 'published'),
+ workspaceId,
+ releaseId
+ );
+ } catch (error) {
+ return mapRequestError(error);
+ }
+ },
+
+ async rollbackRelease(
+ workspaceId: string,
+ releaseId: string
+ ): Promise {
+ const workspace = resourceSegment(workspaceId, 'Workspace');
+ const release = resourceSegment(releaseId, 'Release');
+ try {
+ const response = await apiClient.post(
+ `/api/workspaces/${workspace}/mapping-releases/${release}/rollback`
+ );
+ return assertGovernanceResource(
+ parseGovernanceSnapshot(response.data, 'deprecated'),
+ workspaceId,
+ releaseId
+ );
+ } catch (error) {
+ return mapRequestError(error);
+ }
+ },
};
diff --git a/src/services/api/workspaceService.test.ts b/src/services/api/workspaceService.test.ts
index 24d3ca3..d00ccb6 100644
--- a/src/services/api/workspaceService.test.ts
+++ b/src/services/api/workspaceService.test.ts
@@ -118,6 +118,31 @@ describe('workspaceService', () => {
).rejects.toMatchObject({ kind: 'invalid_response' });
});
+ it.each([
+ { execute: true, explain: true, author: true, compile: false, publish: false },
+ { execute: true, explain: true, author: false, compile: true, publish: false },
+ { execute: true, explain: true, author: false, compile: false, publish: true },
+ ])('falha fechado quando Sysmiddle anuncia mutação em %o', async capabilities => {
+ vi.mocked(apiClient.get).mockResolvedValue({
+ data: {
+ mappingId: 'mapper-sysmiddle',
+ version: 'current',
+ engine: 'sysmiddle',
+ capabilities,
+ sourceSchema: null,
+ targetSchema: null,
+ rules: [],
+ description: null,
+ opaqueRuleCount: 0,
+ limitations: [],
+ },
+ });
+
+ await expect(
+ workspaceService.getMappingExplanation('workspace', 'mapper-sysmiddle', 'current')
+ ).rejects.toMatchObject({ kind: 'invalid_response' });
+ });
+
it.each([
() => workspaceService.listAnalyses('', 'project', {}),
() => workspaceService.listAnalyses('workspace', ' ', {}),
diff --git a/src/services/api/workspaceService.ts b/src/services/api/workspaceService.ts
index 1acc1d0..102616d 100644
--- a/src/services/api/workspaceService.ts
+++ b/src/services/api/workspaceService.ts
@@ -153,11 +153,24 @@ function parseMappingExplanation(value: unknown): MappingExplanation {
throw invalidExplanation();
}
+ const engine = value.engine as MappingEngine;
+ const capabilities = parseCapabilities(value.capabilities);
+
+ // O cliente não confia em capabilities mutáveis para Sysmiddle. Além de esconder controles,
+ // recusamos o payload inteiro para que uma resposta adulterada não seja exibida como capacidade
+ // legítima em deep link, cache intermediário ou estado reidratado.
+ if (
+ engine === 'sysmiddle' &&
+ (capabilities.author || capabilities.compile || capabilities.publish)
+ ) {
+ throw invalidExplanation();
+ }
+
return {
mappingId: value.mappingId,
version: value.version,
- engine: value.engine as MappingEngine,
- capabilities: parseCapabilities(value.capabilities),
+ engine,
+ capabilities,
sourceSchema: parseSchema(value.sourceSchema),
targetSchema: parseSchema(value.targetSchema),
rules: value.rules.map(parseExplainedRule),
diff --git a/src/types/mappingRelease.ts b/src/types/mappingRelease.ts
index 61605f6..89c5a50 100644
--- a/src/types/mappingRelease.ts
+++ b/src/types/mappingRelease.ts
@@ -1,7 +1,16 @@
import type { MappingAuthoringEngine, MappingDraftEvidence } from './mappingDraft';
-export type MappingReleaseStatus = 'draft_compiled' | 'test_passed' | 'test_failed';
+export type MappingReleaseStatus =
+ | 'draft_compiled'
+ | 'test_passed'
+ | 'test_failed'
+ | 'in_review'
+ | 'approved'
+ | 'published'
+ | 'deprecated'
+ | 'archived';
export type MappingAsyncJobStatus = 'queued' | 'running' | 'completed' | 'failed';
+export type MappingGovernanceEnvironment = 'development' | 'validation' | 'production';
export interface MappingReleaseArtifact {
kind: string;
@@ -50,6 +59,32 @@ export interface MappingRelease {
correlationId: string;
createdAt: string;
eTag: string;
+ /** O GET do Slice 5 ainda omite estes campos; as mutações do Slice 7 os preenchem. */
+ environment: string | null;
+ approvedByUserId: string | null;
+ approvedAt: string | null;
+ approvalJustification: string | null;
+ publishedByUserId: string | null;
+ publishedAt: string | null;
+ previousPublishedReleaseId: string | null;
+}
+
+/** Resposta parcial e autoritativa devolvida por approve/publish/rollback. */
+export interface MappingGovernanceSnapshot {
+ releaseId: string;
+ workspaceId: string;
+ draftId: string;
+ engine: MappingAuthoringEngine;
+ status: MappingReleaseStatus;
+ environment: string;
+ approvedByUserId: string | null;
+ approvedAt: string | null;
+ approvalJustification: string | null;
+ publishedByUserId: string | null;
+ publishedAt: string | null;
+ previousPublishedReleaseId: string | null;
+ correlationId: string;
+ eTag: string;
}
export interface MappingCompileJob {