Files
tilbudgivern/backend/__tests__/smartPackagesReadAuthorization.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

184 lines
7.3 KiB
JavaScript

const express = require('express');
const request = require('supertest');
const jwt = require('jsonwebtoken');
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'smart-package-route-inventory-secret';
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'smart-package-route-inventory-refresh-secret';
process.env.AUTH_USERNAME = 'configured-operator';
jest.mock('uuid', () => ({ v4: () => 'route-inventory-correlation-id' }));
jest.mock('../src/utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
logInfo: jest.fn()
}));
const smartPackagesRoutes = require('../src/routes/smartPackagesRoutes');
const BASE_PATH = '/api/smart-packages';
const PUBLIC_READ_ROUTES = [
{ method: 'get', path: '/', requestPath: '' },
{ method: 'get', path: '/:id', requestPath: '/7' }
];
const INTERNAL_READ_ROUTES = [
{ method: 'get', path: '/management', requestPath: '/management' },
{ method: 'get', path: '/tasks', requestPath: '/tasks' },
{ method: 'get', path: '/categories', requestPath: '/categories' },
{ method: 'get', path: '/integrity-report', requestPath: '/integrity-report' },
{ method: 'get', path: '/haandvaerkpriser-preview', requestPath: '/haandvaerkpriser-preview' },
{ method: 'get', path: '/review-queue', requestPath: '/review-queue' },
{ method: 'get', path: '/material-master-search', requestPath: '/material-master-search?q=tagrende' },
{ method: 'get', path: '/excel-uploads', requestPath: '/excel-uploads' },
{ method: 'get', path: '/excel-uploads/:filename/packages', requestPath: '/excel-uploads/example.xlsx/packages' },
{ method: 'get', path: '/excel-mapping/:jobId', requestPath: '/excel-mapping/job-1' },
{ method: 'post', path: '/excel-standard-preview', requestPath: '/excel-standard-preview' },
{ method: 'get', path: '/excel-validation/:jobId', requestPath: '/excel-validation/job-1' },
{ method: 'post', path: '/history-search', requestPath: '/history-search' },
{ method: 'post', path: '/combined-history-search', requestPath: '/combined-history-search' },
{ method: 'get', path: '/components', requestPath: '/components' },
{ method: 'get', path: '/statistics', requestPath: '/statistics' },
{ method: 'get', path: '/export', requestPath: '/export' },
{ method: 'get', path: '/templates', requestPath: '/templates' },
{ method: 'get', path: '/management/:id', requestPath: '/management/7' },
{ method: 'get', path: '/custom-tasks', requestPath: '/custom-tasks' },
{ method: 'get', path: '/:id/work-description', requestPath: '/7/work-description' },
{ method: 'get', path: '/:id/related', requestPath: '/7/related' },
{ method: 'get', path: '/custom-packages', requestPath: '/custom-packages' }
];
const READ_LIKE_POST_PATHS = new Set([
'/excel-standard-preview',
'/history-search',
'/combined-history-search'
]);
const buildApp = () => {
const app = express();
app.use(express.json());
app.use(BASE_PATH, smartPackagesRoutes);
return app;
};
const authHeader = username => (
`Bearer ${jwt.sign({ id: 1, username }, process.env.JWT_ACCESS_SECRET)}`
);
const routeKey = ({ method, path }) => `${method.toUpperCase()} ${path}`;
const inventoryReadRoutes = () => smartPackagesRoutes.stack
.filter(layer => layer.route)
.flatMap(layer => Object.keys(layer.route.methods)
.filter(method => method === 'get' || (method === 'post' && READ_LIKE_POST_PATHS.has(layer.route.path)))
.map(method => ({ method, path: layer.route.path })))
.sort((left, right) => routeKey(left).localeCompare(routeKey(right)));
const findRoute = ({ method, path }) => smartPackagesRoutes.stack.find(layer => (
layer.route?.path === path && layer.route.methods[method]
));
const runAuthChain = (route, authorization) => {
const middleware = findRoute(route).route.stack.slice(0, -1).map(layer => layer.handle);
const req = { headers: { authorization } };
const res = {
locals: {},
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis()
};
return new Promise((resolve, reject) => {
let index = 0;
const next = error => {
if (error) return reject(error);
if (index === middleware.length) return resolve({ req, res });
const handler = middleware[index++];
try {
return handler(req, res, next);
} catch (caught) {
return reject(caught);
}
};
next();
});
};
describe('Smart Package route authorization inventory', () => {
afterEach(() => {
delete global.smartPackageManagementService;
});
test('inventories every read route and allowlists only the active verified composition list/detail', () => {
const expected = [...PUBLIC_READ_ROUTES, ...INTERNAL_READ_ROUTES]
.map(({ method, path }) => ({ method, path }))
.sort((left, right) => routeKey(left).localeCompare(routeKey(right)));
expect(inventoryReadRoutes()).toEqual(expected);
});
test.each(INTERNAL_READ_ROUTES)('$method $path rejects unauthenticated and ordinary JWT requests', async route => {
const app = buildApp();
const unauthenticated = await request(app)[route.method](`${BASE_PATH}${route.requestPath}`).send({});
const ordinaryJwt = await request(app)[route.method](`${BASE_PATH}${route.requestPath}`)
.set('Authorization', authHeader('ordinary-user'))
.send({});
expect(unauthenticated.status).toBe(401);
expect(ordinaryJwt.status).toBe(403);
});
test.each(INTERNAL_READ_ROUTES)('$method $path admits the configured operator through the complete auth chain', async route => {
const { req, res } = await runAuthChain(route, authHeader(process.env.AUTH_USERNAME));
expect(req.user).toEqual(expect.objectContaining({ username: process.env.AUTH_USERNAME }));
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
test('keeps the active verified public composition list and detail unauthenticated and allowlisted', async () => {
const internalFields = {
created_by: 'excel-import',
excel_source_sheet: 'Internal prices',
validation_notes: 'internal review',
search_text: 'internal aggregate'
};
global.smartPackageManagementService = {
getPackages: jest.fn().mockResolvedValue([{
id: 7,
name: 'Tagrende',
validation_status: 'verified',
is_active: 1,
total_count: 1,
...internalFields
}]),
getPackageDetails: jest.fn().mockResolvedValue({
id: 7,
name: 'Tagrende',
validation_status: 'verified',
is_active: 1,
materials: [{ id: 11, name: 'Zink', raw_line: 'internal raw line' }],
...internalFields
})
};
const app = buildApp();
const list = await request(app).get(BASE_PATH);
const detail = await request(app).get(`${BASE_PATH}/7`);
expect(list.status).toBe(200);
expect(detail.status).toBe(200);
expect(list.body.packages).toEqual([{ id: 7, name: 'Tagrende', validation_status: 'verified' }]);
expect(detail.body.package).toEqual({
id: 7,
name: 'Tagrende',
validation_status: 'verified',
materials: [{ id: 11, name: 'Zink' }]
});
});
});
// Route fixtures include the live account required by the shared authorization boundary.
beforeEach(() => {
jest.spyOn(require('../src/services/userService'), 'findByUsername').mockImplementation(async username => ({ id: 1, username, role: 'admin' }));
});
afterEach(() => jest.restoreAllMocks());