import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; import { once } from "node:events"; import { test } from "node:test"; import { SoftwareInstallationResponseSchema, SoftwareInstallationsResponseSchema, } from "@metis/companies/common"; import { ApplicationReferenceCapabilitiesSchema, GetGlobalApplicationAdminResponseSchema, GlobalApplicationTemplateResponseSchema, InventoryResponseSchema, ListApplicationCandidatesResponseSchema, ResolveApplicationReferencesResponseSchema, } from "@metis/resources/common"; import { 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 } from "./resource-scope-api.client"; import { latch, waitBarrier, waitCatalogLock } from "./resource-scope-concurrency.fixture"; import { scopeHttpFixture, seedScopeVolume } from "./resource-scope-http.fixture"; test("R2.4: software global, licencia local, negociación y serialización HTTP/MariaDB", async (t) => { const environment = disposableScopeEnvironment(); const database = createDatabase(environment); const companies = Array.from({ length: 15 }, () => UniqueID.generateNewID().toPrimitive()); const company = companies[0]; const other = companies[1]; const actor = UniqueID.generateNewID().toPrimitive(); assert.ok(company && other); const fixture = await prepareMembershipFixture( database, environment.APP_ORIGIN, actor, companies ); await migrateResourceScope(database); const volumeHttp = await scopeHttpFixture(database, fixture); const volume = await seedScopeVolume(fixture, volumeHttp, company); await volumeHttp.close(); const catalog = await fixture.resourceUseCases.get(company); assert.ok(catalog.isSuccess); const template = (key: string) => { const item = catalog.data.catalog.definitions.find( (item) => item.stockKey.getOrUndefined() === key ); assert.ok(item); return item.id.toPrimitive(); }; const local = async (owner: string, key: string, name: string) => { const saved = await fixture.resourceUseCases.save( owner, undefined, { name, templateId: template(key), active: true, notes: "", characteristics: {} }, actor ); assert.ok(saved.isSuccess); return saved.data.resource.id.toPrimitive(); }; const localIds: string[] = []; for (const owner of companies) { for (let index = 0; index < (owner === company ? 50 : 1); index += 1) { const id = await local(owner, "template:application", `Ágil local ${index + 1}`); if (owner === company) { localIds.push(id); } } } const foreignInventory = await fixture.resourceUseCases.get(other); assert.ok(foreignInventory.isSuccess); const foreignId = foreignInventory.data.resources[0]?.id.toPrimitive(); const license = await local(company, "template:software_license", "Licencia propia"); const foreignLicense = await local(other, "template:software_license", "Licencia ajena"); const os = await local(company, "template:os", "SO particular"); const certificate = await local(company, "template:certificate", "No instalable"); const otherAsset = await fixture.asset(other, "laptop"); const firstAsset = volume.assetIds[0]; const secondAsset = volume.assetIds[1]; assert.ok(firstAsset && secondAsset && foreignId && localIds[0]); let runtime = await createIdentityRuntime( database, readDatabaseEnvironment({ ...process.env, RESOURCE_GLOBAL_CATALOG_ENABLED: "true", RESOURCE_GLOBAL_SOFTWARE_ENABLED: "true", RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED: "true", }) ); let server = createApp(() => undefined, runtime).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}`; const admin = await scopeApiClient(base); const ordinary = await scopeApiClient(base); const password = randomBytes(24).toString("hex"); try { assert.equal( ( await admin.request("/auth/bootstrap", { name: "Preparación R2.4", email: "r24-admin@example.invalid", password, }) ).status, 201 ); await admin.refresh(); assert.equal( ( await admin.request("/users", { name: "Soporte ordinario", email: "r24-user@example.invalid", password, }) ).status, 201 ); assert.equal( (await ordinary.request("/auth/login", { email: "r24-user@example.invalid", password })) .status, 200 ); await ordinary.refresh(); const specification = GlobalApplicationTemplateResponseSchema.parse( await (await admin.request("/resource-global-applications/template")).json() ).specification; const product = async (name: string, active = true) => { const response = await admin.request("/resource-global-applications", { creationId: UniqueID.generateNewID().toPrimitive(), values: { name, templateId: specification.templateId, active, notes: "Nota privada global", characteristics: {}, }, }); assert.equal(response.status, 201); return GetGlobalApplicationAdminResponseSchema.parse(await response.json()).resource; }; const global = await product("Ágil global — Producto sintético"); const inactive = await product("Global inactivo", false); const input = { assetId: firstAsset, resourceId: global.id, licenseResourceId: license, version: "1.0", state: "configured", notes: "Instalación particular", }; const path = `/software-installations/${company}`; const saved = await ordinary.request(path, { values: input }); assert.equal(saved.status, 201, await saved.clone().text()); let installation = SoftwareInstallationResponseSchema.parse(await saved.json()).configuration; const originalId = installation.id; const originalRevision = installation.history[0]?.id; const races: string[] = []; await t.test( "mismo producto, instalaciones, versiones, estados, activos y licencias independientes", async () => { const inOther = await ordinary.request(`/software-installations/${other}`, { values: { ...input, assetId: otherAsset.id.toPrimitive(), version: "99", state: "pending", licenseResourceId: foreignLicense, }, }); assert.equal(inOther.status, 201); const otherInstallation = SoftwareInstallationResponseSchema.parse( await inOther.json() ).configuration; assert.notEqual(otherInstallation.id, installation.id); assert.equal(otherInstallation.companyId, other); assert.equal(otherInstallation.values.resourceId, global.id); assert.equal(otherInstallation.values.licenseResourceId, foreignLicense); assert.equal(installation.companyId, company); assert.equal(installation.values.version, "1.0"); const raw = await database.query<{ amount: number }>( "SELECT COUNT(*) AS amount FROM resources WHERE id=:id", { replacements: { id: global.id }, type: QueryTypes.SELECT } ); assert.equal(Number(raw[0]?.amount), 1); assert.equal( InventoryResponseSchema.parse( await (await ordinary.request(`/resources/${company}`)).json() ).resources.some((item) => item.id === global.id), false ); } ); await t.test("matriz negativa, destino inmutable, CSRF y autenticación reales", async () => { for (const [resourceId, licenseResourceId, assetId, status] of [ [foreignId, null, firstAsset, 404], [inactive.id, null, firstAsset, 409], [certificate, null, firstAsset, 404], [global.id, foreignLicense, secondAsset, 404], [global.id, global.id, secondAsset, 404], [global.id, null, otherAsset.id.toPrimitive(), 404], [UniqueID.generateNewID().toPrimitive(), null, firstAsset, 404], ] as const) { assert.equal( ( await admin.request(path, { values: { ...input, resourceId, licenseResourceId, assetId }, }) ).status, status ); } assert.equal( ( await ordinary.request(`${path}/${installation.id}/update`, { values: { ...input, resourceId: localIds[0] }, }) ).status, 409 ); assert.equal( ( await ordinary.request( path, { values: { ...input, assetId: secondAsset } }, { token: "invalid" } ) ).status, 403 ); assert.equal( (await ordinary.request(path, { values: input }, { origin: "https://invalid.example" })) .status, 403 ); const anonymous = await scopeApiClient(base); assert.equal((await anonymous.request(path)).status, 401); assert.equal((await anonymous.request(path, { values: input })).status, 401); assert.equal( (await ordinary.request(path, { values: { ...input, scope: "global" } })).status, 400 ); assert.equal( (await ordinary.request(`${path}?purpose=application-configuration`)).status, 400 ); const unused = companies[2]; assert.ok(unused); assert.equal( (await ordinary.request(`/software-installations/${unused}`, undefined, { contract: null })) .status, 200 ); assert.equal( ( await admin.request("/resource-global-applications", { creationId: UniqueID.generateNewID().toPrimitive(), values: { name: "OS global prohibido", templateId: template("template:os"), active: true, notes: "", characteristics: {}, }, }) ).status, 409 ); }); await t.test( "compatibilidad v2: locales legacy y respuesta completa global sin downgrade silencioso", async () => { assert.equal((await ordinary.request(path, undefined, { contract: null })).status, 409); assert.equal((await ordinary.request(path, undefined, { contract: "3" })).status, 400); const uppercase = await ordinary.request( path, { values: { ...input, assetId: secondAsset, resourceId: global.id.toUpperCase() } }, { contract: null } ); assert.equal(uppercase.status, 409); assert.equal( z .object({ code: z.literal("RESOURCE_CLIENT_UPGRADE_REQUIRED") }) .safeParse(await uppercase.json()).success, true ); assert.equal( ( await ordinary.request(`${path}/${installation.id.toUpperCase()}/update`, { values: { ...input, resourceId: global.id.toUpperCase() }, }) ).status, 200 ); assert.equal( ( await ordinary.request( `${path}/${installation.id}/update`, { values: input }, { contract: null } ) ).status, 409 ); assert.equal( (await ordinary.request(`${path}/${installation.id}/delete`, {}, { contract: null })) .status, 409 ); const response = SoftwareInstallationsResponseSchema.parse( await (await ordinary.request(path)).json() ); assert.equal(response.configurations.length, 1); assert.equal(Object.hasOwn(response.configurations[0] ?? {}, "scope"), false); } ); await t.test("instalación local de SO mantiene contrato y validación de licencia", async () => { assert.equal( ( await ordinary.request( path, { values: { ...input, resourceId: os, licenseResourceId: null } }, { contract: null } ) ).status, 201 ); }); await t.test( "candidatos mixtos paginados, proyección mínima, búsqueda, homónimos y perfiles cerrados", async () => { for (let index = 0; index < 30; index += 1) { await product(`Ágil global ${index % 7}`, index % 5 !== 0); } let cursor: string | null = null; const ids = new Set(); const scopes = new Set(); do { const response = await ordinary.request( `/resource-application-candidates/${company}?purpose=software-installation&search=agil&pageSize=7${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}` ); const page = ListApplicationCandidatesResponseSchema.parse(await response.json()); assert.equal(response.status, 200); for (const item of page.items) { assert.equal(ids.has(item.id), false); ids.add(item.id); scopes.add(item.scope); assert.equal(item.active, true); for (const key of ["notes", "history", "characteristics", "actorId", "installations"]) { assert.equal(Object.hasOwn(item, key), false); } } cursor = page.nextCursor; } while (cursor); assert.deepEqual([...scopes].sort(), ["company", "global"]); assert.equal(ids.size, 75); const profiles = ListApplicationCandidatesResponseSchema.parse( await ( await ordinary.request( `/resource-application-candidates/${company}?purpose=application-configuration` ) ).json() ); assert.ok(profiles.items.every((item) => item.scope === "company")); assert.equal( ( await ordinary.request(`/application-configurations/${company}`, { values: { profileId: volume.profileIds[0], resourceId: global.id, delivery: "installed", version: null, state: "configured", notes: null, }, }) ).status, 409 ); } ); await t.test( "edición idempotente, licencia propia, renombrado e inactivo histórico con needsReview derivado", async () => { const repeated = SoftwareInstallationResponseSchema.parse( await ( await ordinary.request(`${path}/${installation.id}/update`, { values: input }) ).json() ).configuration; assert.equal(repeated.history.length, 1); installation = SoftwareInstallationResponseSchema.parse( await ( await ordinary.request(`${path}/${installation.id}/update`, { values: { ...input, version: "2.0" }, }) ).json() ).configuration; assert.equal(installation.history.length, 2); assert.equal(installation.history[0]?.values.version, "1.0"); assert.equal( ( await admin.request(`/resource-global-applications/${global.id}/update`, { values: { ...global.values, name: "Producto renombrado" }, }) ).status, 200 ); assert.equal( ( await admin.request(`/resource-global-applications/${global.id}/status`, { active: false, }) ).status, 200 ); const before = await database.query( "SELECT * FROM software_installation_revisions WHERE configurationId=:id ORDER BY position", { replacements: { id: installation.id }, type: QueryTypes.SELECT } ); const all = SoftwareInstallationsResponseSchema.parse( await (await ordinary.request(path)).json() ); assert.equal( all.configurations.find((item) => item.id === installation.id)?.needsReview, true ); const reference = ResolveApplicationReferencesResponseSchema.parse( await ( await ordinary.request(`/resource-application-references/${company}/resolve`, { purpose: "software-installation", ids: [global.id], }) ).json() ).items[0]; assert.ok(reference?.status === "resolved"); assert.equal(reference.reference.name, "Producto renombrado"); assert.equal(reference.reference.active, false); assert.equal( ( await ordinary.request(`${path}/${installation.id}/update`, { values: { ...installation.values, version: "3.0" }, }) ).status, 409 ); const note = await ordinary.request(`${path}/${installation.id}/update`, { values: { ...installation.values, notes: "Revisar producto" }, }); assert.equal(note.status, 200); installation = SoftwareInstallationResponseSchema.parse(await note.json()).configuration; assert.equal(installation.id, originalId); assert.equal(installation.history[0]?.id, originalRevision); assert.equal(installation.values.resourceId, global.id); const unchanged = await database.query( "SELECT * FROM software_installation_revisions WHERE configurationId=:id AND position<2 ORDER BY position", { replacements: { id: installation.id }, type: QueryTypes.SELECT } ); assert.deepEqual(unchanged, before); } ); await t.test( "carrera: instalación primero, desactivación espera el commit y deriva revisión", async () => { const target = await product("Carrera instalación primero"); const arrived = latch(); const release = latch(); database.addHook("afterQuery", "r24-hold-install", async (_options, query) => { const sql: unknown = Reflect.get(query, "sql"); if ( typeof sql === "string" && sql.startsWith("INSERT INTO software_installations") && sql.includes(target.id) ) { arrived.release(); await release.promise; } }); const installing = ordinary.request(path, { values: { ...input, resourceId: target.id, licenseResourceId: null }, }); let disabling: Promise | undefined; try { await waitBarrier(arrived.promise); disabling = admin.request(`/resource-global-applications/${target.id}/status`, { active: false, }); await waitCatalogLock(process.env.METIS_RESOURCE_SCOPE_DISPOSABLE ?? ""); release.release(); assert.equal((await installing).status, 201); assert.equal((await disabling).status, 200); const installed = SoftwareInstallationsResponseSchema.parse( await (await ordinary.request(path)).json() ).configurations.find((item) => item.values.resourceId === target.id); assert.equal(installed?.needsReview, true); races.push("installation-commit-before-deactivation"); } finally { release.release(); database.removeHook("afterQuery", "r24-hold-install"); await Promise.allSettled([installing, ...(disabling ? [disabling] : [])]); } } ); await t.test( "carrera: desactivación primero, instalación espera y se rechaza al releer", async () => { const target = await product("Carrera desactivación primero"); const arrived = latch(); const release = latch(); database.addHook("afterQuery", "r24-hold-status", async (_options, query) => { const sql: unknown = Reflect.get(query, "sql"); if ( typeof sql === "string" && sql.startsWith("INSERT INTO `resources`") && sql.includes(target.id) ) { arrived.release(); await release.promise; } }); const disabling = admin.request(`/resource-global-applications/${target.id}/status`, { active: false, }); let installing: Promise | undefined; try { await waitBarrier(arrived.promise); installing = ordinary.request(path, { values: { ...input, resourceId: target.id, licenseResourceId: null }, }); await waitCatalogLock(process.env.METIS_RESOURCE_SCOPE_DISPOSABLE ?? ""); release.release(); assert.equal((await disabling).status, 200); assert.equal((await installing).status, 409); const rows = await database.query( "SELECT id FROM software_installations WHERE resourceId=:id", { replacements: { id: target.id }, type: QueryTypes.SELECT } ); assert.equal(rows.length, 0); races.push("deactivation-commit-before-installation-revalidation"); } finally { release.release(); database.removeHook("afterQuery", "r24-hold-status"); await Promise.allSettled([disabling, ...(installing ? [installing] : [])]); } } ); await t.test("dos compañías instalan a la vez sin lock exclusivo de catálogo", async () => { const target = await product("Paralelismo entre compañías"); const first = latch(); const second = latch(); const release = latch(); let arrived = 0; database.addHook("afterQuery", "r24-parallel", async (_options, query) => { const sql: unknown = Reflect.get(query, "sql"); if ( typeof sql === "string" && sql.startsWith("INSERT INTO software_installations") && sql.includes(target.id) ) { arrived += 1; if (arrived === 1) { first.release(); } else { second.release(); } await release.promise; } }); const attempts = [ ordinary.request(path, { values: { ...input, resourceId: target.id, licenseResourceId: null }, }), ordinary.request(`/software-installations/${other}`, { values: { ...input, assetId: otherAsset.id.toPrimitive(), resourceId: target.id, licenseResourceId: null, }, }), ]; try { await waitBarrier(Promise.all([first.promise, second.promise]).then(() => undefined)); release.release(); assert.deepEqual( (await Promise.all(attempts)).map((response) => response.status), [201, 201] ); races.push("two-company-inserts-before-either-commit"); } finally { release.release(); database.removeHook("afterQuery", "r24-parallel"); await Promise.allSettled(attempts); } }); await t.test( "volumen operativo: cincuenta instalaciones particulares con productos locales distintos", async () => { for (let index = 0; index < 50; index += 1) { const assetId = volume.assetIds[index]; const resourceId = localIds[index]; assert.ok(assetId && resourceId); assert.equal( ( await ordinary.request(path, { values: { ...input, assetId, resourceId, licenseResourceId: null, version: `Edición ${index + 1}`, state: index % 3 ? "configured" : "pending", }, }) ).status, 201 ); } const listed = SoftwareInstallationsResponseSchema.parse( await (await ordinary.request(path)).json() ); assert.ok(listed.configurations.length >= 50); assert.ok(listed.configurations.every((item) => item.companyId === company)); } ); await t.test("gates independientes de candidatos y asociación, sin abrir perfil", async () => { for (const flags of [ { software: false, candidates: true }, { software: true, candidates: false }, ]) { const isolated = await createIdentityRuntime( database, readDatabaseEnvironment({ ...process.env, RESOURCE_GLOBAL_CATALOG_ENABLED: "true", RESOURCE_GLOBAL_SOFTWARE_ENABLED: String(flags.software), RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED: String(flags.candidates), }) ); const listener = createApp(() => undefined, isolated).listen(0, "127.0.0.1"); await once(listener, "listening"); try { const endpoint = listener.address(); assert.ok(endpoint && typeof endpoint !== "string"); const client = await scopeApiClient(`http://127.0.0.1:${endpoint.port}`); assert.equal( (await client.request("/auth/login", { email: "r24-user@example.invalid", password })) .status, 200 ); await client.refresh(); const capabilities = ApplicationReferenceCapabilitiesSchema.parse( await ( await client.request(`/resource-application-candidates/${company}/capabilities`) ).json() ); assert.equal(capabilities.softwareSelection, flags.candidates); assert.equal(capabilities.softwareAssociation, flags.software); const options = ListApplicationCandidatesResponseSchema.parse( await ( await client.request( `/resource-application-candidates/${company}?purpose=software-installation&search=global` ) ).json() ); assert.equal( options.items.some((item) => item.scope === "global"), flags.candidates ); const existing = await database.query<{ id: string }>( "SELECT id FROM resources WHERE companyId IS NULL AND name LIKE 'Ágil global%' AND active=1 ORDER BY id LIMIT 1", { type: QueryTypes.SELECT } ); assert.ok(existing[0]); const attempt = await client.request(path, { values: { ...input, resourceId: existing[0].id, licenseResourceId: null }, }); assert.equal(attempt.status, flags.software ? 201 : 409); if (!flags.software) { assert.equal( z .object({ code: z.literal("RESOURCE_GLOBAL_FEATURE_DISABLED") }) .safeParse(await attempt.json()).success, true ); } } finally { await new Promise((resolve, reject) => listener.close((error) => (error ? reject(error) : resolve())) ); isolated.stop(); } } }); await t.test( "gate cerrado tras reinicio: lectura, notas y retirada siguen; nuevas dependencias no", 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", RESOURCE_GLOBAL_SOFTWARE_ENABLED: "true", RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED: "true", }) ); server = createApp(() => undefined, runtime).listen(0, "127.0.0.1"); await once(server, "listening"); const restarted = server.address(); assert.ok(restarted && typeof restarted !== "string"); const client = await scopeApiClient(`http://127.0.0.1:${restarted.port}`); assert.equal( (await client.request("/auth/login", { email: "r24-user@example.invalid", password })) .status, 200 ); await client.refresh(); assert.equal((await client.request(path)).status, 200); assert.equal( ( await client.request( path, { values: { ...input, assetId: secondAsset, resourceId: localIds[0], licenseResourceId: null, }, }, { contract: null } ) ).status, 201 ); assert.equal( (await client.request(path, { values: { ...input, assetId: secondAsset } })).status, 409 ); assert.equal( ( await client.request(`${path}/${installation.id}/update`, { values: { ...installation.values, notes: "Puerta cerrada, conservar borrador" }, }) ).status, 200 ); assert.equal( ( await client.request(`${path}/${installation.id}/update`, { values: { ...installation.values, state: "removal_pending" }, }) ).status, 200 ); const candidates = ListApplicationCandidatesResponseSchema.parse( await ( await client.request( `/resource-application-candidates/${company}?purpose=software-installation` ) ).json() ); assert.ok(candidates.items.every((item) => item.scope === "company")); assert.equal((await client.request(`${path}/${installation.id}/delete`, {})).status, 200); } ); await writeScopeEvidence("software-results.json", { companies: 15, principal: { people: 50, assets: 50, profiles: 50, workstations: 50, localApplications: 50, softwareInstallationsAtLeast: 50, }, races, profileGlobalAssociations: false, migrationsAdded: false, }); const foreignKeys = await database.query( "SELECT TABLE_NAME,COLUMN_NAME,REFERENCED_TABLE_NAME,REFERENCED_COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('software_installations','software_installation_revisions') AND REFERENCED_TABLE_NAME IS NOT NULL", { type: QueryTypes.SELECT } ); await writeScopeEvidence("software-foreign-keys.json", foreignKeys); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())) ); runtime.stop(); await database.close(); } });