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: 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]>
184 lines
7.3 KiB
JavaScript
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());
|