Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@

@RestController
@RequestMapping("/api/auth")
/**
* Controller exposing the current caller's resolved authorization capabilities.
*
* <p>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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@

import org.springframework.data.jpa.repository.JpaRepository;

/**
* Repository for one-time invite tokens.
*/
public interface UserInviteTokenRepository extends JpaRepository<UserInviteToken, Long> {

/**
* Finds a token row by its SHA-256 token hash.
*/
Optional<UserInviteToken> findByTokenHash(String tokenHash);

/**
* Deletes still-open invite tokens for a user before issuing a new one.
*/
void deleteByUserIdAndConsumedAtIsNull(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Invite-token lifecycle and onboarding email support.
*
* <p>Includes token persistence, token validation, one-time password setup,
* and externally configured invite-link/email properties.
*/
package backend.fullstack.auth.invite;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Authentication and account-bootstrap APIs.
*
* <p>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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* DTO contracts for user and admin-user APIs.
*
* <p>Contains request/response payloads used by user controllers, including
* profile updates, role changes, temporary scope assignments, and overrides.
*/
package backend.fullstack.user.dto;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* User management, access context, and organization-scoped user operations.
*
* <p>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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Role model and role-update API contracts.
*
* <p>Defines role hierarchy enums and DTOs used when changing user role and
* related location-scoping behavior.
*/
package backend.fullstack.user.role;

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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");
}
Expand Down
185 changes: 185 additions & 0 deletions backend/src/test/java/backend/fullstack/auth/AuthServiceTest.java
Original file line number Diff line number Diff line change
@@ -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<User> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<UserInviteToken> 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<SimpleMailMessage> 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());
Expand Down
Loading
Loading