panels-origin/api/document_validity_test.ts
Cursor Agent 493829d028
api: migrar todo el backend de SQLite a Postgres + Redis (fase 2-4e)
Fase 2 (driver):
- api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run,
  placeholders ? -> $n, withTenant con set_config para RLS), con parsers
  de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto
  del codigo heredado de SQLite (fechas/montos como string, ids como
  number) siga funcionando sin reescribir cada call-site a mano.
- api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados
  por base/esquema (panels_platform, panels_product.iam,
  panels_product.core), owner pool para bootstrap/scripts/lookups
  administrativos que cruzan tenant a proposito.
- api/redis.ts: clientes iam/core separados (ACL panels_iam_redis /
  panels_core_redis).
- api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco,
  no HMAC autocontenido); revocacion real (logout, cambio de password).
- api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage
  (S3), con fallback a disco local si no hay credenciales S3 (dev).
- api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la
  transaccion con app.tenant_id fijado (RLS) para cada request.
- api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la
  llave; aplicado a /v1/catalogs.

Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/
payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts):
- Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT,
  now()/current_date, booleanos reales, RETURNING via lastInsertId()).
- IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id
  ya no dependen de que el handler recuerde el WHERE tenant_id -- Row
  Level Security lo hace estructuralmente (verificado con un segundo
  tenant real: 404 en vez de fuga de datos).
- API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito.

Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion
que siempre se revierte, contra el mismo baseline de Liquibase que
produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts
reescrito con fixtures reales; 11/11 pasan contra Postgres.

Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados
(ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot
desnormalizado (uploaded_by_id/name); seed() en runtime eliminado,
reemplazado por scripts/bootstrap-admin.ts (one-shot).

Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT),
PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone,
document_validity.ts ya no usa new Date() crudo.

Verificado end-to-end contra Postgres+Redis reales: login, sesiones,
catalogos con cache, alta de trabajador, subida/descarga de documento
cifrado, y el fix de IDOR probado con un segundo tenant real (403/404
en vez de fuga de datos).

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-02 20:47:45 +00:00

141 lines
3.8 KiB
TypeScript

import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
import {
evaluateDocumentValidity,
freshnessRequired,
DOCUMENT_TYPE_SEED,
PROJECT_DOCUMENT_TYPE_SEED,
COMPANY_DOCUMENT_TYPE_SEED,
type DocTypePolicy,
type WorkerImssContext,
} from "./document_validity.ts";
const curpPolicy: DocTypePolicy = {
code: "curp",
label: "CURP",
required: true,
validity_mode: "freshness",
freshness_days: 90,
requires_issued_at: true,
requires_expires_at: false,
};
const inePolicy: DocTypePolicy = {
code: "ine",
label: "INE",
required: true,
validity_mode: "expiry",
freshness_days: null,
requires_issued_at: false,
requires_expires_at: true,
};
Deno.test("freshnessRequired: sin alta exige frescor", () => {
assertEquals(
freshnessRequired({ imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null }),
true,
);
});
Deno.test("freshnessRequired: alta continua no exige frescor", () => {
assertEquals(
freshnessRequired({
imss_status: "alta",
last_rehire_at: null,
current_alta_at: "2024-01-01",
}),
false,
);
});
Deno.test("freshnessRequired: rehire sin alta posterior exige frescor", () => {
assertEquals(
freshnessRequired({
imss_status: "alta",
last_rehire_at: "2026-06-01",
current_alta_at: "2025-01-01",
}),
true,
);
});
Deno.test("freshnessRequired: rehire con alta posterior no exige frescor", () => {
assertEquals(
freshnessRequired({
imss_status: "alta",
last_rehire_at: "2026-01-01",
current_alta_at: "2026-02-01",
}),
false,
);
});
Deno.test("CURP stale when freshness required", () => {
const ctx: WorkerImssContext = {
imss_status: "sin_alta",
last_rehire_at: null,
current_alta_at: null,
};
const result = evaluateDocumentValidity(
curpPolicy,
{ type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" },
ctx,
"2026-08-20",
);
assertEquals(result.validity_status, "stale_for_alta");
assertEquals(result.valid, false);
});
Deno.test("CURP old but ok when already alta", () => {
const ctx: WorkerImssContext = {
imss_status: "alta",
last_rehire_at: null,
current_alta_at: "2025-02-01",
};
const result = evaluateDocumentValidity(
curpPolicy,
{ type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" },
ctx,
"2026-08-20",
);
assertEquals(result.validity_status, "ok");
assertEquals(result.valid, true);
});
Deno.test("INE expired always invalid", () => {
const ctx: WorkerImssContext = {
imss_status: "alta",
last_rehire_at: null,
current_alta_at: "2026-01-01",
};
const result = evaluateDocumentValidity(
inePolicy,
{ type_code: "ine", issued_at: null, expires_at: "2025-12-31", uploaded_at: "2025-01-01" },
ctx,
"2026-08-20",
);
assertEquals(result.validity_status, "expired");
assertEquals(result.valid, false);
});
Deno.test("missing issued_at for freshness", () => {
const result = evaluateDocumentValidity(
curpPolicy,
{ type_code: "curp", issued_at: null, expires_at: null, uploaded_at: "2026-08-01" },
{ imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null },
);
assertEquals(result.validity_status, "missing_dates");
});
Deno.test("núcleo de persona no se agranda", () => {
const required = DOCUMENT_TYPE_SEED.filter((d) => d.required).map((d) => d.code).sort();
assertEquals(required, ["curp", "domicilio", "foto", "ine", "nss", "rfc"]);
});
Deno.test("núcleo de obra no se agranda", () => {
const required = PROJECT_DOCUMENT_TYPE_SEED.filter((d) => d.required).map((d) => d.code).sort();
assertEquals(required, ["contrato", "presupuesto"]);
});
Deno.test("empresa no tiene documentos obligatorios", () => {
assertEquals(COMPANY_DOCUMENT_TYPE_SEED.every((d) => d.required === 0), true);
});