deepdebug-local-agent 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/la-protocol-v2.md +70 -16
- package/env.example +6 -0
- package/package.json +1 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +3 -3
- package/src/la-protocol-v2/content-handlers/config.js +10 -2
- package/src/la-protocol-v2/content-handlers/content-index.js +196 -0
- package/src/la-protocol-v2/content-handlers/egress.js +22 -0
- package/src/la-protocol-v2/content-handlers/file-list.js +174 -0
- package/src/la-protocol-v2/content-handlers/git-diff.js +95 -10
- package/src/la-protocol-v2/content-handlers/handler.js +52 -16
- package/src/la-protocol-v2/content-handlers/install.js +35 -15
- package/src/la-protocol-v2/content-handlers/path-guard.js +120 -10
- package/src/la-protocol-v2/content-handlers/ref-registry.js +41 -13
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +87 -2
- package/src/la-protocol-v2/contract/content.index.schema.json +93 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +1 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +1 -0
- package/src/la-protocol-v2/contract/file.list.body.schema.json +69 -0
- package/src/la-protocol-v2/contract/index.json +7 -0
- package/src/la-protocol-v2/contract.js +9 -0
- package/src/la-protocol-v2/runtime.js +12 -2
- package/test/contract/la-protocol-v2/vectors/content.index.vectors.json +3809 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +215 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +30 -0
- package/test/contract/la-protocol-v2/vectors/file.list.body.vectors.json +1645 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-content-index-before-ready.json +68 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-index.json +161 -0
- package/test/la-protocol-v2/content-alignment.test.js +669 -0
- package/test/la-protocol-v2/content-deny-list.test.js +138 -0
- package/test/la-protocol-v2/content-diff-symlink.test.js +83 -0
- package/test/la-protocol-v2/content-file-list.test.js +385 -0
- package/test/la-protocol-v2/content-handlers.test.js +55 -61
- package/test/la-protocol-v2/content-path-guard.test.js +10 -5
- package/test/la-protocol-v2/content-wiring.test.js +39 -14
- package/test/la-protocol-v2/contract.test.js +15 -5
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -0
- package/test/la-protocol-v2/traces.test.js +29 -4
- package/tools/interop/LaV2NodeContentInteropTest.java +18 -4
- package/tools/interop/LaV2NodeContentRelayE2ETest.java +374 -0
- package/tools/interop/LaV2NodeFileListE2ETest.java +369 -0
- package/tools/interop/README.md +16 -0
- package/tools/interop/la-interop-content-client.mjs +19 -8
- package/tools/interop/la-interop-list-client.mjs +93 -0
|
@@ -8,6 +8,8 @@ import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Metrics;
|
|
|
8
8
|
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Session;
|
|
9
9
|
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2SessionStore;
|
|
10
10
|
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit;
|
|
11
|
+
import io.visionhammer.gateway.cloud.application.content.ContentIndexRegistry;
|
|
12
|
+
import io.visionhammer.gateway.cloud.application.content.ContentRelayProperties;
|
|
11
13
|
import io.visionhammer.gateway.governance.application.DataPlanePolicy;
|
|
12
14
|
import io.visionhammer.gateway.identity.application.AgentAuthorizationEntity;
|
|
13
15
|
import io.visionhammer.gateway.identity.application.AgentAuthorizationStore;
|
|
@@ -56,7 +58,8 @@ import static org.mockito.Mockito.when;
|
|
|
56
58
|
*
|
|
57
59
|
* LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeContentInteropTest
|
|
58
60
|
*
|
|
59
|
-
* Skipped when LA_REPO is not set.
|
|
61
|
+
* Skipped when LA_REPO is not set. LA_DLP_MODE=off is the negative control: the agent then registers NO content handler
|
|
62
|
+
* (F3-07b) and the test fails at the first request. Not proven here: Cloud Run / LB / TLS, Memorystore, two gateway instances, the HTTP
|
|
60
63
|
* relay endpoint (F3-06) in front of this session.
|
|
61
64
|
*/
|
|
62
65
|
class LaV2NodeContentInteropTest {
|
|
@@ -82,7 +85,12 @@ class LaV2NodeContentInteropTest {
|
|
|
82
85
|
};
|
|
83
86
|
LaV2InboundListener listener = (t, a, type, rid, payload) -> { };
|
|
84
87
|
AgentRequestSigner signer = new AgentRequestSigner(LaV2TestKit.PEM, "k1", clock);
|
|
85
|
-
|
|
88
|
+
// F3-07b: the agent announces its ids with content.index; the gateway keeps them in this registry (without a sink it would answer
|
|
89
|
+
// `rejected protocol_unsupported`, which the metrics below count)
|
|
90
|
+
ContentRelayProperties relayProps = new ContentRelayProperties();
|
|
91
|
+
relayProps.setEnabled(true);
|
|
92
|
+
ContentIndexRegistry index = new ContentIndexRegistry(relayProps, null, System::currentTimeMillis);
|
|
93
|
+
LaV2Session.Env env = new LaV2Session.Env(LaV2TestKit.CONTRACT, props, signer, OM, clock, metrics, Schedulers.parallel(), List.of(listener), index);
|
|
86
94
|
|
|
87
95
|
AgentAuthorizationStore store = mock(AgentAuthorizationStore.class);
|
|
88
96
|
WorkspaceRepository workspaces = mock(WorkspaceRepository.class);
|
|
@@ -106,7 +114,8 @@ class LaV2NodeContentInteropTest {
|
|
|
106
114
|
ProcessBuilder pb = new ProcessBuilder("node", laRepo + "/tools/interop/la-interop-content-client.mjs");
|
|
107
115
|
pb.environment().putAll(Map.of(
|
|
108
116
|
"GW_URL", "ws://127.0.0.1:" + server.port() + "/api/v1/agent/ws", "TOKEN", "tok", "TENANT", TENANT, "AGENT", AGENT,
|
|
109
|
-
"KEY_ID", "k1", "PUBLIC_KEY", LaV2TestKit.PUB, "MODE", "enforce",
|
|
117
|
+
"KEY_ID", "k1", "PUBLIC_KEY", LaV2TestKit.PUB, "MODE", "enforce",
|
|
118
|
+
"LOCAL_AGENT_DLP_MODE", System.getenv().getOrDefault("LA_DLP_MODE", "enforce")));
|
|
110
119
|
pb.redirectErrorStream(true);
|
|
111
120
|
Process proc = pb.start();
|
|
112
121
|
BlockingQueue<JsonNode> events = new LinkedBlockingQueue<>();
|
|
@@ -137,6 +146,11 @@ class LaV2NodeContentInteropTest {
|
|
|
137
146
|
JsonNode refs = refsEvt.path("refs");
|
|
138
147
|
String ghp = refsEvt.path("ghp").asText();
|
|
139
148
|
assertThat(refs.path("envFile").isNull()).as("no id is minted for .env").isTrue();
|
|
149
|
+
assertThat(refs.path("envBackup").isNull()).as("no id is minted for .env~ (F3-07b)").isTrue();
|
|
150
|
+
// F3-07b: the ids are the 43-char base64url ones the gateway requires
|
|
151
|
+
for (String k : List.of("app", "big", "mid", "swapped", "bin", "diffFile")) {
|
|
152
|
+
assertThat(refs.path(k).asText()).as(k).matches("^[A-Za-z0-9_-]{43}$");
|
|
153
|
+
}
|
|
140
154
|
|
|
141
155
|
// 1. a file with a token: served, the token masked (DLP enforce), the rest intact
|
|
142
156
|
List<LaV2Chunk> app = content("file", refs.path("app").asText(), handler);
|
|
@@ -167,7 +181,7 @@ class LaV2NodeContentInteropTest {
|
|
|
167
181
|
assertThat(swapped.get(0).data()).isEmpty();
|
|
168
182
|
|
|
169
183
|
// 5. paths are never ids: traversal, absolute, plain relative -> not_found; nothing else comes back
|
|
170
|
-
for (String ref : List.of("../../etc/passwd", "/etc/passwd", "src/app.txt", "..%2f..%2fetc%2fpasswd", "cr1_AAAAAAAAAAAAAAAAAAAAAA")) {
|
|
184
|
+
for (String ref : List.of("../../etc/passwd", "/etc/passwd", "src/app.txt", "..%2f..%2fetc%2fpasswd", "cr1_AAAAAAAAAAAAAAAAAAAAAA", "A".repeat(43))) {
|
|
171
185
|
List<LaV2Chunk> r = content("file", ref, handler);
|
|
172
186
|
assertThat(r).hasSize(1);
|
|
173
187
|
assertThat(r.get(0).status()).as(ref).isEqualTo("not_found");
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
package io.visionhammer.gateway.cloud.infrastructure.byoc;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.databind.JsonNode;
|
|
4
|
+
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
5
|
+
import io.micrometer.core.instrument.MeterRegistry;
|
|
6
|
+
import io.visionhammer.gateway.cloud.api.content.ContentRelayController;
|
|
7
|
+
import io.visionhammer.gateway.cloud.application.byoc.LocalAgentSessionRegistry;
|
|
8
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaProtocolV2Properties;
|
|
9
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2InboundListener;
|
|
10
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Metrics;
|
|
11
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2Session;
|
|
12
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2SessionStore;
|
|
13
|
+
import io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit;
|
|
14
|
+
import io.visionhammer.gateway.cloud.application.content.ContentBufferBudget;
|
|
15
|
+
import io.visionhammer.gateway.cloud.application.content.ContentIndexRegistry;
|
|
16
|
+
import io.visionhammer.gateway.cloud.application.content.ContentKind;
|
|
17
|
+
import io.visionhammer.gateway.cloud.application.content.ContentRelayLogStore;
|
|
18
|
+
import io.visionhammer.gateway.cloud.application.content.ContentRelayProperties;
|
|
19
|
+
import io.visionhammer.gateway.cloud.application.content.ContentRelayService;
|
|
20
|
+
import io.visionhammer.gateway.governance.application.DataPlanePolicy;
|
|
21
|
+
import io.visionhammer.gateway.identity.application.AgentAuthorizationEntity;
|
|
22
|
+
import io.visionhammer.gateway.identity.application.AgentAuthorizationStore;
|
|
23
|
+
import io.visionhammer.gateway.security.AuthenticatedUser;
|
|
24
|
+
import io.visionhammer.gateway.security.TenantAccessGuard;
|
|
25
|
+
import io.visionhammer.gateway.shared.localagent.AgentRequestSigner;
|
|
26
|
+
import io.visionhammer.gateway.workspace.application.WorkspaceEntity;
|
|
27
|
+
import io.visionhammer.gateway.workspace.application.WorkspaceRepository;
|
|
28
|
+
import io.visionhammer.gateway.workspace.application.WorkspaceRowStore;
|
|
29
|
+
import org.junit.jupiter.api.Test;
|
|
30
|
+
import org.springframework.beans.factory.ObjectProvider;
|
|
31
|
+
import org.springframework.http.MediaType;
|
|
32
|
+
import org.springframework.http.server.reactive.HttpHandler;
|
|
33
|
+
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
|
34
|
+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
35
|
+
import org.springframework.security.core.Authentication;
|
|
36
|
+
import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers;
|
|
37
|
+
import org.springframework.security.web.reactive.result.method.annotation.AuthenticationPrincipalArgumentResolver;
|
|
38
|
+
import org.springframework.test.web.reactive.server.WebTestClient;
|
|
39
|
+
import org.springframework.web.reactive.socket.server.WebSocketService;
|
|
40
|
+
import org.springframework.web.reactive.socket.server.support.HandshakeWebSocketService;
|
|
41
|
+
import org.springframework.web.reactive.socket.server.upgrade.ReactorNettyRequestUpgradeStrategy;
|
|
42
|
+
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
|
|
43
|
+
import reactor.core.publisher.Mono;
|
|
44
|
+
import reactor.core.scheduler.Schedulers;
|
|
45
|
+
import reactor.netty.DisposableServer;
|
|
46
|
+
import reactor.netty.http.server.HttpServer;
|
|
47
|
+
|
|
48
|
+
import java.io.BufferedReader;
|
|
49
|
+
import java.io.InputStreamReader;
|
|
50
|
+
import java.nio.charset.StandardCharsets;
|
|
51
|
+
import java.security.MessageDigest;
|
|
52
|
+
import java.time.Clock;
|
|
53
|
+
import java.time.Duration;
|
|
54
|
+
import java.util.ArrayList;
|
|
55
|
+
import java.util.Collections;
|
|
56
|
+
import java.util.HexFormat;
|
|
57
|
+
import java.util.List;
|
|
58
|
+
import java.util.Map;
|
|
59
|
+
import java.util.UUID;
|
|
60
|
+
import java.util.concurrent.BlockingQueue;
|
|
61
|
+
import java.util.concurrent.LinkedBlockingQueue;
|
|
62
|
+
import java.util.concurrent.TimeUnit;
|
|
63
|
+
|
|
64
|
+
import static io.visionhammer.gateway.cloud.application.byoc.v2.LaV2TestKit.OM;
|
|
65
|
+
import static org.assertj.core.api.Assertions.assertThat;
|
|
66
|
+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
|
67
|
+
import static org.mockito.ArgumentMatchers.any;
|
|
68
|
+
import static org.mockito.ArgumentMatchers.anyString;
|
|
69
|
+
import static org.mockito.Mockito.mock;
|
|
70
|
+
import static org.mockito.Mockito.when;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* F3-07b end to end, WITHOUT any patch on either side: HTTP ({@code GET} and {@code POST /api/v1/content/{kind}...}) -> the
|
|
74
|
+
* REAL {@link ContentRelayController} and {@link ContentRelayService} (real {@code ContentRef} validation, real
|
|
75
|
+
* {@code TenantAccessGuard.requireOwnedWorkspace}) -> the REAL {@link AgentWebSocketHandler} / {@link LaV2Session} behind a
|
|
76
|
+
* Reactor Netty WebSocket on loopback, real system clock, real Ed25519 signer -> the REAL Node Local Agent
|
|
77
|
+
* (tools/interop/la-interop-content-client.mjs: real content handlers, DLP enforce, path guard, git diff, content.index).
|
|
78
|
+
* The ids are the 43-char ones the Local Agent minted and announced; the diff of the work tree uses {@code worktree}.
|
|
79
|
+
*
|
|
80
|
+
* <p>Copy it next to the other byoc tests of the gateway (it needs package access to nothing, but it lives with them) and run
|
|
81
|
+
* (JDK 21; node >= 18; {@code npm ci} done in the LA repo; gateway branch with F3-06b + F3-07b):
|
|
82
|
+
* <pre>
|
|
83
|
+
* cp tools/interop/LaV2NodeContentRelayE2ETest.java \
|
|
84
|
+
* <gateway>/src/test/java/io/visionhammer/gateway/cloud/infrastructure/byoc/
|
|
85
|
+
* LA_REPO=/path/to/deepdebug-local-agent mvn -o test -Dtest=LaV2NodeContentRelayE2ETest
|
|
86
|
+
* </pre>
|
|
87
|
+
* Skipped when LA_REPO is not set. Still mocked: the workspace store, the data plane policy (tenant in {@code client}), the
|
|
88
|
+
* log store, the JWT (a principal). Not proven: Cloud Run / LB / TLS, Memorystore EU (AUTH + TLS 6378), two gateway
|
|
89
|
+
* instances, load, a tenant of QA/PROD.
|
|
90
|
+
*/
|
|
91
|
+
class LaV2NodeContentRelayE2ETest {
|
|
92
|
+
|
|
93
|
+
private static final String TENANT = LaV2TestKit.TENANT;
|
|
94
|
+
private static final String AGENT = LaV2TestKit.AGENT;
|
|
95
|
+
private static final UUID USER = UUID.fromString("33333333-3333-4333-8333-333333333333");
|
|
96
|
+
private static final String BASE = "/api/v1/content/";
|
|
97
|
+
private static final String ID43 = "^[A-Za-z0-9_-]{43}$";
|
|
98
|
+
|
|
99
|
+
private static Authentication principal(String role) {
|
|
100
|
+
AuthenticatedUser user = AuthenticatedUser.builder().userId(USER).tenantId(UUID.fromString(TENANT)).role(role)
|
|
101
|
+
.email("ana@acme.com").build();
|
|
102
|
+
return new UsernamePasswordAuthenticationToken(user, "n/a", List.of());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@SuppressWarnings("unchecked")
|
|
106
|
+
@Test
|
|
107
|
+
void httpToControllerToLaV2SessionToTheRealNodeAgent() throws Exception {
|
|
108
|
+
String laRepo = System.getenv("LA_REPO");
|
|
109
|
+
assumeTrue(laRepo != null && !laRepo.isBlank(), "LA_REPO not set");
|
|
110
|
+
|
|
111
|
+
// ---- the gateway side: real session store + real handler, the content.index registry as the sink --------------------------
|
|
112
|
+
Clock clock = Clock.systemUTC();
|
|
113
|
+
LaProtocolV2Properties props = new LaProtocolV2Properties();
|
|
114
|
+
props.setEnabled(true);
|
|
115
|
+
ContentRelayProperties relayProps = new ContentRelayProperties();
|
|
116
|
+
relayProps.setEnabled(true);
|
|
117
|
+
ContentIndexRegistry index = new ContentIndexRegistry(relayProps, null, System::currentTimeMillis);
|
|
118
|
+
List<String> rejected = Collections.synchronizedList(new ArrayList<>());
|
|
119
|
+
LaV2Metrics metrics = new LaV2Metrics() {
|
|
120
|
+
@Override public void rejected(String code) { rejected.add(code); }
|
|
121
|
+
};
|
|
122
|
+
LaV2InboundListener listener = (t, a, type, rid, payload) -> { };
|
|
123
|
+
AgentRequestSigner signer = new AgentRequestSigner(LaV2TestKit.PEM, "k1", clock);
|
|
124
|
+
LaV2Session.Env env = new LaV2Session.Env(LaV2TestKit.CONTRACT, props, signer, OM, clock, metrics, Schedulers.parallel(), List.of(listener), index);
|
|
125
|
+
|
|
126
|
+
AgentAuthorizationStore store = mock(AgentAuthorizationStore.class);
|
|
127
|
+
WorkspaceRepository workspaceRepo = mock(WorkspaceRepository.class);
|
|
128
|
+
DataPlanePolicy policy = mock(DataPlanePolicy.class);
|
|
129
|
+
when(workspaceRepo.findDefaultByTenantId(anyString())).thenReturn(Mono.empty());
|
|
130
|
+
when(store.save(any())).thenAnswer(i -> Mono.just(i.getArgument(0)));
|
|
131
|
+
when(store.touchLastSeen(any())).thenReturn(Mono.just(1));
|
|
132
|
+
when(policy.requiresEnforcedSigning(anyString())).thenReturn(Mono.just(true));
|
|
133
|
+
when(policy.modeWire(anyString())).thenReturn(Mono.just("client"));
|
|
134
|
+
AgentAuthorizationEntity auth = new AgentAuthorizationEntity();
|
|
135
|
+
auth.setId(UUID.fromString(AGENT));
|
|
136
|
+
auth.setTenantId(UUID.fromString(TENANT));
|
|
137
|
+
auth.setAuthorizationToken("tok");
|
|
138
|
+
when(store.findByAuthorizationToken("tok")).thenReturn(Mono.just(auth));
|
|
139
|
+
AgentWebSocketHandler handler = new AgentWebSocketHandler(OM, workspaceRepo, store, signer, null, null, null,
|
|
140
|
+
new LaV2SessionStore(env), props, policy);
|
|
141
|
+
|
|
142
|
+
// ---- the HTTP side: the real controller over the real service, the handler as the LocalAgentContentGateway ---------------------
|
|
143
|
+
ContentRelayLogStore logStore = mock(ContentRelayLogStore.class);
|
|
144
|
+
when(logStore.insert(any())).thenReturn(Mono.empty());
|
|
145
|
+
ObjectProvider<MeterRegistry> noMeters = mock(ObjectProvider.class);
|
|
146
|
+
ObjectProvider<LocalAgentSessionRegistry> noRegistry = mock(ObjectProvider.class);
|
|
147
|
+
ObjectProvider<ContentIndexRegistry> indexProvider = mock(ObjectProvider.class);
|
|
148
|
+
when(indexProvider.getIfAvailable()).thenReturn(index);
|
|
149
|
+
WorkspaceRowStore workspaces = mock(WorkspaceRowStore.class);
|
|
150
|
+
WorkspaceEntity ws1 = new WorkspaceEntity();
|
|
151
|
+
ws1.setId("ws-1");
|
|
152
|
+
ws1.setTenantId(TENANT);
|
|
153
|
+
when(workspaces.findByTenantIdAndId(TENANT, "ws-1")).thenReturn(Mono.just(ws1));
|
|
154
|
+
when(workspaces.findByTenantIdAndId(anyString(), anyString())).thenAnswer(i ->
|
|
155
|
+
TENANT.equals(i.getArgument(0)) && "ws-1".equals(i.getArgument(1)) ? Mono.just(ws1) : Mono.empty());
|
|
156
|
+
|
|
157
|
+
ContentRelayService service = new ContentRelayService(handler, policy, logStore, relayProps, noMeters, noRegistry, indexProvider);
|
|
158
|
+
ContentRelayController controller = new ContentRelayController(service, new TenantAccessGuard(workspaces), new ObjectMapper(),
|
|
159
|
+
new ContentBufferBudget(relayProps, null));
|
|
160
|
+
WebTestClient http = WebTestClient.bindToController(controller)
|
|
161
|
+
.argumentResolvers(r -> r.addCustomResolver(
|
|
162
|
+
new AuthenticationPrincipalArgumentResolver(new org.springframework.core.ReactiveAdapterRegistry())))
|
|
163
|
+
.controllerAdvice(new io.visionhammer.gateway.web.GlobalExceptionAdvice())
|
|
164
|
+
.apply(SecurityMockServerConfigurers.springSecurity())
|
|
165
|
+
.configureClient().codecs(c -> c.defaultCodecs().maxInMemorySize(8 << 20)).responseTimeout(Duration.ofSeconds(45)).build()
|
|
166
|
+
.mutateWith(SecurityMockServerConfigurers.mockAuthentication(principal("admin")));
|
|
167
|
+
|
|
168
|
+
WebSocketService wsService = new HandshakeWebSocketService(new ReactorNettyRequestUpgradeStrategy());
|
|
169
|
+
HttpHandler wsHttp = WebHttpHandlerBuilder.webHandler(exchange -> wsService.handleRequest(exchange, handler)).build();
|
|
170
|
+
DisposableServer server = reactor.netty.http.server.HttpServer.create().host("127.0.0.1").port(0).handle(new ReactorHttpHandlerAdapter(wsHttp)).bindNow();
|
|
171
|
+
|
|
172
|
+
// ---- the real Node Local Agent --------------------------------------------------------------------------------------------
|
|
173
|
+
ProcessBuilder pb = new ProcessBuilder("node", laRepo + "/tools/interop/la-interop-content-client.mjs");
|
|
174
|
+
pb.environment().putAll(Map.of(
|
|
175
|
+
"GW_URL", "ws://127.0.0.1:" + server.port() + "/api/v1/agent/ws", "TOKEN", "tok", "TENANT", TENANT, "AGENT", AGENT,
|
|
176
|
+
"KEY_ID", "k1", "PUBLIC_KEY", LaV2TestKit.PUB, "MODE", "enforce",
|
|
177
|
+
"LOCAL_AGENT_DLP_MODE", System.getenv().getOrDefault("LA_DLP_MODE", "enforce")));
|
|
178
|
+
pb.redirectErrorStream(true);
|
|
179
|
+
Process proc = pb.start();
|
|
180
|
+
BlockingQueue<JsonNode> events = new LinkedBlockingQueue<>();
|
|
181
|
+
List<String> transcript = Collections.synchronizedList(new ArrayList<>());
|
|
182
|
+
Thread reader = new Thread(() -> {
|
|
183
|
+
try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
|
|
184
|
+
String line;
|
|
185
|
+
while ((line = r.readLine()) != null) {
|
|
186
|
+
transcript.add(line);
|
|
187
|
+
try {
|
|
188
|
+
events.add(OM.readTree(line));
|
|
189
|
+
} catch (Exception ignored) {
|
|
190
|
+
// node warnings
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch (Exception ignored) {
|
|
194
|
+
// process ended
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
reader.setDaemon(true);
|
|
198
|
+
reader.start();
|
|
199
|
+
java.io.Writer stdin = new java.io.OutputStreamWriter(proc.getOutputStream(), StandardCharsets.UTF_8);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
JsonNode refsEvt = await(events, "refs", 20);
|
|
203
|
+
await(events, "ready", 15);
|
|
204
|
+
assertThat(handler.isAgentConnected(TENANT)).isTrue();
|
|
205
|
+
JsonNode refs = refsEvt.path("refs");
|
|
206
|
+
String ghp = refsEvt.path("ghp").asText();
|
|
207
|
+
for (String k : List.of("app", "big", "mid", "swapped", "bin", "diffFile")) {
|
|
208
|
+
assertThat(refs.path(k).asText()).as("id minted by the LA: " + k).matches(ID43);
|
|
209
|
+
}
|
|
210
|
+
assertThat(refs.path("envFile").isNull()).isTrue();
|
|
211
|
+
assertThat(refs.path("envBackup").isNull()).isTrue();
|
|
212
|
+
|
|
213
|
+
// 0. content.index: the gateway learned the ids the agent announced (kind + workspace + id), nothing else
|
|
214
|
+
String appId = refs.path("app").asText();
|
|
215
|
+
awaitTrue(() -> index.find(TENANT, "ws-1", ContentKind.FILE, appId).isPresent(), 10, "content.index reached the gateway registry");
|
|
216
|
+
for (String k : List.of("app", "big", "mid", "swapped", "bin")) {
|
|
217
|
+
assertThat(index.find(TENANT, "ws-1", ContentKind.FILE, refs.path(k).asText())).as("announced " + k).isPresent();
|
|
218
|
+
}
|
|
219
|
+
assertThat(index.find(TENANT, "ws-1", ContentKind.DIFF, refs.path("diffFile").asText())).isPresent();
|
|
220
|
+
assertThat(index.find(TENANT, "ws-1", ContentKind.FILE, appId).get().size()).isEqualTo(
|
|
221
|
+
("first line\ntoken = \"" + ghp + "\"\nlast line\n").getBytes(StandardCharsets.UTF_8).length);
|
|
222
|
+
assertThat(index.find(UUID.randomUUID().toString(), "ws-1", ContentKind.FILE, appId)).as("other tenant").isEmpty();
|
|
223
|
+
assertThat(index.find(TENANT, "ws-2", ContentKind.FILE, appId)).as("other workspace").isEmpty();
|
|
224
|
+
|
|
225
|
+
// 1. GET with the minted id: served, token masked (DLP enforce), the rest intact; Content-Type from the agent
|
|
226
|
+
String body = new String(http.get().uri(BASE + "file/" + appId + "?workspaceId=ws-1").exchange()
|
|
227
|
+
.expectStatus().isOk().expectHeader().valueEquals("Cache-Control", "no-store")
|
|
228
|
+
.expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8);
|
|
229
|
+
assertThat(body).contains("first line").contains("[REDACTED:github_token]").contains("last line").doesNotContain(ghp);
|
|
230
|
+
|
|
231
|
+
// 2. POST (the id in the body, not in the URL): the same
|
|
232
|
+
String viaPost = new String(http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
233
|
+
.bodyValue(Map.of("ref", appId, "workspaceId", "ws-1")).exchange()
|
|
234
|
+
.expectStatus().isOk().expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8);
|
|
235
|
+
assertThat(viaPost).isEqualTo(body);
|
|
236
|
+
|
|
237
|
+
// 3. 1 MiB byte for byte through the whole chain (32 chunks), by GET and by SSE
|
|
238
|
+
byte[] big = http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
239
|
+
.bodyValue(Map.of("ref", refs.path("big").asText(), "workspaceId", "ws-1")).exchange()
|
|
240
|
+
.expectStatus().isOk().expectBody().returnResult().getResponseBody();
|
|
241
|
+
assertThat(big).hasSize(refsEvt.path("bigBytes").asInt());
|
|
242
|
+
assertThat(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(big))).isEqualTo(refsEvt.path("bigSha256").asText());
|
|
243
|
+
String sse = new String(http.post().uri(BASE + "file/query").accept(MediaType.TEXT_EVENT_STREAM).contentType(MediaType.APPLICATION_JSON)
|
|
244
|
+
.bodyValue(Map.of("ref", refs.path("big").asText(), "workspaceId", "ws-1")).exchange()
|
|
245
|
+
.expectStatus().isOk().expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8);
|
|
246
|
+
assertThat(sse.split("event: chunk", -1).length - 1).as("SSE chunk events").isEqualTo(32);
|
|
247
|
+
assertThat(sse).contains("event: done").doesNotContain("event: error");
|
|
248
|
+
|
|
249
|
+
// 4. a token straddling the 32 KiB boundary is never visible, whole or in part
|
|
250
|
+
String mid = new String(http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
251
|
+
.bodyValue(Map.of("ref", refs.path("mid").asText(), "workspaceId", "ws-1")).exchange()
|
|
252
|
+
.expectStatus().isOk().expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8);
|
|
253
|
+
assertThat(mid).contains("[REDACTED:github_token]").doesNotContain(ghp.substring(0, 12));
|
|
254
|
+
|
|
255
|
+
// 5. the symbolic ref worktree (kind diff, needs the workspace): GET and POST; masked, without .env
|
|
256
|
+
for (String diff : List.of(
|
|
257
|
+
new String(http.get().uri(BASE + "diff/worktree?workspaceId=ws-1").exchange().expectStatus().isOk()
|
|
258
|
+
.expectHeader().contentType("text/x-diff").expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8),
|
|
259
|
+
new String(http.post().uri(BASE + "diff/query").contentType(MediaType.APPLICATION_JSON)
|
|
260
|
+
.bodyValue(Map.of("ref", "worktree", "workspaceId", "ws-1")).exchange().expectStatus().isOk()
|
|
261
|
+
.expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8))) {
|
|
262
|
+
assertThat(diff).contains("tracked.js").contains("[REDACTED:github_token]").doesNotContain(ghp)
|
|
263
|
+
.doesNotContain("SECRET-ENV-VALUE").doesNotContain("EDITOR-BACKUP-SECRET").doesNotContain(".env");
|
|
264
|
+
}
|
|
265
|
+
http.get().uri(BASE + "diff/worktree").exchange().expectStatus().isBadRequest(); // needs a workspace
|
|
266
|
+
http.get().uri(BASE + "file/worktree?workspaceId=ws-1").exchange().expectStatus().isBadRequest(); // diff only
|
|
267
|
+
assertThat(new String(http.post().uri(BASE + "diff/query").contentType(MediaType.APPLICATION_JSON)
|
|
268
|
+
.bodyValue(Map.of("ref", refs.path("diffFile").asText(), "workspaceId", "ws-1")).exchange().expectStatus().isOk()
|
|
269
|
+
.expectBody().returnResult().getResponseBody(), StandardCharsets.UTF_8)).contains("tracked.js");
|
|
270
|
+
|
|
271
|
+
// 6. the agent is the authority: link swapped to a file outside the workspace = 403; binary in enforce = 403;
|
|
272
|
+
// a hard link to a file outside the workspace = 403 (F3-07b); an id the agent never minted = 404
|
|
273
|
+
http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
274
|
+
.bodyValue(Map.of("ref", refs.path("swapped").asText(), "workspaceId", "ws-1")).exchange()
|
|
275
|
+
.expectStatus().isForbidden().expectBody(String.class).value(b -> assertThat(b).doesNotContain("OUTSIDE-SECRET"));
|
|
276
|
+
http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
277
|
+
.bodyValue(Map.of("ref", refs.path("bin").asText(), "workspaceId", "ws-1")).exchange().expectStatus().isForbidden();
|
|
278
|
+
if (refsEvt.path("hardLinked").asBoolean()) {
|
|
279
|
+
http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
280
|
+
.bodyValue(Map.of("ref", refs.path("hard").asText(), "workspaceId", "ws-1")).exchange()
|
|
281
|
+
.expectStatus().isForbidden().expectBody(String.class).value(b -> assertThat(b).doesNotContain("OUTSIDE-SECRET"));
|
|
282
|
+
}
|
|
283
|
+
http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
284
|
+
.bodyValue(Map.of("ref", "A".repeat(43), "workspaceId", "ws-1")).exchange().expectStatus().isNotFound();
|
|
285
|
+
|
|
286
|
+
// 7. the gateway refuses before asking the agent: the F3-07 id of 26 chars, a path, a foreign workspace
|
|
287
|
+
http.get().uri(BASE + "file/cr1_AAAAAAAAAAAAAAAAAAAAAA?workspaceId=ws-1").exchange().expectStatus().isBadRequest();
|
|
288
|
+
http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
289
|
+
.bodyValue(Map.of("ref", "../../etc/passwd", "workspaceId", "ws-1")).exchange().expectStatus().isBadRequest();
|
|
290
|
+
http.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
291
|
+
.bodyValue(Map.of("ref", appId, "workspaceId", "ws-of-somebody-else")).exchange().expectStatus().isNotFound();
|
|
292
|
+
|
|
293
|
+
// 8. the same id with the agent's other tenant's role: the viewer is stopped by the permission filter in the full app
|
|
294
|
+
// (ContentRelayPermissionTest, gateway side); here the controller only needs a principal of THIS tenant: another tenant has no agent
|
|
295
|
+
WebTestClient other = http.mutateWith(SecurityMockServerConfigurers.mockAuthentication(
|
|
296
|
+
new UsernamePasswordAuthenticationToken(AuthenticatedUser.builder().userId(USER).tenantId(UUID.fromString(LaV2TestKit.OTHER_TENANT))
|
|
297
|
+
.role("admin").email("eve@evil.com").build(), "n/a", List.of())));
|
|
298
|
+
other.get().uri(BASE + "file/" + appId).exchange().expectStatus().is5xxServerError(); // 503 agent_offline: no agent for that tenant
|
|
299
|
+
|
|
300
|
+
// 9. a kind the agent does not serve (the id is a UUID of an incident): not a 200, and nothing about the files leaks
|
|
301
|
+
http.get().uri(BASE + "incident.detail/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee?workspaceId=ws-1").exchange()
|
|
302
|
+
.expectStatus().value(s -> assertThat(s).isGreaterThanOrEqualTo(400));
|
|
303
|
+
|
|
304
|
+
// 10. require-known-ref ON (one instance only): an id the agent announced passes; one it never announced is refused
|
|
305
|
+
// by the gateway itself, before the agent is asked
|
|
306
|
+
ContentRelayProperties strict = new ContentRelayProperties();
|
|
307
|
+
strict.setEnabled(true);
|
|
308
|
+
strict.setRequireKnownRef(true);
|
|
309
|
+
ContentRelayService strictService = new ContentRelayService(handler, policy, logStore, strict, noMeters, noRegistry, indexProvider);
|
|
310
|
+
WebTestClient strictHttp = WebTestClient.bindToController(new ContentRelayController(strictService, new TenantAccessGuard(workspaces),
|
|
311
|
+
new ObjectMapper(), new ContentBufferBudget(strict, null)))
|
|
312
|
+
.argumentResolvers(r -> r.addCustomResolver(new AuthenticationPrincipalArgumentResolver(new org.springframework.core.ReactiveAdapterRegistry())))
|
|
313
|
+
.controllerAdvice(new io.visionhammer.gateway.web.GlobalExceptionAdvice())
|
|
314
|
+
.apply(SecurityMockServerConfigurers.springSecurity()).configureClient().codecs(c -> c.defaultCodecs().maxInMemorySize(8 << 20)).responseTimeout(Duration.ofSeconds(45)).build()
|
|
315
|
+
.mutateWith(SecurityMockServerConfigurers.mockAuthentication(principal("admin")));
|
|
316
|
+
strictHttp.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
317
|
+
.bodyValue(Map.of("ref", appId, "workspaceId", "ws-1")).exchange().expectStatus().isOk();
|
|
318
|
+
strictHttp.post().uri(BASE + "file/query").contentType(MediaType.APPLICATION_JSON)
|
|
319
|
+
.bodyValue(Map.of("ref", "A".repeat(43), "workspaceId", "ws-1")).exchange().expectStatus().isNotFound();
|
|
320
|
+
|
|
321
|
+
// 11. the content.index counters of the agent: everything announced was accepted; nothing the agent put on the wire was refused
|
|
322
|
+
send(stdin, "stats");
|
|
323
|
+
JsonNode stats = await(events, "stats", 10);
|
|
324
|
+
assertThat(stats.path("index").path("rejected").asInt()).isZero();
|
|
325
|
+
assertThat(stats.path("index").path("accepted").asInt()).isGreaterThanOrEqualTo(6);
|
|
326
|
+
assertThat(rejected).as("frames the real LaV2Session rejected: %s", rejected).isEmpty();
|
|
327
|
+
// nothing in what the agent printed (events, warnings) carries content, a path or the full id of a file
|
|
328
|
+
String printed = String.join("\n", transcript.stream().filter(l -> !l.contains("\"evt\":\"refs\"")).toList());
|
|
329
|
+
assertThat(printed).doesNotContain("OUTSIDE-SECRET").doesNotContain("SECRET-ENV-VALUE").doesNotContain("EDITOR-BACKUP").doesNotContain("f307-interop-");
|
|
330
|
+
System.out.println("CONTENT RELAY E2E OK: index entries=" + stats.path("index").path("accepted").asInt() + " rejected=" + rejected);
|
|
331
|
+
} finally {
|
|
332
|
+
try {
|
|
333
|
+
send(stdin, "quit");
|
|
334
|
+
} catch (Exception ignored) {
|
|
335
|
+
// gone
|
|
336
|
+
}
|
|
337
|
+
proc.waitFor(5, TimeUnit.SECONDS);
|
|
338
|
+
proc.destroyForcibly();
|
|
339
|
+
server.disposeNow();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private static void send(java.io.Writer w, String line) throws Exception {
|
|
344
|
+
w.write(line + "\n");
|
|
345
|
+
w.flush();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private static void awaitTrue(java.util.function.BooleanSupplier cond, int seconds, String what) throws Exception {
|
|
349
|
+
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
|
|
350
|
+
while (System.nanoTime() < deadline) {
|
|
351
|
+
if (cond.getAsBoolean()) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
Thread.sleep(50);
|
|
355
|
+
}
|
|
356
|
+
throw new AssertionError("timed out: " + what);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private static JsonNode await(BlockingQueue<JsonNode> q, String evt, int seconds) throws Exception {
|
|
360
|
+
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
|
|
361
|
+
List<String> seen = new ArrayList<>();
|
|
362
|
+
while (System.nanoTime() < deadline) {
|
|
363
|
+
JsonNode n = q.poll(100, TimeUnit.MILLISECONDS);
|
|
364
|
+
if (n == null) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (evt.equals(n.path("evt").asText())) {
|
|
368
|
+
return n;
|
|
369
|
+
}
|
|
370
|
+
seen.add(n.path("evt").asText() + (n.has("code") ? ":" + n.path("code").asText() : "") + (n.has("m") ? ":" + n.path("m").asText() : ""));
|
|
371
|
+
}
|
|
372
|
+
throw new AssertionError("no '" + evt + "' event from the Node LA within " + seconds + " s; saw " + seen);
|
|
373
|
+
}
|
|
374
|
+
}
|