import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; import { once } from "node:events"; import { test } from "node:test"; import { GetGlobalApplicationAdminResponseSchema, GetGlobalApplicationResponseSchema, GlobalApplicationCapabilitiesSchema, GlobalApplicationTemplateResponseSchema, InventoryResponseSchema, ListGlobalApplicationHistoryResponseSchema, ListGlobalApplicationsResponseSchema, ResolveApplicationReferencesResponseSchema, } from "@metis/resources/common"; import { createResourceModule, migrateResourceScope } from "@metis/resources/module"; import { QueryTypes } from "sequelize"; import { z } from "zod/v4"; import { UniqueID } from "../../../packages/rdx-ddd/src/index"; import { createApp } from "./app"; import { prepareMembershipFixture } from "./asset-directory-memberships.fixture"; import { createDatabase, readDatabaseEnvironment } from "./config/database"; import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; import { disposableScopeEnvironment, writeScopeEvidence } from "./resource-scope.preflight"; import { scopeApiClient as apiClient } from "./resource-scope-api.client"; import { scopeHttpFixture, seedScopeVolume } from "./resource-scope-http.fixture"; test("R2.3: catálogo global autorizado, historia, idempotencia, privacidad y consumidores cerrados", async (t) => { const environment = disposableScopeEnvironment(); const database = createDatabase(environment); const companies = Array.from({ length: 15 }, () => UniqueID.generateNewID().toPrimitive()); const companyId = companies[0]; const other = companies[1]; const actor = UniqueID.generateNewID().toPrimitive(); assert.ok(companyId && other); const fixture = await prepareMembershipFixture( database, environment.APP_ORIGIN, actor, companies ); const closedOptions = { catalogEnabled: true, canManage: async () => false }; await assert.rejects( () => createResourceModule( database, fixture.authenticate, undefined, undefined, fixture.identity.requireGlobalResourceManager, closedOptions ), /R2.1b/ ); await migrateResourceScope(database); const volumeHttp = await scopeHttpFixture(database, fixture); const volume = await seedScopeVolume(fixture, volumeHttp, companyId); const inventory = await fixture.resourceUseCases.get(companyId); assert.ok(inventory.isSuccess); const application = inventory.data.catalog.definitions.find( (item) => item.stockKey.getOrUndefined() === "template:application" ); const os = inventory.data.catalog.definitions.find( (item) => item.stockKey.getOrUndefined() === "template:os" ); const provider = inventory.data.catalog.definitions.find( (item) => item.stockKey.getOrUndefined() === "field:provider" ); assert.ok(application && os && provider); const locals: string[] = []; for (const owner of companies) { for (let index = 0; index < (owner === companyId ? 50 : 1); index += 1) { const saved = await fixture.resourceUseCases.save( owner, undefined, { name: `Aplicación local ${index + 1}`, templateId: application.id.toPrimitive(), active: true, notes: "Local", characteristics: {}, }, actor ); assert.ok(saved.isSuccess); if (owner === companyId) { locals.push(saved.data.resource.id.toPrimitive()); } } } await volumeHttp.close(); let runtime = await createIdentityRuntime( database, readDatabaseEnvironment({ ...process.env, RESOURCE_GLOBAL_CATALOG_ENABLED: "true" }) ); const diagnostics: unknown[] = []; const app = createApp((event) => { if (event.event === "http_error") { diagnostics.push(event); } }, runtime); let server = app.listen(0, "127.0.0.1"); await once(server, "listening"); const address = server.address(); assert.ok(address && typeof address !== "string"); const base = `http://127.0.0.1:${address.port}`; try { const admin = await apiClient(base); const ordinary = await apiClient(base); const anonymous = await apiClient(base); const password = randomBytes(24).toString("hex"); assert.equal( ( await admin.request("/auth/bootstrap", { name: "Administrador de catálogo", email: "catalog-admin@example.invalid", password, }) ).status, 201 ); await admin.refresh(); assert.equal( ( await admin.request("/users", { name: "Operador local", email: "catalog-user@example.invalid", password, }) ).status, 201 ); assert.equal( (await ordinary.request("/auth/login", { email: "catalog-user@example.invalid", password })) .status, 200 ); await ordinary.refresh(); const template = GlobalApplicationTemplateResponseSchema.parse( await (await admin.request("/resource-global-applications/template")).json() ).specification; const values = { name: "Aplicación global Ágil %_", templateId: template.templateId, active: true, notes: "Nota administrativa privada", characteristics: { [provider.id.toPrimitive()]: "Fábrica sintética" }, }; const creationId = UniqueID.generateNewID().toPrimitive(); const body = { creationId, values }; assert.equal( ListGlobalApplicationsResponseSchema.parse( await (await ordinary.request("/resource-global-applications")).json() ).items.length, 0 ); const created = await admin.request("/resource-global-applications", body); assert.equal(created.status, 201, await created.clone().text()); let product = GetGlobalApplicationAdminResponseSchema.parse(await created.json()).resource; const originalRevisionId = product.revisionId; await t.test( "scope y plantilla persistidos, autorización real y proyección mínima", async () => { assert.equal(product.id, creationId); assert.equal(product.scope, "global"); assert.equal(product.companyId, null); const persisted = await database.query<{ scope: string; companyId: null; templateId: string; }>("SELECT scope,companyId,templateId FROM resources WHERE id=:id", { replacements: { id: creationId }, type: QueryTypes.SELECT, }); assert.deepEqual(persisted, [ { scope: "global", companyId: null, templateId: template.templateId }, ]); assert.equal((await anonymous.request("/resource-global-applications", body)).status, 401); assert.equal((await ordinary.request("/resource-global-applications", body)).status, 403); assert.equal( (await ordinary.request(`/resource-global-applications/${creationId}/update`, { values })) .status, 403 ); assert.equal( ( await ordinary.request(`/resource-global-applications/${creationId}/status`, { active: false, }) ).status, 403 ); assert.equal( (await ordinary.request(`/resource-global-applications/${creationId}/admin`)).status, 403 ); assert.equal( (await ordinary.request(`/resource-global-applications/${creationId}/history`)).status, 403 ); const minimal = GetGlobalApplicationResponseSchema.parse( await (await ordinary.request(`/resource-global-applications/${creationId}`)).json() ).resource; assert.equal(minimal.provider, "Fábrica sintética"); for (const key of [ "notes", "specification", "history", "actorId", "companies", "installations", ]) { assert.equal(Object.hasOwn(minimal, key), false); } assert.equal( GlobalApplicationCapabilitiesSchema.parse( await (await ordinary.request("/resource-global-applications/capabilities")).json() ).canManage, false ); assert.equal( GlobalApplicationCapabilitiesSchema.parse( await (await admin.request("/resource-global-applications/capabilities")).json() ).creationEnabled, true ); assert.equal( ( await admin.request( `/resource-global-applications/${creationId}/status`, { active: false }, { token: "" } ) ).status, 403 ); assert.equal( ( await admin.request( `/resource-global-applications/${creationId}/status`, { active: false }, { origin: "https://invalid.example" } ) ).status, 403 ); } ); await t.test("idempotencia de creationId y concurrencia sin duplicar historia", async () => { const replay = await admin.request("/resource-global-applications", body); assert.equal(replay.status, 200); assert.equal( GetGlobalApplicationAdminResponseSchema.parse(await replay.json()).resource.revisionId, originalRevisionId ); assert.equal( ( await admin.request("/resource-global-applications", { ...body, values: { ...values, name: "Otro producto" }, }) ).status, 409 ); assert.equal( (await admin.request("/resource-global-applications", { ...body, creationId: locals[0] })) .status, 409 ); const concurrentId = UniqueID.generateNewID().toPrimitive(); const responses = await Promise.all([ admin.request("/resource-global-applications", { ...body, creationId: concurrentId }), admin.request("/resource-global-applications", { ...body, creationId: concurrentId }), ]); assert.deepEqual(responses.map((response) => response.status).sort(), [200, 201]); const history = ListGlobalApplicationHistoryResponseSchema.parse( await (await admin.request(`/resource-global-applications/${concurrentId}/history`)).json() ); assert.equal(history.items.length, 1); }); await t.test("límites de plantilla, propietario y UUID; sin borrado global", async () => { for (const extra of [ { scope: "global" }, { companyId }, { actorId: actor }, { isInitialAdmin: true }, ]) { assert.equal( (await admin.request("/resource-global-applications", { ...body, ...extra })).status, 400 ); } assert.equal( ( await admin.request("/resource-global-applications", { ...body, creationId: UniqueID.generateNewID().toPrimitive(), values: { ...values, templateId: os.id.toPrimitive() }, }) ).status, 409 ); assert.equal( ( await admin.request(`/resource-global-applications/${creationId}/update`, { values: { ...values, templateId: os.id.toPrimitive() }, }) ).status, 409 ); assert.equal( (await admin.request(`/resource-global-applications/${locals[0]}/update`, { values })) .status, 404 ); assert.equal( (await admin.request(`/resource-global-applications/${creationId}/delete`, {})).status, 404 ); assert.equal( ( await ordinary.request(`/resource-catalog/%67lobal/${template.templateId}/update`, { values: application.values.data, }) ).status, 403 ); }); await t.test( "edición y optimistic concurrency, snapshot original e idempotencia semántica", async () => { const responses = await Promise.all([ admin.request(`/resource-global-applications/${creationId}/update`, { values: { ...values, name: "Nombre actualizado A" }, expectedRevisionId: originalRevisionId, }), admin.request(`/resource-global-applications/${creationId}/update`, { values: { ...values, name: "Nombre actualizado B" }, expectedRevisionId: originalRevisionId, }), ]); assert.deepEqual(responses.map((response) => response.status).sort(), [200, 409]); product = GetGlobalApplicationAdminResponseSchema.parse( await (await admin.request(`/resource-global-applications/${creationId}/admin`)).json() ).resource; const unchanged = await admin.request( `/resource-global-applications/${creationId}/update`, { values: product.values, expectedRevisionId: product.revisionId } ); assert.equal( GetGlobalApplicationAdminResponseSchema.parse(await unchanged.json()).resource.revisionId, product.revisionId ); const replay = await admin.request("/resource-global-applications", body); assert.equal(replay.status, 200); assert.equal( GetGlobalApplicationAdminResponseSchema.parse(await replay.json()).resource.values.name, product.values.name ); const page = ListGlobalApplicationHistoryResponseSchema.parse( await ( await admin.request(`/resource-global-applications/${creationId}/history?pageSize=1`) ).json() ); assert.equal(page.items.length, 1); assert.ok(page.nextCursor); const next = ListGlobalApplicationHistoryResponseSchema.parse( await ( await admin.request( `/resource-global-applications/${creationId}/history?pageSize=1&cursor=${encodeURIComponent(page.nextCursor)}` ) ).json() ); assert.equal(next.items[0]?.id, originalRevisionId); assert.equal(next.items[0]?.values.name, values.name); } ); await t.test("desactivar/reactivar conserva UUID e historia y excluye candidatos", async () => { const disabled = await admin.request(`/resource-global-applications/${creationId}/status`, { active: false, expectedRevisionId: product.revisionId, }); product = GetGlobalApplicationAdminResponseSchema.parse(await disabled.json()).resource; assert.equal(product.id, creationId); assert.equal(product.values.active, false); const repeated = await admin.request(`/resource-global-applications/${creationId}/status`, { active: false, }); assert.equal( GetGlobalApplicationAdminResponseSchema.parse(await repeated.json()).resource.revisionId, product.revisionId ); const resolve = await ordinary.request( `/resource-application-references/${companyId}/resolve`, { purpose: "software-installation", ids: [creationId] } ); assert.equal( ResolveApplicationReferencesResponseSchema.parse(await resolve.json()).items[0]?.status, "resolved" ); const reactivated = await admin.request( `/resource-global-applications/${creationId}/status`, { active: true } ); product = GetGlobalApplicationAdminResponseSchema.parse(await reactivated.json()).resource; assert.equal(product.values.active, true); }); await t.test( "colección paginada, homónimos y búsqueda literal sin N+1 de cliente", async () => { for (let index = 0; index < 28; index += 1) { assert.equal( ( await admin.request("/resource-global-applications", { creationId: UniqueID.generateNewID().toPrimitive(), values: { ...values, name: `Producto Ágil %_ ${index % 7}`, active: index % 3 !== 0, }, }) ).status, 201 ); } const seen = new Set(); let cursor: string | null = null; do { const response = await ordinary.request( `/resource-global-applications?search=${encodeURIComponent("ágil %_")}&pageSize=7${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}` ); const page = ListGlobalApplicationsResponseSchema.parse(await response.json()); for (const item of page.items) { assert.equal(seen.has(item.id), false); seen.add(item.id); } cursor = page.nextCursor; } while (cursor); assert.equal(seen.size, 29); assert.ok( ListGlobalApplicationsResponseSchema.parse( await (await ordinary.request("/resource-global-applications?active=false")).json() ).items.length > 0 ); } ); await t.test( "rollback por error de infraestructura y recuperación por el mismo creationId", async () => { let inject = true; const failedId = UniqueID.generateNewID().toPrimitive(); database.addHook("afterQuery", "r23-infrastructure-failure", (_options, query) => { const sql: unknown = Reflect.get(query, "sql"); if (inject && typeof sql === "string" && sql.startsWith("INSERT INTO `resources`")) { inject = false; throw new Error("Fallo sintético después de insertar raíz."); } }); try { const response = await admin.request("/resource-global-applications", { creationId: failedId, values, }); assert.equal(response.status, 500); const data = await response.text(); assert.match(data, /INTERNAL_ERROR/); assert.equal(data.includes("INSERT"), false); } finally { database.removeHook("afterQuery", "r23-infrastructure-failure"); } assert.equal( (await admin.request(`/resource-global-applications/${failedId}/admin`)).status, 404 ); assert.equal( (await admin.request("/resource-global-applications", { creationId: failedId, values })) .status, 201 ); } ); await t.test( "propagación autorizada global/local y campos nuevos sin reescribir snapshots", async () => { const response = await admin.request("/resource-catalog/global", { values: { kind: "field", name: "Código adicional de producto", description: "Campo sintético", type: "text", unit: "", choices: [], }, }); const parsed = await response.json(); const fieldId = z.object({ savedId: z.uuid() }).parse(parsed).savedId; assert.equal(response.status, 201); assert.equal(application.values.data.kind, "template"); if (application.values.data.kind !== "template") { throw new Error("Plantilla inesperada."); } const changed = await admin.request( `/resource-catalog/global/${template.templateId}/update`, { values: { ...application.values.data, fields: [...application.values.data.fields, { fieldId, required: true }], }, } ); assert.equal(changed.status, 200, await changed.clone().text()); product = GetGlobalApplicationAdminResponseSchema.parse( await (await admin.request(`/resource-global-applications/${creationId}/admin`)).json() ).resource; assert.ok(product.specification.fields.some((field) => field.fieldId === fieldId)); assert.equal( ( await admin.request(`/resource-global-applications/${creationId}/update`, { values: product.values, }) ).status, 409 ); assert.equal( ( await admin.request(`/resource-global-applications/${creationId}/status`, { active: false, }) ).status, 200 ); assert.equal( ( await admin.request(`/resource-global-applications/${creationId}/status`, { active: true, }) ).status, 409 ); const historical = ListGlobalApplicationHistoryResponseSchema.parse( await ( await admin.request(`/resource-global-applications/${creationId}/history?pageSize=100`) ).json() ); assert.ok( historical.items.some( (item) => item.id === originalRevisionId && !item.specification?.fields.some((field) => field.fieldId === fieldId) ) ); } ); await t.test("CRUD local y consumidores continúan locales tras existir catálogo", async () => { const inventory = InventoryResponseSchema.parse( await (await ordinary.request(`/resources/${companyId}`)).json() ); assert.equal(inventory.resources.length, 50); assert.ok( inventory.resources.every( (item) => item.companyId === companyId && !Object.hasOwn(item, "scope") ) ); assert.equal( ( await admin.request(`/software-installations/${companyId}`, { values: { assetId: volume.assetIds[0], resourceId: creationId, licenseResourceId: null, version: null, state: "configured", notes: null, }, }) ).status, 409 ); assert.equal( ( await admin.request(`/application-configurations/${companyId}`, { values: { profileId: volume.profileIds[0], resourceId: creationId, delivery: "installed", version: null, state: "configured", notes: null, }, }) ).status, 409 ); }); // La plantilla compartida vuelve a su contrato inicial antes de otras suites del contenedor. assert.equal( ( await admin.request(`/resource-catalog/global/${template.templateId}/update`, { values: application.values.data, }) ).status, 200 ); await t.test( "reinicio HTTP: replay persistido y cierre de altas/edición/reactivación con lectura conservada", async () => { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())) ); runtime.stop(); runtime = await createIdentityRuntime( database, readDatabaseEnvironment({ ...process.env, RESOURCE_GLOBAL_CATALOG_ENABLED: "false" }) ); server = createApp(() => undefined, runtime).listen(0, "127.0.0.1"); await once(server, "listening"); const restartedAddress = server.address(); assert.ok(restartedAddress && typeof restartedAddress !== "string"); const restarted = await apiClient(`http://127.0.0.1:${restartedAddress.port}`); assert.equal( ( await restarted.request("/auth/login", { email: "catalog-admin@example.invalid", password, }) ).status, 200 ); await restarted.refresh(); assert.equal((await restarted.request("/resource-global-applications", body)).status, 200); assert.equal( ( await restarted.request("/resource-global-applications", { ...body, creationId: UniqueID.generateNewID().toPrimitive(), }) ).status, 409 ); assert.equal( (await restarted.request(`/resource-global-applications/${creationId}/admin`)).status, 200 ); assert.equal( ( await restarted.request(`/resource-global-applications/${creationId}/update`, { values, }) ).status, 409 ); assert.equal( ( await restarted.request(`/resource-global-applications/${creationId}/status`, { active: false, }) ).status, 200 ); assert.equal( ( await restarted.request(`/resource-global-applications/${creationId}/status`, { active: true, }) ).status, 409 ); const history = ListGlobalApplicationHistoryResponseSchema.parse( await ( await restarted.request( `/resource-global-applications/${creationId}/history?pageSize=1` ) ).json() ); assert.equal(history.items.length, 1); const lastCursor = Buffer.from( JSON.stringify({ version: 1, resourceId: creationId, position: 0 }) ).toString("base64url"); const empty = await restarted.request( `/resource-global-applications/${creationId}/history?cursor=${lastCursor}` ); assert.equal(empty.status, 200); assert.equal( ListGlobalApplicationHistoryResponseSchema.parse(await empty.json()).items.length, 0 ); } ); await writeScopeEvidence("catalog-results.json", { companies: 15, principal: { resources: 50, people: 50, assets: 50, profiles: 50, workstations: 50 }, publicGlobalWrites: "authorized initial administrator", globalConsumers: false, originalId: creationId, originalRevisionId, }); } finally { await writeScopeEvidence("http-error-diagnostics.json", diagnostics); await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())) ); runtime.stop(); await database.close(); } });