feat: gate overseas receiving and harden phase4 workflows
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PdaSessionValidationDeploymentContractTest {
|
||||
@Test
|
||||
void nacosComposeAndJenkinsDeploymentRequireOnlyEnvironmentBackedAuthCoordinates() throws Exception {
|
||||
String application = read("gateway-overseas/src/main/resources/application.yml");
|
||||
String nacos = read("config/nacos/overseas/baiyue-common.yml");
|
||||
String compose = read("deploy/docker-compose.overseas.yml");
|
||||
String deployServices = read("deploy/scripts/deploy-services.sh");
|
||||
String deployBuiltService = read("deploy/scripts/deploy-built-service.sh");
|
||||
String jenkins = read("Jenkinsfile");
|
||||
|
||||
for (String source : new String[]{application, nacos, compose}) {
|
||||
assertThat(source).contains("AUTH_SESSION_VALIDATION_URL", "INTERNAL_SERVICE_TOKEN",
|
||||
"PDA_SESSION_VALIDATION_TIMEOUT");
|
||||
}
|
||||
assertThat(deployServices).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL");
|
||||
assertThat(deployBuiltService).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL");
|
||||
assertThat(jenkins).contains("'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'", "'auth': 'SERVICE_AUTH'",
|
||||
"'iam': 'SERVICE_IAM'");
|
||||
assertThat(application).doesNotContain("/internal/auth/accounts/overseas-session/validate");
|
||||
assertThat(read("gateway-overseas/src/main/resources/application-overseas.yml"))
|
||||
.doesNotContain("/internal/auth/**", "/internal/auth/accounts/overseas-session/validate");
|
||||
assertThat(read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java"))
|
||||
.contains("new Request(userId, tokenVersion, overseasWarehouseId)",
|
||||
".header(\"X-Internal-Service-Token\"")
|
||||
.doesNotContain("Authorization", "accessToken", "rawToken", "getTokenValue");
|
||||
}
|
||||
|
||||
private static String read(String relative) throws Exception {
|
||||
Path path = Path.of(relative);
|
||||
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -21,11 +21,22 @@ class RequiredClaimsJwtValidatorTest {
|
||||
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingAccountTokenVersion() {
|
||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM")
|
||||
.claim("permission_version", 3L).claim("authorities", List.of())
|
||||
.build();
|
||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingAuthorities() {
|
||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", 3L)
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1)
|
||||
.claim("permission_version", 3L)
|
||||
.build();
|
||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||
}
|
||||
@@ -54,6 +65,7 @@ class RequiredClaimsJwtValidatorTest {
|
||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L)
|
||||
.claim("token_version", 1)
|
||||
.claim("authorities", List.of()).build();
|
||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||
}
|
||||
@@ -72,6 +84,7 @@ class RequiredClaimsJwtValidatorTest {
|
||||
.expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", tenantId)
|
||||
.claim("app_code", appCode)
|
||||
.claim("token_version", 1)
|
||||
.claim("authorities", authorities);
|
||||
if (includePermissionVersion) {
|
||||
builder.claim("permission_version", 3L);
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PdaSessionValidationFilterTest {
|
||||
private final PdaSessionValidationClient client = mock(PdaSessionValidationClient.class);
|
||||
private final PdaSessionValidationFilter filter = new PdaSessionValidationFilter(client, new ObjectMapper());
|
||||
|
||||
@Test
|
||||
void oldTokenIsRejectedImmediatelyAfterWarehouseChange() {
|
||||
when(client.validate(42L, 7, 900L)).thenReturn(Mono.just(
|
||||
new PdaSessionValidationClient.Result(false, "TOKEN_VERSION_MISMATCH")));
|
||||
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(7, 900L));
|
||||
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(body(exchange)).contains("A0401", "令牌已失效");
|
||||
}
|
||||
|
||||
@Test
|
||||
void newlyIssuedTokenWithCurrentWarehousePasses() {
|
||||
when(client.validate(42L, 8, 901L)).thenReturn(Mono.just(
|
||||
new PdaSessionValidationClient.Result(true, null)));
|
||||
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L));
|
||||
AtomicBoolean routed = new AtomicBoolean();
|
||||
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> {
|
||||
routed.set(true);
|
||||
return Mono.empty();
|
||||
})).verifyComplete();
|
||||
|
||||
assertThat(routed).isTrue();
|
||||
assertThat(exchange.getResponse().getStatusCode()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundWarehouseIsDeniedWithoutCallingAuth() {
|
||||
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, null));
|
||||
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
assertThat(body(exchange)).contains("A0403", "未绑定仓库");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authUnavailableFailsClosed() {
|
||||
when(client.validate(42L, 8, 901L)).thenReturn(Mono.error(new IllegalStateException("timeout")));
|
||||
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L));
|
||||
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(body(exchange)).contains("A0401", "校验不可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryOverseasAdminAndClientPathsDoNotSynchronouslyValidate() {
|
||||
for (String path : new String[]{"/api/platform/v1/last-leg/orders", "/api/merchant/v1/last-leg/orders"}) {
|
||||
var exchange = exchange(path, claims(7, 900L));
|
||||
AtomicBoolean routed = new AtomicBoolean();
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> {
|
||||
routed.set(true);
|
||||
return Mono.empty();
|
||||
})).verifyComplete();
|
||||
assertThat(routed).isTrue();
|
||||
}
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
private static ServerWebExchange exchange(String path, Map<String, Object> claims) {
|
||||
Jwt.Builder jwt = Jwt.withTokenValue("already-verified-by-resource-server")
|
||||
.header("alg", "RS256")
|
||||
.subject("42")
|
||||
.issuedAt(Instant.now().minusSeconds(5))
|
||||
.expiresAt(Instant.now().plusSeconds(300));
|
||||
claims.forEach(jwt::claim);
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(
|
||||
MockServerHttpRequest.get(path).build());
|
||||
return exchange.mutate()
|
||||
.principal(Mono.just(new JwtAuthenticationToken(jwt.build(),
|
||||
List.of(new SimpleGrantedAuthority("ROLE_WAREHOUSE_PICKER")))))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Map<String, Object> claims(int tokenVersion, Long warehouseId) {
|
||||
var claims = new java.util.LinkedHashMap<String, Object>();
|
||||
claims.put("token_version", tokenVersion);
|
||||
if (warehouseId != null) claims.put("overseas_warehouse_id", warehouseId);
|
||||
return claims;
|
||||
}
|
||||
|
||||
private static String body(ServerWebExchange exchange) {
|
||||
return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse())
|
||||
.getBodyAsString().block();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user