mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Add login agreement disclaimer feature (#6766)
This commit is contained in:
@@ -514,6 +514,14 @@ public class ApplicationProperties {
|
||||
private String accessibilityStatement;
|
||||
private String cookiePolicy;
|
||||
private String impressum;
|
||||
private LoginAgreement loginAgreement = new LoginAgreement();
|
||||
|
||||
@Data
|
||||
public static class LoginAgreement {
|
||||
private boolean enabled = false;
|
||||
private boolean showInAnonymousMode = true;
|
||||
private String fallbackText = "";
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
// Resolves login agreement text from customFiles/disclaimer/<locale>.md (read live);
|
||||
// enable/visibility come from the legal.loginAgreement settings.
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LoginAgreementService {
|
||||
|
||||
// Locale codes only: rejects path separators and dots so the value can never escape the
|
||||
// disclaimer directory. Matches e.g. en, en-GB, fr-FR, zh-Hant, pt-BR.
|
||||
private static final Pattern LOCALE_PATTERN =
|
||||
Pattern.compile("^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$");
|
||||
|
||||
// BCP-47 tags are well under this; the cap also prevents the regex's repetition group
|
||||
// from recursing far enough to overflow the stack on a hostile over-length input.
|
||||
private static final int MAX_LOCALE_LENGTH = 35;
|
||||
|
||||
// Disclaimers are short markdown; cap the read so an oversized file can't be loaded
|
||||
// wholesale into heap on every public request.
|
||||
private static final long MAX_FILE_BYTES = 256 * 1024;
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public LoginAgreementService(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return config().isEnabled();
|
||||
}
|
||||
|
||||
public boolean isShowInAnonymousMode() {
|
||||
return config().isShowInAnonymousMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the markdown to show for the requested language, falling back through the base
|
||||
* language, the configured default locale (and its base), then the configured fallbackText.
|
||||
* Returns an empty string when nothing is configured.
|
||||
*/
|
||||
public String resolveContent(String requestedLang) {
|
||||
List<String> candidates = new ArrayList<>();
|
||||
addLocaleCandidates(candidates, requestedLang);
|
||||
addLocaleCandidates(candidates, applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
for (String candidate : candidates) {
|
||||
String content = readFileIfExists(candidate);
|
||||
if (content != null && !content.isBlank()) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
String fallback = config().getFallbackText();
|
||||
return fallback == null ? "" : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin read of a single locale's raw file. Returns null for an invalid locale, "" if absent.
|
||||
*/
|
||||
public String readRawForLocale(String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return null;
|
||||
}
|
||||
String content = readFileIfExists(locale);
|
||||
return content == null ? "" : content;
|
||||
}
|
||||
|
||||
/** Admin write. Blank content deletes the file so it falls back cleanly. */
|
||||
public void writeForLocale(String locale, String content) throws IOException {
|
||||
Path file = resolveLocaleFile(locale);
|
||||
if (file == null) {
|
||||
throw new IllegalArgumentException("Invalid locale: " + locale);
|
||||
}
|
||||
if (content == null || content.isBlank()) {
|
||||
Files.deleteIfExists(file);
|
||||
return;
|
||||
}
|
||||
Files.createDirectories(file.getParent());
|
||||
// Write to a sibling temp file then atomically swap, so a concurrent reader (the public
|
||||
// /login-disclaimer fetch is lockless) never observes a truncated/partial file.
|
||||
Path tmp = Files.createTempFile(file.getParent(), "disclaimer", ".md.tmp");
|
||||
try {
|
||||
Files.writeString(tmp, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(
|
||||
tmp,
|
||||
file,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locales that currently have a markdown file, for the admin editor. */
|
||||
public Set<String> listLocalesWithContent() {
|
||||
Set<String> result = new TreeSet<>();
|
||||
Path dir = disclaimerDir();
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return result;
|
||||
}
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
files.filter(Files::isRegularFile)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.filter(name -> name.endsWith(".md"))
|
||||
.map(name -> name.substring(0, name.length() - ".md".length()))
|
||||
.filter(this::isValidLocale)
|
||||
.forEach(result::add);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed listing login agreement files", e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ApplicationProperties.Legal.LoginAgreement config() {
|
||||
return applicationProperties.getLegal().getLoginAgreement();
|
||||
}
|
||||
|
||||
private Path disclaimerDir() {
|
||||
return Path.of(InstallationPathConfig.getCustomFilesPath(), "disclaimer").normalize();
|
||||
}
|
||||
|
||||
private void addLocaleCandidates(List<String> out, String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return;
|
||||
}
|
||||
if (!out.contains(locale)) {
|
||||
out.add(locale);
|
||||
}
|
||||
String base = locale.split("[_-]", 2)[0];
|
||||
if (!base.equals(locale) && !out.contains(base)) {
|
||||
out.add(base);
|
||||
}
|
||||
}
|
||||
|
||||
private String readFileIfExists(String locale) {
|
||||
Path file = resolveLocaleFile(locale);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// NOFOLLOW_LINKS: a symlinked entry is treated as non-regular and skipped, so a
|
||||
// planted symlink can't expose files outside the disclaimer dir via the public read.
|
||||
if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.size(file) > MAX_FILE_BYTES) {
|
||||
log.warn(
|
||||
"Login agreement file for locale {} exceeds {} bytes; ignoring",
|
||||
locale,
|
||||
MAX_FILE_BYTES);
|
||||
return null;
|
||||
}
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed reading login agreement file for locale {}", locale, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path resolveLocaleFile(String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return null;
|
||||
}
|
||||
Path dir = disclaimerDir();
|
||||
Path file = dir.resolve(locale + ".md").normalize();
|
||||
// Defence in depth: the regex already blocks separators, but confirm containment.
|
||||
if (!file.startsWith(dir)) {
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private boolean isValidLocale(String locale) {
|
||||
// Length check BEFORE the regex: LOCALE_PATTERN's repetition group recurses one stack
|
||||
// frame per repeat in java.util.regex, so an unbounded input could overflow the stack.
|
||||
return locale != null
|
||||
&& locale.length() <= MAX_LOCALE_LENGTH
|
||||
&& LOCALE_PATTERN.matcher(locale).matches();
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LoginAgreementService}. The service resolves per-language markdown from
|
||||
* {@code <customFiles>/disclaimer/<locale>.md}; here {@link
|
||||
* InstallationPathConfig#getCustomFilesPath()} is mocked to a {@link TempDir} so file IO is
|
||||
* isolated.
|
||||
*/
|
||||
class LoginAgreementServiceTest {
|
||||
|
||||
@TempDir Path customFilesDir;
|
||||
|
||||
private ApplicationProperties properties;
|
||||
private ApplicationProperties.Legal.LoginAgreement config;
|
||||
private LoginAgreementService service;
|
||||
private Path disclaimerDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new ApplicationProperties();
|
||||
config = properties.getLegal().getLoginAgreement();
|
||||
service = new LoginAgreementService(properties);
|
||||
disclaimerDir = customFilesDir.resolve("disclaimer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run {@code action} with InstallationPathConfig.getCustomFilesPath() pointing at the temp dir.
|
||||
*/
|
||||
private void withMockedPath(Runnable action) {
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getCustomFilesPath)
|
||||
.thenReturn(customFilesDir.toString());
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void writeFile(String locale, String content) throws IOException {
|
||||
Files.createDirectories(disclaimerDir);
|
||||
Files.writeString(disclaimerDir.resolve(locale + ".md"), content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagsReflectConfig() {
|
||||
config.setEnabled(true);
|
||||
config.setShowInAnonymousMode(false);
|
||||
assertTrue(service.isEnabled());
|
||||
assertFalse(service.isShowInAnonymousMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentReturnsExactLocaleFile() throws IOException {
|
||||
writeFile("fr-FR", "# Avis");
|
||||
withMockedPath(() -> assertEquals("# Avis", service.resolveContent("fr-FR")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToBaseLanguage() throws IOException {
|
||||
// Only a language-only file exists; a region-specific request should fall back to it.
|
||||
writeFile("de", "# Hinweis");
|
||||
withMockedPath(() -> assertEquals("# Hinweis", service.resolveContent("de-DE")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToDefaultLocale() throws IOException {
|
||||
properties.getSystem().setDefaultLocale("en-GB");
|
||||
writeFile("en-GB", "# Notice");
|
||||
// No file for the requested locale -> falls through to the configured default locale.
|
||||
withMockedPath(() -> assertEquals("# Notice", service.resolveContent("es-ES")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToFallbackTextWhenNoFile() {
|
||||
config.setFallbackText("# Fallback");
|
||||
withMockedPath(() -> assertEquals("# Fallback", service.resolveContent("ja-JP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentReturnsEmptyWhenNothingConfigured() {
|
||||
withMockedPath(() -> assertEquals("", service.resolveContent("ja-JP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentDoesNotEscapeDisclaimerDirectory() throws IOException {
|
||||
// Plant a file outside the disclaimer dir; a traversal-style locale must not read it.
|
||||
Files.writeString(
|
||||
customFilesDir.resolve("secret.md"), "TOP SECRET", StandardCharsets.UTF_8);
|
||||
config.setFallbackText("safe");
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertEquals("safe", service.resolveContent("../secret"));
|
||||
assertEquals("safe", service.resolveContent("..%2Fsecret"));
|
||||
assertEquals("safe", service.resolveContent("/etc/passwd"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void readRawRejectsInvalidLocale() {
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertNull(service.readRawForLocale("../secret"));
|
||||
assertNull(service.readRawForLocale("en/GB"));
|
||||
assertNull(service.readRawForLocale("C:\\x"));
|
||||
assertNull(service.readRawForLocale(null));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void readRawReturnsEmptyForValidButAbsentLocale() {
|
||||
withMockedPath(() -> assertEquals("", service.readRawForLocale("pt-BR")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlongLocaleIsRejectedWithoutStackOverflow() {
|
||||
// Guards against the regex-recursion stack overflow on unbounded input.
|
||||
String hostile = "en" + "-ab".repeat(4000);
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.readRawForLocale(hostile));
|
||||
assertNull(service.readRawForLocale(hostile));
|
||||
assertDoesNotThrow(() -> service.resolveContent(hostile));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeThenReadRoundTrips() throws IOException {
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", "# Bonjour"));
|
||||
assertEquals("# Bonjour", service.readRawForLocale("fr-FR"));
|
||||
});
|
||||
assertTrue(Files.isRegularFile(disclaimerDir.resolve("fr-FR.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeBlankDeletesFile() throws IOException {
|
||||
writeFile("fr-FR", "# Bonjour");
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", " "));
|
||||
assertEquals("", service.readRawForLocale("fr-FR"));
|
||||
});
|
||||
assertFalse(Files.exists(disclaimerDir.resolve("fr-FR.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeRejectsInvalidLocale() {
|
||||
withMockedPath(
|
||||
() ->
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.writeForLocale("../escape", "x")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listLocalesWithContentReturnsOnlyValidMarkdownFiles() throws IOException {
|
||||
writeFile("en-GB", "a");
|
||||
writeFile("fr-FR", "b");
|
||||
Files.writeString(disclaimerDir.resolve("notes.txt"), "x", StandardCharsets.UTF_8);
|
||||
withMockedPath(
|
||||
() -> {
|
||||
var locales = service.listLocalesWithContent();
|
||||
assertTrue(locales.contains("en-GB"));
|
||||
assertTrue(locales.contains("fr-FR"));
|
||||
assertEquals(2, locales.size());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedFileIsIgnored() throws IOException {
|
||||
// Files beyond the read cap are skipped rather than loaded into heap.
|
||||
byte[] big = new byte[300 * 1024];
|
||||
java.util.Arrays.fill(big, (byte) 'x');
|
||||
Files.createDirectories(disclaimerDir);
|
||||
Files.write(disclaimerDir.resolve("en-GB.md"), big);
|
||||
config.setFallbackText("small-fallback");
|
||||
properties.getSystem().setDefaultLocale("en-GB");
|
||||
withMockedPath(() -> assertEquals("small-fallback", service.resolveContent("en-GB")));
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
|
||||
/**
|
||||
* Serves the login agreement / disclaimer for the frontend. Shares the /api/v1/config access rules:
|
||||
* it requires authentication when login is enabled (the modal is shown after login, never on the
|
||||
* login screen) and is permit-all in anonymous/no-login mode and in SaaS. The text is read live
|
||||
* from disk, so admin edits take effect on the next login without a restart.
|
||||
*/
|
||||
@ConfigApi
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
public class LoginDisclaimerController {
|
||||
|
||||
private final LoginAgreementService loginAgreementService;
|
||||
|
||||
@GetMapping("/login-disclaimer")
|
||||
@Operation(
|
||||
summary = "Get the login agreement/disclaimer",
|
||||
description =
|
||||
"Returns whether the login agreement is enabled and, if so, the markdown to"
|
||||
+ " display for the requested language.")
|
||||
public LoginDisclaimerResponse getLoginDisclaimer(
|
||||
@RequestParam(name = "lang", required = false) String lang) {
|
||||
boolean showInAnonymousMode = loginAgreementService.isShowInAnonymousMode();
|
||||
if (!loginAgreementService.isEnabled()) {
|
||||
return new LoginDisclaimerResponse(false, showInAnonymousMode, "", "markdown");
|
||||
}
|
||||
String content = loginAgreementService.resolveContent(lang);
|
||||
// Enabled but no resolvable text (no file for any candidate locale and no fallbackText):
|
||||
// report disabled so clients don't try to render an empty agreement.
|
||||
boolean hasContent = content != null && !content.isBlank();
|
||||
return new LoginDisclaimerResponse(
|
||||
hasContent, showInAnonymousMode, hasContent ? content : "", "markdown");
|
||||
}
|
||||
|
||||
public record LoginDisclaimerResponse(
|
||||
boolean enabled, boolean showInAnonymousMode, String content, String format) {}
|
||||
}
|
||||
@@ -165,6 +165,10 @@ legal:
|
||||
accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
|
||||
cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
|
||||
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
|
||||
loginAgreement:
|
||||
enabled: false # set to 'true' to show a login agreement/disclaimer popup after login (and on app launch when login is disabled). Per-language text is read from customFiles/disclaimer/<locale>.md (e.g. en-GB.md, fr-FR.md)
|
||||
showInAnonymousMode: true # when login is disabled, set to 'false' to suppress the agreement in anonymous (no-login) mode
|
||||
fallbackText: "" # optional markdown used for any language that has no customFiles/disclaimer/<locale>.md file (also settable via the LEGAL_LOGINAGREEMENT_FALLBACKTEXT env var for single-language headless installs)
|
||||
|
||||
system:
|
||||
defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.SPDF.controller.api.misc.LoginDisclaimerController.LoginDisclaimerResponse;
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoginDisclaimerControllerTest {
|
||||
|
||||
@Mock LoginAgreementService loginAgreementService;
|
||||
|
||||
@InjectMocks LoginDisclaimerController controller;
|
||||
|
||||
@Test
|
||||
void disabledReturnsEmptyContent() {
|
||||
when(loginAgreementService.isEnabled()).thenReturn(false);
|
||||
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true);
|
||||
|
||||
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("en-GB");
|
||||
|
||||
assertFalse(resp.enabled());
|
||||
assertEquals("", resp.content());
|
||||
assertTrue(resp.showInAnonymousMode());
|
||||
assertEquals("markdown", resp.format());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledWithContentReturnsIt() {
|
||||
when(loginAgreementService.isEnabled()).thenReturn(true);
|
||||
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(false);
|
||||
when(loginAgreementService.resolveContent("fr-FR")).thenReturn("# Avis");
|
||||
|
||||
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("fr-FR");
|
||||
|
||||
assertTrue(resp.enabled());
|
||||
assertEquals("# Avis", resp.content());
|
||||
assertFalse(resp.showInAnonymousMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledButBlankContentReportsDisabled() {
|
||||
// No file for any candidate locale and no fallbackText -> report disabled so clients
|
||||
// don't render an empty agreement.
|
||||
when(loginAgreementService.isEnabled()).thenReturn(true);
|
||||
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true);
|
||||
when(loginAgreementService.resolveContent("ja-JP")).thenReturn(" ");
|
||||
|
||||
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("ja-JP");
|
||||
|
||||
assertFalse(resp.enabled());
|
||||
assertEquals("", resp.content());
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
|
||||
/**
|
||||
* Admin editing of the per-language login agreement markdown files
|
||||
* (customFiles/disclaimer/<locale>.md). The enable/visibility flags are managed through the
|
||||
* normal admin settings endpoints; only the live-edited text is handled here.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/login-agreement")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Admin Settings", description = "Login agreement text management")
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class AdminLoginAgreementController {
|
||||
|
||||
private final LoginAgreementService loginAgreementService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List locales that currently have login agreement text")
|
||||
public Set<String> listLocales() {
|
||||
return loginAgreementService.listLocalesWithContent();
|
||||
}
|
||||
|
||||
@GetMapping("/{locale}")
|
||||
@Operation(summary = "Read the login agreement markdown for a locale")
|
||||
public ResponseEntity<Map<String, String>> read(@PathVariable String locale) {
|
||||
String content = loginAgreementService.readRawForLocale(locale);
|
||||
if (content == null) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("locale", locale, "content", content));
|
||||
}
|
||||
|
||||
@PutMapping("/{locale}")
|
||||
@Operation(summary = "Write the login agreement markdown for a locale (blank clears it)")
|
||||
public ResponseEntity<Void> write(
|
||||
@PathVariable String locale, @RequestBody DisclaimerContentRequest request) {
|
||||
try {
|
||||
loginAgreementService.writeForLocale(
|
||||
locale, request == null ? null : request.content());
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (IOException e) {
|
||||
log.error("Failed writing login agreement for locale {}", locale, e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
public record DisclaimerContentRequest(String content) {}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
import stirling.software.proprietary.security.controller.api.AdminLoginAgreementController.DisclaimerContentRequest;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AdminLoginAgreementControllerTest {
|
||||
|
||||
@Mock LoginAgreementService loginAgreementService;
|
||||
|
||||
@InjectMocks AdminLoginAgreementController controller;
|
||||
|
||||
@Test
|
||||
void listDelegatesToService() {
|
||||
when(loginAgreementService.listLocalesWithContent()).thenReturn(Set.of("en-GB", "fr-FR"));
|
||||
assertEquals(Set.of("en-GB", "fr-FR"), controller.listLocales());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readReturnsContentForValidLocale() {
|
||||
when(loginAgreementService.readRawForLocale("fr-FR")).thenReturn("# Avis");
|
||||
ResponseEntity<?> resp = controller.read("fr-FR");
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readReturnsBadRequestForInvalidLocale() {
|
||||
// Service returns null for an invalid locale.
|
||||
when(loginAgreementService.readRawForLocale("../escape")).thenReturn(null);
|
||||
ResponseEntity<?> resp = controller.read("../escape");
|
||||
assertEquals(HttpStatus.BAD_REQUEST, resp.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeDelegatesAndReturnsNoContent() throws IOException {
|
||||
ResponseEntity<Void> resp = controller.write("fr-FR", new DisclaimerContentRequest("# Hi"));
|
||||
assertEquals(HttpStatus.NO_CONTENT, resp.getStatusCode());
|
||||
verify(loginAgreementService).writeForLocale("fr-FR", "# Hi");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeReturnsBadRequestOnInvalidLocale() throws IOException {
|
||||
doThrow(new IllegalArgumentException("Invalid locale"))
|
||||
.when(loginAgreementService)
|
||||
.writeForLocale(eq("../escape"), eq("x"));
|
||||
ResponseEntity<Void> resp =
|
||||
controller.write("../escape", new DisclaimerContentRequest("x"));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, resp.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeReturnsServerErrorOnIoException() throws IOException {
|
||||
doThrow(new IOException("disk full"))
|
||||
.when(loginAgreementService)
|
||||
.writeForLocale(eq("fr-FR"), eq("x"));
|
||||
ResponseEntity<Void> resp = controller.write("fr-FR", new DisclaimerContentRequest("x"));
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user