Files
tilbudgivern/backend/__tests__/authAccountBootstrap.test.js
T
f37adae2cb
CI - Test & Build / Lint & Type Check (push) Canceled after 0s
CI - Test & Build / Backend Unit Tests (push) Canceled after 0s
CI - Test & Build / Frontend Build (push) Canceled after 0s
CI - Test & Build / Security Scan (push) Canceled after 0s
CI - Test & Build / E2E Tests (Playwright) (push) Canceled after 0s
CI - Test & Build / CI Summary (push) Canceled after 0s
feat: deliver auditable Smart Pakke quote flow and free site geometry (#31)
* feat: move login credentials to a DB-backed users table with an admin management page

Replaces the hardcoded AUTH_USERNAME/AUTH_PASSWORD login check with a new
auth_accounts table (bcrypt-hashed passwords, admin/user roles). Adds
admin-only /api/users CRUD routes and a "Brugere" admin page in the
frontend for managing logins without redeploying. Removes the unused,
unmounted duplicate login route in src/routes/auth.js.

* docs: add architecture codemaps with diagrams for the whole system

Adds codemaps/architecture.md, backend.md, frontend.md, and data.md —
Mermaid-diagrammed design documentation verified against the live
codebase and database rather than assumed from CLAUDE.md. Covers the
unified-server.js request flow (mounted routers + ~183 inline routes),
68 backend services grouped by domain, the frontend's state-driven
view-switch (no React Router in practice despite BrowserRouter being
present), and the full 122-table DB schema with the auth_accounts vs
unrelated users table naming trap flagged explicitly. Links added from
the root README.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* feat: ship canonical roof quote workflow

* fix: keep migration dry-run idempotent

* [verified] feat: complete Smart Pakker management

* [verified] fix: ignore blank task dependencies

* [verified] fix: align package duplication with schema

* [verified] fix: enforce Discord status limits

* [verified] fix: link Smart Pakke materials safely

* [verified] fix: harden material link review

* [verified] feat: improve material matching

* fix: scope pitch validation to roof packages

* fix: support canonical snapshots on production schema

* [verified] fix: hide internal package metadata from PDF

* [verified] feat: deliver sales-ready customer PDF

* [verified] feat: ship sales-ready PDF with AI overview

* [verified] fix: authenticate project list requests

* [verified] fix: refresh project-list authentication

* [verified] fix: open existing project details

* [verified] fix: keep roof components searchable in builder

* [verified] fix: expose all Smart Package categories

* [verified] fix: authenticate project creation

* [verified] feat: make Smart Pakker the universal project flow

* [verified] feat: preview Smart Package contents

* [verified] test: keep generic release isolated from downpipe work

* feat: add first-class Smart Pakke rentals

* [verified] feat: add gutter and downpipe smart packages

* [verified] fix: prepare six-house gutter quote flow

* [verified] fix: open generic quotes without roof geometry

* [verified] fix: review generic quotes with authenticated APIs

* [verified] fix: calculate generic Smart Package quotes

* [verified] fix: return generic calculation breakdown

* feat: checkpoint generic signed snapshot validation with red-green tests

* feat: complete fail-closed generic quote approval and customer PDF flow

* feat: use generic signed snapshot in final review

* feat: redesign generic quote final review

* fix: harden generic review summaries

* feat: add auditable six-house package basis

* [verified] feat: finish auditable Smart Pakke UI

* [verified] fix: bind auditable quantity and price bases

* [verified] fix: keep six-house basis across package versions

* [verified] fix: complete smart package discovery management

* [verified] fix: simplify composition and generic scope

* [verified] test: keep explicit roof contracts fail closed

* [verified] fix: harden generic quote snapshots

* fix: make generic quote delivery customer safe

* [verified] fix: secure package catalog reads

* [verified] fix: close workspace provenance blockers

* fix: harden customer document language boundary

* [verified] fix: secure smart package internal reads

* fix: version package child mutations atomically

* feat: add generic customer quote text flow

* [verified] fix: allow manual customer numbers

* [verified] fix: expose optional roof geometry

* [verified] fix: rebase hydrated packages after geometry edits

* [verified] feat: add free editable site area map

* [verified] fix: harden map recovery and geocoding gate

* fix: bind map quantities to authoritative geometry

* fix: release geocoder lock before dispatch

* fix: separate roof and site geometry provenance

* fix: revoke stale admin authorization

* fix: migrate task geometry basis

* fix: make backend CI dependency-complete

* ci: seed isolated e2e login account

* fix: allow clean database bootstrap

* fix: skip indexes for optional tables

* test: use canonical mansard geometry in e2e

* [verified] fix(auth): enforce live operator boundary

* fix: fail close Ordrestyring offer transport

* fix(frontend): authenticate customer project requests

* fix: align canonical roof type contract

* [verified] fix: reconcile legacy package labor safely

* [verified] fix: audit site geometry deletion

* docs: add PR 31 reviewer guide

* docs: synchronize Obsidian vault

* docs: sync integrated reviewer guide to Obsidian

* ci: seed isolated auth account explicitly

* fix: close offer bootstrap and service readiness gaps

* fix: authenticate protected package callers

* fix: provision initial admin and disable generic send

* [verified] fix: close final quote release blockers

* [verified] fix: seed gutter packages before deployment

---------

Co-authored-by: alexpolo1 <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-09-26 22:39:18 +02:00

93 lines
4.7 KiB
JavaScript

jest.mock('mysql2/promise', () => ({ createPool: jest.fn(), createConnection: jest.fn() }));
const mysql = require('mysql2/promise');
const db = require('../src/services/databaseService');
afterEach(() => jest.restoreAllMocks());
test('normal clean startup creates auth schema idempotently without credential writes', async () => {
process.env.DB_PASSWORD = 'test-only';
const execute = jest.fn(async sql => {
if (/INFORMATION_SCHEMA\.TABLES/i.test(sql)) return [[{ ENGINE: 'InnoDB' }]];
if (/COUNT\(\*\).*auth_accounts/is.test(sql)) return [[{ account_count: 1 }]];
return [[]];
});
mysql.createPool.mockReturnValue({ execute, getConnection: async () => ({ query: async () => [[]], release() {} }) });
for (const method of ['ensureSystemSettingsTable', 'removeLegacySupportSecrets', 'syncSupportSettingsFromEnv', 'createTables', 'seedAiFeatureSettings']) jest.spyOn(db, method).mockResolvedValue();
await db.initialize();
await db.initialize();
expect(execute.mock.calls).toHaveLength(8);
for (const [sql] of execute.mock.calls) {
expect(sql).not.toMatch(/INSERT|UPDATE auth_accounts|REPLACE/i);
}
expect(execute.mock.calls.filter(([sql]) => /CREATE TABLE IF NOT EXISTS auth_accounts/i.test(sql))).toHaveLength(2);
expect(execute.mock.calls.filter(([sql]) => /CREATE TABLE IF NOT EXISTS ordrestyring_offer_operations/i.test(sql))).toHaveLength(2);
});
test('provisions the first admin once from configured credentials without overwriting accounts', async () => {
const execute = jest.fn()
.mockResolvedValueOnce([[{ account_count: 0 }]])
.mockResolvedValueOnce([{ affectedRows: 1 }]);
const hashPassword = jest.fn().mockResolvedValue('$2b$12$initial-admin-hash');
const { provisionInitialAdmin } = require('../src/services/authAccountSchema');
await expect(provisionInitialAdmin({ execute }, {
env: { AUTH_USERNAME: 'operator', AUTH_PASSWORD: 'configured-secret' },
hashPassword
})).resolves.toBe(true);
expect(hashPassword).toHaveBeenCalledWith('configured-secret', 12);
expect(execute.mock.calls[1]).toEqual([
expect.stringMatching(/INSERT IGNORE INTO auth_accounts/),
['operator', '$2b$12$initial-admin-hash', 'admin']
]);
});
test('fails closed when an empty account table has no initial admin credentials', async () => {
const { provisionInitialAdmin } = require('../src/services/authAccountSchema');
const execute = jest.fn().mockResolvedValueOnce([[{ account_count: 0 }]]);
await expect(provisionInitialAdmin({ execute }, { env: {}, hashPassword: jest.fn() }))
.rejects.toMatchObject({ code: 'INITIAL_ADMIN_REQUIRED' });
expect(execute).toHaveBeenCalledTimes(1);
});
test('converts a legacy non-transactional auth table before account mutations can run', async () => {
const execute = jest.fn(async sql => (
/INFORMATION_SCHEMA\.TABLES/i.test(sql) ? [[{ ENGINE: 'MyISAM' }]] : [[]]
));
await require('../src/services/authAccountSchema').ensureAuthAccountsTable({ execute });
expect(execute.mock.calls.map(([sql]) => sql)).toEqual([
expect.stringMatching(/CREATE TABLE IF NOT EXISTS auth_accounts/i),
expect.stringMatching(/INFORMATION_SCHEMA\.TABLES/i),
expect.stringMatching(/ALTER TABLE auth_accounts ENGINE=InnoDB/i)
]);
});
test('migration has no credential seeding or hashing dependency', () => {
const source = require('fs').readFileSync(require('path').join(__dirname, '../../database/migrations/20260904_users_table.js'), 'utf8');
expect(source).not.toMatch(/bcrypt|AUTH_PASSWORD|INSERT|seedUsers/);
});
test('migration executes only idempotent DDL even when legacy credentials are configured', async () => {
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const filename = path.join(__dirname, '../../database/migrations/20260904_users_table.js');
const execute = jest.fn(async sql => (
/INFORMATION_SCHEMA\.TABLES/i.test(sql) ? [[{ ENGINE: 'InnoDB' }]] : [[]]
));
let finished;
const done = new Promise(resolve => { finished = resolve; });
const connection = { execute, end: jest.fn(async () => finished()) };
const localRequire = name => {
if (name === 'mysql2/promise') return { createConnection: async () => connection };
if (name === 'dotenv') return { config() {} };
if (name.includes('authAccountSchema')) return require('../src/services/authAccountSchema');
return require(name);
};
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), {
require: localRequire, __dirname: path.dirname(filename), console,
process: { env: { AUTH_USERNAME: 'operator', AUTH_PASSWORD: 'must-not-be-written' }, exit: jest.fn() }
});
await done;
expect(execute).toHaveBeenCalledTimes(2);
expect(execute.mock.calls[0][0]).toMatch(/CREATE TABLE IF NOT EXISTS auth_accounts/);
expect(execute.mock.calls[0]).toHaveLength(1);
});