生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #27
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.baiyue.integration.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.integration.config.LingxingWmsProperties;
|
||||||
|
import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot;
|
||||||
|
import com.baiyue.integration.provider.lingxing.LingxingEgressGate;
|
||||||
|
import com.baiyue.integration.security.InternalServiceTokenGuard;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** Internal token is checked before the active gate is inspected. Never calls the provider. */
|
||||||
|
@RestController
|
||||||
|
public class LingxingEgressRuntimeController {
|
||||||
|
private final InternalServiceTokenGuard guard;
|
||||||
|
private final LingxingEgressGate gate;
|
||||||
|
private final LingxingWmsProperties provider;
|
||||||
|
|
||||||
|
public LingxingEgressRuntimeController(InternalServiceTokenGuard guard, LingxingEgressGate gate,
|
||||||
|
LingxingWmsProperties provider) {
|
||||||
|
this.guard = guard; this.gate = gate; this.provider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "读取实际领星出口绑定配置的脱敏快照,无供应商或许可副作用")
|
||||||
|
@GetMapping("/internal/integrations/lingxing/wms/egress/runtime")
|
||||||
|
public ApiResponse<LingxingEgressRuntimeSnapshot> runtime(
|
||||||
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token) {
|
||||||
|
guard.verify(token);
|
||||||
|
return ApiResponse.success(gate.runtimeSnapshot(provider.enabled()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.integration.dto.lingxing;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Bound runtime configuration only. No credentials, addresses or execution attestation. */
|
||||||
|
public record LingxingEgressRuntimeSnapshot(int schemaVersion, String service, Instant observedAt,
|
||||||
|
String mode, String environment, boolean continuousEnabled, String sourceAccountFingerprint,
|
||||||
|
boolean grantPresent, String grantFingerprint, long intervalMs, int permitMaxSeconds,
|
||||||
|
boolean providerEnabled, String budgetAuthorityFingerprint) { }
|
||||||
+24
-1
@@ -8,12 +8,18 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot;
|
||||||
import java.util.concurrent.Semaphore;
|
import java.util.concurrent.Semaphore;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/** Local serialization supplements the exclusive grant and durable account protection. */
|
/** Local serialization supplements the exclusive grant and durable account protection. */
|
||||||
@Component
|
@Component
|
||||||
public final class LingxingEgressGate {
|
public final class LingxingEgressGate {
|
||||||
|
private static final int PERMIT_MAX_SECONDS = 30;
|
||||||
private static final Logger log = LoggerFactory.getLogger(LingxingEgressGate.class);
|
private static final Logger log = LoggerFactory.getLogger(LingxingEgressGate.class);
|
||||||
private final LingxingEgressProperties properties;
|
private final LingxingEgressProperties properties;
|
||||||
private final LingxingBudgetAuthority authority;
|
private final LingxingBudgetAuthority authority;
|
||||||
@@ -30,6 +36,23 @@ public final class LingxingEgressGate {
|
|||||||
LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) {
|
LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) {
|
||||||
this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter;
|
this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter;
|
||||||
}
|
}
|
||||||
|
/** The very properties and clock used by execute; no semaphore, authority, budget or HTTP work. */
|
||||||
|
public LingxingEgressRuntimeSnapshot runtimeSnapshot(boolean providerEnabled) {
|
||||||
|
String grant = properties.externalExclusiveGrantId();
|
||||||
|
return new LingxingEgressRuntimeSnapshot(1, "integration", clock.instant(),
|
||||||
|
properties.collectionMode().name(), properties.environmentLabel(),
|
||||||
|
properties.continuousBusinessCollectionEnabled(), fingerprint("source-account", properties.sourceAccount()),
|
||||||
|
grant != null && !grant.isBlank(), fingerprint("grant", grant), properties.minStartInterval().toMillis(),
|
||||||
|
PERMIT_MAX_SECONDS, providerEnabled, fingerprint("budget-authority", properties.budgetAuthorityBaseUri() == null ? null
|
||||||
|
: properties.budgetAuthorityBaseUri().toString()));
|
||||||
|
}
|
||||||
|
private static String fingerprint(String domain, String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA256_UNAVAILABLE", impossible); }
|
||||||
|
}
|
||||||
public <T> T execute(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
|
public <T> T execute(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
|
||||||
try { return guarded(context, operation, hash, call); }
|
try { return guarded(context, operation, hash, call); }
|
||||||
catch (LingxingWmsException error) {
|
catch (LingxingWmsException error) {
|
||||||
@@ -61,7 +84,7 @@ public final class LingxingEgressGate {
|
|||||||
if (permit == null || !context.equals(permit.context()) || operation != permit.operation()
|
if (permit == null || !context.equals(permit.context()) || operation != permit.operation()
|
||||||
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|
||||||
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|
||||||
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(30))
|
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(PERMIT_MAX_SECONDS))
|
||||||
|| (!business() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) {
|
|| (!business() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) {
|
||||||
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
|
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ info:
|
|||||||
Single-page or single-order adapter. No scheduling, business persistence or public Gateway route.
|
Single-page or single-order adapter. No scheduling, business persistence or public Gateway route.
|
||||||
Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone.
|
Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone.
|
||||||
Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive
|
Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive
|
||||||
account window; BUSINESS requires environmentLabel exactly production or test. TEST business task creation is restricted by Lastleg to controlled manual commands and their legitimate derived work; the adapter does not schedule work. All five endpoints apply the same gate.
|
account window; BUSINESS requires environmentLabel exactly production or test. TEST business task creation is restricted by Lastleg to controlled commands and their legitimate derived work; the adapter does not schedule work. Provider-query endpoints apply the same gate.
|
||||||
Missing exclusive proof, persistent account budget authorization or control context fails closed before egress.
|
Missing exclusive proof, persistent account budget authorization or control context fails closed before egress.
|
||||||
At most one account request is in flight and HTTP request starts are separated by at least 5 seconds.
|
At most one account request is in flight; request starts obey the bound minStartInterval (VALIDATION at least 5 seconds).
|
||||||
A JVM lock is only local protection and is not proof of cross-environment account exclusivity.
|
A JVM lock is only local protection and is not proof of cross-environment account exclusivity.
|
||||||
Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries;
|
Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries;
|
||||||
429 is never retried, immediately pauses the account and returns persistent cooldown metadata.
|
429 is never retried, immediately pauses the account and returns persistent cooldown metadata.
|
||||||
VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence.
|
VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence.
|
||||||
External server configuration supplies externalExclusiveGrantId and externalExclusiveUntil;
|
External server configuration supplies externalExclusiveGrantId and a VALIDATION externalExclusiveUntil;
|
||||||
the grant must match context.exclusiveGrantId and remain valid. A BUSINESS rate-limit pause is
|
the nonempty grant must match context.exclusiveGrantId. BUSINESS has no grant, session or root time cutoff;
|
||||||
recoverable only within the original valid exclusive window, after both the persistently returned
|
explicit stops, persistent account protections and each short single-attempt permit remain enforced.
|
||||||
|
A BUSINESS rate-limit pause is recoverable after both the persistently returned
|
||||||
cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new
|
cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new
|
||||||
strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state.
|
strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state.
|
||||||
Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost
|
Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost
|
||||||
@@ -32,7 +33,7 @@ x-lastleg-egress-coordination:
|
|||||||
requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization
|
requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization
|
||||||
is refused; arbitrary client-created attempts are refused. complete verifies the same bindings.
|
is refused; arbitrary client-created attempts are refused. complete verifies the same bindings.
|
||||||
A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true
|
A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true
|
||||||
and budgetConsumed=true, expires after now but no later than now plus 30 seconds or externalExclusiveUntil,
|
and budgetConsumed=true, expires after now but no later than now plus 30 seconds (also within externalExclusiveUntil for VALIDATION),
|
||||||
and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and
|
and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and
|
||||||
AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose
|
AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose
|
||||||
a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress;
|
a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress;
|
||||||
@@ -50,6 +51,26 @@ x-lastleg-egress-coordination:
|
|||||||
security:
|
security:
|
||||||
- InternalServiceToken: []
|
- InternalServiceToken: []
|
||||||
paths:
|
paths:
|
||||||
|
/internal/integrations/lingxing/wms/egress/runtime:
|
||||||
|
get:
|
||||||
|
operationId: lingxingEgressRuntime
|
||||||
|
summary: Read sanitized configuration from the active egress gate
|
||||||
|
description: >-
|
||||||
|
Internal token authentication only. No provider, database, budget-authority, permit or persistence side effect.
|
||||||
|
Returns actual bound configuration, not a verified claim or a commit identity. Fingerprints use lowercase
|
||||||
|
SHA-256 of UTF-8 "selfop-runtime:v1:<domain>\n" plus the exact original nonblank value, without trimming.
|
||||||
|
Domains are source-account, grant and budget-authority. Missing/blank values yield null, never an empty-value hash.
|
||||||
|
security: [{InternalServiceToken: []}]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ApiResponse code 200 with runtime snapshot, or A0401 for missing/invalid internal token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code: {type: string}
|
||||||
|
data: {$ref: '#/components/schemas/EgressRuntimeSnapshot'}
|
||||||
/internal/integrations/lingxing/wms/warehouses/query:
|
/internal/integrations/lingxing/wms/warehouses/query:
|
||||||
post:
|
post:
|
||||||
summary: Query one page of normal warehouses
|
summary: Query one page of normal warehouses
|
||||||
@@ -154,6 +175,26 @@ components:
|
|||||||
in: header
|
in: header
|
||||||
name: X-Internal-Service-Token
|
name: X-Internal-Service-Token
|
||||||
schemas:
|
schemas:
|
||||||
|
EgressRuntimeSnapshot:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [schemaVersion, service, observedAt, mode, environment, continuousEnabled, grantPresent, intervalMs, permitMaxSeconds, providerEnabled]
|
||||||
|
properties:
|
||||||
|
schemaVersion: {type: integer, enum: [1]}
|
||||||
|
service: {type: string, enum: [integration]}
|
||||||
|
observedAt: {type: string, format: date-time}
|
||||||
|
mode: {type: string, enum: [OFF, VALIDATION, BUSINESS]}
|
||||||
|
environment: {type: string}
|
||||||
|
continuousEnabled: {type: boolean}
|
||||||
|
sourceAccountFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'}
|
||||||
|
grantPresent: {type: boolean}
|
||||||
|
grantFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'}
|
||||||
|
intervalMs:
|
||||||
|
oneOf: [{type: integer, format: int64}, {type: string, pattern: '^[0-9]+$'}]
|
||||||
|
description: Actual bound interval; shared long-value JSON serialization may encode it as a decimal string.
|
||||||
|
permitMaxSeconds: {type: integer, enum: [30]}
|
||||||
|
providerEnabled: {type: boolean}
|
||||||
|
budgetAuthorityFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'}
|
||||||
EgressContext:
|
EgressContext:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.baiyue.integration.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.web.GlobalExceptionHandler;
|
||||||
|
import com.baiyue.integration.config.LingxingWmsProperties;
|
||||||
|
import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot;
|
||||||
|
import com.baiyue.integration.provider.lingxing.LingxingEgressGate;
|
||||||
|
import com.baiyue.integration.security.InternalServiceTokenGuard;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||||
|
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
class LingxingEgressRuntimeControllerTest {
|
||||||
|
@Test void internalTokenIsRequiredBeforeGateInspectionAndPublicPathDoesNotExist() throws Exception {
|
||||||
|
String token = UUID.randomUUID().toString();var guard = new InternalServiceTokenGuard();
|
||||||
|
ReflectionTestUtils.setField(guard, "token", token);ReflectionTestUtils.invokeMethod(guard, "initializeToken");
|
||||||
|
var gate = mock(LingxingEgressGate.class);var provider = new LingxingWmsProperties(false,null,null,null,null,null);
|
||||||
|
var converter = new MappingJackson2HttpMessageConverter(Jackson2ObjectMapperBuilder.json()
|
||||||
|
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).build());
|
||||||
|
var mvc = MockMvcBuilders.standaloneSetup(new LingxingEgressRuntimeController(guard,gate,provider))
|
||||||
|
.setMessageConverters(converter).setControllerAdvice(new GlobalExceptionHandler()).build();
|
||||||
|
String path = "/internal/integrations/lingxing/wms/egress/runtime";
|
||||||
|
for (String bad : new String[] {"", "wrong", "Bearer user-jwt"}) {
|
||||||
|
mvc.perform(get(path).header("X-Internal-Service-Token",bad)).andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
|
||||||
|
}
|
||||||
|
verifyNoInteractions(gate);
|
||||||
|
mvc.perform(get("/api/integrations/lingxing/wms/egress/runtime")).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(org.hamcrest.Matchers.not("200")))
|
||||||
|
.andExpect(jsonPath("$.data.service").doesNotExist());
|
||||||
|
verifyNoInteractions(gate);
|
||||||
|
var snapshot = new LingxingEgressRuntimeSnapshot(1,"integration",Instant.parse("2026-10-13T14:00:00Z"),
|
||||||
|
"OFF","test",false,null,false,null,5000,30,false,null);
|
||||||
|
when(gate.runtimeSnapshot(false)).thenReturn(snapshot);
|
||||||
|
mvc.perform(get(path).header("X-Internal-Service-Token",token)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value("200")).andExpect(jsonPath("$.data.service").value("integration"))
|
||||||
|
.andExpect(jsonPath("$.data.observedAt").value("2026-10-13T14:00:00Z"))
|
||||||
|
.andExpect(jsonPath("$.data.mode").value("OFF")).andExpect(jsonPath("$.data.providerEnabled").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.verified").doesNotExist()).andExpect(jsonPath("$.data.grant").doesNotExist());
|
||||||
|
verify(gate).runtimeSnapshot(false);verifyNoMoreInteractions(gate);
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package com.baiyue.integration.provider.lingxing;
|
||||||
|
|
||||||
|
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||||
|
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class LingxingEgressRuntimeSnapshotTest {
|
||||||
|
private static final Instant NOW = Instant.parse("2026-10-13T14:00:00Z");
|
||||||
|
private final LingxingBudgetAuthority authority = mock(LingxingBudgetAuthority.class);
|
||||||
|
private LingxingEgressGate gate(CollectionMode mode, String account, String grant, URI uri) {
|
||||||
|
return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", account, grant, null, uri,
|
||||||
|
mode == CollectionMode.VALIDATION ? Duration.ofSeconds(5) : Duration.ofMillis(1200), null, true),
|
||||||
|
authority, Clock.fixed(NOW, ZoneOffset.UTC), millis -> { throw new AssertionError("DIAGNOSTIC_MUST_NOT_WAIT"); });
|
||||||
|
}
|
||||||
|
private static String hash(String domain, String value) throws Exception {
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8)));
|
||||||
|
}
|
||||||
|
@Test void sameGateReturnsActualBindingsWithDomainSeparatedExactRawFingerprints() throws Exception {
|
||||||
|
var snapshot = gate(CollectionMode.BUSINESS, "same-raw-value", "same-raw-value", URI.create("http://authority:9303")).runtimeSnapshot(true);
|
||||||
|
assertThat(snapshot.schemaVersion()).isEqualTo(1);assertThat(snapshot.service()).isEqualTo("integration");
|
||||||
|
assertThat(snapshot.observedAt()).isEqualTo(NOW);assertThat(snapshot.mode()).isEqualTo("BUSINESS");
|
||||||
|
assertThat(snapshot.environment()).isEqualTo("test");assertThat(snapshot.continuousEnabled()).isTrue();
|
||||||
|
assertThat(snapshot.providerEnabled()).isTrue();assertThat(snapshot.grantPresent()).isTrue();
|
||||||
|
assertThat(snapshot.intervalMs()).isEqualTo(1200);assertThat(snapshot.permitMaxSeconds()).isEqualTo(30);
|
||||||
|
assertThat(snapshot.sourceAccountFingerprint()).isEqualTo(hash("source-account", "same-raw-value"));
|
||||||
|
assertThat(snapshot.grantFingerprint()).isEqualTo(hash("grant", "same-raw-value")).isNotEqualTo(snapshot.sourceAccountFingerprint());
|
||||||
|
assertThat(snapshot.budgetAuthorityFingerprint()).isEqualTo(hash("budget-authority", "http://authority:9303"));
|
||||||
|
assertThat(gate(CollectionMode.BUSINESS, " raw ", " raw ", null).runtimeSnapshot(false).grantFingerprint())
|
||||||
|
.isEqualTo(hash("grant", " raw ")).isNotEqualTo(hash("grant", "raw"));
|
||||||
|
verifyNoInteractions(authority);
|
||||||
|
}
|
||||||
|
@Test void offAndValidationAreObservableAndAbsentOrBlankValuesAreNotHashed() {
|
||||||
|
for (String value : new String[] {null, "", " "}) {
|
||||||
|
var s = gate(CollectionMode.OFF, value, value, null).runtimeSnapshot(false);
|
||||||
|
assertThat(s.mode()).isEqualTo("OFF");assertThat(s.sourceAccountFingerprint()).isNull();
|
||||||
|
assertThat(s.grantPresent()).isFalse();assertThat(s.grantFingerprint()).isNull();
|
||||||
|
assertThat(s.budgetAuthorityFingerprint()).isNull();assertThat(s.providerEnabled()).isFalse();
|
||||||
|
}
|
||||||
|
assertThat(gate(CollectionMode.VALIDATION, null, null, null).runtimeSnapshot(false).intervalMs()).isEqualTo(5000);
|
||||||
|
verifyNoInteractions(authority);
|
||||||
|
}
|
||||||
|
@Test void serializedSnapshotContainsNoRawIdentityAddressCredentialOrVerifiedClaim() throws Exception {
|
||||||
|
var raw = new ObjectMapper().findAndRegisterModules().writeValueAsString(
|
||||||
|
gate(CollectionMode.BUSINESS, "private-account", "private-grant", URI.create("http://private-host:9303")).runtimeSnapshot(true));
|
||||||
|
assertThat(raw).doesNotContain("private-account", "private-grant", "private-host", "http://", "verified", "appSecret", "internalServiceToken");
|
||||||
|
verifyNoInteractions(authority);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
public class SelfopEgressController {
|
public class SelfopEgressController {
|
||||||
private final InternalServiceTokenGuard guard;private final SelfopEgressService service;
|
private final InternalServiceTokenGuard guard;private final SelfopEgressService service;
|
||||||
public SelfopEgressController(InternalServiceTokenGuard guard,SelfopEgressService service){this.guard=guard;this.service=service;}
|
public SelfopEgressController(InternalServiceTokenGuard guard,SelfopEgressService service){this.guard=guard;this.service=service;}
|
||||||
|
@GetMapping("/runtime") public ApiResponse<SelfopEgressDtos.RuntimeSnapshot> runtime(@RequestHeader(value="X-Internal-Service-Token",required=false) String token){guard.verify(token);return ApiResponse.success(service.runtime());}
|
||||||
@PostMapping("/authorize") public ApiResponse<?> authorize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.AuthorizationRequest request){guard.verify(token);try{return ApiResponse.success(service.authorize(request));}catch(EgressRejectedException denied){return ApiResponse.of(ResultCode.BUSINESS_ERROR,"领星请求许可未授予",new SelfopEgressDtos.AuthorizationDenial(denied.code(),denied.sampleDone(),denied.waitUntilUtc()==null?null:denied.waitUntilUtc().toInstant(ZoneOffset.UTC)),null,null);}}
|
@PostMapping("/authorize") public ApiResponse<?> authorize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.AuthorizationRequest request){guard.verify(token);try{return ApiResponse.success(service.authorize(request));}catch(EgressRejectedException denied){return ApiResponse.of(ResultCode.BUSINESS_ERROR,"领星请求许可未授予",new SelfopEgressDtos.AuthorizationDenial(denied.code(),denied.sampleDone(),denied.waitUntilUtc()==null?null:denied.waitUntilUtc().toInstant(ZoneOffset.UTC)),null,null);}}
|
||||||
@PostMapping("/complete") public ApiResponse<Boolean> complete(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.CompletionRequest request){guard.verify(token);service.complete(request);return ApiResponse.success(true);}
|
@PostMapping("/complete") public ApiResponse<Boolean> complete(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.CompletionRequest request){guard.verify(token);service.complete(request);return ApiResponse.success(true);}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -2,6 +2,11 @@ package com.baiyue.lastleg.selfoperated.analytics.request;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
public final class SelfopEgressDtos {
|
public final class SelfopEgressDtos {
|
||||||
private SelfopEgressDtos(){}
|
private SelfopEgressDtos(){}
|
||||||
|
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)
|
||||||
|
public record RuntimeSnapshot(int schemaVersion,String service,Instant observedAt,String mode,String environment,
|
||||||
|
boolean continuousEnabled,String sourceAccountFingerprint,boolean grantPresent,String grantFingerprint,
|
||||||
|
long intervalMs,int permitMaxSeconds,boolean executionEnabled,int pendingMaxSeconds,
|
||||||
|
boolean exclusiveHandoffVerified,boolean priorEgressClosed,boolean inflightDrained,boolean transferredBudgetVerified){}
|
||||||
public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY}
|
public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY}
|
||||||
public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){}
|
public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){}
|
||||||
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)
|
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)
|
||||||
|
|||||||
+18
-2
@@ -9,10 +9,26 @@ import java.time.*;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
@Service
|
@Service
|
||||||
public class SelfopEgressService {
|
public class SelfopEgressService {
|
||||||
|
private static final int PERMIT_MAX_SECONDS=30,PENDING_MAX_SECONDS=120;
|
||||||
private final SelfopEgressMapper mapper; private final SelfopAnalyticsProperties config; private final Clock clock;
|
private final SelfopEgressMapper mapper; private final SelfopAnalyticsProperties config; private final Clock clock;
|
||||||
@Autowired public SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config){this(mapper,config,Clock.systemUTC());}
|
@Autowired public SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config){this(mapper,config,Clock.systemUTC());}
|
||||||
SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config,Clock clock){this.mapper=mapper;this.config=config;this.clock=clock;}
|
SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config,Clock clock){this.mapper=mapper;this.config=config;this.clock=clock;}
|
||||||
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);}
|
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);}
|
||||||
|
/** Reads the same configuration instance used by authorization; no account, permit or provider access. */
|
||||||
|
public RuntimeSnapshot runtime(){
|
||||||
|
return new RuntimeSnapshot(1,"lastleg",clock.instant(),config.getCollectionMode(),config.getEnvironment(),
|
||||||
|
config.isContinuousBusinessCollectionEnabled(),fingerprint("source-account",config.getSourceAccount()),
|
||||||
|
config.getExclusiveGrantId()!=null&&!config.getExclusiveGrantId().isBlank(),fingerprint("grant",config.getExclusiveGrantId()),
|
||||||
|
config.getBusinessMinRequestIntervalMs(),PERMIT_MAX_SECONDS,config.isExecutionEnabled(),pendingMaxSeconds(),
|
||||||
|
config.isExclusiveHandoffVerified(),config.isPriorEgressClosed(),config.isInflightDrained(),config.isTransferredBudgetVerified());
|
||||||
|
}
|
||||||
|
private int pendingMaxSeconds(){return Math.max(1,Math.min(PENDING_MAX_SECONDS,config.getEgressLeaseSeconds()));}
|
||||||
|
private static String fingerprint(String domain,String value){
|
||||||
|
if(value==null||value.isBlank())return null;
|
||||||
|
try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(("selfop-runtime:v1:"+domain+"\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}
|
||||||
|
catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException("SHA-256 unavailable",impossible);}
|
||||||
|
}
|
||||||
@Transactional(noRollbackFor=EgressRejectedException.class)
|
@Transactional(noRollbackFor=EgressRejectedException.class)
|
||||||
public EgressContext reserve(SelfopRequestContext context,Operation operation,int pageSize,String warehouseCode,String detailKey,String requestHash){
|
public EgressContext reserve(SelfopRequestContext context,Operation operation,int pageSize,String warehouseCode,String detailKey,String requestHash){
|
||||||
if(context==null||context.rootTaskId()==null||context.taskId()==null||context.runId()==null||!Objects.equals(context.sessionId(),"selfop-"+context.rootTaskId()))throw reject("EGRESS_CONTEXT_INVALID");
|
if(context==null||context.rootTaskId()==null||context.taskId()==null||context.runId()==null||!Objects.equals(context.sessionId(),"selfop-"+context.rootTaskId()))throw reject("EGRESS_CONTEXT_INVALID");
|
||||||
@@ -41,7 +57,7 @@ public class SelfopEgressService {
|
|||||||
if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000));
|
if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000));
|
||||||
if("VALIDATION".equals(context.executionPurpose())&&budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8));
|
if("VALIDATION".equals(context.executionPurpose())&&budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8));
|
||||||
var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId());
|
var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId());
|
||||||
var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds())));
|
var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(pendingMaxSeconds());
|
||||||
if("VALIDATION".equals(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc();
|
if("VALIDATION".equals(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc();
|
||||||
if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc;
|
if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc;
|
||||||
budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now;
|
budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now;
|
||||||
@@ -66,7 +82,7 @@ public class SelfopEgressService {
|
|||||||
if(account.getApiPauseReason()!=null||account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw reject("ACCOUNT_PAUSED");
|
if(account.getApiPauseReason()!=null||account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw reject("ACCOUNT_PAUSED");
|
||||||
if("VALIDATION".equals(c.executionPurpose())){var v=validation(root);if(v.stopReason!=null||!now.isBefore(v.deadlineAtUtc)){stop(root,v,v.stopReason==null?"VALIDATION_DEADLINE":v.stopReason,false,now);throw new EgressRejectedException("VALIDATION_STOPPED",true,null);}}
|
if("VALIDATION".equals(c.executionPurpose())){var v=validation(root);if(v.stopReason!=null||!now.isBefore(v.deadlineAtUtc)){stop(root,v,v.stopReason==null?"VALIDATION_DEADLINE":v.stopReason,false,now);throw new EgressRejectedException("VALIDATION_STOPPED",true,null);}}
|
||||||
if (!request.requestHash().matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_HASH_INVALID");
|
if (!request.requestHash().matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_HASH_INVALID");
|
||||||
pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(30);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget);
|
pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(PERMIT_MAX_SECONDS);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget);
|
||||||
return new AttemptPermit(c,request.operation(),request.requestHash(),1,expiry.toInstant(ZoneOffset.UTC),true,true);
|
return new AttemptPermit(c,request.operation(),request.requestHash(),1,expiry.toInstant(ZoneOffset.UTC),true,true);
|
||||||
}
|
}
|
||||||
@Transactional(noRollbackFor=EgressRejectedException.class)
|
@Transactional(noRollbackFor=EgressRejectedException.class)
|
||||||
|
|||||||
@@ -3,6 +3,30 @@ info:
|
|||||||
title: Self-operated Analytics Internal Egress Authority
|
title: Self-operated Analytics Internal Egress Authority
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
paths:
|
paths:
|
||||||
|
/internal/lastleg/self-operated-analytics/egress/runtime:
|
||||||
|
get:
|
||||||
|
operationId: getSelfoperatedEgressRuntime
|
||||||
|
summary: 读取脱敏的实际出口配置快照
|
||||||
|
description: >-
|
||||||
|
仅内部服务令牌,不提供公网路由。直接读取 Egress 执行所用的同一配置实例,
|
||||||
|
不读取数据库、不预留或消费许可、不调用供应商。响应不是部署验证结论,
|
||||||
|
不包含 verified、镜像版本、原始账号、grant、Token 或 URL。
|
||||||
|
intervalMs 为当前配置间隔,持久账户间隔可能更严格,须另行只读核验。
|
||||||
|
pendingMaxSeconds 为实际配置经 1 至 120 秒裁剪后的上限;permitMaxSeconds 为单次许可上限,
|
||||||
|
VALIDATION 仍可能受验证截止及 grant 截止进一步缩短。
|
||||||
|
security: [{ internalServiceToken: [] }]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ApiResponse 成功响应,data 为只读白名单快照
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code: { type: string }
|
||||||
|
data: { $ref: '#/components/schemas/RuntimeSnapshot' }
|
||||||
|
'401': { description: 内部令牌无效 }
|
||||||
|
'503': { description: 内部令牌未配置 }
|
||||||
/internal/lastleg/self-operated-analytics/egress/authorize:
|
/internal/lastleg/self-operated-analytics/egress/authorize:
|
||||||
post:
|
post:
|
||||||
operationId: authorizeSelfoperatedEgressAttempt
|
operationId: authorizeSelfoperatedEgressAttempt
|
||||||
@@ -11,8 +35,10 @@ paths:
|
|||||||
仅内部服务令牌。客户端已登记的 context 和 operation 必须完全匹配;
|
仅内部服务令牌。客户端已登记的 context 和 operation 必须完全匹配;
|
||||||
requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定,
|
requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定,
|
||||||
后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1,
|
后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1,
|
||||||
expiresAt 最多 30 秒且不超过验证根截止或人工独占窗口。OFF 默认拒绝,
|
expiresAt 最多 30 秒;VALIDATION 不超过验证根截止及 grant 截止。
|
||||||
BUSINESS 仅接受 production 或 test;TEST 任务入口仅允许受控人工任务及合法派生,不允许自动采集计划。独占交接证明及接续预算由服务端配置核验。
|
BUSINESS 不受人为会话或 grant 截止阻断,但保留短许可过期、身份、显式停止及账户保护。
|
||||||
|
OFF 默认拒绝;BUSINESS 仅接受 production 或 test,人工根及合法派生保持原身份,
|
||||||
|
连续计划根另须连续开关与持久化 planner 身份。独占交接证明及接续计数由服务端配置核验。
|
||||||
security: [{ internalServiceToken: [] }]
|
security: [{ internalServiceToken: [] }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -64,6 +90,36 @@ components:
|
|||||||
securitySchemes:
|
securitySchemes:
|
||||||
internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
|
internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
|
||||||
schemas:
|
schemas:
|
||||||
|
RuntimeSnapshot:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [schemaVersion, service, observedAt, mode, environment, continuousEnabled, sourceAccountFingerprint, grantPresent, grantFingerprint, intervalMs, permitMaxSeconds, executionEnabled, pendingMaxSeconds, exclusiveHandoffVerified, priorEgressClosed, inflightDrained, transferredBudgetVerified]
|
||||||
|
properties:
|
||||||
|
schemaVersion: { type: integer, enum: [1] }
|
||||||
|
service: { type: string, enum: [lastleg] }
|
||||||
|
observedAt: { type: string, format: date-time, description: UTC 观测时间 }
|
||||||
|
mode: { type: string, nullable: true, description: 原样返回实际配置,不将未知值转为 BUSINESS }
|
||||||
|
environment: { type: string, nullable: true }
|
||||||
|
continuousEnabled: { type: boolean }
|
||||||
|
sourceAccountFingerprint:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
pattern: '^[a-f0-9]{64}$'
|
||||||
|
description: 'SHA-256 UTF-8 输入为 selfop-runtime:v1:source-account 加一个 LF 再加原始值;不 trim;null 或 blank 返回显式 null。'
|
||||||
|
grantPresent: { type: boolean, description: grant 原值非 null 且非 blank }
|
||||||
|
grantFingerprint:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
pattern: '^[a-f0-9]{64}$'
|
||||||
|
description: 'SHA-256 UTF-8 输入为 selfop-runtime:v1:grant 加一个 LF 再加原始值;不 trim;null 或 blank 返回显式 null。'
|
||||||
|
intervalMs: { type: integer, format: int64, description: 实际配置的 BUSINESS 最小请求间隔毫秒数,未合并持久账户间隔 }
|
||||||
|
permitMaxSeconds: { type: integer, enum: [30] }
|
||||||
|
executionEnabled: { type: boolean }
|
||||||
|
pendingMaxSeconds: { type: integer, minimum: 1, maximum: 120 }
|
||||||
|
exclusiveHandoffVerified: { type: boolean }
|
||||||
|
priorEgressClosed: { type: boolean }
|
||||||
|
inflightDrained: { type: boolean }
|
||||||
|
transferredBudgetVerified: { type: boolean }
|
||||||
EgressContext:
|
EgressContext:
|
||||||
type: object
|
type: object
|
||||||
required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId]
|
required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId]
|
||||||
|
|||||||
+57
@@ -4,6 +4,63 @@ import org.junit.jupiter.api.Test;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
class SelfopEgressControllerTest {
|
class SelfopEgressControllerTest {
|
||||||
|
@Test void runtimeRequiresInternalTokenBeforeReadingConfiguration(){
|
||||||
|
var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service);
|
||||||
|
for(String token:new String[]{null,"","wrong"})assertThatThrownBy(()->c.runtime(token))
|
||||||
|
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class)
|
||||||
|
.satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401));
|
||||||
|
verifyNoInteractions(service);
|
||||||
|
var missing=new SelfopEgressController(new InternalServiceTokenGuard(""),service);
|
||||||
|
assertThatThrownBy(()->missing.runtime("synthetic")).isInstanceOf(org.springframework.web.server.ResponseStatusException.class)
|
||||||
|
.satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(503));
|
||||||
|
verifyNoInteractions(service);c.runtime("synthetic");verify(service).runtime();
|
||||||
|
}
|
||||||
|
@Test void runtimeUsesExecutionConfigurationAndSerializesOnlyWhitelistedFields() throws Exception {
|
||||||
|
var storage=mock(SelfopEgressMapper.class);var config=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties();
|
||||||
|
config.setCollectionMode("BUSINESS");config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true);
|
||||||
|
config.setSourceAccount("synthetic-private-account");config.setExclusiveGrantId("synthetic-private-grant");
|
||||||
|
config.setInternalServiceToken("synthetic-private-token");config.setIntegrationBaseUrl("http://synthetic-private-host");
|
||||||
|
config.setBusinessMinRequestIntervalMs(1200);config.setEgressLeaseSeconds(999);
|
||||||
|
config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true);
|
||||||
|
var now=java.time.Instant.parse("2026-10-13T00:00:00Z");
|
||||||
|
var service=new SelfopEgressService(storage,config,java.time.Clock.fixed(now,java.time.ZoneOffset.UTC));
|
||||||
|
var controller=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service);
|
||||||
|
var response=controller.runtime("synthetic");var runtime=response.data();
|
||||||
|
assertThat(runtime.schemaVersion()).isEqualTo(1);assertThat(runtime.service()).isEqualTo("lastleg");assertThat(runtime.observedAt()).isEqualTo(now);
|
||||||
|
assertThat(runtime.mode()).isEqualTo("BUSINESS");assertThat(runtime.environment()).isEqualTo("test");assertThat(runtime.continuousEnabled()).isTrue();
|
||||||
|
assertThat(runtime.intervalMs()).isEqualTo(1200);assertThat(runtime.permitMaxSeconds()).isEqualTo(30);assertThat(runtime.pendingMaxSeconds()).isEqualTo(120);
|
||||||
|
assertThat(runtime.executionEnabled()).isTrue();assertThat(runtime.grantPresent()).isTrue();
|
||||||
|
assertThat(runtime.exclusiveHandoffVerified()&&runtime.priorEgressClosed()&&runtime.inflightDrained()&&runtime.transferredBudgetVerified()).isTrue();
|
||||||
|
var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
|
String serialized=mapper.writeValueAsString(response);var data=mapper.readTree(serialized).path("data");
|
||||||
|
var names=new java.util.HashSet<String>();data.fieldNames().forEachRemaining(names::add);
|
||||||
|
assertThat(names).containsExactlyInAnyOrder("schemaVersion","service","observedAt","mode","environment","continuousEnabled",
|
||||||
|
"sourceAccountFingerprint","grantPresent","grantFingerprint","intervalMs","permitMaxSeconds","executionEnabled","pendingMaxSeconds",
|
||||||
|
"exclusiveHandoffVerified","priorEgressClosed","inflightDrained","transferredBudgetVerified");
|
||||||
|
assertThat(serialized).doesNotContain("synthetic-private-account","synthetic-private-grant","synthetic-private-token","synthetic-private-host");
|
||||||
|
config.setCollectionMode("VALIDATION");config.setContinuousBusinessCollectionEnabled(false);config.setBusinessMinRequestIntervalMs(5000);config.setEgressLeaseSeconds(0);
|
||||||
|
var changed=controller.runtime("synthetic").data();assertThat(changed.mode()).isEqualTo("VALIDATION");assertThat(changed.continuousEnabled()).isFalse();
|
||||||
|
assertThat(changed.intervalMs()).isEqualTo(5000);assertThat(changed.pendingMaxSeconds()).isEqualTo(1);
|
||||||
|
assertThat(runtime.mode()).isEqualTo("BUSINESS");verifyNoInteractions(storage);
|
||||||
|
}
|
||||||
|
@Test void runtimeFingerprintsUseRawUtf8WithSeparateDomainsAndExplicitNulls() throws Exception {
|
||||||
|
var storage=mock(SelfopEgressMapper.class);var config=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties();
|
||||||
|
var service=new SelfopEgressService(storage,config);
|
||||||
|
String value=" fixture-账户 ";config.setSourceAccount(value);config.setExclusiveGrantId(value);
|
||||||
|
var digest=java.security.MessageDigest.getInstance("SHA-256");var hex=java.util.HexFormat.of();
|
||||||
|
var runtime=service.runtime();
|
||||||
|
assertThat(runtime.sourceAccountFingerprint()).isEqualTo(hex.formatHex(digest.digest(("selfop-runtime:v1:source-account\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8))));
|
||||||
|
assertThat(runtime.grantFingerprint()).isEqualTo(hex.formatHex(digest.digest(("selfop-runtime:v1:grant\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8))));
|
||||||
|
assertThat(runtime.sourceAccountFingerprint()).isNotEqualTo(runtime.grantFingerprint());
|
||||||
|
config.setExclusiveGrantId(value.trim());assertThat(service.runtime().grantFingerprint()).isNotEqualTo(runtime.grantFingerprint());
|
||||||
|
var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||||
|
for(String empty:new String[]{null,""," \t"}){
|
||||||
|
config.setSourceAccount(empty);config.setExclusiveGrantId(empty);var absent=service.runtime();
|
||||||
|
assertThat(absent.grantPresent()).isFalse();assertThat(absent.grantFingerprint()).isNull();assertThat(absent.sourceAccountFingerprint()).isNull();
|
||||||
|
var node=mapper.valueToTree(absent);assertThat(node.has("grantFingerprint")).isTrue();assertThat(node.path("grantFingerprint").isNull()).isTrue();
|
||||||
|
}
|
||||||
|
verifyNoInteractions(storage);
|
||||||
|
}
|
||||||
@Test void invalidInternalTokenCannotReachAuthority(){
|
@Test void invalidInternalTokenCannotReachAuthority(){
|
||||||
var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service);
|
var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service);
|
||||||
assertThatThrownBy(()->c.authorize("other",null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401));
|
assertThatThrownBy(()->c.authorize("other",null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401));
|
||||||
|
|||||||
Reference in New Issue
Block a user