584 lines
22 KiB
TypeScript
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();
|
|
}
|
|
});
|