Password Policy/App rename/PM Scheduling Improvements

This commit is contained in:
2026-06-06 02:32:47 -05:00
parent dfd999b6a9
commit f024286b5e
59 changed files with 3814 additions and 299 deletions

View File

@@ -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%×(600000200000)=200000 + SL ((600000200000200000)/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%×(600000500000)=50000 + SL ((60000050000050000)/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');
}