Password Policy/App rename/PM Scheduling Improvements
This commit is contained in:
@@ -9,10 +9,10 @@ const port = 3999;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
// Run against an isolated, freshly-seeded database so the test is deterministic
|
||||
// and never reads or mutates the developer's working data.
|
||||
const dbPath = path.join(os.tmpdir(), `mixedassets-smoke-${Date.now()}.sqlite`);
|
||||
const dbPath = path.join(os.tmpdir(), `deprecore-smoke-${Date.now()}.sqlite`);
|
||||
const child = spawn(process.execPath, ['server/index.js'], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, PORT: String(port), MIXEDASSETS_DB_PATH: dbPath },
|
||||
env: { ...process.env, PORT: String(port), DEPRECORE_DB_PATH: dbPath },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
@@ -54,8 +54,8 @@ async function main() {
|
||||
const login = await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: 'admin@mixedassets.local',
|
||||
password: process.env.MIXEDASSETS_ADMIN_PASSWORD || 'ChangeMe123!'
|
||||
email: 'admin@deprecore.local',
|
||||
password: process.env.DEPRECORE_ADMIN_PASSWORD || 'ChangeMe123!'
|
||||
})
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ async function main() {
|
||||
throw new Error('Login did not return admin capabilities');
|
||||
}
|
||||
const profile = await request('/api/profile', { headers: auth });
|
||||
if (profile.preferences.theme !== 'mixedAssetsDark') {
|
||||
if (profile.preferences.theme !== 'depreCoreDark') {
|
||||
throw new Error(`Expected default dark theme, found ${profile.preferences.theme}`);
|
||||
}
|
||||
if (!Array.isArray(profile.capabilities) || !profile.capabilities.includes('*')) {
|
||||
@@ -73,9 +73,9 @@ async function main() {
|
||||
const updatedProfile = await request('/api/profile', {
|
||||
method: 'PUT',
|
||||
headers: auth,
|
||||
body: JSON.stringify({ preferences: { theme: 'mixedAssetsDark' } })
|
||||
body: JSON.stringify({ preferences: { theme: 'depreCoreDark' } })
|
||||
});
|
||||
if (updatedProfile.preferences.theme !== 'mixedAssetsDark') {
|
||||
if (updatedProfile.preferences.theme !== 'depreCoreDark') {
|
||||
throw new Error('Profile theme preference did not persist');
|
||||
}
|
||||
|
||||
@@ -299,6 +299,36 @@ async function main() {
|
||||
const reclassed = await request(`/api/assets/${classAsset.asset.id}`, { headers: auth });
|
||||
if (reclassed.asset.asset_class_number !== 'AC-200') throw new Error('Class number change did not cascade to assets');
|
||||
|
||||
// Asset class collision: 00.12 is BOTH "Computers" (common) and "Casinos" (business). Linking a
|
||||
// category by the class's unique id must resolve to the exact class, not the first number match.
|
||||
const computersClass = irs.assetClasses.find((c) => c.description === 'Computers' && c.class_number === '00.12');
|
||||
const casinoClass = irs.assetClasses.find((c) => c.description === 'Casinos' && c.class_number === '00.12');
|
||||
if (!computersClass || !casinoClass || computersClass.id === casinoClass.id) {
|
||||
throw new Error('Expected distinct Computers/Casinos classes sharing class number 00.12');
|
||||
}
|
||||
const computerCat = await request('/api/asset-categories', {
|
||||
method: 'POST', headers: auth, body: JSON.stringify({ name: `Computers ${suffix}`, asset_class_id: computersClass.id })
|
||||
});
|
||||
if (computerCat.category.asset_class_id !== computersClass.id) throw new Error('Category did not store the unique asset_class_id');
|
||||
if (computerCat.category.asset_class_number !== '00.12') throw new Error('Category did not derive the class number from the linked class');
|
||||
if (!/Computers/.test(computerCat.category.asset_class_label) || /Casino/.test(computerCat.category.asset_class_label)) {
|
||||
throw new Error(`Category resolved to the wrong class: ${computerCat.category.asset_class_label}`);
|
||||
}
|
||||
// Re-point the same category to the casino class by id; the label must follow the id, not the number.
|
||||
const repointed = await request(`/api/asset-categories/${computerCat.category.id}`, {
|
||||
method: 'PUT', headers: auth, body: JSON.stringify({ asset_class_id: casinoClass.id })
|
||||
});
|
||||
if (!/Casino/.test(repointed.category.asset_class_label) || repointed.category.asset_class_id !== casinoClass.id) {
|
||||
throw new Error('Re-pointing category by id did not resolve to the casino class');
|
||||
}
|
||||
// A manual number with no id stays unlinked (asset_class_id null) but keeps the number.
|
||||
const manualClassCat = await request(`/api/asset-categories/${computerCat.category.id}`, {
|
||||
method: 'PUT', headers: auth, body: JSON.stringify({ asset_class_id: null, asset_class_number: '99.99' })
|
||||
});
|
||||
if (manualClassCat.category.asset_class_id !== null || manualClassCat.category.asset_class_number !== '99.99') {
|
||||
throw new Error('Manual class number override did not clear the id link');
|
||||
}
|
||||
|
||||
// Asset ID templates: pattern, preview, assign to a category, auto-increment on asset creation.
|
||||
const idTemplate = await request('/api/asset-id-templates', {
|
||||
method: 'POST', headers: auth, body: JSON.stringify({ name: `Tag ${suffix}`, pattern: 'T-A#####', next_number: 5 })
|
||||
@@ -603,7 +633,7 @@ async function main() {
|
||||
headers: auth,
|
||||
body: JSON.stringify({
|
||||
smtp_host: 'smtp.example.com', smtp_port: 587, smtp_secure: false,
|
||||
smtp_user: 'mailer@example.com', smtp_password: 'secret', smtp_from: 'MixedAssets <no-reply@example.com>',
|
||||
smtp_user: 'mailer@example.com', smtp_password: 'secret', smtp_from: 'DepreCore <no-reply@example.com>',
|
||||
alerts_enabled: false, alerts_lead_days: 45, alerts_recipients: 'ops@example.com, finance@example.com'
|
||||
})
|
||||
});
|
||||
@@ -743,6 +773,108 @@ async function main() {
|
||||
throw new Error('PM settings did not persist');
|
||||
}
|
||||
|
||||
// ---- Dynamic PM scheduling: usage meters + lifespan wear curve --------------
|
||||
// Turn on both dynamic signals.
|
||||
await request('/api/pm-settings', {
|
||||
method: 'PUT', headers: auth,
|
||||
body: JSON.stringify({ pm_usage_tracking_enabled: true, pm_lifespan_curve_enabled: true, pm_lifespan_min_factor: 0.5, pm_lifespan_curve_exponent: 1 })
|
||||
});
|
||||
const dynSettings = await request('/api/pm-settings', { headers: auth });
|
||||
if (!dynSettings.settings.pm_usage_tracking_enabled || !dynSettings.settings.pm_lifespan_curve_enabled) {
|
||||
throw new Error('Dynamic PM settings did not persist');
|
||||
}
|
||||
|
||||
// A plan that tracks a 500-unit meter (e.g. operating hours).
|
||||
const meterPlan = await request('/api/pm-plans', {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({
|
||||
name: `Usage Plan ${suffix}`, frequency_value: 12, frequency_unit: 'months',
|
||||
meters: [{ label: 'Operating Hours', unit: 'hrs', usage_interval: 500 }]
|
||||
})
|
||||
});
|
||||
if (!meterPlan.plan.meters || meterPlan.plan.meters.length !== 1 || Number(meterPlan.plan.meters[0].usage_interval) !== 500) {
|
||||
throw new Error('Plan meter was not stored');
|
||||
}
|
||||
|
||||
// A brand-new asset so the lifespan curve does NOT compress yet.
|
||||
const youngAsset = await request('/api/assets', {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ asset_id: `PM-YOUNG-${suffix}`, description: 'Young pump', category: 'Computer Equipment', acquisition_cost: 1000, in_service_date: new Date().toISOString().slice(0, 10), useful_life_months: 60 })
|
||||
});
|
||||
const youngId = youngAsset.asset.id;
|
||||
const dueIn12mo = await request(`/api/assets/${youngId}/pm`, {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ plan_id: meterPlan.plan.id, frequency_value: 12, frequency_unit: 'months', lifespan_adjust: true })
|
||||
});
|
||||
const youngApId = dueIn12mo.pm.id;
|
||||
const youngMeterId = (await request(`/api/assets/${youngId}/pm`, { headers: auth })).pm.find((p) => p.id === youngApId).meters[0].id;
|
||||
|
||||
// Complete with a baseline reading; threshold resets to baseline + 500.
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const baseDue = (await request(`/api/assets/${youngId}/pm/${youngApId}/complete`, {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ signature: pngDataUrl, completed_at: today, meter_readings: [{ asset_pm_meter_id: youngMeterId, reading: 1000 }] })
|
||||
})).pm.next_due_date;
|
||||
// A young asset on a 12-month calendar with no usage rate yet → ~12 months out (curve barely moves it).
|
||||
if (!baseDue || baseDue <= today) throw new Error('Composite next-due did not advance after completion');
|
||||
|
||||
// Log a reading 10 days later implying ~30 hrs/day → reaches 1500 (the threshold) well before 12 months.
|
||||
const tenDays = new Date(Date.now() + 10 * 86400000).toISOString().slice(0, 10);
|
||||
const afterReading = await request(`/api/assets/${youngId}/pm/${youngApId}/readings`, {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ asset_pm_meter_id: youngMeterId, reading: 1300, reading_date: tenDays })
|
||||
});
|
||||
if (!(afterReading.pm.next_due_date < baseDue)) {
|
||||
throw new Error('Fast usage rate did not pull the next-due date in ahead of the calendar date');
|
||||
}
|
||||
|
||||
// Push the meter past its threshold → a usage-driven overdue PM alert.
|
||||
await request(`/api/assets/${youngId}/pm/${youngApId}/readings`, {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ asset_pm_meter_id: youngMeterId, reading: 1600, reading_date: tenDays })
|
||||
});
|
||||
const usageScan = await request('/api/alerts/scan', { method: 'POST', headers: auth });
|
||||
const usageAlert = usageScan.alerts.find((a) => a.reference_id === youngApId && /Usage threshold/.test(a.message || ''));
|
||||
if (!usageAlert) throw new Error('Usage-threshold PM alert was not raised');
|
||||
|
||||
// Lifespan curve: an end-of-life asset compresses the same interval vs the young one.
|
||||
const oldDate = new Date(Date.now() - 58 * 30 * 86400000).toISOString().slice(0, 10); // ~58 months old of a 60-month life
|
||||
const oldAsset = await request('/api/assets', {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ asset_id: `PM-OLD-${suffix}`, description: 'Old pump', category: 'Computer Equipment', acquisition_cost: 1000, in_service_date: oldDate, useful_life_months: 60 })
|
||||
});
|
||||
const oldAttach = await request(`/api/assets/${oldAsset.asset.id}/pm`, {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ plan_id: meterPlan.plan.id, frequency_value: 12, frequency_unit: 'months', lifespan_adjust: true })
|
||||
});
|
||||
const oldMeterId = (await request(`/api/assets/${oldAsset.asset.id}/pm`, { headers: auth })).pm.find((p) => p.id === oldAttach.pm.id).meters[0].id;
|
||||
const oldDue = (await request(`/api/assets/${oldAsset.asset.id}/pm/${oldAttach.pm.id}/complete`, {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ signature: pngDataUrl, completed_at: today, meter_readings: [{ asset_pm_meter_id: oldMeterId, reading: 1000 }] })
|
||||
})).pm.next_due_date;
|
||||
if (!(oldDue < baseDue)) {
|
||||
throw new Error('Lifespan curve did not compress the interval for an end-of-life asset');
|
||||
}
|
||||
|
||||
// Nightly recompute job: persist a manually-stale next_due_date, then sweep it back to "today"
|
||||
// because the meter is already past its threshold (usage projection → today).
|
||||
await request(`/api/assets/${youngId}/pm/${youngApId}`, {
|
||||
method: 'PUT', headers: auth, body: JSON.stringify({ next_due_date: '2099-01-01' })
|
||||
});
|
||||
const recompute = await request('/api/pm-recompute', { method: 'POST', headers: auth, body: JSON.stringify({}) });
|
||||
if (!recompute.result || recompute.result.updated < 1) throw new Error('Manual recompute did not update any schedules');
|
||||
if (!recompute.settings.pm_last_recompute_at) throw new Error('Recompute did not stamp last-run time');
|
||||
const sweptSchedule = (await request(`/api/assets/${youngId}/pm`, { headers: auth })).pm.find((p) => p.id === youngApId);
|
||||
if (sweptSchedule.next_due_date >= '2099-01-01') {
|
||||
throw new Error('Recompute did not refresh the stale projected due-date');
|
||||
}
|
||||
// Recompute settings persist.
|
||||
await request('/api/pm-settings', { method: 'PUT', headers: auth, body: JSON.stringify({ pm_recompute_enabled: true, pm_recompute_hour: 3 }) });
|
||||
const recomputeSettings = await request('/api/pm-settings', { headers: auth });
|
||||
if (!recomputeSettings.settings.pm_recompute_enabled || recomputeSettings.settings.pm_recompute_hour !== 3) {
|
||||
throw new Error('Recompute settings did not persist');
|
||||
}
|
||||
|
||||
// Contacts directory: typed records, type-specific fields, filtering, notes.
|
||||
const vendorContact = await request('/api/contacts', {
|
||||
method: 'POST',
|
||||
@@ -949,6 +1081,61 @@ async function main() {
|
||||
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
|
||||
if (ezY1 !== 528000) throw new Error(`Enterprise Zone §179 increase should allow 520k (year-1 528000), got ${ezY1}`);
|
||||
|
||||
// Gulf Opportunity (GO) Zone: combines a 50% §168 allowance AND a +$100,000 §179 increase.
|
||||
const go = ezList.zones.find((z) => z.code === 'go_zone');
|
||||
if (!go || go.allowance_percent !== 50 || go.section_179_increase !== 100000 || go.section_179_threshold_increase !== 600000) {
|
||||
throw new Error('GO Zone was not seeded with the 50% allowance + §179 increase/threshold');
|
||||
}
|
||||
// 2007 base §179 limit is $125,000; GO Zone raises it to $225,000, so the entered $200,000 §179
|
||||
// applies in full. Then the 50% §168 allowance hits the post-§179 basis, plus first-year SL.
|
||||
// §179 200000 + bonus 50%×(600000−200000)=200000 + SL ((600000−200000−200000)/5)×0.5=20000 = 420000.
|
||||
const goAsset = await request('/api/assets', {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({
|
||||
asset_id: `GO-${suffix}`, description: 'GO Zone equipment', category: 'Computer Equipment',
|
||||
acquisition_cost: 600000, in_service_date: '2007-06-01', useful_life_months: 60, special_zone: 'go_zone',
|
||||
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 200000, cost: 600000 }]
|
||||
})
|
||||
});
|
||||
const goY1 = round((await request(`/api/assets/${goAsset.asset.id}/depreciation?startYear=2007&endYear=2007`, { headers: auth }))
|
||||
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
|
||||
if (goY1 !== 420000) throw new Error(`GO Zone year-1 (50% allowance + §179 increase) should be 420000, got ${goY1}`);
|
||||
|
||||
// A GO Zone asset placed in service in 2010 is past the §179 window (ends 2008) but still within
|
||||
// the §168 allowance window (to 2011). It must get the 50% allowance but NOT the §179 increase:
|
||||
// entered $550,000 §179 is capped at the 2010 base limit of $500,000 (not $600,000).
|
||||
// §179 500000 + bonus 50%×(600000−500000)=50000 + SL ((600000−500000−50000)/5)×0.5=5000 = 555000.
|
||||
const goLate = await request('/api/assets', {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({
|
||||
asset_id: `GOL-${suffix}`, description: 'GO Zone late', category: 'Computer Equipment',
|
||||
acquisition_cost: 600000, in_service_date: '2010-06-01', useful_life_months: 60, special_zone: 'go_zone',
|
||||
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 550000, cost: 600000 }]
|
||||
})
|
||||
});
|
||||
const goLateY1 = round((await request(`/api/assets/${goLate.asset.id}/depreciation?startYear=2010&endYear=2010`, { headers: auth }))
|
||||
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
|
||||
if (goLateY1 !== 555000) throw new Error(`GO Zone 2010 asset: §179 increase expired but allowance applies (year-1 555000), got ${goLateY1}`);
|
||||
|
||||
// Kansas Disaster Zone (Qualified Recovery Assistance): 50% §168 allowance + $100,000 §179 increase,
|
||||
// §168 and §179 windows coincide (2007-05-05 to 2008-12-31). Same shape as the GO Zone 2007 case.
|
||||
const kz = ezList.zones.find((z) => z.code === 'kansas_disaster');
|
||||
if (!kz || kz.allowance_percent !== 50 || kz.section_179_increase !== 100000 || kz.section_179_threshold_increase !== 600000) {
|
||||
throw new Error('Kansas Disaster Zone was not seeded with the 50% allowance + §179 increase/threshold');
|
||||
}
|
||||
const kzAsset = await request('/api/assets', {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({
|
||||
asset_id: `KZ-${suffix}`, description: 'Kansas recovery equipment', category: 'Computer Equipment',
|
||||
acquisition_cost: 600000, in_service_date: '2007-06-01', useful_life_months: 60, special_zone: 'kansas_disaster',
|
||||
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 200000, cost: 600000 }]
|
||||
})
|
||||
});
|
||||
const kzY1 = round((await request(`/api/assets/${kzAsset.asset.id}/depreciation?startYear=2007&endYear=2007`, { headers: auth }))
|
||||
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
|
||||
// §179 200000 (limit raised 125k→225k) + 50% allowance 200000 + SL 20000 = 420000.
|
||||
if (kzY1 !== 420000) throw new Error(`Kansas Disaster Zone year-1 (50% allowance + §179 increase) should be 420000, got ${kzY1}`);
|
||||
|
||||
// Disposal: preview gain/loss, then record a full disposal.
|
||||
const disposalAsset = await request('/api/assets', {
|
||||
method: 'POST',
|
||||
@@ -989,6 +1176,65 @@ async function main() {
|
||||
});
|
||||
if (!adjustment.adjustment?.id) throw new Error('Adjustment was not recorded');
|
||||
|
||||
// Revaluation adjustment (feeds the revaluation reports)
|
||||
const reval = await request(`/api/assets/${disposalAsset.asset.id}/adjustments`, {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ type: 'revaluation', amount: 800, book_type: 'GAAP', reason: 'Market revaluation' })
|
||||
});
|
||||
if (!reval.adjustment?.id) throw new Error('Revaluation adjustment was not recorded');
|
||||
|
||||
// Abandonment disposal (counts as a write-off) on a fresh asset
|
||||
const abandonAsset = await request('/api/assets', {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({
|
||||
asset_id: `WO-${suffix}`, description: 'Abandoned unit', category: 'Computer Equipment',
|
||||
acquisition_cost: 5000, acquired_date: '2023-01-01', in_service_date: '2023-01-01', useful_life_months: 60, depreciation_method: 'straight_line'
|
||||
})
|
||||
});
|
||||
const abandoned = await request(`/api/assets/${abandonAsset.asset.id}/disposals`, {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ book_type: 'GAAP', disposal_date: '2026-06-15', disposal_price: 0, disposal_expense: 0, method: 'abandonment' })
|
||||
});
|
||||
if (!abandoned.disposal?.id) throw new Error('Abandonment disposal was not recorded');
|
||||
|
||||
// New report families: catalog presence, summaries, journals, transactions, history, dossier.
|
||||
const newCatalogKeys = (await request('/api/reports/catalog', { headers: auth })).reports.map((r) => r.key);
|
||||
for (const key of ['asset_journal', 'fixed_asset_journal', 'journal_revaluation', 'journal_writeoff', 'txn_revaluations',
|
||||
'txn_writeoffs', 'txn_purchases', 'txn_disposals', 'asset_summary', 'depreciation_summary', 'ytd_depreciation_summary',
|
||||
'purchase_summary', 'disposal_summary', 'revaluation_summary', 'writeoff_summary', 'asset_history', 'future_depreciation',
|
||||
'journal_depreciation_monthly', 'journal_disposals_monthly']) {
|
||||
if (!newCatalogKeys.includes(key)) throw new Error(`Report catalog missing ${key}`);
|
||||
}
|
||||
const revalSummary = await runReportApi('revaluation_summary', { year: 2026 });
|
||||
if (!revalSummary.report.rows.some((r) => /revaluation/i.test(r.type))) throw new Error('Revaluation summary did not include the revaluation');
|
||||
const writeoffTxn = await runReportApi('txn_writeoffs', { year: 2026 });
|
||||
const woSources = writeoffTxn.report.rows.map((r) => r.source);
|
||||
if (!woSources.includes('Impairment') || !woSources.includes('Abandonment')) {
|
||||
throw new Error('Write-off transactions must include both impairment and abandonment');
|
||||
}
|
||||
const woJournal = await runReportApi('journal_writeoff', { year: 2026 });
|
||||
if (Math.round(woJournal.report.totals.debit) !== Math.round(woJournal.report.totals.credit) || woJournal.report.totals.debit <= 0) {
|
||||
throw new Error('Write-off journal entry must be balanced and non-zero');
|
||||
}
|
||||
const assetSummaryReport = await runReportApi('asset_summary', { book: 'GAAP', year: 2026 });
|
||||
if (!assetSummaryReport.report.rows.length || typeof assetSummaryReport.report.totals.count !== 'number') {
|
||||
throw new Error('Asset summary did not aggregate by category');
|
||||
}
|
||||
const historyReport = await runReportApi('asset_history', { asset_id: disposalAsset.asset.id });
|
||||
if (!historyReport.report.rows.some((r) => /disposal/i.test(r.event))) throw new Error('Asset history did not include the disposal event');
|
||||
const journalReport = await runReportApi('asset_journal', { asset_id: disposalAsset.asset.id });
|
||||
if (journalReport.report.meta?.layout !== 'dossier' || !journalReport.report.meta.sections.length) {
|
||||
throw new Error('Asset journal did not return a dossier with sections');
|
||||
}
|
||||
const journalPdf = await fetch(`${baseUrl}/api/reports/export`, {
|
||||
method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'asset_journal', options: { asset_id: disposalAsset.asset.id }, format: 'pdf' })
|
||||
});
|
||||
const journalPdfBuf = Buffer.from(await journalPdf.arrayBuffer());
|
||||
if (!journalPdf.ok || journalPdf.headers.get('content-type') !== 'application/pdf' || journalPdfBuf.length < 500) {
|
||||
throw new Error('Asset journal PDF export failed');
|
||||
}
|
||||
|
||||
// Lease accounting + amortization schedule
|
||||
const lease = await request('/api/leases', {
|
||||
method: 'POST',
|
||||
@@ -1124,7 +1370,7 @@ async function main() {
|
||||
const testHit = webhookHits.find((h) => h.body.event === 'test');
|
||||
if (!testHit) throw new Error('Webhook receiver did not get the test event');
|
||||
const expectedSig = `sha256=${crypto.createHmac('sha256', hookSecret).update(testHit.raw).digest('hex')}`;
|
||||
if (testHit.headers['x-mixedassets-signature'] !== expectedSig) {
|
||||
if (testHit.headers['x-deprecore-signature'] !== expectedSig) {
|
||||
throw new Error('Webhook HMAC signature was missing or incorrect');
|
||||
}
|
||||
|
||||
@@ -1172,7 +1418,7 @@ async function main() {
|
||||
const savedSn = await request('/api/servicenow/settings', {
|
||||
method: 'PUT', headers: auth,
|
||||
body: JSON.stringify({
|
||||
servicenow_instance_url: snUrl, servicenow_username: 'svc_mixedassets', servicenow_password: 'snpass',
|
||||
servicenow_instance_url: snUrl, servicenow_username: 'svc_deprecore', servicenow_password: 'snpass',
|
||||
servicenow_ticket_enabled: true, servicenow_cmdb_table: 'cmdb_ci_hardware'
|
||||
})
|
||||
});
|
||||
@@ -1226,6 +1472,109 @@ async function main() {
|
||||
await new Promise((resolve) => snServer.close(resolve));
|
||||
}
|
||||
|
||||
// ---- Password access control, lockout & audit trail -----------------------
|
||||
// Helper that returns status + body instead of throwing, so we can assert on rejections.
|
||||
async function rawRequest(p, options = {}) {
|
||||
const response = await fetch(`${baseUrl}${p}`, {
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
return { status: response.status, body };
|
||||
}
|
||||
|
||||
// Tighten the policy: long, complex, short reuse window, fast lockout. expiry_days 0 keeps the
|
||||
// admin session from being forced to change mid-test.
|
||||
const policyResp = await request('/api/password-policy', {
|
||||
method: 'PUT', headers: auth,
|
||||
body: JSON.stringify({ policy: {
|
||||
min_length: 14, require_upper: true, require_lower: true, require_number: true, require_symbol: true,
|
||||
disallow_identifiers: true, history_count: 3, expiry_days: 0, min_age_hours: 0,
|
||||
lockout_threshold: 3, lockout_window_minutes: 5
|
||||
} })
|
||||
});
|
||||
if (policyResp.policy.min_length !== 14 || policyResp.policy.lockout_threshold !== 3) {
|
||||
throw new Error('Password policy did not persist');
|
||||
}
|
||||
if (!Array.isArray(policyResp.rules) || !policyResp.rules.length) throw new Error('Policy rules were not described');
|
||||
|
||||
const secEmail = `sec-${suffix}@example.com`;
|
||||
const strongPassword = 'Str0ng!Passphrase#2024';
|
||||
|
||||
// A weak password is rejected by the policy.
|
||||
const weak = await rawRequest('/api/users', {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ name: 'Pat Quinn', email: secEmail, password: 'short', role: 'viewer' })
|
||||
});
|
||||
if (weak.status !== 400) throw new Error(`Weak password should be rejected (got ${weak.status})`);
|
||||
|
||||
// A compliant password succeeds.
|
||||
const created = await request('/api/users', {
|
||||
method: 'POST', headers: auth,
|
||||
body: JSON.stringify({ name: 'Pat Quinn', email: secEmail, password: strongPassword, role: 'viewer' })
|
||||
});
|
||||
const secUserId = created.user.id;
|
||||
|
||||
// The new user can sign in (admin-set real password does not force a change).
|
||||
const secLogin = await request('/api/auth/login', {
|
||||
method: 'POST', body: JSON.stringify({ email: secEmail, password: strongPassword })
|
||||
});
|
||||
if (secLogin.mustChangePassword) throw new Error('Admin-set password should not force a change');
|
||||
const secAuth = { Authorization: `Bearer ${secLogin.token}` };
|
||||
|
||||
// Reusing the current password via self-service change is blocked by password history.
|
||||
const reuse = await rawRequest('/api/profile/password', {
|
||||
method: 'PUT', headers: secAuth,
|
||||
body: JSON.stringify({ current_password: strongPassword, new_password: strongPassword })
|
||||
});
|
||||
if (reuse.status !== 400) throw new Error(`Password reuse should be rejected (got ${reuse.status})`);
|
||||
|
||||
// A fresh compliant password is accepted.
|
||||
const newPassword = 'Rotated!Secret#2025';
|
||||
const changed = await rawRequest('/api/profile/password', {
|
||||
method: 'PUT', headers: secAuth,
|
||||
body: JSON.stringify({ current_password: strongPassword, new_password: newPassword })
|
||||
});
|
||||
if (changed.status !== 200) throw new Error(`Valid password change failed (got ${changed.status} ${JSON.stringify(changed.body)})`);
|
||||
|
||||
// Account lockout: the threshold is 3, so the third bad attempt locks the account.
|
||||
let lockedStatus = null;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const attempt = await rawRequest('/api/auth/login', {
|
||||
method: 'POST', body: JSON.stringify({ email: secEmail, password: 'wrong-password' })
|
||||
});
|
||||
lockedStatus = attempt.status;
|
||||
}
|
||||
if (lockedStatus !== 423) throw new Error(`Account should lock after 3 failures (got ${lockedStatus})`);
|
||||
|
||||
// Even the correct password is refused while locked.
|
||||
const whileLocked = await rawRequest('/api/auth/login', {
|
||||
method: 'POST', body: JSON.stringify({ email: secEmail, password: newPassword })
|
||||
});
|
||||
if (whileLocked.status !== 423) throw new Error('Correct password should be refused while locked');
|
||||
|
||||
// An admin can unlock; the user can then sign in again.
|
||||
await request(`/api/users/${secUserId}/unlock`, { method: 'POST', headers: auth });
|
||||
const afterUnlock = await rawRequest('/api/auth/login', {
|
||||
method: 'POST', body: JSON.stringify({ email: secEmail, password: newPassword })
|
||||
});
|
||||
if (afterUnlock.status !== 200) throw new Error(`Login after unlock failed (got ${afterUnlock.status})`);
|
||||
|
||||
// Audit trail: failed logins and the lockout were recorded and are queryable.
|
||||
const failedLogs = await request('/api/audit-logs?action=login_failed', { headers: auth });
|
||||
if (failedLogs.total < 3) throw new Error(`Expected >=3 login_failed audit rows, found ${failedLogs.total}`);
|
||||
if (!failedLogs.facets || !failedLogs.facets.actions.includes('login_failed')) {
|
||||
throw new Error('Audit facets did not include login_failed');
|
||||
}
|
||||
const lockLogs = await request('/api/audit-logs?action=account_locked', { headers: auth });
|
||||
if (lockLogs.total < 1) throw new Error('account_locked event was not audited');
|
||||
|
||||
// CSV export returns text/csv with a header row.
|
||||
const csvResp = await fetch(`${baseUrl}/api/audit-logs/export?action=login_failed`, { headers: auth });
|
||||
if (!(csvResp.headers.get('content-type') || '').includes('text/csv')) throw new Error('Audit export was not text/csv');
|
||||
const csvText = await csvResp.text();
|
||||
if (!csvText.startsWith('id,timestamp,actor,action')) throw new Error('Audit CSV header was malformed');
|
||||
|
||||
console.log('Smoke test passed');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user