diff --git a/backend/src/main/java/backend/fullstack/auth/AuthCapabilitiesController.java b/backend/src/main/java/backend/fullstack/auth/AuthCapabilitiesController.java index 953413b..7353be3 100644 --- a/backend/src/main/java/backend/fullstack/auth/AuthCapabilitiesController.java +++ b/backend/src/main/java/backend/fullstack/auth/AuthCapabilitiesController.java @@ -12,6 +12,12 @@ @RestController @RequestMapping("/api/auth") +/** + * Controller exposing the current caller's resolved authorization capabilities. + * + *

The endpoint is intended for frontend bootstrap after authentication so + * navigation and feature flags can be derived from effective permissions. + */ public class AuthCapabilitiesController { private final AuthorizationService authorizationService; diff --git a/backend/src/main/java/backend/fullstack/auth/invite/UserInviteTokenRepository.java b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteTokenRepository.java index 89aeb96..cb43fdb 100644 --- a/backend/src/main/java/backend/fullstack/auth/invite/UserInviteTokenRepository.java +++ b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteTokenRepository.java @@ -4,7 +4,18 @@ import org.springframework.data.jpa.repository.JpaRepository; +/** + * Repository for one-time invite tokens. + */ public interface UserInviteTokenRepository extends JpaRepository { + + /** + * Finds a token row by its SHA-256 token hash. + */ Optional findByTokenHash(String tokenHash); + + /** + * Deletes still-open invite tokens for a user before issuing a new one. + */ void deleteByUserIdAndConsumedAtIsNull(Long userId); } diff --git a/backend/src/main/java/backend/fullstack/auth/invite/package-info.java b/backend/src/main/java/backend/fullstack/auth/invite/package-info.java new file mode 100644 index 0000000..9400c05 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/auth/invite/package-info.java @@ -0,0 +1,7 @@ +/** + * Invite-token lifecycle and onboarding email support. + * + *

Includes token persistence, token validation, one-time password setup, + * and externally configured invite-link/email properties. + */ +package backend.fullstack.auth.invite; diff --git a/backend/src/main/java/backend/fullstack/auth/package-info.java b/backend/src/main/java/backend/fullstack/auth/package-info.java new file mode 100644 index 0000000..c4c2e68 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/auth/package-info.java @@ -0,0 +1,9 @@ +/** + * Authentication and account-bootstrap APIs. + * + *

This package contains request/response contracts and controllers for + * login, logout, first-admin bootstrap registration, and invite acceptance. + * JWT generation/validation utilities live in the config package, while invite + * token lifecycle handling is implemented in the auth.invite subpackage. + */ +package backend.fullstack.auth; diff --git a/backend/src/main/java/backend/fullstack/user/dto/package-info.java b/backend/src/main/java/backend/fullstack/user/dto/package-info.java new file mode 100644 index 0000000..2167e30 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/user/dto/package-info.java @@ -0,0 +1,7 @@ +/** + * DTO contracts for user and admin-user APIs. + * + *

Contains request/response payloads used by user controllers, including + * profile updates, role changes, temporary scope assignments, and overrides. + */ +package backend.fullstack.user.dto; diff --git a/backend/src/main/java/backend/fullstack/user/package-info.java b/backend/src/main/java/backend/fullstack/user/package-info.java new file mode 100644 index 0000000..39c4b38 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/user/package-info.java @@ -0,0 +1,8 @@ +/** + * User management, access context, and organization-scoped user operations. + * + *

This package contains the user entity model, repositories, service-layer + * business rules, and controllers for user lifecycle and delegated admin + * operations such as temporary location scope and permission assignment. + */ +package backend.fullstack.user; diff --git a/backend/src/main/java/backend/fullstack/user/role/package-info.java b/backend/src/main/java/backend/fullstack/user/role/package-info.java new file mode 100644 index 0000000..f88c497 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/user/role/package-info.java @@ -0,0 +1,7 @@ +/** + * Role model and role-update API contracts. + * + *

Defines role hierarchy enums and DTOs used when changing user role and + * related location-scoping behavior. + */ +package backend.fullstack.user.role; diff --git a/backend/src/main/resources/db/migration-dev/V9002__seed_bulk_demo_data.sql b/backend/src/main/resources/db/migration-dev/V9002__seed_bulk_demo_data.sql new file mode 100644 index 0000000..a13100f --- /dev/null +++ b/backend/src/main/resources/db/migration-dev/V9002__seed_bulk_demo_data.sql @@ -0,0 +1,334 @@ +-- Bulk demo data for local/dev manual testing. +-- Depends on V9000/V9001 dev seeds (org 1 + core users). + +-- --------------------------------------------------------------------------- +-- Extra locations and users +-- --------------------------------------------------------------------------- + +INSERT INTO locations (id, organization_id, name, address, created_at) +VALUES + (2, 1, 'Munkegata 14', 'Munkegata 14, 7011 Trondheim', CURRENT_TIMESTAMP), + (3, 1, 'Nedre Elvehavn 5', 'Nedre Elvehavn 5, 7014 Trondheim', CURRENT_TIMESTAMP), + (4, 1, 'Solsiden Brygge 3', 'Solsiden Brygge 3, 7014 Trondheim', CURRENT_TIMESTAMP), + (5, 1, 'Bakklandet 22', 'Bakklandet 22, 7013 Trondheim', CURRENT_TIMESTAMP) +ON DUPLICATE KEY UPDATE name = VALUES(name), address = VALUES(address); + +-- Password hash below is the same as admin123 in V9001 for easier local testing. +INSERT INTO users (id, organization_id, home_location_id, email, first_name, last_name, password_hash, role, is_active, created_at) +VALUES + (4, 1, 2, 'lina@everestsushi.no', 'Lina', 'Dahl', '$2a$10$rRubbwtleDuLaReYZClVcOZw9402f0TGEVJfWTjCNt935tUMwkFWm', 'MANAGER', TRUE, CURRENT_TIMESTAMP), + (5, 1, 2, 'jon@everestsushi.no', 'Jon', 'Berg', '$2a$10$rRubbwtleDuLaReYZClVcOZw9402f0TGEVJfWTjCNt935tUMwkFWm', 'STAFF', TRUE, CURRENT_TIMESTAMP), + (6, 1, 3, 'sara@everestsushi.no', 'Sara', 'Nilsen', '$2a$10$rRubbwtleDuLaReYZClVcOZw9402f0TGEVJfWTjCNt935tUMwkFWm', 'STAFF', TRUE, CURRENT_TIMESTAMP), + (7, 1, 3, 'mats@everestsushi.no', 'Mats', 'Andreassen','$2a$10$rRubbwtleDuLaReYZClVcOZw9402f0TGEVJfWTjCNt935tUMwkFWm', 'STAFF', TRUE, CURRENT_TIMESTAMP), + (8, 1, 4, 'ingrid@everestsushi.no', 'Ingrid', 'Hauge', '$2a$10$rRubbwtleDuLaReYZClVcOZw9402f0TGEVJfWTjCNt935tUMwkFWm', 'MANAGER', TRUE, CURRENT_TIMESTAMP), + (9, 1, 4, 'emil@everestsushi.no', 'Emil', 'Lunde', '$2a$10$rRubbwtleDuLaReYZClVcOZw9402f0TGEVJfWTjCNt935tUMwkFWm', 'STAFF', TRUE, CURRENT_TIMESTAMP), + (10,1, 5, 'noah@everestsushi.no', 'Noah', 'Moen', '$2a$10$rRubbwtleDuLaReYZClVcOZw9402f0TGEVJfWTjCNt935tUMwkFWm', 'STAFF', TRUE, CURRENT_TIMESTAMP) +ON DUPLICATE KEY UPDATE email = VALUES(email), home_location_id = VALUES(home_location_id), role = VALUES(role); + +INSERT INTO user_locations (user_id, location_id) +VALUES + (2, 1), (2, 2), (2, 3), + (4, 2), (4, 3), + (8, 4), (8, 5), + (5, 2), (6, 3), (7, 3), (9, 4), (10, 5) +ON DUPLICATE KEY UPDATE user_id = VALUES(user_id); + +INSERT INTO user_location_scope_assignments + (id, user_id, location_id, starts_at, ends_at, assignment_mode, status, completed_at, confirmed_at, reason, created_at) +VALUES + (9001, 5, 2, TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP), NULL, 'INHERIT', 'ACTIVE', NULL, NULL, 'Default assignment', CURRENT_TIMESTAMP), + (9002, 6, 3, TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP), NULL, 'INHERIT', 'ACTIVE', NULL, NULL, 'Default assignment', CURRENT_TIMESTAMP), + (9003, 7, 3, TIMESTAMPADD(DAY, -20, CURRENT_TIMESTAMP), NULL, 'INHERIT', 'ACTIVE', NULL, NULL, 'Default assignment', CURRENT_TIMESTAMP), + (9004, 9, 4, TIMESTAMPADD(DAY, -20, CURRENT_TIMESTAMP), NULL, 'INHERIT', 'ACTIVE', NULL, NULL, 'Default assignment', CURRENT_TIMESTAMP), + (9005, 10, 5, TIMESTAMPADD(DAY, -10, CURRENT_TIMESTAMP), NULL, 'INHERIT', 'ACTIVE', NULL, NULL, 'Default assignment', CURRENT_TIMESTAMP) +ON DUPLICATE KEY UPDATE status = VALUES(status), ends_at = VALUES(ends_at); + +-- --------------------------------------------------------------------------- +-- Temperature units and bulk readings +-- --------------------------------------------------------------------------- + +INSERT INTO temperature_units + (id, organization_id, name, type, target_temperature, min_threshold, max_threshold, description, active, deleted_at, created_at, updated_at) +VALUES + (5001, 1, 'Fryser A1', 'FREEZER', -18.0, -22.0, -15.0, 'Main freezer line A1', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5002, 1, 'Fryser A2', 'FREEZER', -18.0, -22.0, -15.0, 'Main freezer line A2', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5003, 1, 'Fryser B1', 'FREEZER', -18.0, -22.0, -15.0, 'Storage freezer B1', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5004, 1, 'Fryser B2', 'FREEZER', -18.0, -22.0, -15.0, 'Storage freezer B2', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5005, 1, 'Kjoleskap Sushibar 1', 'FRIDGE', 4.0, 1.0, 6.0, 'Cold prep station', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5006, 1, 'Kjoleskap Sushibar 2', 'FRIDGE', 4.0, 1.0, 6.0, 'Cold prep station', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5007, 1, 'Kjolerom Lager', 'COOLER', 4.0, 1.0, 6.0, 'Backroom cooler', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5008, 1, 'Kjolerom Drikke', 'COOLER', 5.0, 2.0, 8.0, 'Beverage cooler', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5009, 1, 'Utstilling Disk 1', 'DISPLAY', 6.0, 2.0, 8.0, 'Front display', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5010, 1, 'Utstilling Disk 2', 'DISPLAY', 6.0, 2.0, 8.0, 'Front display', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5011, 1, 'Sausstasjon', 'OTHER', 8.0, 4.0, 10.0, 'Prepared sauces', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (5012, 1, 'Ravarer Kaldsone', 'OTHER', 5.0, 2.0, 7.0, 'Raw ingredients zone', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON DUPLICATE KEY UPDATE name = VALUES(name), updated_at = CURRENT_TIMESTAMP; + +WITH RECURSIVE seq AS ( + SELECT 0 AS n + UNION ALL + SELECT n + 1 FROM seq WHERE n < 191 +) +INSERT INTO temperature_readings + (id, organization_id, unit_id, recorded_by_user_id, temperature, recorded_at, note, is_deviation, created_at, updated_at) +SELECT + 7000 + n, + 1, + 5001 + MOD(n, 12), + CASE MOD(n, 7) + WHEN 0 THEN 1 + WHEN 1 THEN 2 + WHEN 2 THEN 3 + WHEN 3 THEN 4 + WHEN 4 THEN 5 + WHEN 5 THEN 6 + ELSE 8 + END, + CASE + WHEN MOD(n, 23) = 0 THEN -10.5 + WHEN MOD(n, 29) = 0 THEN 10.2 + ELSE CASE + WHEN MOD(n, 12) IN (0, 1, 2, 3) THEN -18.4 + (MOD(n, 6) * 0.35) + WHEN MOD(n, 12) IN (4, 5, 6, 7) THEN 3.4 + (MOD(n, 7) * 0.28) + ELSE 5.2 + (MOD(n, 5) * 0.31) + END + END, + TIMESTAMPADD(HOUR, -n, CURRENT_TIMESTAMP), + CASE WHEN MOD(n, 23) = 0 OR MOD(n, 29) = 0 THEN 'Auto-generated deviation sample' ELSE NULL END, + CASE WHEN MOD(n, 23) = 0 OR MOD(n, 29) = 0 THEN TRUE ELSE FALSE END, + TIMESTAMPADD(HOUR, -n, CURRENT_TIMESTAMP), + TIMESTAMPADD(HOUR, -n, CURRENT_TIMESTAMP) +FROM seq +ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at); + +-- --------------------------------------------------------------------------- +-- Deviations and comments +-- --------------------------------------------------------------------------- + +INSERT INTO deviations + (id, organization_id, title, description, status, severity, module_type, reported_by_id, related_reading_id, resolved_by_id, resolved_at, resolution, created_at, updated_at) +VALUES + (8001, 1, 'Fryser A1 over terskel', 'Malt temperatur var for hoy i 20 min.', 'OPEN', 'HIGH', 'IK_MAT', 2, 7023, NULL, NULL, NULL, TIMESTAMPADD(HOUR, -30, CURRENT_TIMESTAMP), TIMESTAMPADD(HOUR, -30, CURRENT_TIMESTAMP)), + (8002, 1, 'Kjolerom Drikke ustabil', 'Store variasjoner i temperatur siste dogn.', 'IN_PROGRESS', 'MEDIUM', 'IK_MAT', 4, 7046, NULL, NULL, NULL, TIMESTAMPADD(HOUR, -48, CURRENT_TIMESTAMP), TIMESTAMPADD(HOUR, -12, CURRENT_TIMESTAMP)), + (8003, 1, 'Manglende alderskontroll', 'Gjesten ble servert uten gyldig legitimasjon.', 'RESOLVED', 'CRITICAL', 'IK_ALKOHOL', 8, NULL, 1, TIMESTAMPADD(HOUR, -18, CURRENT_TIMESTAMP), 'Personale fikk oppfriskningskurs og ny dobbelkontrollrutine.', TIMESTAMPADD(HOUR, -72, CURRENT_TIMESTAMP), TIMESTAMPADD(HOUR, -18, CURRENT_TIMESTAMP)), + (8004, 1, 'Utstilling Disk 2 for varm', 'Visningsdisk holdt over 8C i rushperiode.', 'RESOLVED', 'HIGH', 'SHARED', 5, 7069, 4, TIMESTAMPADD(HOUR, -8, CURRENT_TIMESTAMP), 'Byttet vifte og justerte luftstrom.', TIMESTAMPADD(HOUR, -26, CURRENT_TIMESTAMP), TIMESTAMPADD(HOUR, -8, CURRENT_TIMESTAMP)), + (8005, 1, 'Dokumentasjon ikke oppdatert', 'Siste internrevisjon mangler signatur.', 'OPEN', 'LOW', 'SHARED', 1, NULL, NULL, NULL, NULL, TIMESTAMPADD(DAY, -2, CURRENT_TIMESTAMP), TIMESTAMPADD(DAY, -2, CURRENT_TIMESTAMP)), + (8006, 1, 'Underbemannet kveldsvakt', 'Kun en sertifisert personell i serveringssonen.', 'IN_PROGRESS', 'MEDIUM', 'IK_ALKOHOL', 2, NULL, NULL, NULL, NULL, TIMESTAMPADD(HOUR, -15, CURRENT_TIMESTAMP), TIMESTAMPADD(HOUR, -6, CURRENT_TIMESTAMP)) +ON DUPLICATE KEY UPDATE status = VALUES(status), resolution = VALUES(resolution), updated_at = VALUES(updated_at); + +INSERT INTO deviation_comments + (id, organization_id, deviation_id, created_by_id, comment_text, created_at, updated_at) +VALUES + (8101, 1, 8001, 2, 'Venter pa servicepartner for fysisk kontroll.', TIMESTAMPADD(HOUR, -28, CURRENT_TIMESTAMP), TIMESTAMPADD(HOUR, -28, CURRENT_TIMESTAMP)), + (8102, 1, 8002, 4, 'La inn midlertidig kontroll hver 30. minutt.', TIMESTAMPADD(HOUR, -24, CURRENT_TIMESTAMP), TIMESTAMPADD(HOUR, -24, CURRENT_TIMESTAMP)), + (8103, 1, 8003, 1, 'Gjennomfort samtale med involvert ansatt.', TIMESTAMPADD(HOUR, -20, CURRENT_TIMESTAMP), TIMESTAMPADD(HOUR, -20, CURRENT_TIMESTAMP)), + (8104, 1, 8004, 4, 'Sjekket at ny vifte holder stabil temperatur.', TIMESTAMPADD(HOUR, -7, CURRENT_TIMESTAMP), TIMESTAMPADD(HOUR, -7, CURRENT_TIMESTAMP)), + (8105, 1, 8006, 2, 'Oppdaterer vaktplan for kommende helg.', TIMESTAMPADD(HOUR, -5, CURRENT_TIMESTAMP), TIMESTAMPADD(HOUR, -5, CURRENT_TIMESTAMP)) +ON DUPLICATE KEY UPDATE comment_text = VALUES(comment_text), updated_at = VALUES(updated_at); + +-- --------------------------------------------------------------------------- +-- Checklists +-- --------------------------------------------------------------------------- + +INSERT INTO checklist_templates (id, organization_id, title, frequency) +VALUES + (4101, 1, 'Daglig aapning kjokken', 'DAILY'), + (4102, 1, 'Daglig stenging kjokken', 'DAILY'), + (4103, 1, 'Ukentlig renhold fryserom', 'WEEKLY'), + (4104, 1, 'Maanedlig HACCP gjennomgang', 'MONTHLY'), + (4105, 1, 'Daglig alderskontroll bar', 'DAILY') +ON DUPLICATE KEY UPDATE title = VALUES(title), frequency = VALUES(frequency); + +INSERT INTO checklist_template_items (id, template_id, item_text) +VALUES + (4201, 4101, 'Desinfiser alle arbeidsflater'), + (4202, 4101, 'Kalibrer termometer'), + (4203, 4101, 'Kontroller leveringstemperatur for fisk'), + (4204, 4102, 'Logg slutt-temperatur pa alle enheter'), + (4205, 4102, 'Lukk avvik i systemet'), + (4206, 4103, 'Tin og rengjor fordamper'), + (4207, 4103, 'Kontroller pakninger og dorlister'), + (4208, 4104, 'Signer intern revisjonsprotokoll'), + (4209, 4104, 'Oppdater risikovurdering'), + (4210, 4105, 'Brief ansatte pa legitimasjonsrutiner'), + (4211, 4105, 'Test avviksflyt for underaarige') +ON DUPLICATE KEY UPDATE item_text = VALUES(item_text); + +INSERT INTO checklist_instances (id, template_id, organization_id, title, frequency, date, status) +VALUES + (4301, 4101, 1, 'Daglig aapning kjokken', 'DAILY', CURRENT_DATE, 'IN_PROGRESS'), + (4302, 4102, 1, 'Daglig stenging kjokken', 'DAILY', CURRENT_DATE, 'PENDING'), + (4303, 4103, 1, 'Ukentlig renhold fryserom', 'WEEKLY', CURRENT_DATE, 'IN_PROGRESS'), + (4304, 4105, 1, 'Daglig alderskontroll bar', 'DAILY', CURRENT_DATE, 'COMPLETED'), + (4305, 4101, 1, 'Daglig aapning kjokken', 'DAILY', TIMESTAMPADD(DAY, -1, CURRENT_DATE), 'COMPLETED'), + (4306, 4102, 1, 'Daglig stenging kjokken', 'DAILY', TIMESTAMPADD(DAY, -1, CURRENT_DATE), 'COMPLETED'), + (4307, 4101, 1, 'Daglig aapning kjokken', 'DAILY', TIMESTAMPADD(DAY, -2, CURRENT_DATE), 'COMPLETED'), + (4308, 4102, 1, 'Daglig stenging kjokken', 'DAILY', TIMESTAMPADD(DAY, -2, CURRENT_DATE), 'COMPLETED'), + (4309, 4103, 1, 'Ukentlig renhold fryserom', 'WEEKLY', TIMESTAMPADD(DAY, -7, CURRENT_DATE), 'COMPLETED'), + (4310, 4104, 1, 'Maanedlig HACCP gjennomgang', 'MONTHLY', TIMESTAMPADD(DAY, -14, CURRENT_DATE), 'IN_PROGRESS') +ON DUPLICATE KEY UPDATE status = VALUES(status), date = VALUES(date); + +INSERT INTO checklist_instance_items (id, instance_id, item_text, completed, completed_by_user_id, completed_at) +VALUES + (4401, 4301, 'Desinfiser alle arbeidsflater', TRUE, 3, TIMESTAMPADD(HOUR, -3, CURRENT_TIMESTAMP)), + (4402, 4301, 'Kalibrer termometer', TRUE, 3, TIMESTAMPADD(HOUR, -2, CURRENT_TIMESTAMP)), + (4403, 4301, 'Kontroller leveringstemperatur for fisk', FALSE, NULL, NULL), + (4404, 4302, 'Logg slutt-temperatur pa alle enheter', FALSE, NULL, NULL), + (4405, 4302, 'Lukk avvik i systemet', FALSE, NULL, NULL), + (4406, 4303, 'Tin og rengjor fordamper', TRUE, 4, TIMESTAMPADD(HOUR, -8, CURRENT_TIMESTAMP)), + (4407, 4303, 'Kontroller pakninger og dorlister', FALSE, NULL, NULL), + (4408, 4304, 'Brief ansatte pa legitimasjonsrutiner', TRUE, 8, TIMESTAMPADD(HOUR, -9, CURRENT_TIMESTAMP)), + (4409, 4304, 'Test avviksflyt for underaarige', TRUE, 8, TIMESTAMPADD(HOUR, -8, CURRENT_TIMESTAMP)), + (4410, 4310, 'Signer intern revisjonsprotokoll', TRUE, 1, TIMESTAMPADD(DAY, -10, CURRENT_TIMESTAMP)), + (4411, 4310, 'Oppdater risikovurdering', FALSE, NULL, NULL) +ON DUPLICATE KEY UPDATE completed = VALUES(completed), completed_by_user_id = VALUES(completed_by_user_id), completed_at = VALUES(completed_at); + +-- --------------------------------------------------------------------------- +-- Training records +-- --------------------------------------------------------------------------- + +INSERT INTO training_records (id, user_id, training_type, status, completed_at, expires_at) +VALUES + (10001, 1, 'GENERAL', 'COMPLETED', TIMESTAMPADD(DAY, -200, CURRENT_TIMESTAMP), NULL), + (10002, 2, 'CHECKLIST_APPROVAL', 'COMPLETED', TIMESTAMPADD(DAY, -120, CURRENT_TIMESTAMP), TIMESTAMPADD(DAY, 245, CURRENT_TIMESTAMP)), + (10003, 3, 'FREEZER_LOGGING', 'COMPLETED', TIMESTAMPADD(DAY, -60, CURRENT_TIMESTAMP), TIMESTAMPADD(DAY, 120, CURRENT_TIMESTAMP)), + (10004, 4, 'CHECKLIST_APPROVAL', 'COMPLETED', TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP), TIMESTAMPADD(DAY, 330, CURRENT_TIMESTAMP)), + (10005, 5, 'GENERAL', 'IN_PROGRESS', TIMESTAMPADD(DAY, -3, CURRENT_TIMESTAMP), NULL), + (10006, 6, 'FREEZER_LOGGING', 'COMPLETED', TIMESTAMPADD(DAY, -40, CURRENT_TIMESTAMP), TIMESTAMPADD(DAY, 140, CURRENT_TIMESTAMP)), + (10007, 7, 'FREEZER_LOGGING', 'EXPIRED', TIMESTAMPADD(DAY, -380, CURRENT_TIMESTAMP), TIMESTAMPADD(DAY, -10, CURRENT_TIMESTAMP)), + (10008, 8, 'CHECKLIST_APPROVAL', 'COMPLETED', TIMESTAMPADD(DAY, -90, CURRENT_TIMESTAMP), TIMESTAMPADD(DAY, 275, CURRENT_TIMESTAMP)), + (10009, 9, 'GENERAL', 'COMPLETED', TIMESTAMPADD(DAY, -70, CURRENT_TIMESTAMP), NULL), + (10010, 10, 'GENERAL', 'IN_PROGRESS', TIMESTAMPADD(DAY, -1, CURRENT_TIMESTAMP), NULL) +ON DUPLICATE KEY UPDATE status = VALUES(status), completed_at = VALUES(completed_at), expires_at = VALUES(expires_at); + +-- --------------------------------------------------------------------------- +-- IK-Alkohol data +-- --------------------------------------------------------------------------- + +INSERT INTO alcohol_licenses + (id, organization_id, license_type, license_number, issued_at, expires_at, issuing_authority, notes, created_at, updated_at) +VALUES + (11001, 1, 'FULL', 'TRD-ALK-2026-771', TIMESTAMPADD(DAY, -220, CURRENT_DATE), TIMESTAMPADD(DAY, 145, CURRENT_DATE), 'Trondheim kommune', 'Gjelder servering inne og ute.', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (11002, 1, 'TEMPORARY', 'TRD-ALK-TEMP-021', TIMESTAMPADD(DAY, -30, CURRENT_DATE), TIMESTAMPADD(DAY, 20, CURRENT_DATE), 'Trondheim kommune', 'Midlertidig tillatelse for arrangement.', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at), notes = VALUES(notes), updated_at = CURRENT_TIMESTAMP; + +WITH RECURSIVE age_seq AS ( + SELECT 0 AS n + UNION ALL + SELECT n + 1 FROM age_seq WHERE n < 79 +) +INSERT INTO age_verification_logs + (id, organization_id, location_id, verified_by_user_id, verification_method, guest_appeared_underage, id_was_valid, was_refused, note, verified_at, created_at) +SELECT + 12000 + n, + 1, + 1 + MOD(n, 5), + CASE MOD(n, 5) + WHEN 0 THEN 2 + WHEN 1 THEN 4 + WHEN 2 THEN 5 + WHEN 3 THEN 8 + ELSE 9 + END, + CASE MOD(n, 5) + WHEN 0 THEN 'ID_CHECKED' + WHEN 1 THEN 'DRIVING_LICENSE_CHECKED' + WHEN 2 THEN 'PASSPORT_CHECKED' + WHEN 3 THEN 'KNOWN_REGULAR' + ELSE 'VISUALLY_OVER_AGE' + END, + CASE WHEN MOD(n, 9) = 0 THEN TRUE ELSE FALSE END, + CASE WHEN MOD(n, 7) = 0 THEN FALSE ELSE TRUE END, + CASE WHEN MOD(n, 7) = 0 THEN TRUE ELSE FALSE END, + CASE WHEN MOD(n, 7) = 0 THEN 'Refused due to invalid ID.' ELSE NULL END, + TIMESTAMPADD(HOUR, -n, CURRENT_TIMESTAMP), + TIMESTAMPADD(HOUR, -n, CURRENT_TIMESTAMP) +FROM age_seq +ON DUPLICATE KEY UPDATE was_refused = VALUES(was_refused), verified_at = VALUES(verified_at); + +WITH RECURSIVE incident_seq AS ( + SELECT 0 AS n + UNION ALL + SELECT n + 1 FROM incident_seq WHERE n < 19 +) +INSERT INTO alcohol_serving_incidents + (id, organization_id, location_id, reported_by_user_id, resolved_by_user_id, incident_type, severity, status, description, corrective_action, occurred_at, resolved_at, created_at, updated_at) +SELECT + 13000 + n, + 1, + 1 + MOD(n, 5), + CASE MOD(n, 4) + WHEN 0 THEN 2 + WHEN 1 THEN 4 + WHEN 2 THEN 8 + ELSE 9 + END, + CASE WHEN MOD(n, 3) = 0 THEN 1 ELSE NULL END, + CASE MOD(n, 6) + WHEN 0 THEN 'REFUSED_SERVICE' + WHEN 1 THEN 'INTOXICATED_PERSON' + WHEN 2 THEN 'UNDERAGE_ATTEMPT' + WHEN 3 THEN 'OVER_SERVING' + WHEN 4 THEN 'DISTURBANCE' + ELSE 'OTHER' + END, + CASE MOD(n, 4) + WHEN 0 THEN 'LOW' + WHEN 1 THEN 'MEDIUM' + WHEN 2 THEN 'HIGH' + ELSE 'CRITICAL' + END, + CASE MOD(n, 4) + WHEN 0 THEN 'OPEN' + WHEN 1 THEN 'UNDER_REVIEW' + WHEN 2 THEN 'RESOLVED' + ELSE 'CLOSED' + END, + CONCAT('Auto-seeded incident #', n), + CASE WHEN MOD(n, 2) = 0 THEN 'Briefed staff and documented incident.' ELSE NULL END, + TIMESTAMPADD(HOUR, -(n * 6), CURRENT_TIMESTAMP), + CASE WHEN MOD(n, 4) IN (2, 3) THEN TIMESTAMPADD(HOUR, -(n * 6) + 4, CURRENT_TIMESTAMP) ELSE NULL END, + TIMESTAMPADD(HOUR, -(n * 6), CURRENT_TIMESTAMP), + TIMESTAMPADD(HOUR, -(n * 3), CURRENT_TIMESTAMP) +FROM incident_seq +ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at = VALUES(updated_at); + +-- --------------------------------------------------------------------------- +-- Documents +-- --------------------------------------------------------------------------- + +WITH RECURSIVE doc_seq AS ( + SELECT 0 AS n + UNION ALL + SELECT n + 1 FROM doc_seq WHERE n < 17 +) +INSERT INTO documents + (id, organization_id, uploaded_by_id, title, description, category, file_name, content_type, file_size, file_data, created_at, updated_at) +SELECT + 14000 + n, + 1, + CASE MOD(n, 6) + WHEN 0 THEN 1 + WHEN 1 THEN 2 + WHEN 2 THEN 3 + WHEN 3 THEN 4 + WHEN 4 THEN 8 + ELSE 9 + END, + CONCAT('Demo dokument ', n + 1), + CONCAT('Auto-generert testdokument for manuell verifisering #', n + 1), + CASE MOD(n, 5) + WHEN 0 THEN 'POLICY' + WHEN 1 THEN 'TRAINING_MATERIAL' + WHEN 2 THEN 'CERTIFICATION' + WHEN 3 THEN 'INSPECTION_REPORT' + ELSE 'OTHER' + END, + CONCAT('demo_document_', n + 1, '.txt'), + 'text/plain', + LENGTH(CONCAT('seed-file-', n + 1)), + CAST(CONCAT('seed-file-', n + 1) AS BINARY), + TIMESTAMPADD(DAY, -n, CURRENT_TIMESTAMP), + TIMESTAMPADD(DAY, -n, CURRENT_TIMESTAMP) +FROM doc_seq +ON DUPLICATE KEY UPDATE title = VALUES(title), updated_at = VALUES(updated_at); diff --git a/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java b/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java index c24b0b9..17e6a82 100644 --- a/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java +++ b/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java @@ -107,6 +107,20 @@ void acceptInviteReturnsForbiddenForInvalidToken() throws Exception { .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED")); } + @Test + void acceptInviteReturnsSuccessForValidToken() throws Exception { + AcceptInviteRequest request = new AcceptInviteRequest(); + request.setToken("valid-token"); + request.setPassword("Password1"); + + mockMvc.perform(post("/api/auth/invite/accept") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Password set successfully")); + } + private static User buildUser(Long userId, String email, Long orgId) { Organization organization = Organization.builder() .id(orgId) @@ -192,12 +206,17 @@ public ResponseCookie getCleanJwtCookie() { private static final class TestUserInviteService extends UserInviteService { + private String acceptedToken; + private String acceptedPassword; + private TestUserInviteService() { super(null, null, null, null, null); } @Override public void acceptInvite(String token, String password) { + acceptedToken = token; + acceptedPassword = password; if ("invalid-token".equals(token)) { throw new org.springframework.security.access.AccessDeniedException("Invalid invite token"); } diff --git a/backend/src/test/java/backend/fullstack/auth/AuthServiceTest.java b/backend/src/test/java/backend/fullstack/auth/AuthServiceTest.java new file mode 100644 index 0000000..c08dcca --- /dev/null +++ b/backend/src/test/java/backend/fullstack/auth/AuthServiceTest.java @@ -0,0 +1,185 @@ +package backend.fullstack.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.crypto.password.PasswordEncoder; + +import backend.fullstack.exceptions.LocationException; +import backend.fullstack.exceptions.RoleException; +import backend.fullstack.exceptions.UserConflictException; +import backend.fullstack.location.Location; +import backend.fullstack.location.LocationRepository; +import backend.fullstack.organization.Organization; +import backend.fullstack.organization.OrganizationRepository; +import backend.fullstack.user.User; +import backend.fullstack.user.UserRepository; +import backend.fullstack.user.role.Role; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private OrganizationRepository organizationRepository; + @Mock + private LocationRepository locationRepository; + @Mock + private PasswordEncoder passwordEncoder; + + private AuthService authService; + + @BeforeEach + void setUp() { + authService = new AuthService(userRepository, organizationRepository, locationRepository, passwordEncoder); + } + + @Test + void registerBootstrapAdminRejectsWhenUsersAlreadyExist() { + RegisterRequest request = registerRequest(Role.ADMIN, 1L); + when(userRepository.count()).thenReturn(1L); + + assertThrows(AccessDeniedException.class, () -> authService.registerBootstrapAdmin(request)); + } + + @Test + void registerBootstrapAdminRejectsNonAdminRole() { + RegisterRequest request = registerRequest(Role.MANAGER, 1L); + when(userRepository.count()).thenReturn(0L); + + assertThrows(RoleException.class, () -> authService.registerBootstrapAdmin(request)); + } + + @Test + void registerBootstrapAdminRejectsDuplicateEmail() { + RegisterRequest request = registerRequest(Role.ADMIN, 1L); + when(userRepository.count()).thenReturn(0L); + when(userRepository.existsByEmail("admin@everest.no")).thenReturn(true); + + assertThrows(UserConflictException.class, () -> authService.registerBootstrapAdmin(request)); + } + + @Test + void registerBootstrapAdminRejectsLocationFromAnotherOrganization() { + RegisterRequest request = registerRequest(Role.ADMIN, 2L); + Organization organization = organization(1L); + Location otherOrgLocation = location(2L, 999L); + + when(userRepository.count()).thenReturn(0L); + when(userRepository.existsByEmail("admin@everest.no")).thenReturn(false); + when(organizationRepository.findById(1L)).thenReturn(Optional.of(organization)); + when(locationRepository.findById(2L)).thenReturn(Optional.of(otherOrgLocation)); + + assertThrows(LocationException.class, () -> authService.registerBootstrapAdmin(request)); + } + + @Test + void registerBootstrapAdminPersistsAdminWithEncodedPasswordAndLocation() { + RegisterRequest request = registerRequest(Role.ADMIN, 2L); + Organization organization = organization(1L); + Location location = location(2L, 1L); + + when(userRepository.count()).thenReturn(0L); + when(userRepository.existsByEmail("admin@everest.no")).thenReturn(false); + when(organizationRepository.findById(1L)).thenReturn(Optional.of(organization)); + when(locationRepository.findById(2L)).thenReturn(Optional.of(location)); + when(passwordEncoder.encode("Admin123!")).thenReturn("encoded"); + when(userRepository.save(org.mockito.ArgumentMatchers.any(User.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + User saved = authService.registerBootstrapAdmin(request); + + assertEquals("admin@everest.no", saved.getEmail()); + assertEquals(Role.ADMIN, saved.getRole()); + assertEquals("encoded", saved.getPasswordHash()); + assertEquals(1L, saved.getOrganizationId()); + assertEquals(2L, saved.getHomeLocationId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertTrue(captor.getValue().isActive()); + } + + @Test + void buildLoginResponseReturnsAllOrgLocationsForAdmin() { + User admin = User.builder() + .id(10L) + .email("admin@everest.no") + .firstName("Kari") + .lastName("Larsen") + .role(Role.ADMIN) + .organization(organization(1L)) + .build(); + + when(locationRepository.findIdsByOrganizationId(1L)).thenReturn(List.of(1L, 2L, 3L)); + + LoginResponse response = authService.buildLoginResponse(admin); + + assertEquals(10L, response.getUserId()); + assertEquals(Role.ADMIN, response.getRole()); + assertEquals(List.of(1L, 2L, 3L), response.getAllowedLocationIds()); + } + + @Test + void buildLoginResponseMergesHomeAndAdditionalLocationsForNonAdmin() { + User manager = User.builder() + .id(11L) + .email("manager@everest.no") + .firstName("Ola") + .lastName("Nordmann") + .role(Role.MANAGER) + .organization(organization(1L)) + .homeLocation(location(7L, 1L)) + .build(); + + when(userRepository.findAdditionalLocationIdsByUserId(11L)).thenReturn(List.of(7L, 8L, 9L)); + + LoginResponse response = authService.buildLoginResponse(manager); + + assertEquals(7L, response.getPrimaryLocationId()); + assertEquals(List.of(7L, 8L, 9L), response.getAllowedLocationIds()); + } + + private static RegisterRequest registerRequest(Role role, Long primaryLocationId) { + RegisterRequest request = new RegisterRequest(); + request.setEmail("admin@everest.no"); + request.setPassword("Admin123!"); + request.setFirstName("Ola"); + request.setLastName("Nordmann"); + request.setOrganizationId(1L); + request.setPrimaryLocationId(primaryLocationId); + request.setRole(role); + return request; + } + + private static Organization organization(Long id) { + return Organization.builder() + .id(id) + .name("Everest") + .organizationNumber("937219997") + .build(); + } + + private static Location location(Long id, Long orgId) { + return Location.builder() + .id(id) + .organization(organization(orgId)) + .name("Loc") + .address("Street") + .build(); + } +} \ No newline at end of file diff --git a/backend/src/test/java/backend/fullstack/auth/invite/UserInviteServiceTest.java b/backend/src/test/java/backend/fullstack/auth/invite/UserInviteServiceTest.java index 36636e7..b9437f9 100644 --- a/backend/src/test/java/backend/fullstack/auth/invite/UserInviteServiceTest.java +++ b/backend/src/test/java/backend/fullstack/auth/invite/UserInviteServiceTest.java @@ -1,20 +1,23 @@ package backend.fullstack.auth.invite; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.LocalDateTime; import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.crypto.password.PasswordEncoder; @@ -53,6 +56,69 @@ void setUp() { ); } + @Test + void createAndSendInviteReplacesExistingTokenAndSendsEmail() { + User user = User.builder() + .id(7L) + .email("user@everest.no") + .firstName("User") + .lastName("Test") + .role(Role.STAFF) + .organization(Organization.builder().id(1L).name("Everest").organizationNumber("123456789").build()) + .build(); + + userInviteService.createAndSendInvite(user); + + verify(userInviteTokenRepository).deleteByUserIdAndConsumedAtIsNull(7L); + + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(UserInviteToken.class); + verify(userInviteTokenRepository).save(tokenCaptor.capture()); + + UserInviteToken savedToken = tokenCaptor.getValue(); + assertEquals(7L, savedToken.getUserId()); + assertEquals(64, savedToken.getTokenHash().length()); + assertTrue(savedToken.getExpiresAt().isAfter(LocalDateTime.now().plusHours(23))); + + ArgumentCaptor mailCaptor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(mailCaptor.capture()); + + SimpleMailMessage sentMessage = mailCaptor.getValue(); + assertEquals("Set up your account", sentMessage.getSubject()); + assertEquals("no-reply@test.local", sentMessage.getFrom()); + assertEquals("user@everest.no", sentMessage.getTo()[0]); + assertTrue(sentMessage.getText().contains("http://localhost:5173/set-password?token=")); + } + + @Test + void createAndSendInviteWithoutMailSenderStillCreatesToken() { + InviteProperties inviteProperties = new InviteProperties(); + inviteProperties.setFrontendBaseUrl("http://localhost:5173"); + inviteProperties.setFromAddress("no-reply@test.local"); + + UserInviteService serviceWithoutMail = new UserInviteService( + userInviteTokenRepository, + userRepository, + passwordEncoder, + Optional.empty(), + inviteProperties + ); + + User user = User.builder() + .id(8L) + .email("nomail@everest.no") + .firstName("No") + .lastName("Mail") + .role(Role.STAFF) + .organization(Organization.builder().id(1L).name("Everest").organizationNumber("123456789").build()) + .build(); + + serviceWithoutMail.createAndSendInvite(user); + + verify(userInviteTokenRepository).deleteByUserIdAndConsumedAtIsNull(8L); + verify(userInviteTokenRepository, org.mockito.Mockito.times(1)).save(org.mockito.ArgumentMatchers.any(UserInviteToken.class)); + verify(mailSender, never()).send(org.mockito.ArgumentMatchers.any(SimpleMailMessage.class)); + } + @Test void acceptInviteRejectsInvalidToken() { when(userInviteTokenRepository.findByTokenHash(hash("invalid"))).thenReturn(Optional.empty()); diff --git a/backend/src/test/java/backend/fullstack/user/AccessContextServiceTest.java b/backend/src/test/java/backend/fullstack/user/AccessContextServiceTest.java index d36db1a..bb1980b 100644 --- a/backend/src/test/java/backend/fullstack/user/AccessContextServiceTest.java +++ b/backend/src/test/java/backend/fullstack/user/AccessContextServiceTest.java @@ -1,20 +1,21 @@ package backend.fullstack.user; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - import java.util.List; import java.util.Optional; import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import org.mockito.Mock; +import static org.mockito.Mockito.when; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -112,6 +113,97 @@ void assertCanAccessThrowsWhenLocationIsMissing() { () -> accessContextService.assertCanAccess(11L)); } + @Test + void assertCanAccessThrowsWhenLocationIsNull() { + assertThrows(AccessDeniedException.class, () -> accessContextService.assertCanAccess(null)); + } + + @Test + void getCurrentOrganizationIdAndRolePreferJwtClaims() { + JwtPrincipal principal = new JwtPrincipal( + 77L, + "jwt@everest.no", + Role.SUPERVISOR, + 555L, + List.of(9L) + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + assertEquals(555L, accessContextService.getCurrentOrganizationId()); + assertEquals(Role.SUPERVISOR, accessContextService.getCurrentRole()); + } + + @Test + void assertHasRoleAllowsMatchingRoleAndRejectsOthers() { + JwtPrincipal principal = new JwtPrincipal( + 88L, + "manager@everest.no", + Role.MANAGER, + 100L, + List.of(1L) + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + accessContextService.assertHasRole(Role.MANAGER, Role.ADMIN); + + assertThrows(AccessDeniedException.class, () -> accessContextService.assertHasRole(Role.ADMIN)); + } + + @Test + void getCurrentUserResolvesFromJwtUserIdWhenPrincipalHasNoEntity() { + JwtPrincipal principal = new JwtPrincipal( + 123L, + "jwt-user@everest.no", + Role.STAFF, + 100L, + List.of() + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + User stored = user(123L, 100L, Role.STAFF, "jwt-user@everest.no"); + when(userRepository.findById(123L)).thenReturn(Optional.of(stored)); + + User resolved = accessContextService.getCurrentUser(); + + assertEquals(123L, resolved.getId()); + assertEquals("jwt-user@everest.no", resolved.getEmail()); + } + + @Test + void getCurrentUserThrowsWhenUnauthenticated() { + SecurityContextHolder.clearContext(); + + assertThrows(AccessDeniedException.class, () -> accessContextService.getCurrentUser()); + } + + @Test + void getAllowedLocationIdsDeduplicatesOverlappingScopes() { + JwtPrincipal principal = new JwtPrincipal( + 2L, + "manager@everest.no", + Role.MANAGER, + 100L, + List.of(4L, 5L) + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(2L), any())) + .thenReturn(List.of(5L, 6L)); + + List allowed = accessContextService.getAllowedLocationIds(); + + assertEquals(List.of(4L, 5L, 6L), allowed); + assertTrue(allowed.stream().distinct().count() == allowed.size()); + } + private static void setAuthenticatedEmail(String email) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(email, null, List.of()); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a2ae5e3..dc103c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -598,22 +598,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -631,22 +615,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -664,22 +632,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -957,9 +909,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -977,9 +926,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -997,9 +943,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1017,9 +960,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1037,9 +977,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1057,9 +994,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1238,9 +1172,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1255,9 +1186,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1272,9 +1200,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1289,9 +1214,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1306,9 +1228,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1323,9 +1242,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1340,9 +1256,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1357,9 +1270,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1374,9 +1284,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1391,9 +1298,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1408,9 +1312,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1425,9 +1326,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1442,9 +1340,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2899,9 +2794,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2923,9 +2815,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2947,9 +2836,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2971,9 +2857,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3938,374 +3821,6 @@ } } }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "extraneous": true, - "license": "MIT", - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", @@ -4333,48 +3848,6 @@ } } }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "extraneous": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, "node_modules/vitest/node_modules/vite": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",