metis-desk/apps/server/src/global-software.integration.test.ts
2026-10-25 01:19:17 +02:00

883 lines
30 KiB
TypeScript

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<string>();
const scopes = new Set<string>();
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<Response> | 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<Response> | 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<void>((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<void>((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<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
runtime.stop();
await database.close();
}
});