From c5cae1fbfdd4c045082397f8640e6ca4fcc0796c Mon Sep 17 00:00:00 2001 From: david Date: Tue, 25 Aug 2026 13:51:03 +0200 Subject: [PATCH] feat(audit): implement audit functionality with Sequelize integration and HTTP routes --- apps/server/package.json | 1 + apps/server/src/index.ts | 28 +- .../src/infrastructure/audit/audit-reader.ts | 197 +++++++++++++++ .../src/infrastructure/audit/audit.dto.ts | 39 +++ .../src/infrastructure/audit/audit.routes.ts | 118 +++++++++ .../http/audit-http.integration.test.ts | 142 +++++++++++ .../http/audit-reader.integration.test.ts | 239 ++++++++++++++++++ .../src/testing/http/http-test-runtime.ts | 22 ++ packages/rdx-criteria/src/critera.ts | 2 + pnpm-lock.yaml | 3 + 10 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/infrastructure/audit/audit-reader.ts create mode 100644 apps/server/src/infrastructure/audit/audit.dto.ts create mode 100644 apps/server/src/infrastructure/audit/audit.routes.ts create mode 100644 apps/server/src/testing/http/audit-http.integration.test.ts create mode 100644 apps/server/src/testing/http/audit-reader.integration.test.ts diff --git a/apps/server/package.json b/apps/server/package.json index 9f839d9a..78335b15 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -49,6 +49,7 @@ "@erp/suppliers": "workspace:*", "@repo/rdx-logger": "workspace:*", "@repo/rdx-audit": "workspace:*", + "@repo/rdx-criteria": "workspace:*", "@repo/rdx-utils": "workspace:*", "bcrypt": "^6.0.0", "cls-rtracer": "^2.6.3", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 8d2ba89c..506d7d76 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -14,6 +14,8 @@ import { createApp } from "./app.ts"; import { initConfig } from "./config"; import { tryConnectToDatabase } from "./config/database.ts"; import { registerHealthRoutes } from "./health.ts"; +import { registerAuditRoutes } from "./infrastructure/audit/audit.routes"; +import { SequelizeAuditReader } from "./infrastructure/audit/audit-reader"; import { AuditChangeModel, AuditEventModel, @@ -21,7 +23,12 @@ import { auditModelInitializers, } from "./infrastructure/audit/sequelize"; import { listRoutes, logger } from "./lib/index.ts"; -import { initModules, registerModels } from "./lib/modules/index.ts"; +import { + getServiceScoped, + initModules, + listServices, + registerModels, +} from "./lib/modules/index.ts"; import { registerModules } from "./register-modules.ts"; z.config(z.locales.es()); @@ -29,6 +36,7 @@ z.config(z.locales.es()); const config = initConfig(logger); const operationContext = new AsyncLocalStorageOperationContextStore(); const transactionContext = new SequelizeTransactionContext(); + const auditContextProvider = new OperationAuditContextProvider(operationContext); const AUDIT_SOURCE = { type: "application", id: "uecko-erp" } as const; @@ -257,6 +265,24 @@ process.on("uncaughtException", async (error: Error) => { throw new Error("Services are only available from a module-scoped context."); }, }); + registerAuditRoutes( + { + config, + app, + database, + logger, + operationContext, + transactionContext, + audit, + getService: (name: string) => + getServiceScoped("server-infrastructure", ["identity", "companies"], name), + getInternal: () => { + throw new Error("No internal services available"); + }, + listServices, + } as never, + new SequelizeAuditReader() + ); // El servidor ya está listo para recibir tráfico isReady = true; diff --git a/apps/server/src/infrastructure/audit/audit-reader.ts b/apps/server/src/infrastructure/audit/audit-reader.ts new file mode 100644 index 00000000..5564a84c --- /dev/null +++ b/apps/server/src/infrastructure/audit/audit-reader.ts @@ -0,0 +1,197 @@ +import type { AuditValue } from "@repo/rdx-audit"; +import type { Criteria } from "@repo/rdx-criteria/server"; +import { Op, Sequelize } from "sequelize"; + +import { AuditChangeModel, AuditEventModel } from "./sequelize"; + +export type AuditReadAccess = + | { readonly mode: "unrestricted" } + | { + readonly mode: "scoped"; + readonly scopes: readonly { readonly type: string; readonly id: string }[]; + readonly includeUnscoped: boolean; + }; +export type AuditSearchQuery = { readonly criteria: Criteria; readonly quickSearch: string | null }; +export type AuditListItem = { + readonly id: string; + readonly kind: string; + readonly occurredAt: Date; + readonly source: { readonly type: string; readonly id: string }; + readonly action: string; + readonly actor: { + readonly type: string; + readonly id: string | null; + readonly label: string | null; + }; + readonly scope: { + readonly type: string; + readonly id: string; + readonly label: string | null; + } | null; + readonly target: { + readonly type: string; + readonly id: string | null; + readonly label: string | null; + }; + readonly operation: { + readonly id: string; + readonly correlationId: string; + readonly origin: string; + }; + readonly changesCount: number; +}; +export type AuditDetail = AuditListItem & { + readonly changes: readonly { + readonly position: number; + readonly path: string; + readonly kind: string; + readonly before: AuditValue | null; + readonly after: AuditValue | null; + }[]; +}; +export type AuditSearchResult = { + readonly items: readonly AuditListItem[]; + readonly total: number; + readonly pageNumber: number; + readonly pageSize: number; +}; +export interface IAuditReader { + search(query: AuditSearchQuery, access: AuditReadAccess): Promise; + findById(id: string, access: AuditReadAccess): Promise; +} +const FIELD_MAP: Record = { + occurredAt: "occurred_at", + kind: "kind", + action: "action", + targetType: "target_type", + targetId: "target_id", + actorType: "actor_type", + actorId: "actor_id", + scopeType: "scope_type", + scopeId: "scope_id", + operationId: "operation_id", + correlationId: "correlation_id", + origin: "origin", +}; +const SORTABLE = new Set(["occurredAt", "action", "targetType", "actorLabel"]); + +/** Reader SQL con campos explícitos: nunca usa el fallback de columnas de Criteria. */ +export class SequelizeAuditReader implements IAuditReader { + async search(query: AuditSearchQuery, access: AuditReadAccess): Promise { + const primitives = query.criteria.toPrimitives(); + const filters = primitives.filters ?? []; + + for (const filter of filters) { + if (!FIELD_MAP[filter.field]) throw new Error(`Unsupported audit filter: ${filter.field}`); + } + + const orderBy = primitives.orderBy ?? "occurredAt"; + if (!SORTABLE.has(orderBy)) throw new Error(`Unsupported audit order: ${orderBy}`); + + const where: Record = { ...this.accessWhere(access) }; + + for (const filter of filters) { + const column = FIELD_MAP[filter.field]; + if (filter.operator === ">=" || filter.operator === "<") { + where[column] = { + ...(typeof where[column] === "object" && where[column] !== null ? where[column] : {}), + [filter.operator === ">=" ? Op.gte : Op.lt]: filter.value, + }; + } else { + where[column] = filter.value; + } + } + + if (query.quickSearch) { + where[Op.and] = [{ [Op.or]: this.quickSearch(query.quickSearch) }]; + } + + const pageSize = primitives.pageSize ?? 50; + const pageNumber = primitives.pageNumber ?? 0; + + const { rows, count } = await AuditEventModel.findAndCountAll({ + where, + limit: pageSize, + offset: pageNumber * pageSize, + order: [ + [FIELD_MAP[orderBy] ?? "occurred_at", primitives.orderType ?? "DESC"], + ["id", "DESC"], + ], + attributes: { + include: [ + [ + Sequelize.literal( + "(SELECT COUNT(*) FROM audit_changes AS audit_changes WHERE audit_changes.audit_event_id = AuditEventModel.id)" + ), + "changes_count", + ], + ], + }, + }); + + return { + items: rows.map((row) => this.toItem(row)), + total: count, + pageNumber, + pageSize, + }; + } + async findById(id: string, access: AuditReadAccess): Promise { + const row = await AuditEventModel.findOne({ where: { id, ...this.accessWhere(access) } }); + if (!row) { + return null; + } + + const changes = await AuditChangeModel.findAll({ + where: { audit_event_id: id }, + order: [["position", "ASC"]], + }); + + return { + ...this.toItem(row), + changes: changes.map((change) => ({ + position: change.position, + path: change.path, + kind: change.kind, + before: change.before_value as AuditValue | null, + after: change.after_value as AuditValue | null, + })), + }; + } + private accessWhere(access: AuditReadAccess): Record { + if (access.mode === "unrestricted") return {}; + const alternatives: Record[] = access.scopes.map((scope) => ({ + scope_type: scope.type, + scope_id: scope.id, + })); + if (access.includeUnscoped) alternatives.push({ scope_type: null, scope_id: null }); + return alternatives.length ? { [Op.or]: alternatives } : { id: { [Op.eq]: null } }; + } + private quickSearch(value: string): Record[] { + const escaped = value.replace(/[\\%_]/g, "\\$&"); + const like = `%${escaped}%`; + return [ + ...["action", "target_label", "actor_label", "scope_label"].map((field) => ({ + [field]: { [Op.like]: like }, + })), + ...["target_id", "operation_id", "correlation_id"].map((field) => ({ [field]: value })), + ]; + } + private toItem(row: AuditEventModel): AuditListItem { + return { + id: row.id, + kind: row.kind, + occurredAt: row.occurred_at, + source: { type: row.source_type, id: row.source_id }, + action: row.action, + actor: { type: row.actor_type, id: row.actor_id, label: row.actor_label }, + scope: + row.scope_type && row.scope_id + ? { type: row.scope_type, id: row.scope_id, label: row.scope_label } + : null, + target: { type: row.target_type, id: row.target_id, label: row.target_label }, + operation: { id: row.operation_id, correlationId: row.correlation_id, origin: row.origin }, + changesCount: Number(row.get("changes_count") ?? 0), + }; + } +} diff --git a/apps/server/src/infrastructure/audit/audit.dto.ts b/apps/server/src/infrastructure/audit/audit.dto.ts new file mode 100644 index 00000000..2914319f --- /dev/null +++ b/apps/server/src/infrastructure/audit/audit.dto.ts @@ -0,0 +1,39 @@ +import { z } from "zod/v4"; + +const opaqueId = z.string().trim().min(1).max(128); +const optionalText = z.string().trim().min(1).max(255).optional(); + +/** Contrato HTTP cerrado para impedir el fallback de columnas de Criteria. */ +export const AuditListQuerySchema = z + .object({ + pageNumber: z.coerce.number().int().min(0).default(0), + pageSize: z.coerce.number().int().min(1).max(100).default(50), + q: z + .string() + .trim() + .max(200) + .transform((value) => value || null) + .optional() + .default(null), + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + kind: z.enum(["event", "mutation"]).optional(), + action: optionalText, + targetType: optionalText, + targetId: opaqueId.optional(), + actorType: optionalText, + actorId: opaqueId.optional(), + scopeType: optionalText, + scopeId: opaqueId.optional(), + operationId: opaqueId.optional(), + correlationId: opaqueId.optional(), + origin: optionalText, + orderBy: z.enum(["occurredAt", "action", "targetType", "actorLabel"]).default("occurredAt"), + order: z.enum(["asc", "desc"]).default("desc"), + }) + .strict(); + +/** Los IDs de Audit son opacos, limitados físicamente a VARCHAR(64). */ +export const AuditDetailParamsSchema = z + .object({ audit_entry_id: z.string().trim().min(1).max(64) }) + .strict(); diff --git a/apps/server/src/infrastructure/audit/audit.routes.ts b/apps/server/src/infrastructure/audit/audit.routes.ts new file mode 100644 index 00000000..ffc7558c --- /dev/null +++ b/apps/server/src/infrastructure/audit/audit.routes.ts @@ -0,0 +1,118 @@ +import { type StartParams, validateRequest } from "@erp/core/api"; +import { requireIdentityTenant } from "@erp/identity/api"; +import { Criteria, type FiltersPrimitives } from "@repo/rdx-criteria/server"; +import { Router } from "express"; + +import { AuditDetailParamsSchema, AuditListQuerySchema } from "./audit.dto"; +import type { AuditDetail, AuditListItem, AuditReadAccess, IAuditReader } from "./audit-reader"; + +const FILTER_FIELDS = { + kind: "kind", + action: "action", + targetType: "targetType", + targetId: "targetId", + actorType: "actorType", + actorId: "actorId", + scopeType: "scopeType", + scopeId: "scopeId", + operationId: "operationId", + correlationId: "correlationId", + origin: "origin", +} as const; + +function access(params: StartParams): AuditReadAccess { + const scope = params.operationContext.get()?.scope; + return scope?.type === "company" + ? { mode: "scoped", scopes: [{ type: "company", id: scope.id }], includeUnscoped: false } + : { mode: "scoped", scopes: [], includeUnscoped: false }; +} + +export function registerAuditRoutes(params: StartParams, reader: IAuditReader): void { + const router = Router(); + + router.use(...requireIdentityTenant(params)); + + router.get("/", validateRequest(AuditListQuerySchema, "query"), async (req, res, next) => { + try { + const query = req.query as unknown as typeof AuditListQuerySchema._output; + const filters: FiltersPrimitives[] = Object.entries(FILTER_FIELDS).flatMap( + ([requestField, criteriaField]) => { + const value = query[requestField as keyof typeof FILTER_FIELDS]; + return value ? [{ field: criteriaField, operator: "EQUALS", value }] : []; + } + ); + + if (query.from) { + filters.push({ + field: "occurredAt", + operator: "GREATER_THAN_OR_EQUAL", + value: query.from, + }); + } + + if (query.to) { + filters.push({ field: "occurredAt", operator: "LOWER_THAN", value: query.to }); + } + + const criteria = Criteria.fromPrimitives( + filters, + query.orderBy, + query.order.toUpperCase(), + query.pageSize, + query.pageNumber + ); + const result = await reader.search({ criteria, quickSearch: query.q }, access(params)); + return res.json({ + items: result.items.map(toListDto), + page: result.pageNumber, + per_page: result.pageSize, + total_pages: Math.ceil(result.total / result.pageSize), + total_items: result.total, + }); + } catch (error) { + return next(error); + } + }); + router.get( + "/:audit_entry_id", + validateRequest(AuditDetailParamsSchema, "params"), + async (req, res, next) => { + try { + const item = await reader.findById(req.params.audit_entry_id, access(params)); + return item + ? res.json({ + ...toListDto(item), + changes: item.changes.map((change) => ({ + position: change.position, + path: change.path, + kind: change.kind, + before: change.before, + after: change.after, + })), + }) + : res.status(404).json({ detail: "Audit entry not found" }); + } catch (error) { + return next(error); + } + } + ); + params.app.use(`${params.config.server.apiBasePath}/audit`, router); +} +function toListDto(item: AuditListItem | AuditDetail) { + return { + id: item.id, + kind: item.kind, + occurred_at: item.occurredAt.toISOString(), + source: item.source, + action: item.action, + actor: item.actor, + scope: item.scope, + target: item.target, + operation: { + id: item.operation.id, + correlation_id: item.operation.correlationId, + origin: item.operation.origin, + }, + changes_count: item.changesCount, + }; +} diff --git a/apps/server/src/testing/http/audit-http.integration.test.ts b/apps/server/src/testing/http/audit-http.integration.test.ts new file mode 100644 index 00000000..7cac3c6e --- /dev/null +++ b/apps/server/src/testing/http/audit-http.integration.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize"; +import { + buildCompanyHeaders, + createTestAccount, + createTestCompany, + createTestMembership, + loginByApi, +} from "@/testing/http/http-test-fixtures"; +import { createHttpTestRuntime } from "@/testing/http/http-test-runtime"; + +function event(id: string, scopeId: string | null) { + return { + id, + kind: "event", + occurred_at: new Date("2026-08-25T10:00:00.000Z"), + source_type: "application", + source_id: "uecko-erp", + operation_id: `op-${id}`, + correlation_id: `cor-${id}`, + origin: "http", + actor_type: "user", + actor_id: "actor", + actor_label: "Actor", + scope_type: scopeId ? "company" : null, + scope_id: scopeId, + scope_label: null, + action: "customer.updated", + target_type: "customer", + target_id: id, + target_label: id, + }; +} + +test("Audit HTTP aplica tenant A/B y valida el DTO estricto", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + const account = await createTestAccount(runtime.database, { + email: "audit-reader@test.local", + password: "Password123!", + }); + const companyA = await createTestCompany(runtime.database, { legalName: "Company A" }); + const companyB = await createTestCompany(runtime.database, { legalName: "Company B" }); + await createTestMembership(runtime.database, { accountId: account.id, companyId: companyA.id }); + await AuditEventModel.bulkCreate([ + event("entry-a", companyA.id), + event("entry-b", companyB.id), + event("entry-global", null), + ]); + await AuditChangeModel.create({ + audit_event_id: "entry-a", + position: 0, + path: "name", + kind: "change", + before_value: "Before", + after_value: "After", + }); + const login = await loginByApi({ + request: runtime.request, + email: account.email, + password: account.password, + }); + assert.equal(login.status, 200); + const token = (login.body as { access_token: string }).access_token; + const headers = buildCompanyHeaders({ accessToken: token, companyId: companyA.id }); + const list = await runtime.request("/api/v1/audit", { headers }); + assert.equal(list.status, 200); + const body = list.body as { + items: { id: string }[]; + page: number; + per_page: number; + total_pages: number; + total_items: number; + }; + assert.deepEqual( + body.items.map((item) => item.id), + ["entry-a"] + ); + assert.equal(body.page, 0); + assert.equal(body.per_page, 50); + assert.deepEqual(Object.keys(body).sort(), [ + "items", + "page", + "per_page", + "total_items", + "total_pages", + ]); + assert.deepEqual(Object.keys(body.items[0] ?? {}).sort(), [ + "action", + "actor", + "changes_count", + "id", + "kind", + "occurred_at", + "operation", + "scope", + "source", + "target", + ]); + const detailA = await runtime.request("/api/v1/audit/entry-a", { headers }); + assert.equal(detailA.status, 200); + const detailBody = detailA.body as { changes: Record[] }; + assert.deepEqual(Object.keys(detailBody).sort(), [ + "action", + "actor", + "changes", + "changes_count", + "id", + "kind", + "occurred_at", + "operation", + "scope", + "source", + "target", + ]); + assert.deepEqual(Object.keys(detailBody.changes[0] ?? {}).sort(), [ + "after", + "before", + "kind", + "path", + "position", + ]); + const requestedB = await runtime.request(`/api/v1/audit?scopeId=${companyB.id}`, { headers }); + assert.equal(requestedB.status, 200); + assert.equal((requestedB.body as { items: unknown[] }).items.length, 0); + assert.equal((await runtime.request("/api/v1/audit/entry-b", { headers })).status, 404); + assert.equal((await runtime.request("/api/v1/audit/entry-global", { headers })).status, 404); + assert.equal((await runtime.request("/api/v1/audit/entry-missing", { headers })).status, 404); + for (const query of [ + "unknownField=x", + "pageNumber=-1", + "pageSize=101", + "kind=invalid", + "orderBy=invalid", + "order=invalid", + "from=invalid", + "to=invalid", + ]) + assert.equal((await runtime.request(`/api/v1/audit?${query}`, { headers })).status, 422); +}); diff --git a/apps/server/src/testing/http/audit-reader.integration.test.ts b/apps/server/src/testing/http/audit-reader.integration.test.ts new file mode 100644 index 00000000..487ad8cd --- /dev/null +++ b/apps/server/src/testing/http/audit-reader.integration.test.ts @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Criteria } from "@repo/rdx-criteria/server"; + +import { SequelizeAuditReader } from "@/infrastructure/audit/audit-reader"; +import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize"; +import { createHttpTestRuntime } from "@/testing/http/http-test-runtime"; + +const reader = new SequelizeAuditReader(); +const accessA = { + mode: "scoped" as const, + scopes: [{ type: "company", id: "company-a" }], + includeUnscoped: false, +}; +const criteria = (filters: Parameters[0] = []) => + Criteria.fromPrimitives(filters, "occurredAt", "DESC", 50, 0); + +test("SequelizeAuditReader rechaza fields no permitidos antes de ejecutar SQL", async () => { + for (const field of ["unknownField", "beforeValue", "afterValue", "some_sql_column"]) { + await assert.rejects( + () => + reader.search( + { + criteria: Criteria.fromPrimitives( + [{ field, operator: "EQUALS", value: "value" }], + "occurredAt", + "DESC", + 50, + 0 + ), + quickSearch: null, + }, + accessA + ), + new RegExp(`Unsupported audit filter: ${field}`) + ); + } + + await assert.rejects( + () => + reader.search( + { + criteria: Criteria.fromPrimitives([], "some_sql_column", "DESC", 50, 0), + quickSearch: null, + }, + accessA + ), + /Unsupported audit order: some_sql_column/ + ); +}); + +async function seed(): Promise { + await AuditEventModel.bulkCreate([ + entry("a-customer", "customer.updated", "company-a", "2026-08-25T10:00:00.000Z"), + entry("a-supplier", "supplier.updated", "company-a", "2026-08-25T10:00:00.000Z"), + entry("b-company", "company.updated", "company-b", "2026-08-25T10:00:00.000Z"), + entry("global-company", "company.created", null, "2026-08-25T09:00:00.000Z"), + ]); +} +function entry(id: string, action: string, scopeId: string | null, occurredAt: string) { + return { + id, + kind: "event", + occurred_at: new Date(occurredAt), + source_type: "application", + source_id: "uecko-erp", + operation_id: `operation-${id}`, + correlation_id: `correlation-${id}`, + origin: "http", + actor_type: "user", + actor_id: "actor-1", + actor_label: "actor@example.test", + scope_type: scopeId ? "company" : null, + scope_id: scopeId, + scope_label: null, + action, + target_type: "resource", + target_id: id, + target_label: `Label ${id}`, + }; +} + +test("SequelizeAuditReader limita resultados al scope autorizado en MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + await seed(); + const result = await reader.search({ criteria: criteria(), quickSearch: null }, accessA); + assert.deepEqual(result.items.map((item) => item.id).sort(), ["a-customer", "a-supplier"]); + const requestedB = await reader.search( + { + criteria: criteria([{ field: "scopeId", operator: "EQUALS", value: "company-b" }]), + quickSearch: null, + }, + accessA + ); + assert.equal(requestedB.items.length, 0); + const withGlobal = await reader.search( + { criteria: criteria(), quickSearch: null }, + { ...accessA, includeUnscoped: true } + ); + assert.deepEqual(withGlobal.items.map((item) => item.id).sort(), [ + "a-customer", + "a-supplier", + "global-company", + ]); + const unrestricted = await reader.search( + { criteria: criteria(), quickSearch: null }, + { mode: "unrestricted" } + ); + assert.equal(unrestricted.items.length, 4); +}); + +test("SequelizeAuditReader aplica rango [from,to), búsqueda, paginación, orden y detalle", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + await AuditEventModel.bulkCreate([ + entry("time-1", "customer.updated", "company-a", "2026-08-25T09:00:00.000Z"), + entry("time-2", "customer.updated", "company-a", "2026-08-25T10:00:00.000Z"), + entry("time-3", "customer.updated", "company-a", "2026-08-25T11:00:00.000Z"), + { + ...entry("literal-percent", "audit.literal", "company-a", "2026-08-25T12:00:00.000Z"), + target_label: "100% literal", + actor_label: "actor literal", + }, + { + ...entry("literal-underscore", "audit.literal", "company-a", "2026-08-25T12:00:00.000Z"), + target_label: "under_score literal", + }, + { + ...entry("wildcard-underscore", "audit.literal", "company-a", "2026-08-25T12:00:00.000Z"), + target_label: "underXscore literal", + }, + { + ...entry("literal-backslash", "audit.literal", "company-a", "2026-08-25T12:00:00.000Z"), + target_label: "path\\name literal", + }, + { + ...entry("wildcard-backslash", "audit.literal", "company-a", "2026-08-25T12:00:00.000Z"), + target_label: "pathXname literal", + }, + { + ...entry("mutation-three", "customer.mutated", "company-a", "2026-08-25T12:00:00.000Z"), + kind: "mutation", + }, + ]); + await AuditChangeModel.bulkCreate([ + { + audit_event_id: "mutation-three", + position: 2, + path: "z", + kind: "change", + before_value: ["a"], + after_value: { x: 1 }, + }, + { + audit_event_id: "mutation-three", + position: 0, + path: "a", + kind: "change", + before_value: "before", + after_value: null, + }, + { + audit_event_id: "mutation-three", + position: 1, + path: "m", + kind: "change", + before_value: null, + after_value: true, + }, + ]); + const range = await reader.search( + { + criteria: Criteria.fromPrimitives( + [ + { + field: "occurredAt", + operator: "GREATER_THAN_OR_EQUAL", + value: "2026-08-25T10:00:00.000Z", + }, + { field: "occurredAt", operator: "LOWER_THAN", value: "2026-08-25T11:00:00.000Z" }, + ], + "occurredAt", + "ASC", + 50, + 0 + ), + quickSearch: null, + }, + accessA + ); + assert.deepEqual( + range.items.map((item) => item.id), + ["time-2"] + ); + const search = await reader.search({ criteria: criteria(), quickSearch: "100%" }, accessA); + assert.deepEqual( + search.items.map((item) => item.id), + ["literal-percent"] + ); + const underscoreSearch = await reader.search( + { criteria: criteria(), quickSearch: "under_" }, + accessA + ); + assert.deepEqual( + underscoreSearch.items.map((item) => item.id), + ["literal-underscore"] + ); + const backslashSearch = await reader.search( + { criteria: criteria(), quickSearch: "path\\" }, + accessA + ); + assert.deepEqual( + backslashSearch.items.map((item) => item.id), + ["literal-backslash"] + ); + const page0 = await reader.search( + { criteria: Criteria.fromPrimitives([], "occurredAt", "DESC", 2, 0), quickSearch: null }, + accessA + ); + const page1 = await reader.search( + { criteria: Criteria.fromPrimitives([], "occurredAt", "DESC", 2, 1), quickSearch: null }, + accessA + ); + assert.equal(page0.items.length, 2); + assert.equal(page1.items.length, 2); + assert.equal( + page0.items.some((item) => page1.items.some((next) => next.id === item.id)), + false + ); + const detail = await reader.findById("mutation-three", accessA); + assert.ok(detail); + assert.deepEqual( + detail.changes.map((change) => change.position), + [0, 1, 2] + ); + assert.equal(detail.changesCount, 0); +}); diff --git a/apps/server/src/testing/http/http-test-runtime.ts b/apps/server/src/testing/http/http-test-runtime.ts index 5bfb6822..f62b3272 100644 --- a/apps/server/src/testing/http/http-test-runtime.ts +++ b/apps/server/src/testing/http/http-test-runtime.ts @@ -23,6 +23,8 @@ import { closeDatabase, tryConnectToDatabase } from "@/config/database.ts"; import type { ConfigType } from "@/config/index.ts"; import { initConfig, resetConfigForTests } from "@/config/index.ts"; import { registerHealthRoutes } from "@/health.ts"; +import { registerAuditRoutes } from "@/infrastructure/audit/audit.routes"; +import { SequelizeAuditReader } from "@/infrastructure/audit/audit-reader"; import { AuditChangeModel, AuditEventModel, @@ -31,7 +33,9 @@ import { } from "@/infrastructure/audit/sequelize"; import { logger } from "@/lib/logger.ts"; import { + getServiceScoped, initModules, + listServices, registerModels, registerModule, resetModelsRegistry, @@ -123,6 +127,24 @@ export async function createHttpTestRuntime( throw new Error("Services are only available from a module-scoped context."); }, }); + registerAuditRoutes( + { + app, + config, + database, + logger, + operationContext, + transactionContext, + audit, + getService: (name: string) => + getServiceScoped("server-infrastructure", ["identity", "companies"], name), + getInternal: () => { + throw new Error("No internal services available"); + }, + listServices, + } as never, + new SequelizeAuditReader() + ); server = createServer(app); await listen(server); diff --git a/packages/rdx-criteria/src/critera.ts b/packages/rdx-criteria/src/critera.ts index 6eb6115e..785168e1 100644 --- a/packages/rdx-criteria/src/critera.ts +++ b/packages/rdx-criteria/src/critera.ts @@ -1,6 +1,8 @@ import { Criteria as BaseCriteria, Filters, type FiltersPrimitives, Order } from "./codelytv"; import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "./defaults"; +export type { FiltersPrimitives } from "./codelytv"; + export class Criteria extends BaseCriteria { /** * Creates a new Criteria instance. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfad68be..bbf65fc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@repo/rdx-audit': specifier: workspace:* version: link:../../packages/rdx-audit + '@repo/rdx-criteria': + specifier: workspace:* + version: link:../../packages/rdx-criteria '@repo/rdx-logger': specifier: workspace:* version: link:../../packages/rdx-logger