2026-10-24 23:19:17 +00:00
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
|
import { once } from "node:events";
|
|
|
|
|
import { test } from "node:test";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
GetGlobalApplicationAdminResponseSchema,
|
|
|
|
|
GetGlobalApplicationResponseSchema,
|
|
|
|
|
GlobalApplicationCapabilitiesSchema,
|
|
|
|
|
GlobalApplicationTemplateResponseSchema,
|
|
|
|
|
InventoryResponseSchema,
|
2026-10-25 12:46:06 +00:00
|
|
|
ListCompanyApplicationsResponseSchema,
|
2026-10-24 23:19:17 +00:00
|
|
|
ListGlobalApplicationHistoryResponseSchema,
|
|
|
|
|
ListGlobalApplicationsResponseSchema,
|
|
|
|
|
ResolveApplicationReferencesResponseSchema,
|
2026-10-25 12:46:06 +00:00
|
|
|
ResourceResponseSchema,
|
2026-10-24 23:19:17 +00:00
|
|
|
} from "@metis/resources/common";
|
|
|
|
|
import { createResourceModule, 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 as apiClient } from "./resource-scope-api.client";
|
|
|
|
|
import { scopeHttpFixture, seedScopeVolume } from "./resource-scope-http.fixture";
|
|
|
|
|
|
|
|
|
|
test("R2.3: catálogo global autorizado, historia, idempotencia, privacidad y consumidores cerrados", async (t) => {
|
|
|
|
|
const environment = disposableScopeEnvironment();
|
|
|
|
|
const database = createDatabase(environment);
|
|
|
|
|
const companies = Array.from({ length: 15 }, () => UniqueID.generateNewID().toPrimitive());
|
|
|
|
|
const companyId = companies[0];
|
|
|
|
|
const other = companies[1];
|
|
|
|
|
const actor = UniqueID.generateNewID().toPrimitive();
|
|
|
|
|
|
|
|
|
|
assert.ok(companyId && other);
|
|
|
|
|
const fixture = await prepareMembershipFixture(
|
|
|
|
|
database,
|
|
|
|
|
environment.APP_ORIGIN,
|
|
|
|
|
actor,
|
|
|
|
|
companies
|
|
|
|
|
);
|
|
|
|
|
const closedOptions = { catalogEnabled: true, canManage: async () => false };
|
|
|
|
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
() =>
|
|
|
|
|
createResourceModule(
|
|
|
|
|
database,
|
|
|
|
|
fixture.authenticate,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
fixture.identity.requireGlobalResourceManager,
|
|
|
|
|
closedOptions
|
|
|
|
|
),
|
|
|
|
|
/R2.1b/
|
|
|
|
|
);
|
|
|
|
|
await migrateResourceScope(database);
|
|
|
|
|
const volumeHttp = await scopeHttpFixture(database, fixture);
|
|
|
|
|
const volume = await seedScopeVolume(fixture, volumeHttp, companyId);
|
|
|
|
|
const inventory = await fixture.resourceUseCases.get(companyId);
|
|
|
|
|
|
|
|
|
|
assert.ok(inventory.isSuccess);
|
|
|
|
|
const application = inventory.data.catalog.definitions.find(
|
|
|
|
|
(item) => item.stockKey.getOrUndefined() === "template:application"
|
|
|
|
|
);
|
|
|
|
|
const os = inventory.data.catalog.definitions.find(
|
|
|
|
|
(item) => item.stockKey.getOrUndefined() === "template:os"
|
|
|
|
|
);
|
|
|
|
|
const provider = inventory.data.catalog.definitions.find(
|
|
|
|
|
(item) => item.stockKey.getOrUndefined() === "field:provider"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.ok(application && os && provider);
|
|
|
|
|
const locals: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const owner of companies) {
|
|
|
|
|
for (let index = 0; index < (owner === companyId ? 50 : 1); index += 1) {
|
|
|
|
|
const saved = await fixture.resourceUseCases.save(
|
|
|
|
|
owner,
|
|
|
|
|
undefined,
|
|
|
|
|
{
|
|
|
|
|
name: `Aplicación local ${index + 1}`,
|
|
|
|
|
templateId: application.id.toPrimitive(),
|
|
|
|
|
active: true,
|
|
|
|
|
notes: "Local",
|
|
|
|
|
characteristics: {},
|
|
|
|
|
},
|
|
|
|
|
actor
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.ok(saved.isSuccess);
|
|
|
|
|
if (owner === companyId) {
|
|
|
|
|
locals.push(saved.data.resource.id.toPrimitive());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await volumeHttp.close();
|
|
|
|
|
let runtime = await createIdentityRuntime(
|
|
|
|
|
database,
|
|
|
|
|
readDatabaseEnvironment({ ...process.env, RESOURCE_GLOBAL_CATALOG_ENABLED: "true" })
|
|
|
|
|
);
|
|
|
|
|
const diagnostics: unknown[] = [];
|
|
|
|
|
const app = createApp((event) => {
|
|
|
|
|
if (event.event === "http_error") {
|
|
|
|
|
diagnostics.push(event);
|
|
|
|
|
}
|
|
|
|
|
}, runtime);
|
|
|
|
|
let server = app.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}`;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const admin = await apiClient(base);
|
|
|
|
|
const ordinary = await apiClient(base);
|
|
|
|
|
const anonymous = await apiClient(base);
|
|
|
|
|
const password = randomBytes(24).toString("hex");
|
|
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request("/auth/bootstrap", {
|
|
|
|
|
name: "Administrador de catálogo",
|
|
|
|
|
email: "catalog-admin@example.invalid",
|
|
|
|
|
password,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
201
|
|
|
|
|
);
|
|
|
|
|
await admin.refresh();
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request("/users", {
|
|
|
|
|
name: "Operador local",
|
|
|
|
|
email: "catalog-user@example.invalid",
|
|
|
|
|
password,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
201
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await ordinary.request("/auth/login", { email: "catalog-user@example.invalid", password }))
|
|
|
|
|
.status,
|
|
|
|
|
200
|
|
|
|
|
);
|
|
|
|
|
await ordinary.refresh();
|
|
|
|
|
const template = GlobalApplicationTemplateResponseSchema.parse(
|
|
|
|
|
await (await admin.request("/resource-global-applications/template")).json()
|
|
|
|
|
).specification;
|
|
|
|
|
const values = {
|
|
|
|
|
name: "Aplicación global Ágil %_",
|
|
|
|
|
templateId: template.templateId,
|
|
|
|
|
active: true,
|
|
|
|
|
notes: "Nota administrativa privada",
|
|
|
|
|
characteristics: { [provider.id.toPrimitive()]: "Fábrica sintética" },
|
|
|
|
|
};
|
|
|
|
|
const creationId = UniqueID.generateNewID().toPrimitive();
|
|
|
|
|
const body = { creationId, values };
|
|
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
ListGlobalApplicationsResponseSchema.parse(
|
|
|
|
|
await (await ordinary.request("/resource-global-applications")).json()
|
|
|
|
|
).items.length,
|
|
|
|
|
0
|
|
|
|
|
);
|
|
|
|
|
const created = await admin.request("/resource-global-applications", body);
|
|
|
|
|
|
|
|
|
|
assert.equal(created.status, 201, await created.clone().text());
|
|
|
|
|
let product = GetGlobalApplicationAdminResponseSchema.parse(await created.json()).resource;
|
|
|
|
|
const originalRevisionId = product.revisionId;
|
|
|
|
|
|
2026-10-25 12:46:06 +00:00
|
|
|
await t.test(
|
|
|
|
|
"catálogo de compañía combina ámbitos, pagina y permite altas locales aisladas",
|
|
|
|
|
async () => {
|
|
|
|
|
const path = `/resource-application-candidates/${companyId}/catalog`;
|
|
|
|
|
const firstResponse = await ordinary.request(`${path}?pageSize=20`);
|
|
|
|
|
|
|
|
|
|
assert.equal(firstResponse.status, 200);
|
|
|
|
|
const first = ListCompanyApplicationsResponseSchema.parse(await firstResponse.json());
|
|
|
|
|
const ids = first.items.map((item) => item.id);
|
|
|
|
|
let cursor = first.nextCursor;
|
|
|
|
|
|
|
|
|
|
while (cursor) {
|
|
|
|
|
const response = await ordinary.request(
|
|
|
|
|
`${path}?pageSize=20&cursor=${encodeURIComponent(cursor)}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(response.status, 200);
|
|
|
|
|
const page = ListCompanyApplicationsResponseSchema.parse(await response.json());
|
|
|
|
|
|
|
|
|
|
ids.push(...page.items.map((item) => item.id));
|
|
|
|
|
cursor = page.nextCursor;
|
|
|
|
|
}
|
|
|
|
|
assert.equal(ids.length, 51);
|
|
|
|
|
assert.equal(new Set(ids).size, 51);
|
|
|
|
|
assert.ok(ids.includes(creationId));
|
|
|
|
|
assert.ok(locals.every((id) => ids.includes(id)));
|
|
|
|
|
const foreign = ListCompanyApplicationsResponseSchema.parse(
|
|
|
|
|
await (
|
|
|
|
|
await ordinary.request(`/resource-application-candidates/${other}/catalog?pageSize=100`)
|
|
|
|
|
).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(foreign.items.length, 2);
|
|
|
|
|
assert.ok(foreign.items.some((item) => item.id === creationId && item.scope === "global"));
|
|
|
|
|
assert.ok(
|
|
|
|
|
foreign.items.every((item) => item.scope === "global" || item.companyId === other)
|
|
|
|
|
);
|
|
|
|
|
assert.equal((await anonymous.request(path)).status, 401);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await ordinary.request(
|
|
|
|
|
`/resource-application-candidates/${UniqueID.generateNewID().toPrimitive()}/catalog`
|
|
|
|
|
)
|
|
|
|
|
).status,
|
|
|
|
|
404
|
|
|
|
|
);
|
|
|
|
|
assert.equal((await ordinary.request(`${path}?unknown=value`)).status, 400);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await ordinary.request(
|
|
|
|
|
`/resource-application-candidates/${other}/catalog?pageSize=20&cursor=${encodeURIComponent(first.nextCursor ?? "invalid")}`
|
|
|
|
|
)
|
|
|
|
|
).status,
|
|
|
|
|
400
|
|
|
|
|
);
|
|
|
|
|
for (const item of first.items) {
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
Object.keys(item).sort(),
|
|
|
|
|
["id", "templateId", "name", "active", "kind", "scope", "companyId", "provider"].sort()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const createdLocal = await ordinary.request(`/resources/${companyId}`, {
|
|
|
|
|
values: {
|
|
|
|
|
name: "Aplicación exclusiva Ñ",
|
|
|
|
|
templateId: template.templateId,
|
|
|
|
|
active: true,
|
|
|
|
|
notes: "Privada",
|
|
|
|
|
characteristics: {},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.equal(createdLocal.status, 201);
|
|
|
|
|
const local = ResourceResponseSchema.parse(await createdLocal.json()).resource;
|
|
|
|
|
const found = ListCompanyApplicationsResponseSchema.parse(
|
|
|
|
|
await (await ordinary.request(`${path}?search=exclusiva`)).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(found.items.length, 1);
|
|
|
|
|
assert.equal(found.items[0]?.scope, "company");
|
|
|
|
|
assert.equal(found.items[0]?.companyId, companyId);
|
|
|
|
|
const absent = ListCompanyApplicationsResponseSchema.parse(
|
|
|
|
|
await (
|
|
|
|
|
await ordinary.request(
|
|
|
|
|
`/resource-application-candidates/${other}/catalog?search=exclusiva`
|
|
|
|
|
)
|
|
|
|
|
).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(absent.items.length, 0);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await ordinary.request(`/resources/${companyId}/${local.id}/status`, { active: false }))
|
|
|
|
|
.status,
|
|
|
|
|
200
|
|
|
|
|
);
|
|
|
|
|
const inactive = ListCompanyApplicationsResponseSchema.parse(
|
|
|
|
|
await (await ordinary.request(`${path}?active=false`)).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
inactive.items.map((item) => item.id),
|
|
|
|
|
[local.id]
|
|
|
|
|
);
|
|
|
|
|
const active = ListCompanyApplicationsResponseSchema.parse(
|
|
|
|
|
await (await ordinary.request(`${path}?active=true&search=exclusiva`)).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(active.items.length, 0);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await ordinary.request(`/resources/${companyId}/${local.id}/delete`, {})).status,
|
|
|
|
|
200
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-24 23:19:17 +00:00
|
|
|
await t.test(
|
|
|
|
|
"scope y plantilla persistidos, autorización real y proyección mínima",
|
|
|
|
|
async () => {
|
|
|
|
|
assert.equal(product.id, creationId);
|
|
|
|
|
assert.equal(product.scope, "global");
|
|
|
|
|
assert.equal(product.companyId, null);
|
|
|
|
|
const persisted = await database.query<{
|
|
|
|
|
scope: string;
|
|
|
|
|
companyId: null;
|
|
|
|
|
templateId: string;
|
|
|
|
|
}>("SELECT scope,companyId,templateId FROM resources WHERE id=:id", {
|
|
|
|
|
replacements: { id: creationId },
|
|
|
|
|
type: QueryTypes.SELECT,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(persisted, [
|
|
|
|
|
{ scope: "global", companyId: null, templateId: template.templateId },
|
|
|
|
|
]);
|
|
|
|
|
assert.equal((await anonymous.request("/resource-global-applications", body)).status, 401);
|
|
|
|
|
assert.equal((await ordinary.request("/resource-global-applications", body)).status, 403);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await ordinary.request(`/resource-global-applications/${creationId}/update`, { values }))
|
|
|
|
|
.status,
|
|
|
|
|
403
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await ordinary.request(`/resource-global-applications/${creationId}/status`, {
|
|
|
|
|
active: false,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
403
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await ordinary.request(`/resource-global-applications/${creationId}/admin`)).status,
|
|
|
|
|
403
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await ordinary.request(`/resource-global-applications/${creationId}/history`)).status,
|
|
|
|
|
403
|
|
|
|
|
);
|
|
|
|
|
const minimal = GetGlobalApplicationResponseSchema.parse(
|
|
|
|
|
await (await ordinary.request(`/resource-global-applications/${creationId}`)).json()
|
|
|
|
|
).resource;
|
|
|
|
|
|
|
|
|
|
assert.equal(minimal.provider, "Fábrica sintética");
|
|
|
|
|
for (const key of [
|
|
|
|
|
"notes",
|
|
|
|
|
"specification",
|
|
|
|
|
"history",
|
|
|
|
|
"actorId",
|
|
|
|
|
"companies",
|
|
|
|
|
"installations",
|
|
|
|
|
]) {
|
|
|
|
|
assert.equal(Object.hasOwn(minimal, key), false);
|
|
|
|
|
}
|
|
|
|
|
assert.equal(
|
|
|
|
|
GlobalApplicationCapabilitiesSchema.parse(
|
|
|
|
|
await (await ordinary.request("/resource-global-applications/capabilities")).json()
|
|
|
|
|
).canManage,
|
|
|
|
|
false
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
GlobalApplicationCapabilitiesSchema.parse(
|
|
|
|
|
await (await admin.request("/resource-global-applications/capabilities")).json()
|
|
|
|
|
).creationEnabled,
|
|
|
|
|
true
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request(
|
|
|
|
|
`/resource-global-applications/${creationId}/status`,
|
|
|
|
|
{ active: false },
|
|
|
|
|
{ token: "" }
|
|
|
|
|
)
|
|
|
|
|
).status,
|
|
|
|
|
403
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request(
|
|
|
|
|
`/resource-global-applications/${creationId}/status`,
|
|
|
|
|
{ active: false },
|
|
|
|
|
{ origin: "https://invalid.example" }
|
|
|
|
|
)
|
|
|
|
|
).status,
|
|
|
|
|
403
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await t.test("idempotencia de creationId y concurrencia sin duplicar historia", async () => {
|
|
|
|
|
const replay = await admin.request("/resource-global-applications", body);
|
|
|
|
|
|
|
|
|
|
assert.equal(replay.status, 200);
|
|
|
|
|
assert.equal(
|
|
|
|
|
GetGlobalApplicationAdminResponseSchema.parse(await replay.json()).resource.revisionId,
|
|
|
|
|
originalRevisionId
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request("/resource-global-applications", {
|
|
|
|
|
...body,
|
|
|
|
|
values: { ...values, name: "Otro producto" },
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await admin.request("/resource-global-applications", { ...body, creationId: locals[0] }))
|
|
|
|
|
.status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
const concurrentId = UniqueID.generateNewID().toPrimitive();
|
|
|
|
|
const responses = await Promise.all([
|
|
|
|
|
admin.request("/resource-global-applications", { ...body, creationId: concurrentId }),
|
|
|
|
|
admin.request("/resource-global-applications", { ...body, creationId: concurrentId }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(responses.map((response) => response.status).sort(), [200, 201]);
|
|
|
|
|
const history = ListGlobalApplicationHistoryResponseSchema.parse(
|
|
|
|
|
await (await admin.request(`/resource-global-applications/${concurrentId}/history`)).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(history.items.length, 1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await t.test("límites de plantilla, propietario y UUID; sin borrado global", async () => {
|
|
|
|
|
for (const extra of [
|
|
|
|
|
{ scope: "global" },
|
|
|
|
|
{ companyId },
|
|
|
|
|
{ actorId: actor },
|
|
|
|
|
{ isInitialAdmin: true },
|
|
|
|
|
]) {
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await admin.request("/resource-global-applications", { ...body, ...extra })).status,
|
|
|
|
|
400
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request("/resource-global-applications", {
|
|
|
|
|
...body,
|
|
|
|
|
creationId: UniqueID.generateNewID().toPrimitive(),
|
|
|
|
|
values: { ...values, templateId: os.id.toPrimitive() },
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request(`/resource-global-applications/${creationId}/update`, {
|
|
|
|
|
values: { ...values, templateId: os.id.toPrimitive() },
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await admin.request(`/resource-global-applications/${locals[0]}/update`, { values }))
|
|
|
|
|
.status,
|
|
|
|
|
404
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await admin.request(`/resource-global-applications/${creationId}/delete`, {})).status,
|
|
|
|
|
404
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await ordinary.request(`/resource-catalog/%67lobal/${template.templateId}/update`, {
|
|
|
|
|
values: application.values.data,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
403
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await t.test(
|
|
|
|
|
"edición y optimistic concurrency, snapshot original e idempotencia semántica",
|
|
|
|
|
async () => {
|
|
|
|
|
const responses = await Promise.all([
|
|
|
|
|
admin.request(`/resource-global-applications/${creationId}/update`, {
|
|
|
|
|
values: { ...values, name: "Nombre actualizado A" },
|
|
|
|
|
expectedRevisionId: originalRevisionId,
|
|
|
|
|
}),
|
|
|
|
|
admin.request(`/resource-global-applications/${creationId}/update`, {
|
|
|
|
|
values: { ...values, name: "Nombre actualizado B" },
|
|
|
|
|
expectedRevisionId: originalRevisionId,
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(responses.map((response) => response.status).sort(), [200, 409]);
|
|
|
|
|
product = GetGlobalApplicationAdminResponseSchema.parse(
|
|
|
|
|
await (await admin.request(`/resource-global-applications/${creationId}/admin`)).json()
|
|
|
|
|
).resource;
|
|
|
|
|
const unchanged = await admin.request(
|
|
|
|
|
`/resource-global-applications/${creationId}/update`,
|
|
|
|
|
{ values: product.values, expectedRevisionId: product.revisionId }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
GetGlobalApplicationAdminResponseSchema.parse(await unchanged.json()).resource.revisionId,
|
|
|
|
|
product.revisionId
|
|
|
|
|
);
|
|
|
|
|
const replay = await admin.request("/resource-global-applications", body);
|
|
|
|
|
|
|
|
|
|
assert.equal(replay.status, 200);
|
|
|
|
|
assert.equal(
|
|
|
|
|
GetGlobalApplicationAdminResponseSchema.parse(await replay.json()).resource.values.name,
|
|
|
|
|
product.values.name
|
|
|
|
|
);
|
|
|
|
|
const page = ListGlobalApplicationHistoryResponseSchema.parse(
|
|
|
|
|
await (
|
|
|
|
|
await admin.request(`/resource-global-applications/${creationId}/history?pageSize=1`)
|
|
|
|
|
).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(page.items.length, 1);
|
|
|
|
|
assert.ok(page.nextCursor);
|
|
|
|
|
const next = ListGlobalApplicationHistoryResponseSchema.parse(
|
|
|
|
|
await (
|
|
|
|
|
await admin.request(
|
|
|
|
|
`/resource-global-applications/${creationId}/history?pageSize=1&cursor=${encodeURIComponent(page.nextCursor)}`
|
|
|
|
|
)
|
|
|
|
|
).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(next.items[0]?.id, originalRevisionId);
|
|
|
|
|
assert.equal(next.items[0]?.values.name, values.name);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await t.test("desactivar/reactivar conserva UUID e historia y excluye candidatos", async () => {
|
|
|
|
|
const disabled = await admin.request(`/resource-global-applications/${creationId}/status`, {
|
|
|
|
|
active: false,
|
|
|
|
|
expectedRevisionId: product.revisionId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
product = GetGlobalApplicationAdminResponseSchema.parse(await disabled.json()).resource;
|
|
|
|
|
assert.equal(product.id, creationId);
|
|
|
|
|
assert.equal(product.values.active, false);
|
|
|
|
|
const repeated = await admin.request(`/resource-global-applications/${creationId}/status`, {
|
|
|
|
|
active: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
GetGlobalApplicationAdminResponseSchema.parse(await repeated.json()).resource.revisionId,
|
|
|
|
|
product.revisionId
|
|
|
|
|
);
|
|
|
|
|
const resolve = await ordinary.request(
|
|
|
|
|
`/resource-application-references/${companyId}/resolve`,
|
|
|
|
|
{ purpose: "software-installation", ids: [creationId] }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
ResolveApplicationReferencesResponseSchema.parse(await resolve.json()).items[0]?.status,
|
|
|
|
|
"resolved"
|
|
|
|
|
);
|
|
|
|
|
const reactivated = await admin.request(
|
|
|
|
|
`/resource-global-applications/${creationId}/status`,
|
|
|
|
|
{ active: true }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
product = GetGlobalApplicationAdminResponseSchema.parse(await reactivated.json()).resource;
|
|
|
|
|
assert.equal(product.values.active, true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await t.test(
|
|
|
|
|
"colección paginada, homónimos y búsqueda literal sin N+1 de cliente",
|
|
|
|
|
async () => {
|
|
|
|
|
for (let index = 0; index < 28; index += 1) {
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request("/resource-global-applications", {
|
|
|
|
|
creationId: UniqueID.generateNewID().toPrimitive(),
|
|
|
|
|
values: {
|
|
|
|
|
...values,
|
|
|
|
|
name: `Producto Ágil %_ ${index % 7}`,
|
|
|
|
|
active: index % 3 !== 0,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
201
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
let cursor: string | null = null;
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
const response = await ordinary.request(
|
|
|
|
|
`/resource-global-applications?search=${encodeURIComponent("ágil %_")}&pageSize=7${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`
|
|
|
|
|
);
|
|
|
|
|
const page = ListGlobalApplicationsResponseSchema.parse(await response.json());
|
|
|
|
|
|
|
|
|
|
for (const item of page.items) {
|
|
|
|
|
assert.equal(seen.has(item.id), false);
|
|
|
|
|
seen.add(item.id);
|
|
|
|
|
}
|
|
|
|
|
cursor = page.nextCursor;
|
|
|
|
|
} while (cursor);
|
|
|
|
|
assert.equal(seen.size, 29);
|
|
|
|
|
assert.ok(
|
|
|
|
|
ListGlobalApplicationsResponseSchema.parse(
|
|
|
|
|
await (await ordinary.request("/resource-global-applications?active=false")).json()
|
|
|
|
|
).items.length > 0
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await t.test(
|
|
|
|
|
"rollback por error de infraestructura y recuperación por el mismo creationId",
|
|
|
|
|
async () => {
|
|
|
|
|
let inject = true;
|
|
|
|
|
const failedId = UniqueID.generateNewID().toPrimitive();
|
|
|
|
|
|
|
|
|
|
database.addHook("afterQuery", "r23-infrastructure-failure", (_options, query) => {
|
|
|
|
|
const sql: unknown = Reflect.get(query, "sql");
|
|
|
|
|
|
|
|
|
|
if (inject && typeof sql === "string" && sql.startsWith("INSERT INTO `resources`")) {
|
|
|
|
|
inject = false;
|
|
|
|
|
throw new Error("Fallo sintético después de insertar raíz.");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
const response = await admin.request("/resource-global-applications", {
|
|
|
|
|
creationId: failedId,
|
|
|
|
|
values,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.equal(response.status, 500);
|
|
|
|
|
const data = await response.text();
|
|
|
|
|
|
|
|
|
|
assert.match(data, /INTERNAL_ERROR/);
|
|
|
|
|
assert.equal(data.includes("INSERT"), false);
|
|
|
|
|
} finally {
|
|
|
|
|
database.removeHook("afterQuery", "r23-infrastructure-failure");
|
|
|
|
|
}
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await admin.request(`/resource-global-applications/${failedId}/admin`)).status,
|
|
|
|
|
404
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await admin.request("/resource-global-applications", { creationId: failedId, values }))
|
|
|
|
|
.status,
|
|
|
|
|
201
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await t.test(
|
|
|
|
|
"propagación autorizada global/local y campos nuevos sin reescribir snapshots",
|
|
|
|
|
async () => {
|
|
|
|
|
const response = await admin.request("/resource-catalog/global", {
|
|
|
|
|
values: {
|
|
|
|
|
kind: "field",
|
|
|
|
|
name: "Código adicional de producto",
|
|
|
|
|
description: "Campo sintético",
|
|
|
|
|
type: "text",
|
|
|
|
|
unit: "",
|
|
|
|
|
choices: [],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const parsed = await response.json();
|
|
|
|
|
const fieldId = z.object({ savedId: z.uuid() }).parse(parsed).savedId;
|
|
|
|
|
|
|
|
|
|
assert.equal(response.status, 201);
|
|
|
|
|
assert.equal(application.values.data.kind, "template");
|
|
|
|
|
if (application.values.data.kind !== "template") {
|
|
|
|
|
throw new Error("Plantilla inesperada.");
|
|
|
|
|
}
|
|
|
|
|
const changed = await admin.request(
|
|
|
|
|
`/resource-catalog/global/${template.templateId}/update`,
|
|
|
|
|
{
|
|
|
|
|
values: {
|
|
|
|
|
...application.values.data,
|
|
|
|
|
fields: [...application.values.data.fields, { fieldId, required: true }],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(changed.status, 200, await changed.clone().text());
|
|
|
|
|
product = GetGlobalApplicationAdminResponseSchema.parse(
|
|
|
|
|
await (await admin.request(`/resource-global-applications/${creationId}/admin`)).json()
|
|
|
|
|
).resource;
|
|
|
|
|
assert.ok(product.specification.fields.some((field) => field.fieldId === fieldId));
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request(`/resource-global-applications/${creationId}/update`, {
|
|
|
|
|
values: product.values,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request(`/resource-global-applications/${creationId}/status`, {
|
|
|
|
|
active: false,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
200
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request(`/resource-global-applications/${creationId}/status`, {
|
|
|
|
|
active: true,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
const historical = ListGlobalApplicationHistoryResponseSchema.parse(
|
|
|
|
|
await (
|
|
|
|
|
await admin.request(`/resource-global-applications/${creationId}/history?pageSize=100`)
|
|
|
|
|
).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
historical.items.some(
|
|
|
|
|
(item) =>
|
|
|
|
|
item.id === originalRevisionId &&
|
|
|
|
|
!item.specification?.fields.some((field) => field.fieldId === fieldId)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await t.test("CRUD local y consumidores continúan locales tras existir catálogo", async () => {
|
|
|
|
|
const inventory = InventoryResponseSchema.parse(
|
|
|
|
|
await (await ordinary.request(`/resources/${companyId}`)).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(inventory.resources.length, 50);
|
|
|
|
|
assert.ok(
|
|
|
|
|
inventory.resources.every(
|
|
|
|
|
(item) => item.companyId === companyId && !Object.hasOwn(item, "scope")
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request(`/software-installations/${companyId}`, {
|
|
|
|
|
values: {
|
|
|
|
|
assetId: volume.assetIds[0],
|
|
|
|
|
resourceId: creationId,
|
|
|
|
|
licenseResourceId: null,
|
|
|
|
|
version: null,
|
|
|
|
|
state: "configured",
|
|
|
|
|
notes: null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request(`/application-configurations/${companyId}`, {
|
|
|
|
|
values: {
|
|
|
|
|
profileId: volume.profileIds[0],
|
|
|
|
|
resourceId: creationId,
|
|
|
|
|
delivery: "installed",
|
|
|
|
|
version: null,
|
|
|
|
|
state: "configured",
|
|
|
|
|
notes: null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
// La plantilla compartida vuelve a su contrato inicial antes de otras suites del contenedor.
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await admin.request(`/resource-catalog/global/${template.templateId}/update`, {
|
|
|
|
|
values: application.values.data,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
200
|
|
|
|
|
);
|
|
|
|
|
await t.test(
|
|
|
|
|
"reinicio HTTP: replay persistido y cierre de altas/edición/reactivación con lectura conservada",
|
|
|
|
|
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" })
|
|
|
|
|
);
|
|
|
|
|
server = createApp(() => undefined, runtime).listen(0, "127.0.0.1");
|
|
|
|
|
await once(server, "listening");
|
|
|
|
|
const restartedAddress = server.address();
|
|
|
|
|
|
|
|
|
|
assert.ok(restartedAddress && typeof restartedAddress !== "string");
|
|
|
|
|
const restarted = await apiClient(`http://127.0.0.1:${restartedAddress.port}`);
|
|
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await restarted.request("/auth/login", {
|
|
|
|
|
email: "catalog-admin@example.invalid",
|
|
|
|
|
password,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
200
|
|
|
|
|
);
|
|
|
|
|
await restarted.refresh();
|
|
|
|
|
assert.equal((await restarted.request("/resource-global-applications", body)).status, 200);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await restarted.request("/resource-global-applications", {
|
|
|
|
|
...body,
|
|
|
|
|
creationId: UniqueID.generateNewID().toPrimitive(),
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await restarted.request(`/resource-global-applications/${creationId}/admin`)).status,
|
|
|
|
|
200
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await restarted.request(`/resource-global-applications/${creationId}/update`, {
|
|
|
|
|
values,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await restarted.request(`/resource-global-applications/${creationId}/status`, {
|
|
|
|
|
active: false,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
200
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await restarted.request(`/resource-global-applications/${creationId}/status`, {
|
|
|
|
|
active: true,
|
|
|
|
|
})
|
|
|
|
|
).status,
|
|
|
|
|
409
|
|
|
|
|
);
|
|
|
|
|
const history = ListGlobalApplicationHistoryResponseSchema.parse(
|
|
|
|
|
await (
|
|
|
|
|
await restarted.request(
|
|
|
|
|
`/resource-global-applications/${creationId}/history?pageSize=1`
|
|
|
|
|
)
|
|
|
|
|
).json()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(history.items.length, 1);
|
|
|
|
|
const lastCursor = Buffer.from(
|
|
|
|
|
JSON.stringify({ version: 1, resourceId: creationId, position: 0 })
|
|
|
|
|
).toString("base64url");
|
|
|
|
|
const empty = await restarted.request(
|
|
|
|
|
`/resource-global-applications/${creationId}/history?cursor=${lastCursor}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.equal(empty.status, 200);
|
|
|
|
|
assert.equal(
|
|
|
|
|
ListGlobalApplicationHistoryResponseSchema.parse(await empty.json()).items.length,
|
|
|
|
|
0
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
await writeScopeEvidence("catalog-results.json", {
|
|
|
|
|
companies: 15,
|
|
|
|
|
principal: { resources: 50, people: 50, assets: 50, profiles: 50, workstations: 50 },
|
|
|
|
|
publicGlobalWrites: "authorized initial administrator",
|
|
|
|
|
globalConsumers: false,
|
|
|
|
|
originalId: creationId,
|
|
|
|
|
originalRevisionId,
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
await writeScopeEvidence("http-error-diagnostics.json", diagnostics);
|
|
|
|
|
await new Promise<void>((resolve, reject) =>
|
|
|
|
|
server.close((error) => (error ? reject(error) : resolve()))
|
|
|
|
|
);
|
|
|
|
|
runtime.stop();
|
|
|
|
|
await database.close();
|
|
|
|
|
}
|
|
|
|
|
});
|