feat(audit): implement audit functionality with Sequelize integration and HTTP routes
This commit is contained in:
parent
6b9d131c14
commit
c5cae1fbfd
@ -49,6 +49,7 @@
|
|||||||
"@erp/suppliers": "workspace:*",
|
"@erp/suppliers": "workspace:*",
|
||||||
"@repo/rdx-logger": "workspace:*",
|
"@repo/rdx-logger": "workspace:*",
|
||||||
"@repo/rdx-audit": "workspace:*",
|
"@repo/rdx-audit": "workspace:*",
|
||||||
|
"@repo/rdx-criteria": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cls-rtracer": "^2.6.3",
|
"cls-rtracer": "^2.6.3",
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import { createApp } from "./app.ts";
|
|||||||
import { initConfig } from "./config";
|
import { initConfig } from "./config";
|
||||||
import { tryConnectToDatabase } from "./config/database.ts";
|
import { tryConnectToDatabase } from "./config/database.ts";
|
||||||
import { registerHealthRoutes } from "./health.ts";
|
import { registerHealthRoutes } from "./health.ts";
|
||||||
|
import { registerAuditRoutes } from "./infrastructure/audit/audit.routes";
|
||||||
|
import { SequelizeAuditReader } from "./infrastructure/audit/audit-reader";
|
||||||
import {
|
import {
|
||||||
AuditChangeModel,
|
AuditChangeModel,
|
||||||
AuditEventModel,
|
AuditEventModel,
|
||||||
@ -21,7 +23,12 @@ import {
|
|||||||
auditModelInitializers,
|
auditModelInitializers,
|
||||||
} from "./infrastructure/audit/sequelize";
|
} from "./infrastructure/audit/sequelize";
|
||||||
import { listRoutes, logger } from "./lib/index.ts";
|
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";
|
import { registerModules } from "./register-modules.ts";
|
||||||
|
|
||||||
z.config(z.locales.es());
|
z.config(z.locales.es());
|
||||||
@ -29,6 +36,7 @@ z.config(z.locales.es());
|
|||||||
const config = initConfig(logger);
|
const config = initConfig(logger);
|
||||||
const operationContext = new AsyncLocalStorageOperationContextStore();
|
const operationContext = new AsyncLocalStorageOperationContextStore();
|
||||||
const transactionContext = new SequelizeTransactionContext();
|
const transactionContext = new SequelizeTransactionContext();
|
||||||
|
|
||||||
const auditContextProvider = new OperationAuditContextProvider(operationContext);
|
const auditContextProvider = new OperationAuditContextProvider(operationContext);
|
||||||
const AUDIT_SOURCE = { type: "application", id: "uecko-erp" } as const;
|
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.");
|
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
|
// El servidor ya está listo para recibir tráfico
|
||||||
isReady = true;
|
isReady = true;
|
||||||
|
|||||||
197
apps/server/src/infrastructure/audit/audit-reader.ts
Normal file
197
apps/server/src/infrastructure/audit/audit-reader.ts
Normal file
@ -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<AuditSearchResult>;
|
||||||
|
findById(id: string, access: AuditReadAccess): Promise<AuditDetail | null>;
|
||||||
|
}
|
||||||
|
const FIELD_MAP: Record<string, string> = {
|
||||||
|
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<AuditSearchResult> {
|
||||||
|
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<PropertyKey, unknown> = { ...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<AuditDetail | null> {
|
||||||
|
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<PropertyKey, unknown> {
|
||||||
|
if (access.mode === "unrestricted") return {};
|
||||||
|
const alternatives: Record<string, string | null>[] = 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<PropertyKey, unknown>[] {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/server/src/infrastructure/audit/audit.dto.ts
Normal file
39
apps/server/src/infrastructure/audit/audit.dto.ts
Normal file
@ -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();
|
||||||
118
apps/server/src/infrastructure/audit/audit.routes.ts
Normal file
118
apps/server/src/infrastructure/audit/audit.routes.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
142
apps/server/src/testing/http/audit-http.integration.test.ts
Normal file
142
apps/server/src/testing/http/audit-http.integration.test.ts
Normal file
@ -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<string, unknown>[] };
|
||||||
|
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);
|
||||||
|
});
|
||||||
239
apps/server/src/testing/http/audit-reader.integration.test.ts
Normal file
239
apps/server/src/testing/http/audit-reader.integration.test.ts
Normal file
@ -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<typeof Criteria.fromPrimitives>[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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
@ -23,6 +23,8 @@ import { closeDatabase, tryConnectToDatabase } from "@/config/database.ts";
|
|||||||
import type { ConfigType } from "@/config/index.ts";
|
import type { ConfigType } from "@/config/index.ts";
|
||||||
import { initConfig, resetConfigForTests } from "@/config/index.ts";
|
import { initConfig, resetConfigForTests } from "@/config/index.ts";
|
||||||
import { registerHealthRoutes } from "@/health.ts";
|
import { registerHealthRoutes } from "@/health.ts";
|
||||||
|
import { registerAuditRoutes } from "@/infrastructure/audit/audit.routes";
|
||||||
|
import { SequelizeAuditReader } from "@/infrastructure/audit/audit-reader";
|
||||||
import {
|
import {
|
||||||
AuditChangeModel,
|
AuditChangeModel,
|
||||||
AuditEventModel,
|
AuditEventModel,
|
||||||
@ -31,7 +33,9 @@ import {
|
|||||||
} from "@/infrastructure/audit/sequelize";
|
} from "@/infrastructure/audit/sequelize";
|
||||||
import { logger } from "@/lib/logger.ts";
|
import { logger } from "@/lib/logger.ts";
|
||||||
import {
|
import {
|
||||||
|
getServiceScoped,
|
||||||
initModules,
|
initModules,
|
||||||
|
listServices,
|
||||||
registerModels,
|
registerModels,
|
||||||
registerModule,
|
registerModule,
|
||||||
resetModelsRegistry,
|
resetModelsRegistry,
|
||||||
@ -123,6 +127,24 @@ export async function createHttpTestRuntime(
|
|||||||
throw new Error("Services are only available from a module-scoped context.");
|
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);
|
server = createServer(app);
|
||||||
await listen(server);
|
await listen(server);
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Criteria as BaseCriteria, Filters, type FiltersPrimitives, Order } from "./codelytv";
|
import { Criteria as BaseCriteria, Filters, type FiltersPrimitives, Order } from "./codelytv";
|
||||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "./defaults";
|
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "./defaults";
|
||||||
|
|
||||||
|
export type { FiltersPrimitives } from "./codelytv";
|
||||||
|
|
||||||
export class Criteria extends BaseCriteria {
|
export class Criteria extends BaseCriteria {
|
||||||
/**
|
/**
|
||||||
* Creates a new Criteria instance.
|
* Creates a new Criteria instance.
|
||||||
|
|||||||
@ -89,6 +89,9 @@ importers:
|
|||||||
'@repo/rdx-audit':
|
'@repo/rdx-audit':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-audit
|
version: link:../../packages/rdx-audit
|
||||||
|
'@repo/rdx-criteria':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/rdx-criteria
|
||||||
'@repo/rdx-logger':
|
'@repo/rdx-logger':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-logger
|
version: link:../../packages/rdx-logger
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user