metis-desk/apps/server/src/global-profile-applications.integration.test.ts
David d2bf856c97 feat(global-operating-systems): implement global operating systems management features
- Add softwareReferenceHref helper function for generating resource links.
- Create useGlobalResourceEditor hook for managing global application resources.
- Implement useGlobalResourceForm hook for handling global resource forms.
- Introduce GlobalSoftwareCatalogProvider for context management of global applications.
- Develop GlobalOperatingSystemsPage to render global operating systems using the existing GlobalApplicationsPage.
- Add operating system form identity helpers for managing OS-specific fields.
- Create operating system specification labels for better localization.
- Implement OperatingSystemIdentityReview component for reviewing OS identities.
- Add OperatingSystemProductFields component for displaying OS product details.
- Write tests for resource characteristic control to ensure proper translation and value handling.
- Create a Python script for auditing global operating systems UI interactions.
- Add tests for expanded workstation profiles to ensure proper functionality and responsiveness.
2026-10-25 20:39:06 +01:00

851 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 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<string>();
const scopes = new Set<string>();
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<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 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<Response> | 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();
}
});