This commit is contained in:
2026-08-25 16:40:48 +08:00
parent 1f6f1a0677
commit 87c5e18ca6
93 changed files with 1154 additions and 527 deletions
@@ -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());
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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)));
}
}
@@ -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);
}
}
@@ -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 冲突");
}
}