优化
This commit is contained in:
+6
@@ -9,4 +9,10 @@ class InternalServiceTokenGuardTest {
|
||||
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value());
|
||||
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test void missingServerConfigurationFailsClosed() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
|
||||
assertEquals(503, assertThrows(ResponseStatusException.class,
|
||||
() -> guard.verify("any-token")).getStatusCode().value());
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.baiyue.lastleg.security;
|
||||
|
||||
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 LastlegSecurityContractTest {
|
||||
@Test void securityProjectionLivesInLastlegDatabaseWithReliableInbox() throws Exception {
|
||||
String baseline = read("lastleg-service/src/main/resources/db/lastleg.sql");
|
||||
String migration = read("deploy/sql/海外区域统一增量-MySQL8.sql");
|
||||
String authSeed = read("auth/src/main/resources/db/phase1-admin-accounts.sql");
|
||||
String service = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java");
|
||||
String validation = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java");
|
||||
String validationDto = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java");
|
||||
String mirror = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java");
|
||||
assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS ll_security_event_inbox",
|
||||
"PRIMARY KEY (event_id)", "payload_hash CHAR(64)",
|
||||
"CREATE TABLE IF NOT EXISTS ll_security_projection",
|
||||
"PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT",
|
||||
"permission_version BIGINT");
|
||||
for (String sql : new String[]{baseline, migration}) {
|
||||
assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,0",
|
||||
"('AUTHORIZATION','1000001',0,NULL,0,0",
|
||||
"baseline-platform-admin-user-v0",
|
||||
"baseline-platform-admin-authorization-v0")
|
||||
.doesNotContain("baseline-merchant-admin");
|
||||
}
|
||||
assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id",
|
||||
"default_app_code");
|
||||
assertThat(migration).contains("USE baiyue_lastleg;", "table_schema='baiyue_lastleg'")
|
||||
.doesNotContain("CREATE DATABASE IF NOT EXISTS baiyue_overseas_security",
|
||||
"USE baiyue_overseas_security;", "CREATE TABLE IF NOT EXISTS overseas_security_event_inbox",
|
||||
"CREATE TABLE IF NOT EXISTS overseas_security_projection");
|
||||
assertThat(service).contains("SecurityProjectionMonotonicity.decide", "Decision.APPLY");
|
||||
assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash")
|
||||
.doesNotContain("LoadingCompletedConsumer");
|
||||
assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败");
|
||||
assertThat(validation).contains("SELECT MAX(permission_version)", "tenant_id=?",
|
||||
"SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED",
|
||||
"TENANT_DISABLED", "PERMISSION_VERSION_STALE");
|
||||
assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion")
|
||||
.doesNotContain("Long tokenVersion");
|
||||
}
|
||||
|
||||
@Test void genericLastlegEventEndpointWhitelistsBusinessEventsOnly() throws Exception {
|
||||
String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java");
|
||||
assertThat(controller).contains("FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED",
|
||||
"FIRST_LEG_LOADING_PLAN_COMPLETED", "Lastleg 不消费该事件类型")
|
||||
.doesNotContain("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED",
|
||||
"IAM_PERMISSION_VERSION_CHANGED");
|
||||
}
|
||||
|
||||
@Test void internalControllerUsesLastlegOwnedPathsAndExistingTokenGuard() throws Exception {
|
||||
String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java");
|
||||
String guard = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java");
|
||||
String application = read("lastleg-service/src/main/resources/application.yml");
|
||||
assertThat(controller).contains("@RequestMapping(\"/internal/lastleg/security/v1\")",
|
||||
"@PostMapping(\"/events\")", "@PostMapping(\"/validate\")",
|
||||
"com.baiyue.lastleg.security.InternalServiceTokenGuard")
|
||||
.doesNotContain("/internal/overseas-security/v1");
|
||||
assertThat(guard).contains("MessageDigest.isEqual", "SERVICE_UNAVAILABLE", "UNAUTHORIZED");
|
||||
assertThat(application).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN:}")
|
||||
.doesNotContain("phase1-local-internal-token");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.lastleg.security;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
|
||||
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
class SecurityProjectionArchitectureTest {
|
||||
private static final String LASTLEG = "com.baiyue.lastleg..";
|
||||
private static final String CONTROLLER = "com.baiyue.lastleg.security.controller..";
|
||||
private static final String DTO = "com.baiyue.lastleg.security.dto..";
|
||||
private static final String SERVICE = "com.baiyue.lastleg.security.service..";
|
||||
|
||||
private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
|
||||
.importPackages("com.baiyue.lastleg");
|
||||
|
||||
@Test
|
||||
void projectionInternalsDoNotDependOnLastlegBusinessSlices() {
|
||||
classes().that().resideInAnyPackage(CONTROLLER, DTO, SERVICE)
|
||||
.should().onlyDependOnClassesThat(
|
||||
resideOutsideOfPackage(LASTLEG)
|
||||
.or(resideInAPackage(CONTROLLER))
|
||||
.or(resideInAPackage(DTO))
|
||||
.or(resideInAPackage(SERVICE))
|
||||
.or(resideInAPackage("com.baiyue.lastleg.security")))
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessSlicesDoNotDependOnProjectionInternals() {
|
||||
noClasses().that().resideOutsideOfPackages(CONTROLLER, DTO, SERVICE)
|
||||
.should().dependOnClassesThat().resideInAnyPackage(CONTROLLER, DTO, SERVICE)
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dtoAndServicesStayIndependentFromInternalWebAdapter() {
|
||||
noClasses().that().resideInAnyPackage(DTO, SERVICE)
|
||||
.should().dependOnClassesThat().resideInAPackage(CONTROLLER)
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.IamSecurityEventsV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class LastlegSecurityProjectionTransportTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
@Test
|
||||
void validatesEnvelopeHashAndStoresTheSixtyFourCharacterDigest() {
|
||||
EventEnvelope envelope = envelope();
|
||||
|
||||
String stored = LastlegSecurityProjectionService.validatedStorageHash(envelope);
|
||||
|
||||
assertThat(stored).hasSize(64).doesNotStartWith("sha256:");
|
||||
assertThat(envelope.payloadHash()).isEqualTo("sha256:" + stored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTamperedPayloadBeforeInboxMutation() {
|
||||
EventEnvelope source = envelope();
|
||||
EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
|
||||
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
|
||||
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
|
||||
source.traceId(), source.correlationId(), source.causationId(), source.payload(),
|
||||
"sha256:" + "0".repeat(64));
|
||||
|
||||
assertThatThrownBy(() -> LastlegSecurityProjectionService.validatedStorageHash(tampered))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("payloadHash");
|
||||
}
|
||||
|
||||
private EventEnvelope envelope() {
|
||||
var value = new IamSecurityEventsV1.PermissionVersionChanged(
|
||||
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"));
|
||||
var payload = objectMapper.valueToTree(value);
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000091",
|
||||
EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION,
|
||||
Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, RegionCode.OVERSEAS, "0",
|
||||
"AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678",
|
||||
null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class LastlegSecurityValidationServiceTest {
|
||||
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc);
|
||||
|
||||
@Test void disabledUserFailsClosed() {
|
||||
doReturn(List.of("DISABLED")).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.USER_DISABLED);
|
||||
}
|
||||
|
||||
@Test void disabledCustomerFailsClosed() {
|
||||
doReturn(List.of("ACTIVE"), List.of("DISABLED")).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.TENANT_DISABLED);
|
||||
}
|
||||
|
||||
@Test void missingUserProjectionFailsClosed() {
|
||||
doReturn(List.of()).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
|
||||
@Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() {
|
||||
doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L)).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test void permissionVersionMustExactlyMatchReliableProjection() {
|
||||
doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L),
|
||||
List.of("ACTIVE"), List.of(9L)).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.PERMISSION_VERSION_STALE);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 10L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_NOT_READY);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test void missingPermissionProjectionFailsClosed() {
|
||||
doReturn(List.of("ACTIVE"), List.of()).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class SecurityProjectionMonotonicityTest {
|
||||
@Test void appliesFirstAndNewerVersions() {
|
||||
assertThat(SecurityProjectionMonotonicity.decide(null, null, 1, "hash-1"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY);
|
||||
assertThat(SecurityProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY);
|
||||
}
|
||||
|
||||
@Test void ignoresOlderAndSameHashVersions() {
|
||||
assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.STALE);
|
||||
assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.DUPLICATE_VERSION);
|
||||
}
|
||||
|
||||
@Test void rejectsSameVersionWithDifferentHash() {
|
||||
assertThatThrownBy(() -> SecurityProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user