import assert from "node:assert/strict"; import { once } from "node:events"; import { test } from "node:test"; import { createAssetModule } from "@metis/assets/module"; import { CompaniesResponseSchema, CompanyErrorResponseSchema, CompanyResponseSchema, } from "@metis/companies/common"; import { createCompanyModule } from "@metis/companies/module"; import { IdentityErrorResponseSchema, SessionResponseSchema, UserResponseSchema, UsersResponseSchema, } from "@metis/identity/common"; import { QueryTypes } from "sequelize"; import { verifyAccess } from "./access.integration"; import { createApp } from "./app"; import { verifyAssets } from "./assets.integration"; import { createDatabase, readDatabaseEnvironment } from "./config/database"; import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; import { verifyInventory } from "./inventory.integration"; import { verifyMail } from "./mail.integration"; import { verifyRelationships } from "./relationships.integration"; import { verifyWorkProfiles } from "./work-profiles.integration"; import { verifyWorkstations } from "./workstations.integration"; test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => { const environment = readDatabaseEnvironment(process.env); // Este proceso solo puede limpiar la base de pruebas dedicada. assert.equal(environment.DB_NAME, "metis_desk_test"); const database = createDatabase(environment); const identity = await createIdentityRuntime(database, environment); const server = createApp(() => undefined, identity).listen(0, "127.0.0.1"); try { await once(server, "listening"); await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1"); await database.query("DELETE FROM http_sessions"); await database.query("DELETE FROM mail_configuration_revisions"); await database.query("DELETE FROM mail_configurations"); await database.query("DELETE FROM work_profile_revisions"); await database.query("DELETE FROM work_profiles"); await database.query("DELETE FROM workstation_assets"); await database.query("DELETE FROM workstation_revisions"); await database.query("DELETE FROM workstations"); await database.query("DELETE FROM asset_relationships"); await database.query("DELETE FROM asset_revisions"); await database.query("DELETE FROM assets"); await database.query("DELETE FROM asset_availability"); await database.query("DELETE FROM asset_template_fields"); await database.query("DELETE FROM asset_definitions"); await database.query("DELETE FROM asset_catalogs"); await database.query("DELETE FROM access_person_references"); await database.query("DELETE FROM access_references"); await database.query("DELETE FROM access_revisions"); await database.query("DELETE FROM access_items"); await database.query("DELETE FROM access_workspaces WHERE companyId IS NOT NULL"); await database.query("DELETE FROM users"); await database.query("DELETE FROM department_assignment_revisions"); await database.query("DELETE FROM person_department_assignments"); await database.query("DELETE FROM company_contacts"); await database.query("DELETE FROM company_departments"); await database.query("DELETE FROM company_sites"); await database.query("DELETE FROM companies"); await createAssetModule(database, (_request, _response, next) => next()); // Simular la tabla de contactos de la versión anterior solo en la base de pruebas. const legacyCompanyId = "48f13545-1529-4ba1-a353-a5f844141411"; const legacyPersonId = "4ec8f8c5-3ad9-4e22-abf3-b95370400898"; await database.query("INSERT INTO companies (id,name,active) VALUES (:id,'Legacy',1)", { replacements: { id: legacyCompanyId }, }); await database.query( "INSERT INTO company_contacts (id,companyId,name,position,email,phone,active,isContact) VALUES (:id,:companyId,'Contacto anterior','Compras','legacy@example.test','900123456',1,1)", { replacements: { id: legacyPersonId, companyId: legacyCompanyId } } ); await database.query( "ALTER TABLE company_contacts DROP COLUMN isContact, MODIFY position VARCHAR(100) NOT NULL, MODIFY email VARCHAR(254) NOT NULL, MODIFY phone VARCHAR(50) NOT NULL" ); await createCompanyModule(database, (_request, _response, next) => next()); await createCompanyModule(database, (_request, _response, next) => next()); const legacy = await database.query<{ id: string; email: string; isContact: number }>( "SELECT id,email,isContact FROM company_contacts WHERE id=:id", { replacements: { id: legacyPersonId }, type: QueryTypes.SELECT } ); assert.equal(legacy[0]?.id, legacyPersonId); assert.equal(legacy[0]?.email, "legacy@example.test"); assert.equal(Number(legacy[0]?.isContact), 1); await database.query("DELETE FROM company_contacts WHERE id=:id", { replacements: { id: legacyPersonId }, }); await database.query("DELETE FROM companies WHERE id=:id", { replacements: { id: legacyCompanyId }, }); const address = server.address(); assert.ok(address && typeof address !== "string"); const baseUrl = `http://127.0.0.1:${address.port}/api`; function browser() { let cookie = ""; let csrf = ""; return { async request( path: string, body?: unknown, origin = environment.APP_ORIGIN, token?: string ) { const response = await fetch(`${baseUrl}${path}`, { method: body === undefined ? "GET" : "POST", headers: { cookie, origin, "content-type": "application/json", "x-csrf-token": token ?? csrf, }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const newCookie = response.headers.get("set-cookie"); if (newCookie) { cookie = newCookie.split(";")[0] ?? ""; } return response; }, async session() { const response = await this.request("/auth/session"); assert.equal(response.status, 200); const data = SessionResponseSchema.parse(await response.json()); csrf = data.csrfToken; return data; }, cookie: () => cookie, }; } async function expectError(response: Response, status: number, code: string) { assert.equal(response.status, status); assert.equal(IdentityErrorResponseSchema.parse(await response.json()).code, code); } const admin = browser(); const competitor = browser(); assert.equal((await admin.session()).setupRequired, true); await competitor.session(); await expectError(await admin.request("/users"), 401, "UNAUTHENTICATED"); await expectError(await admin.request("/asset-catalog/global"), 401, "UNAUTHENTICATED"); const first = { name: "Admin test", email: "ADMIN@example.test", password: "Admin123", }; await expectError( await admin.request("/auth/bootstrap", first, "https://other.example"), 403, "FORBIDDEN" ); await expectError( await admin.request("/auth/bootstrap", first, environment.APP_ORIGIN, "á".repeat(64)), 403, "FORBIDDEN" ); await expectError( await admin.request("/auth/bootstrap", { ...first, password: "1234567" }), 400, "INVALID_REQUEST" ); const beforeCookie = admin.cookie(); const attempts = await Promise.all([ admin.request("/auth/bootstrap", first), competitor.request("/auth/bootstrap", { ...first, email: "other@example.test" }), ]); assert.deepEqual(attempts.map((response) => response.status).sort(), [201, 409]); const winnerIndex = attempts.findIndex((response) => response.status === 201); const administrator = winnerIndex === 0 ? admin : competitor; const winnerResponse = attempts[winnerIndex]; assert.ok(winnerResponse); const initial = UserResponseSchema.parse(await winnerResponse.json()).user; assert.equal(initial.isInitialAdmin, true); assert.equal(initial.id.charAt(14), "7"); if (winnerIndex === 0) { assert.notEqual(admin.cookie(), beforeCookie); } assert.equal((await administrator.session()).setupRequired, false); await expectError(await administrator.request("/auth/bootstrap", first), 409, "SETUP_CLOSED"); await expectError( await administrator.request(`/users/${initial.id}/update`, { name: initial.name, email: initial.email, active: false, }), 409, "ADMIN_PROTECTED" ); await expectError( await administrator.request("/users", { name: "Intruso", email: "intruder@example.test", password: first.password, isInitialAdmin: true, }), 400, "INVALID_REQUEST" ); await expectError( await administrator.request(`/users/${initial.id.toUpperCase()}/reset-password`, { password: "Admin123", }), 409, "ADMIN_PROTECTED" ); const person = { name: "Operador test", email: "operator@example.test", password: "User1234", }; const created = await administrator.request("/users", person); assert.equal(created.status, 201); const user = UserResponseSchema.parse(await created.json()).user; assert.equal(user.isInitialAdmin, false); await expectError( await administrator.request("/users", { ...person, email: "OPERATOR@example.test" }), 409, "EMAIL_EXISTS" ); const list = UsersResponseSchema.parse(await (await administrator.request("/users")).json()); assert.equal(list.users.length, 2); assert.equal(JSON.stringify(list).includes("passwordHash"), false); const operator = browser(); await operator.session(); await expectError( await operator.request("/auth/login", { email: person.email, password: "incorrect" }), 401, "INVALID_CREDENTIALS" ); assert.equal( (await operator.request("/auth/login", { email: person.email, password: person.password })) .status, 200 ); await operator.session(); await expectError(await operator.request("/users"), 403, "FORBIDDEN"); await expectError( await operator.request("/auth/change-password", { currentPassword: "incorrect", password: "Nuevo123", }), 401, "INVALID_CREDENTIALS" ); const oldSession = browser(); await oldSession.session(); assert.equal( (await oldSession.request("/auth/login", { email: person.email, password: person.password })) .status, 200 ); await oldSession.session(); const changedPassword = "Nuevo123"; assert.equal( ( await operator.request("/auth/change-password", { currentPassword: person.password, password: changedPassword, }) ).status, 200 ); assert.equal((await operator.session()).user?.id, user.id); assert.equal((await oldSession.session()).user, null); const resetPassword = "Reset123"; assert.equal( (await administrator.request(`/users/${user.id}/reset-password`, { password: resetPassword })) .status, 200 ); assert.equal((await operator.session()).user, null); await expectError( await operator.request("/auth/login", { email: person.email, password: changedPassword }), 401, "INVALID_CREDENTIALS" ); assert.equal( (await operator.request("/auth/login", { email: person.email, password: resetPassword })) .status, 200 ); await operator.session(); assert.equal( ( await administrator.request(`/users/${user.id}/update`, { name: "Operador editado", email: person.email, active: false, }) ).status, 200 ); assert.equal((await operator.session()).user, null); await expectError( await operator.request("/auth/login", { email: person.email, password: resetPassword }), 401, "INVALID_CREDENTIALS" ); assert.equal( ( await administrator.request(`/users/${user.id}/update`, { name: person.name, email: person.email, active: true, }) ).status, 200 ); assert.equal( (await operator.request("/auth/login", { email: person.email, password: resetPassword })) .status, 200 ); await operator.session(); const anonymousCompanyBrowser = browser(); await anonymousCompanyBrowser.session(); await expectError(await anonymousCompanyBrowser.request("/companies"), 401, "UNAUTHENTICATED"); const companyCreated = await operator.request("/companies", { name: "Compañía de pruebas", active: true, }); assert.equal(companyCreated.status, 201); const customer = CompanyResponseSchema.parse(await companyCreated.json()).company; assert.equal(customer.id.charAt(14), "7"); const secondCreated = await operator.request("/companies", { name: "Segunda compañía", active: true, }); const secondCompany = CompanyResponseSchema.parse(await secondCreated.json()).company; const personContact = { name: "Ana Test", position: "Responsable IT", email: "ANA@example.test", phone: "+34 900 123 456", active: true, }; await expectError( await operator.request( `/companies/${customer.id}/contacts`, personContact, "https://other.example" ), 403, "FORBIDDEN" ); await expectError( await operator.request(`/companies/${customer.id}/contacts`, { ...personContact, phone: "invalid", }), 400, "INVALID_REQUEST" ); const addedContact = await operator.request( `/companies/${customer.id}/contacts`, personContact ); assert.equal(addedContact.status, 201); const afterContact = CompanyResponseSchema.parse(await addedContact.json()).company; const contact = afterContact.contacts[0]; assert.ok(contact); assert.equal(contact.companyId, customer.id); assert.equal(contact.id.charAt(14), "7"); assert.equal(contact.email, "ana@example.test"); const crossContact = await operator.request( `/companies/${secondCompany.id}/contacts/${contact.id}/update`, personContact ); assert.equal(crossContact.status, 404); assert.equal( CompanyErrorResponseSchema.parse(await crossContact.json()).code, "CONTACT_NOT_FOUND" ); const changedContact = await operator.request( `/companies/${customer.id}/contacts/${contact.id}/update`, { ...personContact, name: "Ana Actualizada", active: false } ); assert.equal(changedContact.status, 200); assert.equal( CompanyResponseSchema.parse(await changedContact.json()).company.contacts[0]?.active, false ); const noAddressSite = await operator.request(`/companies/${customer.id}/sites`, { name: "Almacén", }); assert.equal(noAddressSite.status, 201); const emptySite = CompanyResponseSchema.parse(await noAddressSite.json()).company.sites[0]; assert.ok(emptySite); assert.equal(emptySite.address, null); const siteAddress = "Calle Mayor, 10\n28001 Madrid"; const changedSite = await operator.request( `/companies/${customer.id}/sites/${emptySite.id}/update`, { name: "Sede central", address: siteAddress } ); assert.equal(changedSite.status, 200); assert.equal( CompanyResponseSchema.parse(await changedSite.json()).company.sites[0]?.address, siteAddress ); const crossSite = await operator.request( `/companies/${secondCompany.id}/sites/${emptySite.id}/update`, { name: "Ajena", address: null } ); assert.equal(crossSite.status, 404); const largeAddress = "Á".repeat(10000); assert.equal( ( await operator.request(`/companies/${customer.id}/sites`, { name: "Oficina", address: largeAddress, }) ).status, 201 ); assert.equal( ( await operator.request(`/companies/${customer.id}/sites`, { name: "Demasiado", address: "a".repeat(10001), }) ).status, 400 ); const editedCompany = await operator.request(`/companies/${customer.id}/update`, { name: "Compañía actualizada", active: false, }); assert.equal(editedCompany.status, 200); const details = CompanyResponseSchema.parse( await (await operator.request(`/companies/${customer.id}`)).json() ).company; assert.equal(details.id, customer.id); assert.equal(details.active, false); assert.equal(details.contacts[0]?.id, contact.id); assert.equal(details.sites.length, 2); const companiesList = CompaniesResponseSchema.parse( await (await operator.request("/companies")).json() ); assert.equal(companiesList.companies.length, 2); await assert.rejects( database.query("DELETE FROM companies WHERE id = :id", { replacements: { id: customer.id } }) ); const clearedSite = await operator.request( `/companies/${customer.id}/sites/${emptySite.id}/update`, { name: "Sede central", address: null } ); assert.equal(clearedSite.status, 200); // Personas y departamentos: mismas fronteras de sesión, UUID y compañía. const peoplePath = `/companies/${customer.id}`; async function companyData(response: Response) { assert.ok(response.status === 200 || response.status === 201); return CompanyResponseSchema.parse(await response.json()).company; } async function companyError(response: Response, status: number, code: string) { assert.equal(response.status, status); assert.equal(CompanyErrorResponseSchema.parse(await response.json()).code, code); } const personBody = { name: "Persona sin correo", position: null, email: null, phone: null, active: true, isContact: false, }; const personResponse = await companyData( await operator.request(`${peoplePath}/people`, personBody) ); const companyPerson = personResponse.people.find((item) => item.name === personBody.name); assert.ok(companyPerson); assert.equal(companyPerson.email, null); assert.equal(companyPerson.id.charAt(14), "7"); assert.equal( personResponse.contacts.some((item) => item.id === companyPerson.id), false ); await companyError( await operator.request(`${peoplePath}/people`, { ...personBody, isInitialAdmin: true }), 400, "INVALID_REQUEST" ); await companyError( await operator.request( `${peoplePath}/departments`, { name: "Forbidden", active: true }, "https://other.example" ), 403, "FORBIDDEN" ); const departmentResponse = await companyData( await operator.request(`${peoplePath}/departments`, { name: " Compras ", active: true }) ); const department = departmentResponse.departments.find((item) => item.name === "Compras"); assert.ok(department); await companyError( await operator.request(`${peoplePath}/departments`, { name: "COMPRAS", active: true }), 409, "DEPARTMENT_NAME_EXISTS" ); const otherDepartmentResponse = await companyData( await operator.request(`/companies/${secondCompany.id}/departments`, { name: "Compras", active: true, }) ); const foreignDepartment = otherDepartmentResponse.departments[0]; assert.ok(foreignDepartment); const assignmentBody = { departmentId: department.id, position: "Responsable", startDate: "2026-01-01", endDate: null, isPrimary: true, }; await companyError( await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, { ...assignmentBody, departmentId: foreignDepartment.id, }), 404, "DEPARTMENT_NOT_FOUND" ); const assignmentResponse = await companyData( await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, assignmentBody) ); const membership = assignmentResponse.assignments.find( (item) => item.personId === companyPerson.id ); assert.ok(membership); assert.equal(membership.id.charAt(14), "7"); assert.equal(membership.history.length, 1); await companyError( await operator.request( `${peoplePath}/people/${companyPerson.id}/assignments`, assignmentBody ), 409, "ASSIGNMENT_CONFLICT" ); const secondResponse = await companyData( await operator.request(`${peoplePath}/departments`, { name: "Administración", active: true }) ); const secondDepartment = secondResponse.departments.find( (item) => item.name === "Administración" ); assert.ok(secondDepartment); await companyError( await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, { ...assignmentBody, departmentId: secondDepartment.id, }), 409, "ASSIGNMENT_CONFLICT" ); await companyData( await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, { ...assignmentBody, departmentId: secondDepartment.id, isPrimary: false, }) ); const editedAssignment = { position: "Supervisor", startDate: "2026-01-01", endDate: null, isPrimary: true, }; const editedMembership = await companyData( await operator.request( `${peoplePath}/people/${companyPerson.id}/assignments/${membership.id}/update`, editedAssignment ) ); const history = editedMembership.assignments.find((item) => item.id === membership.id)?.history; assert.equal(history?.length, 2); assert.equal(history?.[0]?.position, "Responsable"); const repeatedMembership = await companyData( await operator.request( `${peoplePath}/people/${companyPerson.id}/assignments/${membership.id}/update`, editedAssignment ) ); assert.equal( repeatedMembership.assignments.find((item) => item.id === membership.id)?.history.length, 2 ); await companyError( await operator.request( `${peoplePath}/people/${contact.id}/assignments/${membership.id}/update`, editedAssignment ), 404, "ASSIGNMENT_NOT_FOUND" ); await companyError( await operator.request( `${peoplePath}/people/${companyPerson.id}/assignments/${membership.id}/update`, { ...editedAssignment, endDate: "2025-12-31", isPrimary: false } ), 400, "INVALID_REQUEST" ); await companyData( await operator.request(`${peoplePath}/departments/${secondDepartment.id}/update`, { name: secondDepartment.name, active: false, }) ); await companyError( await operator.request(`${peoplePath}/people/${contact.id}/assignments`, { ...assignmentBody, departmentId: secondDepartment.id, isPrimary: false, }), 409, "INACTIVE_ASSIGNMENT_TARGET" ); await companyData( await operator.request(`${peoplePath}/people/${companyPerson.id}/update`, { ...personBody, active: false, }) ); await companyError( await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, { ...assignmentBody, startDate: "2025-01-01", endDate: "2025-02-01", isPrimary: false, }), 409, "INACTIVE_ASSIGNMENT_TARGET" ); const closed = await companyData( await operator.request( `${peoplePath}/people/${companyPerson.id}/assignments/${membership.id}/update`, { ...editedAssignment, endDate: "2026-03-01", isPrimary: false } ) ); assert.equal(closed.assignments.find((item) => item.id === membership.id)?.history.length, 3); await companyData( await operator.request(`${peoplePath}/people/${contact.id}/update`, { name: contact.name, position: contact.position, email: contact.email, phone: contact.phone, active: true, isContact: true, }) ); // Concurrencia: el bloqueo de compañía evita dos asignaciones abiertas al mismo departamento. const concurrent = await Promise.all([ operator.request(`${peoplePath}/people/${contact.id}/assignments`, { ...assignmentBody, isPrimary: false, }), operator.request(`${peoplePath}/people/${contact.id}/assignments`, { ...assignmentBody, isPrimary: false, }), ]); assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]); // La FK compuesta impide pertenencias cruzadas aunque se intente escribir SQL directamente. await assert.rejects( database.query( "INSERT INTO person_department_assignments (id, companyId, personId, departmentId, position, startDate, endDate, isPrimary) VALUES (:id, :companyId, :personId, :departmentId, NULL, '2026-01-01', NULL, 0)", { replacements: { id: "a2349bc1-8c29-4d8b-b53a-5ec40e37a002", companyId: customer.id, personId: companyPerson.id, departmentId: foreignDepartment.id, }, } ) ); const userCount = await database.query<{ total: number }>( "SELECT COUNT(*) AS total FROM users", { type: QueryTypes.SELECT } ); assert.equal(Number(userCount[0]?.total), 2); const accessCompany = CompanyResponseSchema.parse( await (await operator.request(peoplePath)).json() ).company; const accessOther = CompanyResponseSchema.parse( await (await operator.request(`/companies/${secondCompany.id}`)).json() ).company; await verifyAccess((...args) => operator.request(...args), accessCompany, accessOther); await verifyAssets( (...args) => operator.request(...args), customer.id, secondCompany.id, database ); await verifyInventory( (...args) => operator.request(...args), customer.id, secondCompany.id, database ); await verifyRelationships( (...args) => operator.request(...args), customer.id, secondCompany.id, database ); await verifyWorkstations( (...args) => operator.request(...args), customer.id, secondCompany.id, database ); await verifyMail( (...args) => operator.request(...args), customer.id, secondCompany.id, database ); await verifyWorkProfiles( (...args) => operator.request(...args), customer.id, secondCompany.id, database ); const countAfterAccess = await database.query<{ total: number }>( "SELECT COUNT(*) AS total FROM users", { type: QueryTypes.SELECT } ); assert.equal(Number(countAfterAccess[0]?.total), 2); assert.equal((await operator.request("/auth/logout", {})).status, 200); await expectError(await operator.request("/access/global"), 401, "UNAUTHENTICATED"); await expectError( await operator.request(`/work-profiles/${customer.id}`), 401, "UNAUTHENTICATED" ); await expectError(await operator.request(`/assets/${customer.id}`), 401, "UNAUTHENTICATED"); assert.equal((await operator.session()).user, null); await expectError( await operator.request(`/assets/${customer.id}/relationships`, {}), 401, "UNAUTHENTICATED" ); await expectError( await operator.request(`/assets/${customer.id}/workstations`, {}), 401, "UNAUTHENTICATED" ); await expectError( await operator.request(`/mail-configurations/${customer.id}`), 401, "UNAUTHENTICATED" ); await expectError( await operator.request(`/mail-configurations/${customer.id}`, {}), 401, "UNAUTHENTICATED" ); await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED"); await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED"); await assert.rejects( database.query("UPDATE users SET active = 0 WHERE id = :id", { replacements: { id: initial.id }, }) ); await assert.rejects( database.query("DELETE FROM users WHERE id = :id", { replacements: { id: initial.id } }) ); const stored = await database.query<{ passwordHash: string }>( "SELECT passwordHash FROM users WHERE id = :id", { replacements: { id: user.id }, type: QueryTypes.SELECT } ); assert.ok(stored[0]?.passwordHash.startsWith("scrypt-v1$")); assert.equal(JSON.stringify(stored).includes(resetPassword), false); // Al acumular fallos se rechazan los siguientes intentos. let limited = false; for (let index = 0; index < 12; index += 1) { const response = await operator.request("/auth/login", { email: "missing@example.test", password: "wrong", }); if (response.status === 429) { limited = true; break; } assert.equal(response.status, 401); } assert.equal(limited, true); } finally { identity.stop(); await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); server.closeAllConnections(); }); await database.close(); } });