auth
All checks were successful
Build Org Backend / build (push) Successful in 18s

This commit is contained in:
2026-05-09 00:26:41 +01:00
parent efd096bb9a
commit c8937357e5
12 changed files with 210 additions and 25 deletions

View File

@@ -31,6 +31,13 @@ Create that file once on the server with the production values required by the
app. The Gitea deploy step verifies that it exists, but it does not create or app. The Gitea deploy step verifies that it exists, but it does not create or
overwrite it. overwrite it.
Protected API routes require the shared API key from `ORG_BACKEND_API_KEY`.
Clients send it as:
```text
X-Org-Api-Key: <key>
```
The live systemd unit at `/etc/systemd/system/org-backend.service` is managed The live systemd unit at `/etc/systemd/system/org-backend.service` is managed
as a symlink to `misc/org-backend.service`, so the project copy is the source of as a symlink to `misc/org-backend.service`, so the project copy is the source of
truth. truth.

View File

@@ -4,6 +4,7 @@ package org.zaine.app.controller;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.zaine.app.security.JwtUtil; import org.zaine.app.security.JwtUtil;
import org.zaine.app.security.RequiresAuth;
import org.zaine.app.user.User; import org.zaine.app.user.User;
import org.zaine.app.user.UserRepository; import org.zaine.app.user.UserRepository;
import org.springframework.http.*; import org.springframework.http.*;
@@ -16,6 +17,7 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
@RequiresAuth
@Tag(name = "Authentication") @Tag(name = "Authentication")
public class AuthController { public class AuthController {
@@ -74,4 +76,4 @@ public class AuthController {
public record LoginRequest(String username, String password) {} public record LoginRequest(String username, String password) {}
public record RegisterRequest(String username, String password) {} public record RegisterRequest(String username, String password) {}
} }

View File

@@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.zaine.app.dto.CalibreBookDTO; import org.zaine.app.dto.CalibreBookDTO;
import org.zaine.app.security.RequiresAuth;
import org.zaine.app.service.CalibreService; import org.zaine.app.service.CalibreService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -16,6 +17,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
@RestController @RestController
@RequestMapping("/api/calibre") @RequestMapping("/api/calibre")
@RequiresAuth
@Tag(name = "Calibre", description = "Calibre API") @Tag(name = "Calibre", description = "Calibre API")
public class CalibreController { public class CalibreController {
@@ -32,4 +34,4 @@ public class CalibreController {
public List<CalibreBookDTO> getBooks() { public List<CalibreBookDTO> getBooks() {
return calibreService.getAllBooks(); return calibreService.getAllBooks();
} }
} }

View File

@@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.zaine.app.dto.CreateCommentDTO; import org.zaine.app.dto.CreateCommentDTO;
import org.zaine.app.model.Comments; import org.zaine.app.model.Comments;
import org.zaine.app.security.RequiresAuth;
import org.zaine.app.service.CommentsService; import org.zaine.app.service.CommentsService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -85,6 +86,7 @@ public class CommentsController {
value = "", value = "",
consumes = "application/json" consumes = "application/json"
) )
@RequiresAuth
public void addComment(@RequestBody CreateCommentDTO dto) { public void addComment(@RequestBody CreateCommentDTO dto) {
if (dto.getContent() == null || dto.getContent().trim().isEmpty()) { if (dto.getContent() == null || dto.getContent().trim().isEmpty()) {

View File

@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.zaine.app.dto.CompetenciesDTO; import org.zaine.app.dto.CompetenciesDTO;
import org.zaine.app.model.Competencies; import org.zaine.app.model.Competencies;
import org.zaine.app.security.RequiresAuth;
import org.zaine.app.service.CompetenciesService; import org.zaine.app.service.CompetenciesService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -20,6 +21,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
@RestController @RestController
@RequestMapping("/api/competencies") @RequestMapping("/api/competencies")
@RequiresAuth
@Tag(name = "Competencies", description = "Operations about competencies") @Tag(name = "Competencies", description = "Operations about competencies")
public class CompetenciesController { public class CompetenciesController {

View File

@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.zaine.app.dto.CreateNoteDTO; import org.zaine.app.dto.CreateNoteDTO;
import org.zaine.app.model.Notes; import org.zaine.app.model.Notes;
import org.zaine.app.security.RequiresAuth;
import org.zaine.app.service.NotesService; import org.zaine.app.service.NotesService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -19,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
@RequiresAuth
@Tag(name = "Notes", description = "Operations related to notes") @Tag(name = "Notes", description = "Operations related to notes")
public class NotesController { public class NotesController {
@Autowired @Autowired
@@ -59,4 +61,3 @@ public class NotesController {
return notesService.createNote(note); return notesService.createNote(note);
} }
} }

View File

@@ -19,6 +19,7 @@ import org.zaine.app.dto.MotalahSessionDTO;
import org.zaine.app.dto.WirdEntryDTO; import org.zaine.app.dto.WirdEntryDTO;
import org.zaine.app.model.MotalahSession; import org.zaine.app.model.MotalahSession;
import org.zaine.app.model.WirdEntry; import org.zaine.app.model.WirdEntry;
import org.zaine.app.security.RequiresAuth;
import org.zaine.app.service.MotalahService; import org.zaine.app.service.MotalahService;
import org.zaine.app.service.WirdService; import org.zaine.app.service.WirdService;
@@ -26,6 +27,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
@RestController @RestController
@RequestMapping("/api/wird") @RequestMapping("/api/wird")
@RequiresAuth
@Tag(name = "Wird", description = "Wird API") @Tag(name = "Wird", description = "Wird API")
public class WirdController { public class WirdController {

View File

@@ -28,6 +28,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.zaine.app.security.RequiresAuth;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -142,6 +143,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@PostMapping("/build-web") @PostMapping("/build-web")
@RequiresAuth
public ResponseEntity<String> triggerWebBuild() { public ResponseEntity<String> triggerWebBuild() {
return startBuild(webBuildDirectory, webBuildLogFile, webBuildRunning, webProcess, true); return startBuild(webBuildDirectory, webBuildLogFile, webBuildRunning, webProcess, true);
} }
@@ -155,6 +157,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@DeleteMapping("/build-web") @DeleteMapping("/build-web")
@RequiresAuth
public ResponseEntity<String> cancelWebBuild() { public ResponseEntity<String> cancelWebBuild() {
return killProcess(webProcess, webBuildRunning, "web build"); return killProcess(webProcess, webBuildRunning, "web build");
} }
@@ -168,6 +171,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@GetMapping("/build-web/status") @GetMapping("/build-web/status")
@RequiresAuth
public ResponseEntity<BuildStatus> getWebBuildStatus() { public ResponseEntity<BuildStatus> getWebBuildStatus() {
return ResponseEntity.ok(new BuildStatus(webBuildRunning.get(), webLastRun, webLastExitCode)); return ResponseEntity.ok(new BuildStatus(webBuildRunning.get(), webLastRun, webLastExitCode));
} }
@@ -181,6 +185,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@GetMapping("/build-web/logs") @GetMapping("/build-web/logs")
@RequiresAuth
public SseEmitter streamWebLogs() { public SseEmitter streamWebLogs() {
return streamLogs(webBuildLogFile); return streamLogs(webBuildLogFile);
} }
@@ -198,6 +203,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@PostMapping("/build-roam") @PostMapping("/build-roam")
@RequiresAuth
public ResponseEntity<String> triggerRoamBuild() { public ResponseEntity<String> triggerRoamBuild() {
return startBuild(roamBuildDirectory, roamBuildLogFile, roamBuildRunning, roamProcess, false); return startBuild(roamBuildDirectory, roamBuildLogFile, roamBuildRunning, roamProcess, false);
} }
@@ -211,6 +217,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@DeleteMapping("/build-roam") @DeleteMapping("/build-roam")
@RequiresAuth
public ResponseEntity<String> cancelRoamBuild() { public ResponseEntity<String> cancelRoamBuild() {
return killProcess(roamProcess, roamBuildRunning, "roam build"); return killProcess(roamProcess, roamBuildRunning, "roam build");
} }
@@ -224,6 +231,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@GetMapping("/build-roam/status") @GetMapping("/build-roam/status")
@RequiresAuth
public ResponseEntity<BuildStatus> getRoamBuildStatus() { public ResponseEntity<BuildStatus> getRoamBuildStatus() {
return ResponseEntity.ok(new BuildStatus(roamBuildRunning.get(), roamLastRun, roamLastExitCode)); return ResponseEntity.ok(new BuildStatus(roamBuildRunning.get(), roamLastRun, roamLastExitCode));
} }
@@ -237,6 +245,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@GetMapping("/build-roam/logs") @GetMapping("/build-roam/logs")
@RequiresAuth
public SseEmitter streamRoamLogs() { public SseEmitter streamRoamLogs() {
return streamLogs(roamBuildLogFile); return streamLogs(roamBuildLogFile);
} }
@@ -254,6 +263,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@PostMapping("/rerun-emacs") @PostMapping("/rerun-emacs")
@RequiresAuth
public ResponseEntity<String> triggerEmacs() { public ResponseEntity<String> triggerEmacs() {
log.info("Triggering Emacs command"); log.info("Triggering Emacs command");
return startCommand(emacsRunDirectory, emacsRunLogFile, emacsRunning, emacsProcess); return startCommand(emacsRunDirectory, emacsRunLogFile, emacsRunning, emacsProcess);
@@ -268,6 +278,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@DeleteMapping("/rerun-emacs") @DeleteMapping("/rerun-emacs")
@RequiresAuth
public ResponseEntity<String> cancelEmacs() { public ResponseEntity<String> cancelEmacs() {
return killProcess(emacsProcess, emacsRunning, "emacs"); return killProcess(emacsProcess, emacsRunning, "emacs");
} }
@@ -281,6 +292,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@GetMapping("/rerun-emacs/status") @GetMapping("/rerun-emacs/status")
@RequiresAuth
public ResponseEntity<BuildStatus> getEmacsStatus() { public ResponseEntity<BuildStatus> getEmacsStatus() {
return ResponseEntity.ok(new BuildStatus(emacsRunning.get(), emacsLastRun, emacsLastExitCode)); return ResponseEntity.ok(new BuildStatus(emacsRunning.get(), emacsLastRun, emacsLastExitCode));
} }
@@ -294,6 +306,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@GetMapping("/rerun-emacs/logs") @GetMapping("/rerun-emacs/logs")
@RequiresAuth
public SseEmitter streamEmacsLogs() { public SseEmitter streamEmacsLogs() {
return streamLogs(emacsRunLogFile); return streamLogs(emacsRunLogFile);
} }
@@ -311,6 +324,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@PostMapping("/run-combined") @PostMapping("/run-combined")
@RequiresAuth
public ResponseEntity<String> triggerCombined() { public ResponseEntity<String> triggerCombined() {
if (!combinedRunning.compareAndSet(false, true)) { if (!combinedRunning.compareAndSet(false, true)) {
return ResponseEntity.status(HttpStatus.CONFLICT).body("Combined run already in progress"); return ResponseEntity.status(HttpStatus.CONFLICT).body("Combined run already in progress");
@@ -389,6 +403,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@DeleteMapping("/run-combined") @DeleteMapping("/run-combined")
@RequiresAuth
public ResponseEntity<String> cancelCombined() { public ResponseEntity<String> cancelCombined() {
return killProcess(combinedProcess, combinedRunning, "combined run"); return killProcess(combinedProcess, combinedRunning, "combined run");
} }
@@ -402,6 +417,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@GetMapping("/run-combined/status") @GetMapping("/run-combined/status")
@RequiresAuth
public ResponseEntity<BuildStatus> getCombinedStatus() { public ResponseEntity<BuildStatus> getCombinedStatus() {
return ResponseEntity.ok(new BuildStatus(combinedRunning.get(), combinedLastRun, combinedLastExitCode)); return ResponseEntity.ok(new BuildStatus(combinedRunning.get(), combinedLastRun, combinedLastExitCode));
} }
@@ -415,6 +431,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@GetMapping("/run-combined/logs") @GetMapping("/run-combined/logs")
@RequiresAuth
public SseEmitter streamCombinedLogs() { public SseEmitter streamCombinedLogs() {
return streamLogs(combinedRunLogFile); return streamLogs(combinedRunLogFile);
} }
@@ -432,6 +449,7 @@ public class BuildController {
@ApiResponse(responseCode = "500", description = "Internal server error") @ApiResponse(responseCode = "500", description = "Internal server error")
}) })
@GetMapping("/last-runs") @GetMapping("/last-runs")
@RequiresAuth
public ResponseEntity<Map<String, Object>> getLastRuns() { public ResponseEntity<Map<String, Object>> getLastRuns() {
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"web", Map.of("lastRun", nullSafe(webLastRun), "exitCode", nullSafe(webLastExitCode)), "web", Map.of("lastRun", nullSafe(webLastRun), "exitCode", nullSafe(webLastExitCode)),
@@ -776,4 +794,4 @@ public class BuildController {
this.lastExitCode = lastExitCode; this.lastExitCode = lastExitCode;
} }
} }
} }

View File

@@ -0,0 +1,31 @@
package org.zaine.app.security;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ApiKeyAuthService {
private final String apiKey;
public ApiKeyAuthService(@Value("${org.auth.api-key:}") String apiKey) {
this.apiKey = apiKey == null ? "" : apiKey.trim();
}
public boolean isConfigured() {
return !apiKey.isBlank();
}
public boolean isValid(String candidate) {
if (!isConfigured() || candidate == null || candidate.isBlank()) {
return false;
}
byte[] expected = apiKey.getBytes(StandardCharsets.UTF_8);
byte[] provided = candidate.trim().getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(expected, provided);
}
}

View File

@@ -19,11 +19,14 @@ import java.util.List;
@Component @Component
public class JwtAuthFilter extends OncePerRequestFilter { public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil; public static final String API_KEY_HEADER = "X-Org-Api-Key";
private final ApiKeyAuthService apiKeyAuthService;
private final RequestMappingHandlerMapping requestMappingHandlerMapping; private final RequestMappingHandlerMapping requestMappingHandlerMapping;
public JwtAuthFilter(JwtUtil jwtUtil, RequestMappingHandlerMapping requestMappingHandlerMapping) { public JwtAuthFilter(ApiKeyAuthService apiKeyAuthService,
this.jwtUtil = jwtUtil; RequestMappingHandlerMapping requestMappingHandlerMapping) {
this.apiKeyAuthService = apiKeyAuthService;
this.requestMappingHandlerMapping = requestMappingHandlerMapping; this.requestMappingHandlerMapping = requestMappingHandlerMapping;
} }
@@ -36,26 +39,17 @@ public class JwtAuthFilter extends OncePerRequestFilter {
return; return;
} }
String authHeader = request.getHeader("Authorization"); String apiKey = request.getHeader(API_KEY_HEADER);
if (apiKeyAuthService.isValid(apiKey)) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) { var auth = new UsernamePasswordAuthenticationToken("api-key", null, List.of());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid Authorization header"); auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
return; return;
} }
String token = authHeader.substring(7); response.sendError(HttpServletResponse.SC_UNAUTHORIZED,
"Missing or invalid " + API_KEY_HEADER + " header");
if (!jwtUtil.isTokenValid(token)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token");
return;
}
String username = jwtUtil.extractUsername(token);
var auth = new UsernamePasswordAuthenticationToken(username, null, List.of());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
} }
private boolean endpointRequiresAuth(HttpServletRequest request) { private boolean endpointRequiresAuth(HttpServletRequest request) {
@@ -71,4 +65,4 @@ public class JwtAuthFilter extends OncePerRequestFilter {
return false; return false;
} }
} }
} }

View File

@@ -0,0 +1,27 @@
package org.zaine.app.security;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class ApiKeyAuthServiceTest {
@Test
void rejectsAllKeysWhenNoApiKeyIsConfigured() {
ApiKeyAuthService service = new ApiKeyAuthService("");
assertFalse(service.isConfigured());
assertFalse(service.isValid("anything"));
}
@Test
void acceptsOnlyConfiguredApiKey() {
ApiKeyAuthService service = new ApiKeyAuthService("secret-key");
assertTrue(service.isConfigured());
assertTrue(service.isValid("secret-key"));
assertFalse(service.isValid("wrong-key"));
assertFalse(service.isValid(null));
}
}

View File

@@ -0,0 +1,97 @@
package org.zaine.app.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JwtAuthFilterTest {
private TestHandlerMapping handlerMapping;
private JwtAuthFilter filter;
@BeforeEach
void setUp() {
handlerMapping = new TestHandlerMapping();
filter = new JwtAuthFilter(new ApiKeyAuthService("secret-key"), handlerMapping);
}
@Test
void rejectsProtectedEndpointWithoutApiKey() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/secure");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean chainCalled = new AtomicBoolean(false);
handlerMapping.setHandler(handlerFor("secure"));
filter.doFilter(request, response, chain(chainCalled));
assertEquals(401, response.getStatus());
assertFalse(chainCalled.get());
}
@Test
void allowsProtectedEndpointWithValidApiKey() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/secure");
request.addHeader(JwtAuthFilter.API_KEY_HEADER, "secret-key");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean chainCalled = new AtomicBoolean(false);
handlerMapping.setHandler(handlerFor("secure"));
filter.doFilter(request, response, chain(chainCalled));
assertEquals(200, response.getStatus());
assertTrue(chainCalled.get());
}
@Test
void allowsPublicEndpointWithoutApiKey() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/public");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean chainCalled = new AtomicBoolean(false);
handlerMapping.setHandler(handlerFor("open"));
filter.doFilter(request, response, chain(chainCalled));
assertEquals(200, response.getStatus());
assertTrue(chainCalled.get());
}
private HandlerMethod handlerFor(String methodName) throws NoSuchMethodException {
DemoController controller = new DemoController();
return new HandlerMethod(controller, DemoController.class.getMethod(methodName));
}
private FilterChain chain(AtomicBoolean called) {
return (request, response) -> called.set(true);
}
static class TestHandlerMapping extends RequestMappingHandlerMapping {
private HandlerMethod handler;
void setHandler(HandlerMethod handler) {
this.handler = handler;
}
@Override
protected HandlerMethod getHandlerInternal(HttpServletRequest request) {
return handler;
}
}
static class DemoController {
@RequiresAuth
public void secure() {}
public void open() {}
}
}