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

584 lines
22 KiB
TypeScript

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { test } from "node:test";
import { serialize } from "node:v8";
import { SupportListSchema, SupportResponseSchema } from "@metis/assets/common";
import { InventoryResponseSchema, ResourceResponseSchema } from "@metis/resources/common";
import { createResourceModule } from "@metis/resources/module";
import type { Sequelize } from "sequelize";
import { QueryTypes, Sequelize as SequelizeDatabase } from "sequelize";
import { ResourceUseCases } from "../../../modules/resources/src/api/application/resource.use-cases";
import { buildScopedResourceResponse } from "../../../modules/resources/src/api/application/resource-response.builder";
import type { Resource } from "../../../modules/resources/src/api/domain/resource.aggregate";
import { ResourceDashboardReader } from "../../../modules/resources/src/api/infrastructure/persistence/resource-dashboard-reader";
import {
readInventoryByIds,
writeInventory,
} from "../../../modules/resources/src/api/infrastructure/persistence/resource-inventory-relational";
import { ResourcePersistenceMapper } from "../../../modules/resources/src/api/infrastructure/persistence/resource-persistence.mapper";
import {
migrateResourceScope,
resourceScopeMigrationSteps,
} from "../../../modules/resources/src/api/infrastructure/persistence/resource-scope-migration";
import {
ResourceScopeSchemaReader,
inspectResourceScopeSchema,
resourceScopeOwnerClause,
} from "../../../modules/resources/src/api/infrastructure/persistence/resource-scope-schema";
import { SequelizeResourceRepository } from "../../../modules/resources/src/api/infrastructure/persistence/sequelize-resource.repository";
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
import { prepareMembershipFixture } from "./asset-directory-memberships.fixture";
import { createDatabase } from "./config/database";
import { seedGlobal } from "./resource-global.fixture";
import {
disposableScopeEnvironment,
scopeSchemaEvidence,
writeScopeEvidence,
} from "./resource-scope.preflight";
import {
captureScopeBackup,
prepareScopeBackup,
restoreLegacyResourceScope,
} from "./resource-scope-backup.fixture";
import { seedScopeConsumers } from "./resource-scope-consumers.fixture";
import { scopeHttpFixture, seedScopeVolume } from "./resource-scope-http.fixture";
async function assertPreserved(
database: Sequelize,
before: Awaited<ReturnType<typeof captureScopeBackup>>
) {
const after = await captureScopeBackup(database);
const proof = before.map((table) => {
const current = after.find((entry) => entry.name === table.name);
assert.ok(current);
assert.deepEqual(current.keys, table.keys);
const rows = current.rows.map((row) => {
if (table.name !== "resources") {
return row;
}
assert.equal(row.scope, "company");
const { scope: _scope, ...legacy } = row;
return legacy;
});
assert.deepEqual(rows, table.rows);
return {
table: table.name,
beforeCount: table.rows.length,
afterCount: rows.length,
beforeSha256: createHash("sha256").update(serialize(table.rows)).digest("hex"),
afterSha256: createHash("sha256").update(serialize(rows)).digest("hex"),
};
});
await writeScopeEvidence("data-preservation.json", proof);
}
async function assertSqlScopes(
database: Sequelize,
global: Record<string, unknown>,
ownerId: string
) {
for (const [scope, companyId] of [
["company", null],
["global", ownerId],
["other", null],
["GLOBAL", null],
["global ", null],
["global\t", null],
["", null],
["Company", null],
[null, null],
] as const) {
await assert.rejects(() =>
database
.getQueryInterface()
.bulkInsert("resources", [
{ ...global, id: UniqueID.generateNewID().toPrimitive(), scope, companyId },
])
);
}
await assert.rejects(
() =>
database
.getQueryInterface()
.bulkInsert("resources", [
{ ...global, id: UniqueID.generateNewID().toPrimitive(), integrationKind: "mailbox" },
]),
/resource_global_no_integration/
);
await assert.rejects(() =>
database.getQueryInterface().bulkInsert("resources", [
{
...global,
id: UniqueID.generateNewID().toPrimitive(),
accountId: UniqueID.generateNewID().toPrimitive(),
},
])
);
}
test("R2.1b: instalación, backfill, FKs, recuperación completa e integración sobre MariaDB desechable", async (t) => {
const database = createDatabase(disposableScopeEnvironment());
const actor = UniqueID.generateNewID().toPrimitive();
const companies = Array.from({ length: 15 }, () => UniqueID.generateNewID().toPrimitive());
const company = companies[0];
const other = companies[1];
assert.ok(company && other);
const closeHttp: (() => Promise<void>)[] = [];
const verified = { sql: false, localWriter: false, httpConsumers: false, backupRestore: false };
try {
const fixture = await prepareMembershipFixture(
database,
"http://localhost:5173",
actor,
companies
);
let http = await scopeHttpFixture(database, fixture);
closeHttp.push(http.close);
await http.close();
await t.test("instalación nueva y repetición sin registros", async () => {
assert.equal((await migrateResourceScope(database)).mode, "scoped");
assert.deepEqual((await migrateResourceScope(database)).completed, []);
await restoreLegacyResourceScope(database);
});
http = await scopeHttpFixture(database, fixture);
closeHttp.push(http.close);
const volume = await seedScopeVolume(fixture, http, company);
const resources: Resource[] = [];
const catalog = await fixture.resourceUseCases.get(company);
assert.ok(catalog.isSuccess);
const template = catalog.data.catalog.definitions.find(
(definition) => definition.stockKey.getOrUndefined() === "template:application"
);
assert.ok(template);
for (const owner of companies) {
for (let index = 0; index < (owner === company ? 45 : 1); index += 1) {
const saved = await fixture.resourceUseCases.save(
owner,
undefined,
{
name: `Aplicación sintética con tildes ${index + 1}`,
templateId: template.id.toPrimitive(),
active: index % 3 !== 0,
notes: "Historial preservado",
characteristics: {},
},
actor
);
assert.ok(saved.isSuccess);
resources.push(saved.data.resource);
}
}
const first = resources[0];
assert.ok(first);
assert.ok(
(await fixture.resourceUseCases.setActive(company, first.id.toPrimitive(), true, actor))
.isSuccess
);
assert.ok(volume.assetIds[1]);
await seedScopeConsumers(
fixture,
http,
company,
first.id.toPrimitive(),
volume.assetIds[1],
volume.profileIds
);
await fixture.asset(other);
await http.close();
const backup = await prepareScopeBackup(database);
try {
const beforeEvidence = await scopeSchemaEvidence(database);
await writeScopeEvidence("schema-before.json", beforeEvidence);
const recovery: { interruptedAfter: number; resumed: string[]; rowsPreserved: boolean }[] =
[];
for (const [index, step] of Object.values(resourceScopeMigrationSteps).entries()) {
await t.test(
`interrupción después del paso ${index + 1}, reanudación y preservación`,
async () => {
await restoreLegacyResourceScope(database);
for (const previous of Object.values(resourceScopeMigrationSteps).slice(0, index)) {
await database.query(previous);
}
await database.query(step);
const schema = await inspectResourceScopeSchema(database);
if (index < 3) {
assert.equal(schema.mode, "partial");
await assert.rejects(() => new ResourceScopeSchemaReader(database).read(), /parcial/);
await assert.rejects(
() => createResourceModule(database, fixture.authenticate),
/parcial/
);
}
const resumed = await migrateResourceScope(database);
assert.deepEqual((await migrateResourceScope(database)).completed, []);
await assertPreserved(database, backup.snapshot);
recovery.push({
interruptedAfter: index + 1,
resumed: resumed.completed,
rowsPreserved: true,
});
}
);
}
await writeScopeEvidence("recovery.json", recovery);
await writeScopeEvidence("schema-after.json", await scopeSchemaEvidence(database));
await t.test("esquema incompatible falla sin ejecutar DDL y puede restaurarse", async () => {
await restoreLegacyResourceScope(database);
await database.query("ALTER TABLE resources ADD COLUMN scope VARCHAR(20) NULL");
const incompatible = await captureScopeBackup(database);
await assert.rejects(() => migrateResourceScope(database), /incompatible/);
assert.deepEqual(await captureScopeBackup(database), incompatible);
await database.query("ALTER TABLE resources DROP COLUMN scope");
await migrateResourceScope(database);
});
await t.test(
"CHECK desactivado se detecta antes de DDL y CHECK falsificado no se acepta",
async () => {
const environment = disposableScopeEnvironment();
const guarded = new SequelizeDatabase(
environment.DB_NAME,
environment.DB_USER,
environment.DB_PASSWORD,
{
dialect: "mariadb",
host: environment.DB_HOST,
port: environment.DB_PORT,
logging: false,
pool: { max: 1, min: 0 },
}
);
try {
await guarded.query("SET SESSION check_constraint_checks=0");
await assert.rejects(() => migrateResourceScope(guarded), /requiere CHECK/);
await guarded.query("SET SESSION check_constraint_checks=1");
} finally {
await guarded.close();
}
await restoreLegacyResourceScope(database);
await database.query(resourceScopeMigrationSteps.scope);
for (const clause of ["1", resourceScopeOwnerClause.replace("'global'", "'GLOBAL'")]) {
await database.query(
`ALTER TABLE resources ADD CONSTRAINT resource_scope_owner CHECK (${clause})`
);
await assert.rejects(() => migrateResourceScope(database), /definición inesperada/);
await database.query("ALTER TABLE resources DROP CONSTRAINT resource_scope_owner");
}
await database.query("ALTER TABLE resources DROP COLUMN scope");
await migrateResourceScope(database);
}
);
await migrateResourceScope(database);
assert.deepEqual(
(await scopeSchemaEvidence(database)).foreignKeys,
beforeEvidence.foreignKeys
);
assert.deepEqual((await scopeSchemaEvidence(database)).indexes, beforeEvidence.indexes);
await t.test("FK compuesta ausente bloquea el migrador sin debilitar garantías", async () => {
await database
.getQueryInterface()
.removeConstraint("resource_network_shares", "network_share_resource");
try {
await assert.rejects(() => migrateResourceScope(database), /falta FK compatible/);
} finally {
await database.query(
"ALTER TABLE resource_network_shares ADD CONSTRAINT network_share_resource FOREIGN KEY(resourceId,companyId,templateId) REFERENCES resources(id,companyId,templateId) ON UPDATE RESTRICT ON DELETE RESTRICT"
);
}
assert.deepEqual(
(await scopeSchemaEvidence(database)).foreignKeys,
beforeEvidence.foreignKeys
);
});
const global = await seedGlobal(database, first.id.toPrimitive());
await t.test("CHECK real, lectura interna global e inmutabilidad de escritura", async () => {
await assertSqlScopes(database, global, company);
await database.transaction(async (transaction) => {
const loaded = await readInventoryByIds(database, [global.id], transaction);
const row = loaded.rows[0];
assert.ok(row);
const mapper = new ResourcePersistenceMapper();
const resource = mapper.toScopedDomain(row, loaded.revisions);
assert.equal(resource.scope, "global");
assert.equal(resource.companyId, null);
assert.equal(resource.history.length, 2);
assert.equal(buildScopedResourceResponse(resource, new Map()).scope, "global");
assert.throws(() => mapper.toScopedPersistence(resource), /RESOURCE_SCOPE_UNSUPPORTED/);
const local = resources.find((item) => item.requireCompanyId().toPrimitive() === other);
assert.ok(local);
const candidate = mapper.toScopedPersistence(local);
candidate.resource.id = first.id.toPrimitive();
await assert.rejects(
() => writeInventory(database, candidate, new Set(), transaction),
/RESOURCE_OWNERSHIP_FIXED/
);
});
verified.sql = true;
});
const useCases = new ResourceUseCases(new SequelizeResourceRepository(database));
await t.test(
"escritor scoped local, DTO legacy y aislamiento con global físico",
async () => {
for (const owner of companies) {
const read = await useCases.get(owner);
assert.ok(read.isSuccess);
assert.equal(read.data.resources.length, owner === company ? 50 : 1);
assert.ok(
read.data.resources.every((item) => item.requireCompanyId().toPrimitive() === owner)
);
}
const saved = await useCases.save(
company,
undefined,
{ ...first.values.toPrimitive(), active: true, name: "Alta local tras expansión" },
actor
);
assert.ok(saved.isSuccess);
const status = await useCases.setActive(
company,
saved.data.resource.id.toPrimitive(),
false,
actor
);
assert.ok(status.isSuccess);
const stored = await database.query<{ scope: string; companyId: string }>(
"SELECT scope,companyId FROM resources WHERE id=:id",
{ replacements: { id: saved.data.resource.id.toPrimitive() }, type: QueryTypes.SELECT }
);
assert.deepEqual(stored, [{ scope: "company", companyId: company }]);
assert.equal(status.data.resource.history.length, 2);
assert.ok(
(await useCases.remove(company, saved.data.resource.id.toPrimitive())).isSuccess
);
assert.equal(
(await useCases.setActive(company, global.id, false, actor)).isSuccess,
false
);
const dashboard = new ResourceDashboardReader(database);
assert.equal(
(await dashboard.stats()).reduce((sum, entry) => sum + Number(entry.count), 0),
64
);
assert.equal((await dashboard.entries()).total, 64);
verified.localWriter = true;
}
);
http = await scopeHttpFixture(database, fixture);
closeHttp.push(http.close);
try {
await t.test(
"HTTP local y consumidores: software, licencia, perfil, soporte y rechazo global",
async () => {
const list = await http.request(`/resources/${company}`);
const listed = InventoryResponseSchema.parse(await list.json());
assert.equal(listed.resources.length, 50);
assert.ok(listed.resources.every((item) => !Object.hasOwn(item, "scope")));
const added = await http.request(`/resources/${company}`, {
values: { ...first.values.toPrimitive(), name: "HTTP tras migración" },
});
const created = ResourceResponseSchema.parse(await added.json()).resource;
assert.equal(added.status, 201);
assert.equal(
(await http.request(`/resources/${company}/${global.id}/status`, { active: false }))
.status,
404
);
assert.equal(
(
await http.request(`/resources/${company}`, {
values: first.values.toPrimitive(),
scope: "global",
})
).status,
400
);
await http.consumers(company, other);
const supportPath = `/asset-support/${company}/${volume.assetIds[1]}`;
const support = SupportListSchema.parse(await (await http.request(supportPath)).json());
const connection = support.connections[0];
assert.ok(connection);
assert.equal(connection.history.length, 1);
assert.ok(support.tools.every((item) => item.id !== global.id));
const editedSupport = await http.request(`${supportPath}/${connection.id}/update`, {
values: { ...connection.values, notes: "Edición local tras DDL" },
});
assert.equal(editedSupport.status, 200);
assert.equal(
SupportResponseSchema.parse(await editedSupport.json()).connection.history.length,
2
);
const rejectedSupport = await http.request(supportPath, {
values: { ...connection.values, resourceId: global.id },
});
assert.equal(rejectedSupport.status, 409);
assert.match(await rejectedSupport.text(), /TOOL_UNAVAILABLE/);
for (const resourceId of [
global.id,
resources
.find((item) => item.requireCompanyId().toPrimitive() === other)
?.id.toPrimitive(),
]) {
assert.ok(resourceId);
assert.equal(
(
await http.request(`/software-installations/${company}`, {
values: {
assetId: volume.assetIds[0],
resourceId,
licenseResourceId: null,
version: null,
state: "configured",
notes: null,
},
})
).status,
404
);
assert.equal(
(
await http.request(`/application-configurations/${company}`, {
values: {
profileId: volume.profileIds[0],
resourceId,
delivery: "installed",
version: null,
state: "configured",
notes: null,
},
})
).status,
404
);
}
assert.equal(created.companyId, company);
const blocked = await http.request(
`/resource-catalog/global/${template.id.toPrimitive()}/update`,
{
values: {
...template.values.data,
name: "Plantilla con propagación global bloqueada",
},
}
);
// R2.2 deniega la mutación global al operador antes de intentar propagar.
assert.equal(blocked.status, 403);
assert.match(await blocked.text(), /FORBIDDEN/);
assert.equal(
(
await http.request(`/software-installations/${company}`, {
values: {
assetId: volume.assetIds[1],
resourceId: first.id.toPrimitive(),
licenseResourceId: global.id,
version: null,
state: "configured",
notes: null,
},
})
).status,
404
);
verified.httpConsumers = true;
}
);
} finally {
await http.close();
}
const plans = {};
for (const [name, sql] of Object.entries({
company: "SELECT * FROM resources WHERE companyId=:companyId",
id: "SELECT * FROM resources WHERE id=:id",
history: "SELECT * FROM resource_revisions WHERE resourceId=:id ORDER BY position",
scope: "SELECT id FROM resources WHERE scope='global'",
})) {
Object.assign(plans, {
[name]: await database.query(`EXPLAIN ${sql}`, {
replacements: { companyId: other, id: first.id.toPrimitive() },
type: QueryTypes.SELECT,
}),
});
}
await writeScopeEvidence("explain.json", plans);
await t.test(
"rollback con global: restaurar NOT NULL es rechazado; copia completa recupera legacy",
async () => {
await assert.rejects(() => restoreLegacyResourceScope(database), /existen globales/);
await assert.rejects(() =>
database.query(
"ALTER TABLE resources MODIFY COLUMN companyId CHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL"
)
);
await backup.restore();
assert.equal((await inspectResourceScopeSchema(database)).mode, "legacy");
assert.deepEqual(await captureScopeBackup(database), backup.snapshot);
verified.backupRestore = true;
}
);
await writeScopeEvidence("integration-results.json", {
companies: 15,
principal: {
people: 50,
assets: 50,
resourcesBeforeMigration: 50,
profiles: 50,
workstations: 50,
},
resourcesBeforeMigration: 64,
syntheticGlobal: 1,
recoveryCases: 4,
verified,
});
} finally {
await backup.cleanup();
}
} finally {
for (const close of closeHttp) {
await close();
}
await database.close();
}
});