Added Multi-Company Support

This commit is contained in:
2026-06-08 00:54:21 -05:00
parent c164395915
commit db614a6707
32 changed files with 1133 additions and 323 deletions

View File

@@ -1630,6 +1630,142 @@ async function main() {
const csvText = await csvResp.text();
if (!csvText.startsWith('id,timestamp,actor,action')) throw new Error('Audit CSV header was malformed');
// ---- Multi-company scoping -------------------------------------------------
const companyHeaders = (id) => ({ ...auth, 'X-Company-Id': String(id) });
// Company A is the seeded default; create company B.
const companyA = (await request('/api/entities', { headers: auth })).entities[0].id;
const companyB = (await request('/api/entities', {
method: 'POST', headers: auth, body: JSON.stringify({ name: `Company B ${suffix}`, base_currency: 'USD' })
})).entity.id;
if (!companyB || companyB === companyA) throw new Error('Second company was not created');
// A new company auto-seeds its own book set (distinct ids from company A's).
const booksA = (await request('/api/books', { headers: companyHeaders(companyA) })).books;
const booksB = (await request('/api/books', { headers: companyHeaders(companyB) })).books;
if (!booksB.some((b) => b.code === 'GAAP')) throw new Error('New company did not get a seeded book set');
if (booksA.find((b) => b.code === 'GAAP').id === booksB.find((b) => b.code === 'GAAP').id) {
throw new Error('Company B GAAP book is not a distinct per-company record');
}
// Create an asset under each company via the X-Company-Id header.
const assetB = (await request('/api/assets', {
method: 'POST', headers: companyHeaders(companyB),
body: JSON.stringify({ asset_id: `B-${suffix}`, description: 'Company B asset', category: 'Computer Equipment', acquisition_cost: 5000, in_service_date: '2024-01-01' })
})).asset;
const assetAonly = (await request('/api/assets', {
method: 'POST', headers: companyHeaders(companyA),
body: JSON.stringify({ asset_id: `A-${suffix}`, description: 'Company A only', category: 'Computer Equipment', acquisition_cost: 1000, in_service_date: '2024-01-01' })
})).asset;
// Listings are scoped to the active company.
const listA = (await request('/api/assets', { headers: companyHeaders(companyA) })).assets;
const listB = (await request('/api/assets', { headers: companyHeaders(companyB) })).assets;
if (listB.some((a) => a.id === assetAonly.id)) throw new Error('Company B sees company A assets');
if (!listB.some((a) => a.id === assetB.id)) throw new Error('Company B is missing its own asset');
if (listA.some((a) => a.id === assetB.id)) throw new Error('Company A sees company B assets');
// Cross-company asset access is blocked (company A cannot fetch company B's asset).
const crossFetch = await fetch(`${baseUrl}/api/assets/${assetB.id}`, { headers: companyHeaders(companyA) });
if (crossFetch.status !== 404) throw new Error('Cross-company asset fetch should 404');
// PM plans are per-company.
await request('/api/pm-plans', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Plan ${suffix}`, frequency_value: 3, frequency_unit: 'months' }) });
const plansB = (await request('/api/pm-plans', { headers: companyHeaders(companyB) })).plans;
const plansA = (await request('/api/pm-plans', { headers: companyHeaders(companyA) })).plans;
if (!plansB.some((p) => p.name === `B Plan ${suffix}`)) throw new Error('Company B PM plan missing');
if (plansA.some((p) => p.name === `B Plan ${suffix}`)) throw new Error('Company A sees company B PM plan');
// Dashboards differ per company.
const dashA = (await request('/api/dashboard', { headers: companyHeaders(companyA) })).stats;
const dashB = (await request('/api/dashboard', { headers: companyHeaders(companyB) })).stats;
if (dashB.asset_count !== 1) throw new Error(`Company B dashboard should count exactly its 1 asset (got ${dashB.asset_count})`);
if (dashA.asset_count <= dashB.asset_count) throw new Error('Company A (seeded data) should have more assets than the new company B');
// Dispose company B: it leaves the active list but its data is preserved; then restore it.
await request(`/api/entities/${companyB}/dispose`, { method: 'POST', headers: auth, body: JSON.stringify({ reason: 'smoke test' }) });
const activeAfter = (await request('/api/entities', { headers: auth })).entities;
if (activeAfter.some((e) => e.id === companyB)) throw new Error('Disposed company still appears in the active list');
const allAfter = (await request('/api/entities?includeDisposed=1', { headers: auth })).entities;
const disposedRow = allAfter.find((e) => e.id === companyB);
if (!disposedRow || disposedRow.status !== 'disposed') throw new Error('Disposed company missing from includeDisposed list');
await request(`/api/entities/${companyB}/restore`, { method: 'POST', headers: auth, body: JSON.stringify({}) });
const restoredAssets = (await request('/api/assets', { headers: companyHeaders(companyB) })).assets;
if (!restoredAssets.some((a) => a.id === assetB.id)) throw new Error('Company B asset was lost across dispose/restore');
// ---- Phase 2: reference data / contacts / templates are per-company ---------
// A new company auto-seeds the standard categories.
const seededCatsB = (await request('/api/asset-categories', { headers: companyHeaders(companyB) })).categories;
if (!seededCatsB.some((c) => c.name === 'Computer Equipment')) throw new Error('New company did not auto-seed reference categories');
await request('/api/asset-categories', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Category ${suffix}` }) });
const catsA = (await request('/api/asset-categories', { headers: companyHeaders(companyA) })).categories;
const catsB = (await request('/api/asset-categories', { headers: companyHeaders(companyB) })).categories;
if (!catsB.some((c) => c.name === `B Category ${suffix}`)) throw new Error('Company B category missing');
if (catsA.some((c) => c.name === `B Category ${suffix}`)) throw new Error('Company A sees company B category');
// Contacts are per-company.
await request('/api/contacts', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ type: 'vendor', organization: `B Vendor ${suffix}` }) });
const contactsA = (await request('/api/contacts', { headers: companyHeaders(companyA) })).contacts;
const contactsB = (await request('/api/contacts', { headers: companyHeaders(companyB) })).contacts;
if (!contactsB.some((c) => c.organization === `B Vendor ${suffix}`)) throw new Error('Company B contact missing');
if (contactsA.some((c) => c.organization === `B Vendor ${suffix}`)) throw new Error('Company A sees company B contact');
// Templates are per-company.
await request('/api/templates', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Template ${suffix}`, defaults: {}, custom_fields: [] }) });
const tplA = (await request('/api/templates', { headers: companyHeaders(companyA) })).templates;
const tplB = (await request('/api/templates', { headers: companyHeaders(companyB) })).templates;
if (!tplB.some((t) => t.name === `B Template ${suffix}`)) throw new Error('Company B template missing');
if (tplA.some((t) => t.name === `B Template ${suffix}`)) throw new Error('Company A sees company B template');
// ---- Per-company user access control ---------------------------------------
const limitedEmail = `limited-${suffix}@example.com`;
const limitedPassword = 'Acc3ssControl!2026X';
await request('/api/users', {
method: 'POST', headers: companyHeaders(companyA),
body: JSON.stringify({ name: 'Limited Person', email: limitedEmail, password: limitedPassword, role: 'viewer', company_ids: [companyA] })
});
const limitedLogin = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ email: limitedEmail, password: limitedPassword }) });
const limitedAuth = { Authorization: `Bearer ${limitedLogin.token}` };
// The switcher only lists company A for this user.
const limitedCompanies = (await request('/api/entities', { headers: limitedAuth })).entities;
if (limitedCompanies.length !== 1 || limitedCompanies[0].id !== companyA) throw new Error('Limited user can see companies beyond their assignment');
// Forging X-Company-Id to company B falls back to company A (no leak of B's data).
const limitedBView = (await request('/api/assets', { headers: { ...limitedAuth, 'X-Company-Id': String(companyB) } })).assets;
if (limitedBView.some((a) => a.id === assetB.id)) throw new Error('Limited user accessed another company by forging the header');
if (!limitedBView.some((a) => a.id === assetAonly.id)) throw new Error('Limited user did not fall back to their permitted company');
// Admins still see every active company.
const adminCompanies = (await request('/api/entities', { headers: auth })).entities;
if (!adminCompanies.some((e) => e.id === companyA) || !adminCompanies.some((e) => e.id === companyB)) {
throw new Error('Admin should see all active companies');
}
// ---- Parts + assignments are per-company -----------------------------------
// The same part number is allowed in different companies (per-company uniqueness).
const partB = (await request('/api/parts', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ part_number: `PN-${suffix}`, name: 'B Part' }) })).part;
const partA = (await request('/api/parts', { method: 'POST', headers: companyHeaders(companyA), body: JSON.stringify({ part_number: `PN-${suffix}`, name: 'A Part' }) })).part;
if (!partA || !partB || partA.id === partB.id) throw new Error('Per-company part-number uniqueness failed');
const partsA = (await request('/api/parts', { headers: companyHeaders(companyA) })).parts;
const partsB = (await request('/api/parts', { headers: companyHeaders(companyB) })).parts;
if (partsA.some((p) => p.id === partB.id)) throw new Error('Company A sees company B part');
if (!partsB.some((p) => p.id === partB.id)) throw new Error('Company B part missing');
const crossPart = await fetch(`${baseUrl}/api/parts/${partB.id}`, { headers: companyHeaders(companyA) });
if (crossPart.status !== 404) throw new Error('Cross-company part fetch should 404');
// Assignments scope to the assigned asset's company.
await request('/api/assignments', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ asset_id: assetB.id }) });
const assignsB = (await request('/api/assignments', { headers: companyHeaders(companyB) })).assignments;
const assignsA = (await request('/api/assignments', { headers: companyHeaders(companyA) })).assignments;
if (!assignsB.some((a) => a.asset_id === assetB.id)) throw new Error('Company B assignment missing');
if (assignsA.some((a) => a.asset_id === assetB.id)) throw new Error('Company A sees company B assignment');
// Assigning another company's asset is rejected.
const crossAssign = await fetch(`${baseUrl}/api/assignments`, {
method: 'POST', headers: { ...companyHeaders(companyA), 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_id: assetB.id })
});
if (crossAssign.status !== 404) throw new Error('Assigning an asset from another company should 404');
console.log('Smoke test passed');
}