diff --git a/auth-migration/src/test/java/com/example/auth/AuthInvariantTest.java b/auth-migration/src/test/java/com/example/auth/AuthInvariantTest.java new file mode 100644 index 0000000..cc5529b --- /dev/null +++ b/auth-migration/src/test/java/com/example/auth/AuthInvariantTest.java @@ -0,0 +1,119 @@ +package com.example.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Verification tests for AUTH-* invariants. + * These tests MUST pass after Spring migration to confirm behavioral parity. + */ +@SpringBootTest +@AutoConfigureMockMvc +public class AuthInvariantTest { + + @Autowired + private MockMvc mockMvc; + + // AUTH-004: Bearer token required + @Test + void missingAuthorizationHeader_returns401WithAUTH_MISSING_TOKEN() throws Exception { + mockMvc.perform(get("/api/protected/resource")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("AUTH_MISSING_TOKEN")) + .andExpect(jsonPath("$.message").value("Authorization header required")) + .andExpect(jsonPath("$.timestamp").exists()); + } + + // AUTH-005: Bearer format required + @Test + void malformedBearerToken_returns401WithAUTH_MALFORMED_TOKEN() throws Exception { + mockMvc.perform(get("/api/protected/resource") + .header("Authorization", "Basic dXNlcjpwYXNz")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("AUTH_MALFORMED_TOKEN")) + .andExpect(jsonPath("$.message").value("Bearer token format required")); + } + + // AUTH-002: Expired token + @Test + void expiredToken_returns401WithAUTH_TOKEN_EXPIRED() throws Exception { + String expiredToken = TestTokenUtil.generateExpiredToken(); + mockMvc.perform(get("/api/protected/resource") + .header("Authorization", "Bearer " + expiredToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("AUTH_TOKEN_EXPIRED")) + .andExpect(jsonPath("$.message").value("Token has expired")); + } + + // AUTH-003: Invalid signature + @Test + void invalidSignature_returns401WithAUTH_INVALID_SIGNATURE() throws Exception { + String tamperedToken = TestTokenUtil.generateTamperedToken(); + mockMvc.perform(get("/api/protected/resource") + .header("Authorization", "Bearer " + tamperedToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("AUTH_INVALID_SIGNATURE")) + .andExpect(jsonPath("$.message").value("Token signature verification failed")); + } + + // AUTH-001: Missing sub claim + @Test + void tokenWithoutSub_returns401WithAUTH_MISSING_SUB() throws Exception { + String noSubToken = TestTokenUtil.generateTokenWithoutSub(); + mockMvc.perform(get("/api/protected/resource") + .header("Authorization", "Bearer " + noSubToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("AUTH_MISSING_SUB")) + .andExpect(jsonPath("$.message").value("Token missing subject claim")); + } + + // AUTH-010: Admin role required + @Test + void nonAdminAccessingAdminEndpoint_returns403WithAUTH_FORBIDDEN_ADMIN() throws Exception { + String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); + mockMvc.perform(get("/api/admin/users") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").value("AUTH_FORBIDDEN_ADMIN")) + .andExpect(jsonPath("$.message").value("Admin role required")); + } + + // AUTH-011: Resource owner check + @Test + void nonOwnerAccessingUserResource_returns403WithAUTH_FORBIDDEN_RESOURCE() throws Exception { + String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); + mockMvc.perform(get("/api/users/999/profile") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").value("AUTH_FORBIDDEN_RESOURCE")) + .andExpect(jsonPath("$.message").value("Not authorized for this resource")); + } + + // AUTH-012: Public profile accessible to authenticated users + @Test + void authenticatedUserCanReadPublicProfile_returns200() throws Exception { + String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); + mockMvc.perform(get("/api/users/public/johndoe") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()); + } + + // SESS-004: CSRF required for state-changing ops + @Test + void postWithoutCSRF_returns403() throws Exception { + String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); + mockMvc.perform(post("/api/users/profile") + .header("Authorization", "Bearer " + userToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"displayName\":\"test\"}")) + .andExpect(status().isForbidden()); + } +}