571 lines
20 KiB
TypeScript
571 lines
20 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createHash, 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 {
|
|
ApplicationCoincidencesResponseSchema,
|
|
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 { scopeHttpFixture, seedScopeVolume } from "./resource-scope-http.fixture";
|
|
|
|
test("R2.6: coincidencias contextuales acotadas sin mutaciones", 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: "false",
|
|
})
|
|
);
|
|
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.6",
|
|
email: "r25-admin@example.invalid",
|
|
password,
|
|
})
|
|
).status,
|
|
201
|
|
);
|
|
await admin.refresh();
|
|
assert.equal(
|
|
(
|
|
await admin.request("/users", {
|
|
name: "Operador R2.6",
|
|
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.6",
|
|
characteristics: {},
|
|
},
|
|
});
|
|
|
|
assert.equal(response.status, 201, await response.clone().text());
|
|
|
|
return GetGlobalApplicationAdminResponseSchema.parse(await response.json()).resource;
|
|
};
|
|
|
|
const global = await product("Ágil compartida R2.6");
|
|
const _inactive = await product("Inactiva R2.6", 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.6",
|
|
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;
|
|
};
|
|
|
|
const first = await save({ ...input, resourceId: own });
|
|
const installed = await ordinary.request(`/software-installations/${company}`, {
|
|
values: {
|
|
assetId: host,
|
|
resourceId: own,
|
|
licenseResourceId: license,
|
|
version: "LOCAL-123",
|
|
state: "configured",
|
|
notes: "No migrar",
|
|
},
|
|
});
|
|
|
|
assert.equal(installed.status, 201);
|
|
const endpoint = `/resource-application-coincidences/${company}/${own}`;
|
|
const measurements: unknown[] = [];
|
|
|
|
const snapshot = async () => {
|
|
const result: Record<string, string> = {};
|
|
|
|
for (const table of [
|
|
"resources",
|
|
"resource_revisions",
|
|
"resource_characteristics",
|
|
"software_installations",
|
|
"software_installation_revisions",
|
|
"application_configurations",
|
|
"application_configuration_revisions",
|
|
]) {
|
|
const rows = await database.query(
|
|
`SELECT * FROM ${table} ORDER BY ${table === "resource_characteristics" ? "resourceId,fieldId" : "id"}`,
|
|
{ type: QueryTypes.SELECT }
|
|
);
|
|
|
|
result[table] = createHash("sha256").update(JSON.stringify(rows)).digest("hex");
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
await t.test("volumen 10/100/1000/10000: recorrido acotado y consultas medidas", async () => {
|
|
const rootRows = await database.query<Record<string, unknown>>(
|
|
"SELECT * FROM resources WHERE id=:id",
|
|
{ replacements: { id: global.id }, type: QueryTypes.SELECT }
|
|
);
|
|
const root = rootRows[0];
|
|
|
|
assert.ok(root);
|
|
const revisionRows = await database.query<Record<string, unknown>>(
|
|
"SELECT * FROM resource_revisions WHERE resourceId=:id",
|
|
{ replacements: { id: global.id }, type: QueryTypes.SELECT }
|
|
);
|
|
const revision = revisionRows[0];
|
|
|
|
assert.ok(revision && typeof revision.id === "string");
|
|
const related = await database.query<{ tableName: string; columnName: string }>(
|
|
"SELECT DISTINCT TABLE_NAME AS tableName,COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME IN ('resources','resource_revisions')",
|
|
{ type: QueryTypes.SELECT }
|
|
);
|
|
const children = new Map<string, Record<string, unknown>[]>();
|
|
|
|
for (const table of new Set(related.map((row) => row.tableName))) {
|
|
if (table === "resource_revisions") {
|
|
continue;
|
|
}
|
|
assert.match(table, /^[a-z_]+$/u);
|
|
const fields = related
|
|
.filter((row) => row.tableName === table)
|
|
.map((row) => row.columnName);
|
|
|
|
for (const field of fields) {
|
|
assert.match(field, /^[A-Za-z_]+$/u);
|
|
}
|
|
const rows = await database.query<Record<string, unknown>>(
|
|
`SELECT * FROM ${table} WHERE ${fields.map((field) => `\`${field}\` IN (:parents)`).join(" OR ")}`,
|
|
{ replacements: { parents: [global.id, revision.id] }, type: QueryTypes.SELECT }
|
|
);
|
|
|
|
if (rows.length) {
|
|
children.set(table, rows);
|
|
}
|
|
}
|
|
let total = 2;
|
|
|
|
for (const size of [10, 100, 1000, 10000]) {
|
|
await database.transaction(async (transaction) => {
|
|
while (total < size) {
|
|
const ids = Array.from({ length: Math.min(100, size - total) }, () => ({
|
|
root: UniqueID.generateNewID().toPrimitive(),
|
|
revision: UniqueID.generateNewID().toPrimitive(),
|
|
}));
|
|
|
|
const copy = (row: Record<string, unknown>, pair: { root: string; revision: string }) =>
|
|
Object.fromEntries(
|
|
Object.entries(row).map(([key, value]) => [
|
|
key,
|
|
value === global.id ? pair.root : value === revision.id ? pair.revision : value,
|
|
])
|
|
);
|
|
|
|
await database.getQueryInterface().bulkInsert(
|
|
"resources",
|
|
ids.map((pair) => copy(root, pair)),
|
|
{ transaction }
|
|
);
|
|
await database.getQueryInterface().bulkInsert(
|
|
"resource_revisions",
|
|
ids.map((pair) => copy(revision, pair)),
|
|
{ transaction }
|
|
);
|
|
for (const [table, rows] of children) {
|
|
await database.getQueryInterface().bulkInsert(
|
|
table,
|
|
ids.flatMap((pair) => rows.map((row) => copy(row, pair))),
|
|
{ transaction }
|
|
);
|
|
}
|
|
total += ids.length;
|
|
}
|
|
});
|
|
const warm = await ordinary.request(endpoint);
|
|
|
|
assert.equal(warm.status, 200);
|
|
let queries = 0;
|
|
|
|
database.addHook("afterQuery", "r26-count", () => {
|
|
queries++;
|
|
});
|
|
try {
|
|
const started = performance.now();
|
|
const response = await ordinary.request(endpoint);
|
|
const elapsed = performance.now() - started;
|
|
|
|
assert.equal(response.status, 200);
|
|
const data = ApplicationCoincidencesResponseSchema.parse(await response.json());
|
|
|
|
assert.ok(data.scanned <= 200);
|
|
measurements.push({
|
|
globals: size,
|
|
elapsedMs: elapsed,
|
|
queries,
|
|
scanned: data.scanned,
|
|
complete: data.complete,
|
|
});
|
|
} finally {
|
|
database.removeHook("afterQuery", "r26-count");
|
|
}
|
|
}
|
|
});
|
|
const matching: { id: string; values: { active: boolean } }[] = [];
|
|
|
|
for (let index = 0; index < 24; index++) {
|
|
matching.push(await product("Ágil particular", index % 4 !== 0));
|
|
}
|
|
await product("Ágil particular Pro");
|
|
await product("Agil particular");
|
|
const provider = catalog.items.find((item) => item.builtinKey === "field:provider");
|
|
|
|
assert.ok(provider);
|
|
const ownValues = InventoryResponseSchema.parse(
|
|
await (await ordinary.request(`/resources/${company}`)).json()
|
|
).resources.find((item) => item.id === own)?.values;
|
|
|
|
assert.ok(ownValues);
|
|
assert.equal(
|
|
(
|
|
await ordinary.request(`/resources/${company}/${own}/update`, {
|
|
values: { ...ownValues, characteristics: { [provider.id]: "Fábrica" } },
|
|
})
|
|
).status,
|
|
200
|
|
);
|
|
|
|
const addProvider = async (value: string) => {
|
|
const response = await admin.request("/resource-global-applications", {
|
|
creationId: UniqueID.generateNewID().toPrimitive(),
|
|
values: {
|
|
...global.values,
|
|
name: "Ágil particular",
|
|
characteristics: { [provider.id]: value },
|
|
},
|
|
});
|
|
|
|
assert.equal(response.status, 201);
|
|
|
|
return GetGlobalApplicationAdminResponseSchema.parse(await response.json()).resource.id;
|
|
};
|
|
|
|
const strongId = await addProvider("Fábrica");
|
|
|
|
await addProvider("Otro proveedor");
|
|
const before = await snapshot();
|
|
|
|
await t.test(
|
|
"paginación determinista, inactivos y proyección mínima sin escribir",
|
|
async () => {
|
|
let cursor: string | null = null;
|
|
const ids: string[] = [];
|
|
let pages = 0;
|
|
|
|
do {
|
|
const response = await ordinary.request(
|
|
`${endpoint}?pageSize=7${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
const data = ApplicationCoincidencesResponseSchema.parse(await response.json());
|
|
|
|
assert.equal(data.source.id, own);
|
|
assert.equal(data.source.companyId, company);
|
|
assert.ok(data.scanned <= 200);
|
|
for (const item of data.items) {
|
|
assert.equal(
|
|
item.classification,
|
|
item.candidate.id === strongId ? "name-provider" : "name-only"
|
|
);
|
|
assert.ok(item.reasons.includes("same-normalized-name"));
|
|
assert.equal(Object.hasOwn(item.candidate, "notes"), false);
|
|
assert.equal(Object.hasOwn(item.candidate, "history"), false);
|
|
ids.push(item.candidate.id);
|
|
}
|
|
cursor = data.nextCursor;
|
|
pages++;
|
|
assert.ok(pages < 80);
|
|
} while (cursor);
|
|
assert.equal(new Set(ids).size, 25);
|
|
assert.deepEqual(ids, [...ids].sort());
|
|
assert.equal(
|
|
ids.filter((id) => matching.some((item) => item.id === id && !item.values.active)).length,
|
|
6
|
|
);
|
|
assert.deepEqual(await snapshot(), before);
|
|
}
|
|
);
|
|
await t.test("contexto, elegibilidad, validación, autenticación y cursores", async () => {
|
|
for (const resourceId of [
|
|
foreign,
|
|
os,
|
|
license,
|
|
certificate,
|
|
global.id,
|
|
UniqueID.generateNewID().toPrimitive(),
|
|
]) {
|
|
assert.equal(
|
|
(await admin.request(`/resource-application-coincidences/${company}/${resourceId}`))
|
|
.status,
|
|
404
|
|
);
|
|
}
|
|
assert.equal((await ordinary.request(`${endpoint}?pageSize=0`)).status, 400);
|
|
assert.equal((await ordinary.request(`${endpoint}?cursor=invalid`)).status, 400);
|
|
assert.equal((await ordinary.request(`${endpoint}?scope=global`)).status, 400);
|
|
const anonymous = await scopeApiClient(base);
|
|
|
|
assert.equal((await anonymous.request(endpoint)).status, 401);
|
|
const initial = ApplicationCoincidencesResponseSchema.parse(
|
|
await (await ordinary.request(endpoint)).json()
|
|
);
|
|
|
|
assert.ok(initial.nextCursor);
|
|
assert.equal(
|
|
(
|
|
await ordinary.request(
|
|
`${endpoint}?search=otro&cursor=${encodeURIComponent(initial.nextCursor)}`
|
|
)
|
|
).status,
|
|
400
|
|
);
|
|
assert.equal(
|
|
(
|
|
await ordinary.request(
|
|
`/resource-application-coincidences/${other}/${foreign}?cursor=${encodeURIComponent(initial.nextCursor)}`
|
|
)
|
|
).status,
|
|
400
|
|
);
|
|
const response = await ordinary.request(`${endpoint}?search=Pro`);
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(
|
|
ApplicationCoincidencesResponseSchema.parse(await response.json()).items.length,
|
|
0
|
|
);
|
|
assert.deepEqual(await snapshot(), before);
|
|
});
|
|
await t.test(
|
|
"uso local conserva producto, licencia, versión, configuración e historial",
|
|
async () => {
|
|
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, own);
|
|
assert.equal(read.history.length, 1);
|
|
const software = await (
|
|
await ordinary.request(`/software-installations/${company}`)
|
|
).json();
|
|
|
|
assert.match(JSON.stringify(software), /LOCAL-123/);
|
|
assert.match(JSON.stringify(software), new RegExp(own));
|
|
const page = ListApplicationCandidatesResponseSchema.parse(
|
|
await (
|
|
await ordinary.request(
|
|
`/resource-application-candidates/${company}?purpose=application-configuration&search=particular`
|
|
)
|
|
).json()
|
|
);
|
|
|
|
assert.ok(page.items.some((item) => item.id === own));
|
|
assert.ok(page.items.every((item) => item.scope === "company"));
|
|
assert.deepEqual(await snapshot(), before);
|
|
}
|
|
);
|
|
await t.test(
|
|
"catálogo cerrado no permite detección ni altera referencias existentes",
|
|
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: "false",
|
|
RESOURCE_GLOBAL_PROFILE_ENABLED: "false",
|
|
})
|
|
);
|
|
server = createApp(() => undefined, runtime).listen(address.port, "127.0.0.1");
|
|
await once(server, "listening");
|
|
assert.equal((await ordinary.request(endpoint)).status, 409);
|
|
assert.deepEqual(await snapshot(), before);
|
|
}
|
|
);
|
|
await writeScopeEvidence("coincidence-results.json", {
|
|
companies: 15,
|
|
globalVolume: 10028,
|
|
measurements,
|
|
mutationFree: true,
|
|
sourceLocalResource: own,
|
|
});
|
|
} finally {
|
|
server.close();
|
|
await once(server, "close");
|
|
runtime.stop();
|
|
await database.close();
|
|
}
|
|
});
|