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> ) { 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, 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)[] = []; 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(); } });