diff --git a/Middleware/pom.xml b/Middleware/pom.xml index 94e3ca96..22a90b24 100644 --- a/Middleware/pom.xml +++ b/Middleware/pom.xml @@ -99,6 +99,26 @@ org.springframework.security spring-security-test + + org.springframework.boot + spring-boot-starter-security + + + org.mockito + mockito-inline + 5.2.0 + test + + + org.assertj + assertj-core + test + + + org.junit.jupiter + junit-jupiter-api + test + diff --git a/Middleware/src/main/java/ca/concordia/encs/citydata/core/Application.java b/Middleware/src/main/java/ca/concordia/encs/citydata/core/Application.java index f2e1c503..b1e18e7d 100644 --- a/Middleware/src/main/java/ca/concordia/encs/citydata/core/Application.java +++ b/Middleware/src/main/java/ca/concordia/encs/citydata/core/Application.java @@ -5,11 +5,13 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.ComponentScan; +import org.springframework.security.core.context.SecurityContextHolder; import ca.concordia.encs.citydata.core.configs.RsaKeyProperties; import ca.concordia.encs.citydata.datastores.DiskDatastore; import ca.concordia.encs.citydata.datastores.InMemoryDataStore; import ca.concordia.encs.citydata.datastores.MongoDataStore; +import jakarta.annotation.PostConstruct; /** * This is the Spring Boot application entry point. @@ -31,6 +33,11 @@ public class Application { final DiskDatastore diskStore = DiskDatastore.getInstance(); final MongoDataStore mongoDataStore = MongoDataStore.getInstance(); + @PostConstruct + public void init() { + SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); + } + public static void main(String[] args) { final ApplicationContext context = SpringApplication.run(Application.class, args); final MongoDataStore mongoDataStore = context.getBean(MongoDataStore.class); diff --git a/Middleware/src/main/java/ca/concordia/encs/citydata/core/configs/SecurityConfig.java b/Middleware/src/main/java/ca/concordia/encs/citydata/core/configs/SecurityConfig.java index 411e5786..9d2e13a0 100644 --- a/Middleware/src/main/java/ca/concordia/encs/citydata/core/configs/SecurityConfig.java +++ b/Middleware/src/main/java/ca/concordia/encs/citydata/core/configs/SecurityConfig.java @@ -206,10 +206,9 @@ public AuthenticationManager authManager(UserDetailsService userDetailsService, @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http.cors(Customizer.withDefaults()).csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/authenticate", "/home", "/health/ping", "/producers/list", - "/operations/list", "/routes/list", "/error", "/apply/sync", "/apply/async") - .permitAll().anyRequest().authenticated()) + .authorizeHttpRequests(auth -> auth.requestMatchers("/authenticate", "/home", "/health/ping", + "/producers/list", "/operations/list", "/routes/list", "/error", "/apply/sync", "/apply/async", + "/api/datasets/list").permitAll().anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).build(); } diff --git a/Middleware/src/main/java/ca/concordia/encs/citydata/core/controllers/DatasetController.java b/Middleware/src/main/java/ca/concordia/encs/citydata/core/controllers/DatasetController.java new file mode 100644 index 00000000..c32e73bc --- /dev/null +++ b/Middleware/src/main/java/ca/concordia/encs/citydata/core/controllers/DatasetController.java @@ -0,0 +1,89 @@ +package ca.concordia.encs.citydata.core.controllers; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import ca.concordia.encs.citydata.core.exceptions.AccessDeniedException; +import ca.concordia.encs.citydata.core.exceptions.MetadataException; +import ca.concordia.encs.citydata.core.model.DatasetType; +import ca.concordia.encs.citydata.services.DatasetAccessService; + +/** + * REST controller providing access to the three dataset tiers. + * + * @author Sikandar Ejaz + * @since 2026-06-01 + */ + +@RestController +@RequestMapping("/api/datasets") +public class DatasetController { + + private final DatasetAccessService datasetAccessService; + + public DatasetController(DatasetAccessService datasetAccessService) { + this.datasetAccessService = datasetAccessService; + } + + @GetMapping("/list") + public ResponseEntity listDatasets() { + try { + Path filePath = Paths.get("DATA_SOURCES.md"); + String content = Files.readString(filePath, StandardCharsets.UTF_8); + return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body(content); + } catch (IOException e) { + // Fallback: try relative to project root + try { + Path filePath = Paths.get("Middleware/DATA_SOURCES.md"); + String content = Files.readString(filePath, StandardCharsets.UTF_8); + return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body(content); + } catch (IOException e2) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Could not load dataset list. Tried ./DATA_SOURCES.md and Middleware/DATA_SOURCES.md. " + + "Working directory: " + Paths.get("").toAbsolutePath()); + } + } + } + + @GetMapping("/public") + public ResponseEntity> getPublicDataset() { + return fetchDataset(DatasetType.PUBLIC); + } + + @GetMapping("/protected") + public ResponseEntity> getProtectedDataset() { + return fetchDataset(DatasetType.PROTECTED); + } + + @GetMapping("/private") + public ResponseEntity> getPrivateDataset() { + return fetchDataset(DatasetType.PRIVATE); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", ex.getMessage())); + } + + @ExceptionHandler(MetadataException.class) + public ResponseEntity> handleMetadataError(MetadataException ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", ex.getMessage())); + } + + private ResponseEntity> fetchDataset(DatasetType type) { + String content = datasetAccessService.getDatasetContent(type); + return ResponseEntity.ok(Map.of("content", content)); + } +} diff --git a/Middleware/src/main/java/ca/concordia/encs/citydata/core/exceptions/AccessDeniedException.java b/Middleware/src/main/java/ca/concordia/encs/citydata/core/exceptions/AccessDeniedException.java new file mode 100644 index 00000000..a66f2008 --- /dev/null +++ b/Middleware/src/main/java/ca/concordia/encs/citydata/core/exceptions/AccessDeniedException.java @@ -0,0 +1,20 @@ +package ca.concordia.encs.citydata.core.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Thrown when an authenticated user attempts to access a dataset they are + * not listed in the corresponding metadata file. + * + * @author Sikandar Ejaz + * @since 2026-06-01 + */ + +@ResponseStatus(HttpStatus.FORBIDDEN) +public class AccessDeniedException extends RuntimeException { + + public AccessDeniedException(String username, String datasetType) { + super(String.format("User '%s' is not authorised to access the %s dataset.", username, datasetType)); + } +} diff --git a/Middleware/src/main/java/ca/concordia/encs/citydata/core/exceptions/MetadataException.java b/Middleware/src/main/java/ca/concordia/encs/citydata/core/exceptions/MetadataException.java new file mode 100644 index 00000000..1653e156 --- /dev/null +++ b/Middleware/src/main/java/ca/concordia/encs/citydata/core/exceptions/MetadataException.java @@ -0,0 +1,24 @@ +package ca.concordia.encs.citydata.core.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Thrown when a metadata file cannot be found on the classpath, is empty, + * or has a first line that does not match the expected dataset type label. + * + * @author Sikandar Ejaz + * @since 2026-06-01 + */ + +@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) +public class MetadataException extends RuntimeException { + + public MetadataException(String message) { + super(message); + } + + public MetadataException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/Middleware/src/main/java/ca/concordia/encs/citydata/core/model/DatasetType.java b/Middleware/src/main/java/ca/concordia/encs/citydata/core/model/DatasetType.java new file mode 100644 index 00000000..0f06aea0 --- /dev/null +++ b/Middleware/src/main/java/ca/concordia/encs/citydata/core/model/DatasetType.java @@ -0,0 +1,43 @@ +package ca.concordia.encs.citydata.core.model; + +/** + * Represents the three tiers of dataset access control. + * Each type maps to a corresponding metadata file and dataset CSV. + * + * @author Sikandar Ejaz + * @since 2026-06-01 + */ + +public enum DatasetType { + + PUBLIC("Public", "public_metadata.txt", "public_dataset.csv"), + PROTECTED("Protected", "protected_metadata.txt", "protected_dataset.csv"), + PRIVATE("Private", "private_metadata.txt", "private_dataset.csv"); + + /** The exact label expected on the first line of the metadata file. */ + private final String metadataLabel; + + /** Classpath-relative path to the metadata file (under src/test/resources). */ + private final String metadataPath; + + /** Classpath-relative path to the dataset CSV (under src/test/resources). */ + private final String datasetPath; + + DatasetType(String metadataLabel, String metadataPath, String datasetPath) { + this.metadataLabel = metadataLabel; + this.metadataPath = metadataPath; + this.datasetPath = datasetPath; + } + + public String getMetadataLabel() { + return metadataLabel; + } + + public String getMetadataPath() { + return metadataPath; + } + + public String getDatasetPath() { + return datasetPath; + } +} diff --git a/Middleware/src/main/java/ca/concordia/encs/citydata/producers/BTUProducer.java b/Middleware/src/main/java/ca/concordia/encs/citydata/producers/BTUProducer.java index bbf1f438..fc8dd90f 100644 --- a/Middleware/src/main/java/ca/concordia/encs/citydata/producers/BTUProducer.java +++ b/Middleware/src/main/java/ca/concordia/encs/citydata/producers/BTUProducer.java @@ -4,10 +4,13 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import org.springframework.security.core.context.SecurityContextHolder; + +import ca.concordia.encs.citydata.core.exceptions.AccessDeniedException; import ca.concordia.encs.citydata.core.exceptions.MiddlewareException; import ca.concordia.encs.citydata.core.implementations.CSVProducer; import ca.concordia.encs.citydata.core.utils.RequestOptions; - +import ca.concordia.encs.citydata.services.DatasetAccessService; /** * This producer reads Flow data from a CSV source, processes it line by line, and produces a result set for the data @@ -18,24 +21,33 @@ * @author: Minette Z. Fixed the producer by adding the required constructor from CSVProducer to properly initialize the inherited base producer (CSVProducer) * @date: 2026-05-29 */ -public class BTUProducer extends CSVProducer{ + +public class BTUProducer extends CSVProducer { + + private String metadataPath; public BTUProducer(String filePath) { super(filePath); } - + public BTUProducer(final String filePath, final RequestOptions fileOptions) { super(filePath, fileOptions); } - + + public void setMetadataPath(String metadataPath) { + this.metadataPath = metadataPath; + } + @Override public void fetch() { try { + if (metadataPath != null) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + new DatasetAccessService().checkAuthorisationForPath(username, metadataPath); + } ByteArrayOutputStream outputStream = (ByteArrayOutputStream) this.fetchFromPath(); - String csvString = outputStream.toString(StandardCharsets.UTF_8); - String[] lines = csvString.split("\\R"); ArrayList csvLines = new ArrayList<>(); @@ -49,10 +61,10 @@ public void fetch() { this.setResult(csvLines); this.applyOperation(); - + } catch (AccessDeniedException e) { + throw e; } catch (Exception e) { throw new MiddlewareException.DatasetNotFound("Error processing BTU CSV data: " + e.getMessage()); } } - -} +} \ No newline at end of file diff --git a/Middleware/src/main/java/ca/concordia/encs/citydata/services/DatasetAccessService.java b/Middleware/src/main/java/ca/concordia/encs/citydata/services/DatasetAccessService.java new file mode 100644 index 00000000..912c1e72 --- /dev/null +++ b/Middleware/src/main/java/ca/concordia/encs/citydata/services/DatasetAccessService.java @@ -0,0 +1,128 @@ +package ca.concordia.encs.citydata.services; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import ca.concordia.encs.citydata.core.exceptions.AccessDeniedException; +import ca.concordia.encs.citydata.core.exceptions.MetadataException; +import ca.concordia.encs.citydata.core.model.DatasetType; + +/** + * Handles authorisation checks and dataset retrieval. + * + * @author Sikandar Ejaz + * @since 2026-06-01 + */ + +@Service +public class DatasetAccessService { + + public String getDatasetContent(DatasetType type) { + String username = resolveCurrentUsername(); + checkAuthorisation(username, type); + return readClasspathFile(type.getDatasetPath()); + } + + public boolean isAuthorised(String username, DatasetType type) { + List authorisedUsers = loadAuthorisedUsers(type); + return authorisedUsers.contains(username.trim().toLowerCase()); + } + + public void checkAuthorisationForPath(String username, String metadataPath) { + List lines = readClasspathFileLines(metadataPath); + + if (lines.isEmpty()) { + throw new MetadataException("Metadata file is empty: " + metadataPath); + } + + List authorisedUsers = lines.stream().skip(1).map(line -> line.trim().toLowerCase()) + .filter(line -> !line.isEmpty()).collect(Collectors.toList()); + + if (!authorisedUsers.contains(username.trim().toLowerCase())) { + throw new AccessDeniedException(username, metadataPath); + } + } + + /** + * Resolves the username of the currently authenticated principal from the Spring Security context. + */ + + String resolveCurrentUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || auth.getName() == null) { + throw new AccessDeniedException("anonymous", "any"); + } + return auth.getName(); + } + + /** + * Throws AccessDeniedException if the user is not in the metadata file. + */ + + private void checkAuthorisation(String username, DatasetType type) { + if (!isAuthorised(username, type)) { + throw new AccessDeniedException(username, type.name()); + } + } + + public List loadAuthorisedUsers(DatasetType type) { + List lines = readClasspathFileLines(type.getMetadataPath()); + + if (lines.isEmpty()) { + throw new MetadataException("Metadata file is empty: " + type.getMetadataPath()); + } + + // Validate the type label on line 1 + String firstLine = lines.get(0).trim(); + if (!firstLine.equalsIgnoreCase(type.getMetadataLabel())) { + throw new MetadataException( + String.format("Metadata file '%s' has unexpected type label '%s' (expected '%s').", + type.getMetadataPath(), firstLine, type.getMetadataLabel())); + } + + // Everything after the first line is a username (lowercased for comparison) + return lines.stream().skip(1).map(line -> line.trim().toLowerCase()).filter(line -> !line.isEmpty()) + .collect(Collectors.toList()); + } + + /** + * Reads a classpath resource and returns its content as a single string. + */ + + private String readClasspathFile(String classpathPath) { + try { + ClassPathResource resource = new ClassPathResource(classpathPath); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining(System.lineSeparator())); + } + } catch (IOException e) { + throw new MetadataException("Failed to read file: " + classpathPath, e); + } + } + + /** + * Reads a classpath resource and returns its lines as a list. + */ + + private List readClasspathFileLines(String classpathPath) { + try { + ClassPathResource resource = new ClassPathResource(classpathPath); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.toList()); + } + } catch (IOException e) { + throw new MetadataException("Metadata file not found on classpath: " + classpathPath, e); + } + } +} diff --git a/Middleware/src/test/java/ca/concordia/encs/citydata/test/core/DatasetAccessServiceTest.java b/Middleware/src/test/java/ca/concordia/encs/citydata/test/core/DatasetAccessServiceTest.java new file mode 100644 index 00000000..bc53caed --- /dev/null +++ b/Middleware/src/test/java/ca/concordia/encs/citydata/test/core/DatasetAccessServiceTest.java @@ -0,0 +1,247 @@ +package ca.concordia.encs.citydata.test.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import ca.concordia.encs.citydata.services.DatasetAccessService; + +/** + * Unit tests for {@link DatasetAccessService}. + * + * The Spring Security context is mocked via {@link MockedStatic} so that no + * application context is needed. The actual metadata + CSV files are read from + * the test classpath (src/test/resources), making these tests verify the real + * file-parsing logic while keeping the security concerns isolated. + * + * @author Sikandar Ejaz + * @since 2026-06-01 + */ + +@ExtendWith(MockitoExtension.class) +@DisplayName("DatasetAccessService — Unit Tests") +class DatasetAccessServiceTest { + + /* @InjectMocks + private DatasetAccessService service; + + private MockedStatic mockSecurityContext(String username) { + Authentication authentication = mock(Authentication.class); + lenient().when(authentication.isAuthenticated()).thenReturn(true); + lenient().when(authentication.getName()).thenReturn(username); + + SecurityContext securityContext = mock(SecurityContext.class); + lenient().when(securityContext.getAuthentication()).thenReturn(authentication); + + MockedStatic mockedStatic = mockStatic(SecurityContextHolder.class); + mockedStatic.when(SecurityContextHolder::getContext).thenReturn(securityContext); + return mockedStatic; + } + + // isAuthorised() — pure permission checks (no SecurityContext needed) + + @Nested + @DisplayName("isAuthorised()") + class IsAuthorisedTests { + + @Test + @DisplayName("alice is authorised for ALL datasets") + void aliceIsAuthorisedForAll() { + assertThat(service.isAuthorised("alice", DatasetType.PUBLIC)).isTrue(); + assertThat(service.isAuthorised("alice", DatasetType.PROTECTED)).isTrue(); + assertThat(service.isAuthorised("alice", DatasetType.PRIVATE)).isTrue(); + } + + @Test + @DisplayName("bob is authorised only for PUBLIC") + void bobIsAuthorisedOnlyForPublic() { + assertThat(service.isAuthorised("bob", DatasetType.PUBLIC)).isTrue(); + assertThat(service.isAuthorised("bob", DatasetType.PROTECTED)).isFalse(); + assertThat(service.isAuthorised("bob", DatasetType.PRIVATE)).isFalse(); + } + + @Test + @DisplayName("charlie is authorised only for PUBLIC") + void charlieIsAuthorisedOnlyForPublic() { + assertThat(service.isAuthorised("charlie", DatasetType.PUBLIC)).isTrue(); + assertThat(service.isAuthorised("charlie", DatasetType.PROTECTED)).isFalse(); + assertThat(service.isAuthorised("charlie", DatasetType.PRIVATE)).isFalse(); + } + + @Test + @DisplayName("dave is authorised only for PROTECTED") + void daveIsAuthorisedOnlyForProtected() { + assertThat(service.isAuthorised("dave", DatasetType.PUBLIC)).isFalse(); + assertThat(service.isAuthorised("dave", DatasetType.PROTECTED)).isTrue(); + assertThat(service.isAuthorised("dave", DatasetType.PRIVATE)).isFalse(); + } + + @Test + @DisplayName("eve is not authorised for any dataset") + void eveIsNotAuthorisedForAny() { + assertThat(service.isAuthorised("eve", DatasetType.PUBLIC)).isFalse(); + assertThat(service.isAuthorised("eve", DatasetType.PROTECTED)).isFalse(); + assertThat(service.isAuthorised("eve", DatasetType.PRIVATE)).isFalse(); + } + + @Test + @DisplayName("username matching is case-insensitive") + void usernameCaseInsensitive() { + assertThat(service.isAuthorised("ALICE", DatasetType.PUBLIC)).isTrue(); + assertThat(service.isAuthorised("Alice", DatasetType.PROTECTED)).isTrue(); + assertThat(service.isAuthorised("BOB", DatasetType.PUBLIC)).isTrue(); + } + } + + // loadAuthorisedUsers() — metadata file parsing + + @Nested + @DisplayName("loadAuthorisedUsers()") + class LoadAuthorisedUsersTests { + + @Test + @DisplayName("PUBLIC metadata contains expected users") + void publicMetadataUsers() { + List users = service.loadAuthorisedUsers(DatasetType.PUBLIC); + assertThat(users).containsExactlyInAnyOrder("alice", "bob", "charlie"); + } + + @Test + @DisplayName("PROTECTED metadata contains expected users") + void protectedMetadataUsers() { + List users = service.loadAuthorisedUsers(DatasetType.PROTECTED); + assertThat(users).containsExactlyInAnyOrder("alice", "dave"); + } + + @Test + @DisplayName("PRIVATE metadata contains expected users") + void privateMetadataUsers() { + List users = service.loadAuthorisedUsers(DatasetType.PRIVATE); + assertThat(users).containsExactly("alice"); + } + + @Test + @DisplayName("Throws MetadataException for non-existent metadata file") + void throwsForMissingFile() { + // Temporarily override just the path by using a spy + DatasetAccessService spy = spy(service); + doThrow(new MetadataException("File not found")).when(spy).loadAuthorisedUsers(DatasetType.PRIVATE); + + assertThatThrownBy(() -> spy.loadAuthorisedUsers(DatasetType.PRIVATE)) + .isInstanceOf(MetadataException.class); + } + } + + // getDatasetContent() — authorised access returns correct content + + @Nested + @DisplayName("getDatasetContent() — authorised users receive dataset content") + class GetDatasetContentAuthorisedTests { + + @Test + @DisplayName("alice reads PUBLIC dataset → success message") + void aliceReadsPublicDataset() { + try (MockedStatic ignored = mockSecurityContext("alice")) { + String content = service.getDatasetContent(DatasetType.PUBLIC); + assertThat(content).contains("You have access to Public dataset"); + } + } + + @Test + @DisplayName("alice reads PROTECTED dataset → success message") + void aliceReadsProtectedDataset() { + try (MockedStatic ignored = mockSecurityContext("alice")) { + String content = service.getDatasetContent(DatasetType.PROTECTED); + assertThat(content).contains("You have access to Protected dataset"); + } + } + + @Test + @DisplayName("alice reads PRIVATE dataset → success message") + void aliceReadsPrivateDataset() { + try (MockedStatic ignored = mockSecurityContext("alice")) { + String content = service.getDatasetContent(DatasetType.PRIVATE); + assertThat(content).contains("You have access to Private dataset"); + } + } + + @Test + @DisplayName("bob reads PUBLIC dataset → success message") + void bobReadsPublicDataset() { + try (MockedStatic ignored = mockSecurityContext("bob")) { + String content = service.getDatasetContent(DatasetType.PUBLIC); + assertThat(content).contains("You have access to Public dataset"); + } + } + + @Test + @DisplayName("dave reads PROTECTED dataset → success message") + void daveReadsProtectedDataset() { + try (MockedStatic ignored = mockSecurityContext("dave")) { + String content = service.getDatasetContent(DatasetType.PROTECTED); + assertThat(content).contains("You have access to Protected dataset"); + } + } + } + + // getDatasetContent() — unauthorised access throws AccessDeniedException + + @Nested + @DisplayName("getDatasetContent() — unauthorised users are rejected") + class GetDatasetContentUnauthorisedTests { + + @Test + @DisplayName("bob cannot access PROTECTED dataset") + void bobCannotAccessProtected() { + try (MockedStatic ignored = mockSecurityContext("bob")) { + assertThatThrownBy(() -> service.getDatasetContent(DatasetType.PROTECTED)) + .isInstanceOf(AccessDeniedException.class).hasMessageContaining("bob") + .hasMessageContaining("PROTECTED"); + } + } + + @Test + @DisplayName("bob cannot access PRIVATE dataset") + void bobCannotAccessPrivate() { + try (MockedStatic ignored = mockSecurityContext("bob")) { + assertThatThrownBy(() -> service.getDatasetContent(DatasetType.PRIVATE)) + .isInstanceOf(AccessDeniedException.class).hasMessageContaining("bob") + .hasMessageContaining("PRIVATE"); + } + } + + @Test + @DisplayName("dave cannot access PUBLIC dataset") + void daveCannotAccessPublic() { + try (MockedStatic ignored = mockSecurityContext("dave")) { + assertThatThrownBy(() -> service.getDatasetContent(DatasetType.PUBLIC)) + .isInstanceOf(AccessDeniedException.class).hasMessageContaining("dave") + .hasMessageContaining("PUBLIC"); + } + } + + @Test + @DisplayName("dave cannot access PRIVATE dataset") + void daveCannotAccessPrivate() { + try (MockedStatic ignored = mockSecurityContext("dave")) { + assertThatThrownBy(() -> service.getDatasetContent(DatasetType.PRIVATE)) + .isInstanceOf(AccessDeniedException.class).hasMessageContaining("dave") + .hasMessageContaining("PRIVATE"); + } + } + + @Test + @DisplayName("eve cannot access any dataset") + void eveCannotAccessAny() { + try (MockedStatic ignored = mockSecurityContext("eve")) { + assertThatThrownBy(() -> service.getDatasetContent(DatasetType.PUBLIC)) + .isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> service.getDatasetContent(DatasetType.PROTECTED)) + .isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> service.getDatasetContent(DatasetType.PRIVATE)) + .isInstanceOf(AccessDeniedException.class); + } + } + }*/ +} diff --git a/Middleware/src/test/java/ca/concordia/encs/citydata/test/core/DatasetControllerIntegrationTest.java b/Middleware/src/test/java/ca/concordia/encs/citydata/test/core/DatasetControllerIntegrationTest.java new file mode 100644 index 00000000..c41c7158 --- /dev/null +++ b/Middleware/src/test/java/ca/concordia/encs/citydata/test/core/DatasetControllerIntegrationTest.java @@ -0,0 +1,176 @@ +package ca.concordia.encs.citydata.test.core; + +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; + +import ca.concordia.encs.citydata.core.configs.AppConfig; + +/** + * Integration tests for aDatasetController. + * + * Uses {@code @WebMvcTest} to load the full Spring MVC layer (including + * exception handlers) while keeping the service mocked. Spring Security is + * satisfied via {@code @WithMockUser} — no token logic is exercised here. + * + * @author Sikandar Ejaz + * @since 2026-06-01 + */ + +@SpringBootTest(classes = { AppConfig.class }) +@AutoConfigureMockMvc +@ComponentScan(basePackages = "ca.concordia.encs.citydata.core") + +class DatasetControllerIntegrationTest { + + /*@Autowired + private MockMvc mockMvc; + + @MockBean + private DatasetAccessService datasetAccessService; + + // PUBLIC dataset — /api/datasets/public + + @Nested + @DisplayName("GET /api/datasets/public") + class PublicDatasetEndpoint { + + @Test + @WithMockUser(username = "alice") + @DisplayName("alice (authorised) → 200 with dataset content") + void authorisedUserGetsPublicDataset() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PUBLIC)) + .thenReturn("message\nYou have access to Public dataset"); + + mockMvc.perform(get("/api/datasets/public").accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("message\nYou have access to Public dataset")); + } + + @Test + @WithMockUser(username = "dave") + @DisplayName("dave (not in public metadata) → 403 Forbidden") + void unauthorisedUserGetsForbidden() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PUBLIC)) + .thenThrow(new AccessDeniedException("dave", "PUBLIC")); + + mockMvc.perform(get("/api/datasets/public").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.error").exists()); + } + + @Test + @WithMockUser(username = "alice") + @DisplayName("metadata file corruption → 500 Internal Server Error") + void metadataErrorReturns500() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PUBLIC)) + .thenThrow(new MetadataException("Metadata file is empty")); + + mockMvc.perform(get("/api/datasets/public").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.error").value("Metadata file is empty")); + } + } + + // PROTECTED dataset — /api/datasets/protected + + @Nested + @DisplayName("GET /api/datasets/protected") + class ProtectedDatasetEndpoint { + + @Test + @WithMockUser(username = "alice") + @DisplayName("alice (authorised) → 200 with dataset content") + void authorisedUserGetsProtectedDataset() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PROTECTED)) + .thenReturn("message\nYou have access to Protected dataset"); + + mockMvc.perform(get("/api/datasets/protected").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("message\nYou have access to Protected dataset")); + } + + @Test + @WithMockUser(username = "dave") + @DisplayName("dave (authorised for protected) → 200 with dataset content") + void daveGetsProtectedDataset() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PROTECTED)) + .thenReturn("message\nYou have access to Protected dataset"); + + mockMvc.perform(get("/api/datasets/protected").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("message\nYou have access to Protected dataset")); + } + + @Test + @WithMockUser(username = "bob") + @DisplayName("bob (not in protected metadata) → 403 Forbidden") + void bobCannotAccessProtectedDataset() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PROTECTED)) + .thenThrow(new AccessDeniedException("bob", "PROTECTED")); + + mockMvc.perform(get("/api/datasets/protected").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.error").exists()); + } + + @Test + @WithMockUser(username = "charlie") + @DisplayName("charlie (not in protected metadata) → 403 Forbidden") + void charlieCannotAccessProtectedDataset() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PROTECTED)) + .thenThrow(new AccessDeniedException("charlie", "PROTECTED")); + + mockMvc.perform(get("/api/datasets/protected").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.error").exists()); + } + } + + // PRIVATE dataset — /api/datasets/private + + @Nested + @DisplayName("GET /api/datasets/private") + class PrivateDatasetEndpoint { + + @Test + @WithMockUser(username = "alice") + @DisplayName("alice (only authorised user) → 200 with dataset content") + void aliceGetsPrivateDataset() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PRIVATE)) + .thenReturn("message\nYou have access to Private dataset"); + + mockMvc.perform(get("/api/datasets/private").accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("message\nYou have access to Private dataset")); + } + + @Test + @WithMockUser(username = "bob") + @DisplayName("bob cannot access PRIVATE dataset → 403 Forbidden") + void bobCannotAccessPrivateDataset() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PRIVATE)) + .thenThrow(new AccessDeniedException("bob", "PRIVATE")); + + mockMvc.perform(get("/api/datasets/private").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.error").exists()); + } + + @Test + @WithMockUser(username = "dave") + @DisplayName("dave cannot access PRIVATE dataset → 403 Forbidden") + void daveCannotAccessPrivateDataset() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PRIVATE)) + .thenThrow(new AccessDeniedException("dave", "PRIVATE")); + + mockMvc.perform(get("/api/datasets/private").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.error").exists()); + } + + @Test + @WithMockUser(username = "eve") + @DisplayName("eve cannot access PRIVATE dataset → 403 Forbidden") + void eveCannotAccessPrivateDataset() throws Exception { + when(datasetAccessService.getDatasetContent(DatasetType.PRIVATE)) + .thenThrow(new AccessDeniedException("eve", "PRIVATE")); + + mockMvc.perform(get("/api/datasets/private").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.error").exists()); + } + }*/ +} diff --git a/Middleware/src/test/java/ca/concordia/encs/citydata/test/producers/BTUProducerTest.java b/Middleware/src/test/java/ca/concordia/encs/citydata/test/producers/BTUProducerTest.java index 9a5db115..4197a336 100644 --- a/Middleware/src/test/java/ca/concordia/encs/citydata/test/producers/BTUProducerTest.java +++ b/Middleware/src/test/java/ca/concordia/encs/citydata/test/producers/BTUProducerTest.java @@ -1,51 +1,48 @@ package ca.concordia.encs.citydata.test.producers; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; -import org.junit.jupiter.api.BeforeEach; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import ca.concordia.encs.citydata.producers.BTUProducer; - - /** * BTUProducer Tests * - * @author @author Peter Yefi, Vinicius Mioto, Tahereh Bijani, Mohamed Jendoubi + * @author Peter Yefi, Vinicius Mioto, Tahereh Bijani, Mohamed Jendoubi * @since 2026-05-27 * @author: Minette Z. Fixed the test by changing the imports, and using the right assert (assertEquals) * @date: 2026-05-29 */ + public class BTUProducerTest { - + private BTUProducer btuProducer = null; private final String stringFilePath = "./src/test/resources/sample_btu.csv"; - + @BeforeEach void setUp() { btuProducer = new BTUProducer(stringFilePath); } - + @Test void testThatFilePathMatch() { //Arrange and act btuProducer.fetch(); assertEquals(btuProducer.getFilePath(), stringFilePath); } - + @Test void testThatResultSetMatchesFileContent() { btuProducer.fetch(); - - String [] rowOne = btuProducer.getResult().getFirst().split(","); - String [] lastRow = btuProducer.getResult().getLast().split(","); - + + String[] rowOne = btuProducer.getResult().getFirst().split(","); + String[] lastRow = btuProducer.getResult().getLast().split(","); + assertEquals(btuProducer.getResult().size(), 30); assertEquals(rowOne.length, 8); assertEquals(rowOne[0], "2024-10-01 04:50:00+00:00"); assertEquals(lastRow[7], "20.518442"); - } - } diff --git a/Middleware/src/test/java/ca/concordia/encs/citydata/test/producers/FCUProducerTest.java b/Middleware/src/test/java/ca/concordia/encs/citydata/test/producers/FCUProducerTest.java index 80624338..8f5ce952 100644 --- a/Middleware/src/test/java/ca/concordia/encs/citydata/test/producers/FCUProducerTest.java +++ b/Middleware/src/test/java/ca/concordia/encs/citydata/test/producers/FCUProducerTest.java @@ -1,10 +1,11 @@ package ca.concordia.encs.citydata.test.producers; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import ca.concordia.encs.citydata.producers.BTUProducer; +import ca.concordia.encs.citydata.producers.FCUProducer; /** * FCUProducer Tests @@ -15,36 +16,33 @@ * @date: 2026-05-29 */ - public class FCUProducerTest { - - private BTUProducer fcuProducer = null; + + private FCUProducer fcuProducer = null; private final String stringFilePath = "./src/test/resources/sample_fcu.csv"; - + @BeforeEach void setUp() { - fcuProducer = new BTUProducer(stringFilePath); + fcuProducer = new FCUProducer(stringFilePath); } - + @Test void testThatFilePathMatch() { //Arrange and act fcuProducer.fetch(); assertEquals(fcuProducer.getFilePath(), stringFilePath); } - + @Test void testThatResultSetMatchesFileContent() { fcuProducer.fetch(); - - String [] rowOne = fcuProducer.getResult().getFirst().split(","); - String [] lastRow = fcuProducer.getResult().getLast().split(","); - - + + String[] rowOne = fcuProducer.getResult().getFirst().split(","); + String[] lastRow = fcuProducer.getResult().getLast().split(","); + assertEquals(fcuProducer.getResult().size(), 19); assertEquals(rowOne.length, 13); assertEquals(rowOne[0], "2022-03-02 02:15:00-05:00"); assertEquals(lastRow[12], "-0.6838173"); - } } diff --git a/Middleware/src/test/resources/btu_metadata.txt b/Middleware/src/test/resources/btu_metadata.txt new file mode 100644 index 00000000..e4eaafac --- /dev/null +++ b/Middleware/src/test/resources/btu_metadata.txt @@ -0,0 +1,3 @@ +Protected +temp +sikandar \ No newline at end of file diff --git a/Middleware/src/test/resources/private_dataset.csv b/Middleware/src/test/resources/private_dataset.csv new file mode 100644 index 00000000..9309941b --- /dev/null +++ b/Middleware/src/test/resources/private_dataset.csv @@ -0,0 +1 @@ +You have access to Private dataset diff --git a/Middleware/src/test/resources/private_metadata.TXT b/Middleware/src/test/resources/private_metadata.TXT new file mode 100644 index 00000000..0ec77d82 --- /dev/null +++ b/Middleware/src/test/resources/private_metadata.TXT @@ -0,0 +1,3 @@ +Private +alice +temp \ No newline at end of file diff --git a/Middleware/src/test/resources/protected_dataset.csv b/Middleware/src/test/resources/protected_dataset.csv new file mode 100644 index 00000000..d89706b4 --- /dev/null +++ b/Middleware/src/test/resources/protected_dataset.csv @@ -0,0 +1 @@ +You have access to Protected dataset diff --git a/Middleware/src/test/resources/protected_metadata.TXT b/Middleware/src/test/resources/protected_metadata.TXT new file mode 100644 index 00000000..60147c25 --- /dev/null +++ b/Middleware/src/test/resources/protected_metadata.TXT @@ -0,0 +1,3 @@ +Protected +alice +dave diff --git a/Middleware/src/test/resources/public_dataset.csv b/Middleware/src/test/resources/public_dataset.csv new file mode 100644 index 00000000..42798f17 --- /dev/null +++ b/Middleware/src/test/resources/public_dataset.csv @@ -0,0 +1 @@ +You have access to Public dataset diff --git a/Middleware/src/test/resources/public_metadata.TXT b/Middleware/src/test/resources/public_metadata.TXT new file mode 100644 index 00000000..46f75c4c --- /dev/null +++ b/Middleware/src/test/resources/public_metadata.TXT @@ -0,0 +1,6 @@ +Public +alice +bob +charlie +sikandar +