import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; import { once } from "node:events"; import { test } from "node:test"; import type { ApplicationConfigurationInput } from "@metis/companies/common"; import { ApplicationConfigurationResponseSchema, ApplicationConfigurationsResponseSchema, CompanyResponseSchema, WorkProfileResponseSchema, } from "@metis/companies/common"; import { ApplicationReferenceCapabilitiesSchema, CatalogResponseSchema, GetGlobalApplicationAdminResponseSchema, GlobalApplicationTemplateResponseSchema, InventoryResponseSchema, ListApplicationCandidatesResponseSchema, ResourceResponseSchema, } from "@metis/resources/common"; import { migrateResourceScope } from "@metis/resources/module"; import { QueryTypes } from "sequelize"; 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.5: configuraciones globales locales al perfil, HTTP y concurrencia MariaDB", async (t) => { const database = createDatabase(disposableScopeEnvironment()); const owners = Array.from({ length: 15 }, () => UniqueID.generateNewID().toPrimitive()); const company = owners[0]; const other = owners[1]; assert.ok(company && other); const actor = UniqueID.generateNewID().toPrimitive(); const fixture = await prepareMembershipFixture(database, "http://localhost:5173", actor, owners); await migrateResourceScope(database); const seedHttp = await scopeHttpFixture(database, fixture); const volume = await seedScopeVolume(fixture, seedHttp, company); await seedHttp.close(); const a = volume.profileIds[0]; const a2 = volume.profileIds[1]; const host = volume.assetIds[0]; assert.ok(a && a2 && host); const foreignHost = await fixture.asset(other, "laptop"); let runtime = await createIdentityRuntime( database, readDatabaseEnvironment({ ...process.env, RESOURCE_GLOBAL_CATALOG_ENABLED: "true", RESOURCE_GLOBAL_SOFTWARE_ENABLED: "false", RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED: "false", RESOURCE_GLOBAL_PROFILE_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.5", email: "r25-admin@example.invalid", password, }) ).status, 201 ); await admin.refresh(); assert.equal( ( await admin.request("/users", { name: "Operador R2.5", email: "r25-user@example.invalid", password, }) ).status, 201 ); assert.equal( (await ordinary.request("/auth/login", { email: "r25-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: "Gestión privada R2.5", characteristics: {}, }, }); assert.equal(response.status, 201, await response.clone().text()); return GetGlobalApplicationAdminResponseSchema.parse(await response.json()).resource; }; const global = await product("Ágil compartida R2.5"); const inactive = await product("Inactiva R2.5", false); const catalog = CatalogResponseSchema.parse( await (await ordinary.request(`/resource-catalog/${company}`)).json() ); const template = (key: string) => { const found = catalog.items.find((item) => item.builtinKey === key); assert.ok(found); return found; }; const local = async (owner: string, templateId: string, name: string) => { const response = await ordinary.request(`/resources/${owner}`, { values: { name, templateId, active: true, notes: "", characteristics: {} }, }); assert.equal(response.status, 201); return ResourceResponseSchema.parse(await response.json()).resource.id; }; const own = await local(company, specification.templateId, "Ágil particular"); const foreign = await local(other, specification.templateId, "Ágil ajena"); const os = await local(company, template("template:os").id, "SO no configurable"); const license = await local( company, template("template:software_license").id, "Licencia no configurable" ); const certificate = await local( company, template("template:certificate").id, "Certificado no configurable" ); const locals: string[] = []; for (let index = 0; index < 50; index++) { locals.push(await local(company, specification.templateId, `Ágil volumen ${index + 1}`)); } const personResult = await ordinary.request(`/companies/${other}/people`, { name: "Persona de B", active: true, isContact: false, position: null, email: null, phone: null, }); const person = CompanyResponseSchema.parse(await personResult.json()).company.people.at(-1); assert.ok(person); const otherWorkstation = await ordinary.request(`/assets/${other}/workstations`, { values: { name: "Puesto B R2.5", active: true, siteId: null, personId: person.id, computerAssetId: foreignHost.id.toPrimitive(), assetIds: [foreignHost.id.toPrimitive()], }, }); assert.equal(otherWorkstation.status, 201); const profileResult = await ordinary.request(`/work-profiles/${other}`, { values: { name: "Perfil de B", personId: person.id, assetId: foreignHost.id.toPrimitive(), accountId: null, active: true, notes: null, }, }); assert.equal(profileResult.status, 201, await profileResult.clone().text()); const b = WorkProfileResponseSchema.parse(await profileResult.json()).profile.id; const path = `/application-configurations/${company}`; const input: ApplicationConfigurationInput = { profileId: a, resourceId: global.id, delivery: "installed", version: "123.4", state: "pending", notes: "Configuración A", }; const save = async (values: ApplicationConfigurationInput, owner = company) => { const response = await ordinary.request(`/application-configurations/${owner}`, { values }); assert.equal(response.status, 201, await response.clone().text()); return ApplicationConfigurationResponseSchema.parse(await response.json()).configuration; }; let first = await save(input); const races: string[] = []; await t.test( "mismo UUID en varios perfiles y compañías, delivery/versión/estado propios, sin instalaciones", async () => { const second = await save({ ...input, profileId: a2, delivery: "web", version: null, state: "configured", notes: "Perfil A2", }); const third = await save( { ...input, profileId: b, version: "B-9", state: "configured", notes: "Compañía B" }, other ); assert.notEqual(first.id, second.id); assert.notEqual(second.id, third.id); assert.equal(second.values.version, null); assert.equal(second.values.delivery, "web"); assert.equal(third.companyId, other); assert.equal(third.values.version, "B-9"); assert.equal(first.values.version, "123.4"); const rows = await database.query("SELECT id FROM resources WHERE id=:id", { replacements: { id: global.id }, type: QueryTypes.SELECT, }); assert.equal(rows.length, 1); assert.equal( ( await database.query("SELECT id FROM software_installations", { type: QueryTypes.SELECT, }) ).length, 0 ); const inventory = InventoryResponseSchema.parse( await (await ordinary.request(`/resources/${company}`)).json() ); assert.ok(inventory.resources.every((item) => item.id !== global.id)); } ); await t.test( "matriz negativa, recurso/perfil inmutables, autorización, CSRF y contrato estricto", async () => { for (const [profileId, resourceId, status] of [ [a, foreign, 404], [b, own, 404], [a, inactive.id, 409], [a, os, 404], [a, license, 404], [a, certificate, 404], [a, UniqueID.generateNewID().toPrimitive(), 404], ] as const) { assert.equal( (await admin.request(path, { values: { ...input, profileId, resourceId } })).status, status ); } for (const values of [ { ...input, profileId: a2 }, { ...input, resourceId: own }, ]) { assert.equal( (await ordinary.request(`${path}/${first.id}/update`, { values })).status, 409 ); } assert.equal( ( await ordinary.request( path, { values: { ...input, resourceId: own } }, { token: "invalid" } ) ).status, 403 ); assert.equal( ( await ordinary.request( path, { values: { ...input, resourceId: own } }, { 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, { values: { ...input, delivery: "web", version: "no admitida" }, }) ).status, 400 ); assert.equal( ( await ordinary.request(`/work-profiles/${company}`, { values: { name: "Activo ajeno", personId: person.id, assetId: foreignHost.id.toPrimitive(), accountId: null, active: true, notes: null, }, }) ).status, 404 ); } ); await t.test( "locales de serie y plantilla propia application, legacy y negociación global v2", async () => { const created = await ordinary.request(`/resource-catalog/${company}`, { values: { ...template("template:application").values, name: "Aplicación propia R2.5", usage: "application", }, }); assert.equal(created.status, 201); const custom = CatalogResponseSchema.parse( await (await ordinary.request(`/resource-catalog/${company}`)).json() ).items.find((item) => item.values.name === "Aplicación propia R2.5"); assert.ok(custom); const customId = await local(company, custom.id, "Aplicación propia elegible"); assert.equal( ( await ordinary.request( path, { values: { ...input, resourceId: own } }, { contract: null } ) ).status, 201 ); assert.equal( ( await ordinary.request( path, { values: { ...input, resourceId: customId } }, { contract: null } ) ).status, 201 ); assert.equal((await ordinary.request(path, undefined, { contract: null })).status, 409); assert.equal((await ordinary.request(path, undefined, { contract: "3" })).status, 200); assert.equal((await ordinary.request(path, undefined, { contract: "4" })).status, 400); assert.equal( ( await ordinary.request( `${path}/${first.id}/update`, { values: first.values }, { contract: null } ) ).status, 409 ); assert.equal( (await ordinary.request(`${path}/${first.id}/delete`, {}, { contract: null })).status, 409 ); assert.equal( ( await ordinary.request( path, { values: { ...input, profileId: volume.profileIds[2], resourceId: global.id.toUpperCase(), }, }, { contract: null } ) ).status, 409 ); assert.equal( ( await ordinary.request(`${path}/${first.id.toUpperCase()}/update`, { values: { ...first.values, resourceId: global.id.toUpperCase() }, }) ).status, 200 ); } ); await t.test( "candidatos de perfil mixtos sin SO/licencias, proyección privada y cursor por propósito", async () => { for (let index = 0; index < 25; index++) { await product(`Ágil global de perfil ${index}`, 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=application-configuration&search=agil&pageSize=7${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}` ); assert.equal(response.status, 200); const page = ListApplicationCandidatesResponseSchema.parse(await response.json()); assert.equal(page.purpose, "application-configuration"); for (const item of page.items) { assert.equal(item.kind, "application"); assert.equal(item.active, true); assert.equal(ids.has(item.id), false); ids.add(item.id); scopes.add(item.scope); for (const key of [ "notes", "history", "actorId", "profiles", "installations", "characteristics", ]) { assert.equal(Object.hasOwn(item, key), false); } } cursor = page.nextCursor; } while (cursor); assert.deepEqual([...scopes].sort(), ["company", "global"]); assert.equal(ids.size, 72); const caps = ApplicationReferenceCapabilitiesSchema.parse( await ( await ordinary.request(`/resource-application-candidates/${company}/capabilities`) ).json() ); assert.equal(caps.profileAssociation, true); assert.equal(caps.softwareAssociation, false); assert.equal(caps.softwareSelection, false); assert.equal( ( await ordinary.request(`/software-installations/${company}`, { values: { assetId: host, resourceId: global.id, licenseResourceId: null, version: null, state: "pending", notes: null, }, }) ).status, 409 ); } ); await t.test( "edición semántica, historial y UUID preservados tras renombrar/desactivar; notas y retirada", async () => { const snapshot = first.history; const unchanged = ApplicationConfigurationResponseSchema.parse( await ( await ordinary.request(`${path}/${first.id}/update`, { values: first.values }) ).json() ).configuration; assert.deepEqual(unchanged.history, first.history); const updated = await ordinary.request(`${path}/${first.id}/update`, { values: { ...input, version: "124", state: "configured" }, }); assert.equal(updated.status, 200); first = ApplicationConfigurationResponseSchema.parse(await updated.json()).configuration; assert.equal(first.history.length, 2); assert.deepEqual(first.history.slice(0, 1), snapshot); assert.equal( ( await admin.request(`/resource-global-applications/${global.id}/update`, { values: { ...global.values, name: "Ágil renombrada R2.5" }, }) ).status, 200 ); assert.equal( ( await admin.request(`/resource-global-applications/${global.id}/status`, { active: false, }) ).status, 200 ); const before = ApplicationConfigurationsResponseSchema.parse( await (await ordinary.request(path)).json() ).configurations.find((item) => item.id === first.id); assert.ok(before); assert.equal(before.needsReview, true); assert.deepEqual(before.history, first.history); assert.equal( ( await ordinary.request(`${path}/${first.id}/update`, { values: { ...first.values, version: "125" }, }) ).status, 409 ); const notes = await ordinary.request(`${path}/${first.id}/update`, { values: { ...first.values, notes: "Revisión documentada" }, }); assert.equal(notes.status, 200); first = ApplicationConfigurationResponseSchema.parse(await notes.json()).configuration; const retirement = await ordinary.request(`${path}/${first.id}/update`, { values: { ...first.values, state: "removal_pending" }, }); assert.equal(retirement.status, 200); first = ApplicationConfigurationResponseSchema.parse(await retirement.json()).configuration; assert.equal(first.values.resourceId, global.id); assert.equal(first.values.version, "124"); const otherConfigurations = ApplicationConfigurationsResponseSchema.parse( await (await ordinary.request(`/application-configurations/${other}`)).json() ); assert.equal(otherConfigurations.configurations[0]?.history.length, 1); const resolver = await ordinary.request( `/resource-application-references/${company}/resolve`, { purpose: "application-configuration", ids: [global.id] } ); assert.equal(resolver.status, 200); assert.match(await resolver.text(), /Ágil renombrada/); } ); await t.test("carrera: configuración primero, desactivación espera el commit", async () => { const target = await product("Carrera perfil primero"); const arrived = latch(); const release = latch(); database.addHook("afterQuery", "r25-create", async (_options, query) => { const sql: unknown = Reflect.get(query, "sql"); if (typeof sql === "string" && sql.startsWith("INSERT INTO application_configurations")) { arrived.release(); await release.promise; } }); const configuring = ordinary.request(path, { values: { ...input, resourceId: target.id } }); 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 configuring).status, 201); assert.equal((await disabling).status, 200); const read = ApplicationConfigurationsResponseSchema.parse( await (await ordinary.request(path)).json() ).configurations.find((item) => item.values.resourceId === target.id); assert.ok(read); assert.equal(read.needsReview, true); races.push("configuration-before-deactivation"); } finally { release.release(); database.removeHook("afterQuery", "r25-create"); await Promise.allSettled([configuring, ...(disabling ? [disabling] : [])]); } }); await t.test( "carrera: desactivación primero, revalidación posterior rechaza sin crear revisión", async () => { const target = await product("Carrera desactivación perfil"); const arrived = latch(); const release = latch(); database.addHook("afterQuery", "r25-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 configuring: Promise | undefined; try { await waitBarrier(arrived.promise); configuring = ordinary.request(path, { values: { ...input, resourceId: target.id } }); await waitCatalogLock(process.env.METIS_RESOURCE_SCOPE_DISPOSABLE ?? ""); release.release(); assert.equal((await disabling).status, 200); assert.equal((await configuring).status, 409); assert.equal( ( await database.query( "SELECT id FROM application_configurations WHERE resourceId=:id", { replacements: { id: target.id }, type: QueryTypes.SELECT } ) ).length, 0 ); assert.equal( ( await database.query( "SELECT id FROM application_configuration_revisions WHERE resourceId=:id", { replacements: { id: target.id }, type: QueryTypes.SELECT } ) ).length, 0 ); races.push("deactivation-before-configuration"); } finally { release.release(); database.removeHook("afterQuery", "r25-status"); await Promise.allSettled([disabling, ...(configuring ? [configuring] : [])]); } } ); await t.test( "concurrencia de la pareja: un único registro abierto y revisiones propias", async () => { const target = await product("Concurrencia pareja de perfil"); const results = await Promise.all([ ordinary.request(path, { values: { ...input, resourceId: target.id } }), ordinary.request(path, { values: { ...input, resourceId: target.id } }), ]); assert.deepEqual(results.map((result) => result.status).sort(), [201, 409]); assert.equal( ( await database.query( "SELECT id FROM application_configurations WHERE profileId=:profileId AND resourceId=:resourceId", { replacements: { profileId: a, resourceId: target.id }, type: QueryTypes.SELECT } ) ).length, 1 ); } ); await t.test( "volumen de cincuenta configuraciones preserva aislamiento por perfil", async () => { for (let index = 0; index < 50; index++) { const id = volume.profileIds[index]; const resourceId = locals[index]; assert.ok(id && resourceId); await save({ ...input, profileId: id, resourceId, version: `Perfil-${index + 1}` }); } const listed = ApplicationConfigurationsResponseSchema.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 y apagado: lectores, notas, retirada y locales siguen funcionando", async () => { server.close(); await once(server, "close"); runtime.stop(); runtime = await createIdentityRuntime( database, readDatabaseEnvironment({ ...process.env, RESOURCE_GLOBAL_CATALOG_ENABLED: "true", RESOURCE_GLOBAL_SOFTWARE_ENABLED: "true", RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED: "true", RESOURCE_GLOBAL_PROFILE_ENABLED: "false", }) ); server = createApp(() => undefined, runtime).listen(address.port, "127.0.0.1"); await once(server, "listening"); const caps = ApplicationReferenceCapabilitiesSchema.parse( await ( await ordinary.request(`/resource-application-candidates/${company}/capabilities`) ).json() ); assert.equal(caps.profileAssociation, false); assert.equal(caps.softwareAssociation, true); const target = await product("Gates independientes R2.5"); assert.equal( (await ordinary.request(path, { values: { ...input, resourceId: target.id } })).status, 409 ); assert.equal( ( await ordinary.request(`/software-installations/${company}`, { values: { assetId: host, resourceId: target.id, licenseResourceId: null, version: "R2.4", state: "configured", notes: null, }, }) ).status, 201 ); const read = ApplicationConfigurationsResponseSchema.parse( await (await ordinary.request(path)).json() ).configurations.find((item) => item.id === first.id); assert.ok(read); assert.equal(read.values.resourceId, global.id); assert.equal( ( await ordinary.request(`${path}/${first.id}/update`, { values: { ...read.values, notes: "Notas tras apagado" }, }) ).status, 200 ); assert.equal( ( await ordinary.request(`${path}/${first.id}/update`, { values: { ...read.values, state: "removed" }, }) ).status, 200 ); const candidates = ListApplicationCandidatesResponseSchema.parse( await ( await ordinary.request( `/resource-application-candidates/${company}?purpose=application-configuration` ) ).json() ); assert.ok(candidates.items.every((item) => item.scope === "company")); assert.equal( ( await ordinary.request( path, { values: { ...input, profileId: a2, resourceId: own } }, { contract: null } ) ).status, 201 ); assert.equal((await ordinary.request(`${path}/${first.id}/delete`, {})).status, 200); server.close(); await once(server, "close"); runtime.stop(); runtime = await createIdentityRuntime( database, readDatabaseEnvironment({ ...process.env, RESOURCE_GLOBAL_CATALOG_ENABLED: "false", RESOURCE_GLOBAL_SOFTWARE_ENABLED: "true", RESOURCE_GLOBAL_PROFILE_ENABLED: "true", }) ); server = createApp(() => undefined, runtime).listen(address.port, "127.0.0.1"); await once(server, "listening"); const closed = ApplicationReferenceCapabilitiesSchema.parse( await ( await ordinary.request(`/resource-application-candidates/${company}/capabilities`) ).json() ); assert.equal(closed.profileAssociation, false); assert.equal(closed.softwareAssociation, false); assert.equal((await ordinary.request(path)).status, 200); } ); await writeScopeEvidence("profile-results.json", { companies: 15, principal: { people: 50, assets: 50, profiles: 50, workstations: 50, localResourcesAtLeast: 50, configurationsAtLeast: 50, }, races, softwareAndProfileIndependent: true, migrationsAdded: false, }); await writeScopeEvidence( "profile-foreign-keys.json", 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 ('application_configurations','application_configuration_revisions') AND REFERENCED_TABLE_NAME IS NOT NULL ORDER BY TABLE_NAME,COLUMN_NAME", { type: QueryTypes.SELECT } ) ); } finally { server.close(); await once(server, "close"); runtime.stop(); await database.close(); } });