Added Multi-Company Support
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user