fix(selfop): allow controlled manual collection in test

This commit is contained in:
2026-10-12 23:21:57 +08:00
parent 6e9f4b7932
commit 86e05a77e6
15 changed files with 160 additions and 20 deletions
@@ -90,7 +90,8 @@ public final class LingxingEgressGate {
}
private void check(EgressContext context) {
if (properties.collectionMode() == CollectionMode.OFF) throw failure("EGRESS_OFF");
if (properties.collectionMode() == CollectionMode.BUSINESS && !"production".equals(properties.environmentLabel()))
if (properties.collectionMode() == CollectionMode.BUSINESS
&& !("production".equals(properties.environmentLabel()) || "test".equals(properties.environmentLabel())))
throw failure("BUSINESS_ENVIRONMENT_REJECTED");
if (paused && (!recoverableRatePause || properties.collectionMode() != CollectionMode.BUSINESS
|| cooldownUntil == null || clock.instant().isBefore(cooldownUntil))) throw failure("ACCOUNT_PAUSED");
@@ -6,7 +6,7 @@ info:
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.
Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive
account window; BUSINESS requires environmentLabel exactly production. 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 manual commands and their legitimate derived work; the adapter does not schedule work. All five endpoints apply the same gate.
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.
A JVM lock is only local protection and is not proof of cross-environment account exclusivity.
@@ -37,16 +37,46 @@ class LingxingEgressGateTest {
assertThat(completion.sourceCode()).isNull();
}
}
@Test void defaultOffAndNonProductionBusinessNeverAuthorize() {
@Test void defaultOffAndUnknownBusinessEnvironmentsNeverAuthorize() {
var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null);
assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF);
assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5));
var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF");
var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "test"), ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> business.execute(context("a"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("BUSINESS_ENVIRONMENT_REJECTED");
for(String environment:List.of("dev","gray","staging","prod","TEST","")) {
var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, environment), ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> business.execute(businessContext("rejected"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("BUSINESS_ENVIRONMENT_REJECTED");
}
assertThat(ledger.authorizations).isZero();
}
@Test void testAndProductionBusinessUseTheSameSingleConsumedPermitAndCompletionProtocol() {
for(String environment:List.of("test","production")) {
var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,environment),ledger,clock,clock::advanceMillis);
var result=gate.execute(businessContext(environment+"-inventory"),Operation.PRODUCT_INVENTORY,HASH,handle->{handle.startHttp();return handle.metadata();});
assertThat(result.actualHttpAttempts()).isEqualTo(1);assertThat(result.accountPaused()).isFalse();
}
assertThat(ledger.authorizations).isEqualTo(2);assertThat(ledger.consumed).hasSize(2);assertThat(ledger.completions).hasSize(2);
assertThat(ledger.completions).allSatisfy(c->{assertThat(c.context().executionPurpose()).isEqualTo(CollectionMode.BUSINESS);assertThat(c.operation()).isEqualTo(Operation.PRODUCT_INVENTORY);assertThat(c.actualHttpAttempts()).isEqualTo(1);});
}
@Test void testBusinessStillRejectsPurposeAccountAndGrantMismatchBeforeAuthorization() {
var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"test"),ledger,clock,clock::advanceMillis);
assertThatThrownBy(()->gate.execute(context("wrong-purpose"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EGRESS_CONTEXT_INVALID");
assertThatThrownBy(()->gate.execute(new EgressContext("other-account",CollectionMode.BUSINESS,"root","session","wrong-account","demo-grant"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EGRESS_CONTEXT_INVALID");
assertThatThrownBy(()->gate.execute(new EgressContext("demo-account",CollectionMode.BUSINESS,"root","session","wrong-grant","other-grant"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
clock.advanceMillis(3600000);
assertThatThrownBy(()->gate.execute(businessContext("expired-window"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
assertThat(ledger.authorizations).isZero();assertThat(ledger.completions).isEmpty();
}
@Test void testBusinessUnconfirmedCompletionStillRequiresManualReviewAfterTimePasses() {
var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"test"),ledger,clock,clock::advanceMillis);
ledger.failComplete=true;
var failure=assertThrows(LingxingWmsException.class,()->gate.execute(businessContext("test-lost-ack"),Operation.PRODUCT_INVENTORY,HASH,h->{h.startHttp();return true;}));
assertThat(failure.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
assertThat(failure.egress().actualHttpAttempts()).isEqualTo(1);assertThat(failure.egress().accountPaused()).isTrue();
clock.advanceMillis(600000);ledger.failComplete=false;
assertThatThrownBy(()->gate.execute(businessContext("test-after-uncertainty"),Operation.PRODUCT_INVENTORY,HASH,h->{h.startHttp();return true;})).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);assertThat(ledger.completions).hasSize(1);
}
@Test void proofAndContextAreServerBoundBeforeAuthorization() {
var contexts = Arrays.asList(null, new EgressContext("other", CollectionMode.VALIDATION, "root", "session", "a", "demo-grant"),
new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", "a", "demo-grant"),