fix(api,web): corrige autorização da área do rep e falhas silenciosas no front

Auditoria de segurança + robustez da área do representante antes de produção.

API (autorização e injeção):
- IDOR em clients: endpoints de detalhe (findOne, notas, ctr, top-produtos,
  histórico, contatos) validam carteira do rep via assertClienteDaCarteira;
  cliente alheio retorna 404. orders.create idem antes de aceitar idCliente.
- Role guard em dashboards supervisor/manager e ger/chamados (rep -> 403).
- Datas em $queryRawUnsafe agora exigem YYYY-MM-DD (DateOnlySchema + revalidação);
  dtAniversario de contato validado e escapado.
- id_empresa de CTR/NF usa matrizEmpresa() em vez de 1/9001 hardcoded.
- Alçada de desconto: transmit valida desconto efetivo por item (preço vs tabela
  pauta>promo>base + promoção ativa), não só o desconto global.
- Idempotency-Key escopado a empresa+vendedor; unsubscribe com DTO e dono.

Web (erros nunca silenciosos):
- Remove dupla serialização (apiFetch já faz JSON.stringify) em funil, sac e
  politicas — corrige criar oportunidade, abrir chamado e editar políticas.
- Handler global de erro no QueryClient (query e mutation viram toast).
- Error boundary por rota (payload divergente não derruba o app em branco).
- Alert usa message= em vez de title= (AntD 6); sw.js usa globalThis.

Qualidade:
- Corrige ping.contract.spec (idEmpresa) e zera erros de lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 20:40:19 +00:00
parent 2a33a5aa4b
commit 518769218f
29 changed files with 400 additions and 75 deletions

View File

@@ -93,7 +93,10 @@ export const CreateContatoSchema = z.object({
empresa: z.string().max(100).optional(),
cargo: z.string().max(100).optional(),
departamento: z.string().max(100).optional(),
dtAniversario: z.string().optional(),
dtAniversario: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD')
.optional(),
telefone: z.string().max(20).optional(),
ramal: z.string().max(10).optional(),
celular: z.string().max(20).optional(),

View File

@@ -11,6 +11,11 @@ export const SubscribePayloadSchema = z.object({
});
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
export const UnsubscribePayloadSchema = z.object({
endpoint: z.string().url(),
});
export type UnsubscribePayload = z.infer<typeof UnsubscribePayloadSchema>;
export const PendingCountResponseSchema = z.object({
count: z.number().int().min(0),
});

View File

@@ -101,12 +101,16 @@ export type PedidoDetail = z.infer<typeof PedidoDetailSchema>;
// ─── List query + response ────────────────────────────────────────────────────
// Datas de filtro sempre YYYY-MM-DD — interpoladas em SQL no servidor, o formato
// fechado é parte da defesa contra injection, não só validação de UX.
export const DateOnlySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD');
export const PedidoListQuerySchema = z.object({
idCliente: z.coerce.number().int().optional(),
situa: z.coerce.number().int().optional(),
numPedSar: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
from: DateOnlySchema.optional(),
to: DateOnlySchema.optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
@@ -184,8 +188,8 @@ export type PedidoErpConsultaItem = z.infer<typeof PedidoErpConsultaItemSchema>;
export const PedidoErpConsultaQuerySchema = z.object({
search: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
from: DateOnlySchema.optional(),
to: DateOnlySchema.optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50),
});

View File

@@ -5,7 +5,7 @@ describe('PingResponseSchema', () => {
status: 'ok',
service: 'sar-api',
version: '0.1.0',
workspaceId: 'dev-workspace',
idEmpresa: 1,
requestId: '550e8400-e29b-41d4-a716-446655440000',
uptimeSeconds: 42,
now: '2026-05-27T12:34:56.000Z',
@@ -36,8 +36,8 @@ describe('PingResponseSchema', () => {
expect(() => PingResponseSchema.parse(bad)).toThrow();
});
it('rejeita workspaceId vazio', () => {
const bad = { ...validPayload, workspaceId: '' };
it('rejeita idEmpresa não inteiro', () => {
const bad = { ...validPayload, idEmpresa: 1.5 };
expect(() => PingResponseSchema.parse(bad)).toThrow();
});
});