883 lines
30 KiB
TypeScript
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();
|
|
}
|
|
});
|