feat(api,ui): MFA/TOTP, Inventory, Support, HR-Timeclock, Fiscal, Billing e Design System
Leva grande, pedida como "pelo menos mais 20 etapas, use subagentes":
fecha a pendência de MFA/TOTP da Fase 1 (feito diretamente, autenticação é
sensível demais para delegar) e lança 5 subagentes em paralelo em 2 ondas
(eden-inventory, eden-support, eden-hr-timeclock; depois eden-finance,
eden-fiscal, eden-frontend), cada um em worktree isolado, com integração
seletiva manual (barrel, app.module.ts, migrations renomeadas/aplicadas,
build/typecheck/teste ao vivo antes de cada merge).
## Backend
- MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `crypto/field-encryption.service.ts`):
login em duas etapas quando `mfa_enabled=true`, `FieldEncryptionService`
genérico (AES-256-GCM versionado, ADR-0005) reaproveitado por outros
segredos reversíveis (ex. credencial de equipamento Control iD).
- Inventory: warehouses, stock_movements (ledger append-only), serialized_assets,
asset_assignments, stock_reservations — invariante "serial/patrimônio/MAC
nunca duplicado entre ativos ativos" reforçada por índices únicos parciais
no banco (Master Prompt §15.3).
- Support/Service Desk: tickets com máquina de estados, SLA com pausa/retomada
em `waiting_customer`/`waiting_third_party` (funções puras testáveis).
- HR-Timeclock (fundação): dispositivos Control iD (senha cifrada), import de
AFD com parser + CRC-16/KERMIT, `afd_records` imutável sem exceção nem
para super_admin (sem válvula de escape, diferente de audit_log).
- Fiscal: NCM/CFOP/municípios/naturezas de operação (eden.md módulo 5, MVP de
5 das ~19 tabelas), product_fiscal_profiles com invariante de único
profile ativo por billing_component.
- Billing: subscription→billing_run→invoice idempotente (regenerar em draft
substitui, nunca duplica) e imutável após consolidar (ADR-0008: nunca
confunde faturamento/documento fiscal/recebimento).
- FKs cruzadas fechadas: quotes/client_registrations/contracts.
## Frontend — primeira leva
- `packages/ui`: Design System real extraído do tema DreamsERP comprado —
achado que mudou o escopo: o tema já vem em React+Tailwind (não só
Angular), tokens visuais reais portados (cores, Geist como fonte real,
divergindo da doc antiga que citava Nunito/Poppins).
- `apps/core-web`: login real (com suporte a MFA), sessão, shell autenticado
via `AppShell` — substitui a tela estática "Bootstrap da Fase 1".
## Bugs reais encontrados e corrigidos (todos via teste ao vivo, não leitura)
1. **Segurança**: o token de desafio MFA era aceito como access token normal
pelo `JwtAuthGuard` (mesma assinatura, nenhuma checagem da claim
`purpose`) — bypass total do segundo fator. Explorado e fechado ao vivo
contra o container real; teste de regressão em `test/mfa.spec.ts`.
2. Crase solta dentro de comentário SQL na migration de hr-timeclock quebrava
o template literal JS (`SyntaxError` ao rodar a migration).
3. CORS ausente por completo na API.
4. `turbo.json` não declarava `VITE_API_BASE_URL` como input de build — o
Turborepo cacheava o bundle do frontend ignorando mudança na env var.
5. `compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`, que colidia com a
variável de mesmo nome do `.env` local — o default nunca era aplicado.
6. Não existia `.dockerignore` — todo build copiava node_modules/.git/
worktrees de subagentes (665MB) para dentro do contexto Docker.
Decisão do operador incorporada: a API nunca tem domínio público próprio
(só IP interno) — os 3 frontends chamam por caminho relativo `/api/v1`,
proxy externo roteia same-origin; CORS só é relevante para dev local.
Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter
@eden/api test` (30/30, cresceu de 15), todo o lote testado ao vivo via
curl contra os containers reais reconstruídos, incluindo os 6 bugs acima
já fechados e confirmados, e a trilha de auditoria conferida no Postgres.
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
117
apps/api/test/afd-parser.spec.ts
Normal file
117
apps/api/test/afd-parser.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { crc16Hex, crc16Kermit, normalizeAfdCpf, parseAfdFile, parseAfdLine } from "../src/hr-timeclock/afd-parser.js";
|
||||
|
||||
/**
|
||||
* Pure-function unit tests for the AFD parser — no DB, no Nest app, unlike
|
||||
* every other spec in this directory (which are DB-backed integration
|
||||
* tests per vitest.config.ts's own doc comment). Placed here anyway
|
||||
* because `include: ["test/**\/*.spec.ts"]` is the only pattern vitest
|
||||
* picks up for @eden/api; these tests just happen not to need Postgres.
|
||||
*/
|
||||
describe("crc16Kermit", () => {
|
||||
it("matches the known CRC-16/KERMIT test vector for ASCII '123456789'", () => {
|
||||
expect(crc16Kermit("123456789")).toBe(0x2189);
|
||||
expect(crc16Hex("123456789")).toBe("2189");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAfdCpf", () => {
|
||||
it("takes the last 11 digits of a wider raw field", () => {
|
||||
expect(normalizeAfdCpf("01234567890X")).toBe("01234567890");
|
||||
expect(normalizeAfdCpf(" 123.456.789-01 ")).toBe("12345678901");
|
||||
});
|
||||
|
||||
it("never invents padding for a short field", () => {
|
||||
expect(normalizeAfdCpf("123")).toBe("123");
|
||||
expect(normalizeAfdCpf(null)).toBeNull();
|
||||
expect(normalizeAfdCpf("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function buildType3Line(nsr: string, dh: string, cpf: string): string {
|
||||
const dataPart = `${nsr}3${dh}${cpf}`;
|
||||
return dataPart + crc16Hex(dataPart);
|
||||
}
|
||||
|
||||
describe("parseAfdLine — type 3 (marcação REP-C/REP-A)", () => {
|
||||
it("parses a well-formed, CRC-valid punch line", () => {
|
||||
const line = buildType3Line("000000001", "2026-08-06T08:30:00-0300", "01234567890X");
|
||||
const record = parseAfdLine(line, 1);
|
||||
|
||||
expect(record.nsr).toBe(1);
|
||||
expect(record.recordType).toBe("3");
|
||||
expect(record.crcValid).toBe("VALID");
|
||||
expect(record.validationStatus).toBe("VALID");
|
||||
expect(record.employeeCpfRaw).toBe("01234567890X");
|
||||
expect(record.eventDatetime?.toISOString()).toBe(new Date("2026-08-06T08:30:00-03:00").toISOString());
|
||||
});
|
||||
|
||||
it("flags a tampered CRC as INVALID but still preserves the raw line", () => {
|
||||
const line = buildType3Line("000000002", "2026-08-06T08:30:00-0300", "01234567890X");
|
||||
const tampered = line.slice(0, -1) + (line.at(-1) === "0" ? "1" : "0");
|
||||
const record = parseAfdLine(tampered, 2);
|
||||
|
||||
expect(record.crcValid).toBe("INVALID");
|
||||
expect(record.validationStatus).toBe("INVALID");
|
||||
expect(record.rawLine).toBe(tampered);
|
||||
expect(record.validationNotes).toMatch(/CRC divergente/);
|
||||
});
|
||||
|
||||
it("never invents an NSR — a non-numeric NSR field stays null", () => {
|
||||
const line = buildType3Line("ABCDEFGHI", "2026-08-06T08:30:00-0300", "01234567890X");
|
||||
const record = parseAfdLine(line, 3);
|
||||
|
||||
expect(record.nsr).toBeNull();
|
||||
expect(record.nsrRaw).toBe("ABCDEFGHI");
|
||||
expect(record.validationStatus).toBe("INVALID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAfdLine — type 7 (marcação REP-P)", () => {
|
||||
it("never recomputes the type-7 hash chain — crcValid is always NOT_APPLICABLE", () => {
|
||||
const dh = "2026-08-06T08:30:00-0300";
|
||||
const line = "0000000037" + dh + "01234567890X" + dh + "01" + "O" + "a".repeat(64);
|
||||
const record = parseAfdLine(line, 1);
|
||||
|
||||
expect(record.recordType).toBe("7");
|
||||
expect(record.crcValid).toBe("NOT_APPLICABLE");
|
||||
expect(record.crcCalculated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAfdLine — unsupported/malformed", () => {
|
||||
it("captures an unrecognized record type as UNSUPPORTED_LAYOUT, never dropped", () => {
|
||||
const record = parseAfdLine("000000005X" + "resto-nao-documentado", 5);
|
||||
expect(record.validationStatus).toBe("UNSUPPORTED_LAYOUT");
|
||||
expect(record.parsedData.raw_tail).toBe("resto-nao-documentado");
|
||||
});
|
||||
|
||||
it("preserves a too-short line instead of throwing", () => {
|
||||
const record = parseAfdLine("12345", 6);
|
||||
expect(record.validationStatus).toBe("INVALID");
|
||||
expect(record.rawLine).toBe("12345");
|
||||
expect(record.nsr).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAfdFile — partial-error policy", () => {
|
||||
it("keeps parsing all lines even when some are malformed, never dropping raw data", () => {
|
||||
const good = buildType3Line("000000001", "2026-08-06T08:30:00-0300", "01234567890X");
|
||||
const bad = "short";
|
||||
const content = [good, bad].join("\r\n");
|
||||
|
||||
const result = parseAfdFile(content);
|
||||
expect(result.records).toHaveLength(2);
|
||||
expect(result.failures).toHaveLength(0); // "bad" is malformed-but-handled, not an unexpected exception
|
||||
expect(result.summary.byValidationStatus.VALID).toBe(1);
|
||||
expect(result.summary.byValidationStatus.INVALID).toBe(1);
|
||||
expect(result.summary.firstNsr).toBe(1);
|
||||
expect(result.summary.lastNsr).toBe(1);
|
||||
});
|
||||
|
||||
it("skips blank trailing lines without generating a phantom record", () => {
|
||||
const good = buildType3Line("000000001", "2026-08-06T08:30:00-0300", "01234567890X");
|
||||
const result = parseAfdFile(good + "\r\n");
|
||||
expect(result.records).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user