@indigoai-us/hq-cloud 5.26.0 → 5.27.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/.github/workflows/ci.yml +34 -0
- package/dist/bin/sync-runner.d.ts +38 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +75 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/sync/feature-flags.d.ts +136 -0
- package/dist/sync/feature-flags.d.ts.map +1 -0
- package/dist/sync/feature-flags.js +160 -0
- package/dist/sync/feature-flags.js.map +1 -0
- package/dist/sync/feature-flags.test.d.ts +24 -0
- package/dist/sync/feature-flags.test.d.ts.map +1 -0
- package/dist/sync/feature-flags.test.js +330 -0
- package/dist/sync/feature-flags.test.js.map +1 -0
- package/dist/sync/index.d.ts +10 -2
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +5 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/logger.d.ts +61 -0
- package/dist/sync/logger.d.ts.map +1 -0
- package/dist/sync/logger.js +51 -0
- package/dist/sync/logger.js.map +1 -0
- package/dist/sync/logger.test.d.ts +19 -0
- package/dist/sync/logger.test.d.ts.map +1 -0
- package/dist/sync/logger.test.js +199 -0
- package/dist/sync/logger.test.js.map +1 -0
- package/dist/sync/metrics.d.ts +89 -0
- package/dist/sync/metrics.d.ts.map +1 -0
- package/dist/sync/metrics.js +105 -0
- package/dist/sync/metrics.js.map +1 -0
- package/dist/sync/metrics.test.d.ts +19 -0
- package/dist/sync/metrics.test.d.ts.map +1 -0
- package/dist/sync/metrics.test.js +280 -0
- package/dist/sync/metrics.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +442 -0
- package/dist/sync/push-receiver.d.ts.map +1 -0
- package/dist/sync/push-receiver.js +782 -0
- package/dist/sync/push-receiver.js.map +1 -0
- package/dist/sync/push-receiver.test.d.ts +25 -0
- package/dist/sync/push-receiver.test.d.ts.map +1 -0
- package/dist/sync/push-receiver.test.js +477 -0
- package/dist/sync/push-receiver.test.js.map +1 -0
- package/dist/sync/push-transport.d.ts +84 -1
- package/dist/sync/push-transport.d.ts.map +1 -1
- package/dist/sync/push-transport.js +84 -0
- package/dist/sync/push-transport.js.map +1 -1
- package/dist/watcher.d.ts +113 -2
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +204 -25
- package/dist/watcher.js.map +1 -1
- package/package.json +9 -5
- package/src/bin/sync-runner.ts +102 -1
- package/src/index.ts +21 -0
- package/src/sync/feature-flags.test.ts +392 -0
- package/src/sync/feature-flags.ts +229 -0
- package/src/sync/index.ts +57 -2
- package/src/sync/logger.test.ts +241 -0
- package/src/sync/logger.ts +79 -0
- package/src/sync/metrics.test.ts +380 -0
- package/src/sync/metrics.ts +158 -0
- package/src/sync/push-receiver.test.ts +545 -0
- package/src/sync/push-receiver.ts +1077 -0
- package/src/sync/push-transport.ts +148 -1
- package/src/watcher.ts +299 -17
- package/test/e2e/sync/cross-tenant-isolation.test.ts +502 -0
- package/test/e2e/watcher-real-chokidar.test.ts +105 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-010 — E2E acceptance test: cross-tenant isolation invariant (BLOCKING).
|
|
3
|
+
*
|
|
4
|
+
* ╔══════════════════════════════════════════════════════════════════════╗
|
|
5
|
+
* ║ A FAILURE OF THIS TEST IS A P0 INCIDENT. ║
|
|
6
|
+
* ║ ║
|
|
7
|
+
* ║ Cross-tenant data leakage kills the event-driven-sync project ║
|
|
8
|
+
* ║ regardless of any latency win. If this test goes red, STOP — do not ║
|
|
9
|
+
* ║ merge, do not deploy, do not flip a feature flag. Treat it as a ║
|
|
10
|
+
* ║ security incident: root-cause it, record it in the project journal ║
|
|
11
|
+
* ║ (companies/indigo/projects/event-driven-sync-menubar), and only ║
|
|
12
|
+
* ║ re-enable the pipeline once the leak path is closed AND this test ║
|
|
13
|
+
* ║ is green again. (PRD US-010 AC#4 — "A failure is treated as a P0".) ║
|
|
14
|
+
* ╚══════════════════════════════════════════════════════════════════════╝
|
|
15
|
+
*
|
|
16
|
+
* What this pins (the CLIENT-side invariant)
|
|
17
|
+
* ───────────────────────────────────────────
|
|
18
|
+
* This is the hq-cloud CLIENT mirror of hq-pro PR #112's server-side
|
|
19
|
+
* `cross-tenant-isolation` gate. Where the server proves its push handler
|
|
20
|
+
* rejects a forged cross-tenant `originTenantId`, THIS test proves the
|
|
21
|
+
* client pipeline — the US-008 {@link PushEventEmitter} push side and the
|
|
22
|
+
* US-009 receiver pull side — can never let one tenant's data reach another
|
|
23
|
+
* tenant's device. The invariant, verbatim from the PRD `e2eTests`:
|
|
24
|
+
*
|
|
25
|
+
* "Given two tenants with two devices each, when tenant A saves a file,
|
|
26
|
+
* then no tenant B device observes it via push or pull within 60s."
|
|
27
|
+
*
|
|
28
|
+
* Two-tenant, two-device-each pair-work simulation
|
|
29
|
+
* ─────────────────────────────────────────────────
|
|
30
|
+
* Built entirely on the REAL client seams shipped by US-007/008/009 — no
|
|
31
|
+
* production code is re-implemented here:
|
|
32
|
+
*
|
|
33
|
+
* • Each tenant owns ONE fanout ({@link InMemoryFanout}) — the in-process
|
|
34
|
+
* analogue of that tenant's SNS topic → per-client SQS queue. This is
|
|
35
|
+
* the subscription boundary the whole isolation argument rests on.
|
|
36
|
+
* • A tenant's outbound device ships PushEvents through a
|
|
37
|
+
* {@link PushEventEmitter} (US-008) wired to a tenant-scoped
|
|
38
|
+
* {@link PushTransport}. The transport routes a published event ONLY
|
|
39
|
+
* into ITS OWN tenant's fanout AND refuses to ship any event whose
|
|
40
|
+
* `originTenantId` is not its tenant — this models the server's
|
|
41
|
+
* topic-per-tenant + cross-tenant rejection. So a tenant-A device
|
|
42
|
+
* physically cannot publish onto tenant B's fanout.
|
|
43
|
+
* • Each tenant's TWO devices run a {@link InMemoryPushReceiver} (US-009)
|
|
44
|
+
* subscribed ONLY to their own tenant's fanout. A receiver never reads
|
|
45
|
+
* another tenant's queue; isolation is enforced at the subscription
|
|
46
|
+
* boundary, never by post-hoc filtering.
|
|
47
|
+
*
|
|
48
|
+
* Assertions (map to PRD US-010 acceptanceCriteria + e2eTests)
|
|
49
|
+
* ─────────────────────────────────────────────────────────────
|
|
50
|
+
* AC#1 Tenant A device saves a file; NO PushEvent reaches ANY tenant-B
|
|
51
|
+
* device over a simulated 60s window. We model the save by emitting
|
|
52
|
+
* through tenant A's emitter, then advance fake timers across a full
|
|
53
|
+
* 60s window and assert every tenant-B receiver's syncFn fired ZERO
|
|
54
|
+
* times and no tenant-B receiver advanced its dedupe/seen state.
|
|
55
|
+
* AC#2 Tenant B's receiver never PULLS a tenant-A file: a tenant-B
|
|
56
|
+
* {@link SqsPushReceiver} polling ITS OWN (empty) per-tenant queue —
|
|
57
|
+
* while tenant A's queue is full of A's messages — drains zero
|
|
58
|
+
* tenant-A events. Cross-tenant messages are filtered by the
|
|
59
|
+
* subscription boundary (own-tenant queue only).
|
|
60
|
+
* AC#3 Positive control: a tenant-A device emits and a SECOND tenant-A
|
|
61
|
+
* device DOES receive it — proving the machinery is alive and the
|
|
62
|
+
* negative assertions are load-bearing, not trivially-passing.
|
|
63
|
+
* AC#4 Meta: the BLOCKING `cross-tenant-isolation` CI job in
|
|
64
|
+
* .github/workflows/ci.yml still references THIS test file. A future
|
|
65
|
+
* PR that silently drops the gate fails the very test it tried to
|
|
66
|
+
* skip.
|
|
67
|
+
*
|
|
68
|
+
* Fake timers (no real 60s wait)
|
|
69
|
+
* ───────────────────────────────
|
|
70
|
+
* The PRD says "over a simulated 60s window". We use `vi.useFakeTimers()`
|
|
71
|
+
* and `vi.advanceTimersByTimeAsync(60_000)` so the 60s observation window
|
|
72
|
+
* elapses instantly. The receivers' fanout delivery is synchronous on
|
|
73
|
+
* publish; the SQS poll loop uses an injected fake `sqs` + injected fast
|
|
74
|
+
* `sleep`, so nothing in the test depends on wall-clock. For the negative
|
|
75
|
+
* assertion ("nothing reached tenant B") there is no event to wait FOR —
|
|
76
|
+
* absence is proven by advancing through the whole window and asserting the
|
|
77
|
+
* tenant-B counters stayed at zero.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
81
|
+
import { fileURLToPath } from "node:url";
|
|
82
|
+
import { readFile as fsReadFile } from "node:fs/promises";
|
|
83
|
+
import path from "node:path";
|
|
84
|
+
|
|
85
|
+
import {
|
|
86
|
+
StaticFlagProvider,
|
|
87
|
+
PushEventEmitter,
|
|
88
|
+
encodePushEvent,
|
|
89
|
+
type PushEvent,
|
|
90
|
+
type PushTransport,
|
|
91
|
+
} from "../../../src/index.js";
|
|
92
|
+
// The US-009 receiver/fanout seams are surfaced via the sync barrel (they are
|
|
93
|
+
// intentionally not part of the top-level public package surface yet — the
|
|
94
|
+
// production SQS path ships behind a noop default). Import them from the same
|
|
95
|
+
// barrel the rest of the sync code uses.
|
|
96
|
+
import {
|
|
97
|
+
InMemoryFanout,
|
|
98
|
+
InMemoryPushReceiver,
|
|
99
|
+
SqsPushReceiver,
|
|
100
|
+
type PushReceiverContext,
|
|
101
|
+
type SqsClientLike,
|
|
102
|
+
type SqsMessageLike,
|
|
103
|
+
} from "../../../src/sync/index.js";
|
|
104
|
+
|
|
105
|
+
// ─── Test doubles ────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
const ZERO_HASH =
|
|
108
|
+
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A tenant-scoped {@link PushTransport} that ships an emitted PushEvent into
|
|
112
|
+
* exactly ONE tenant's fanout — and ONLY when the event's `originTenantId`
|
|
113
|
+
* matches this transport's bound tenant. This models the server's
|
|
114
|
+
* topic-per-tenant boundary plus its cross-tenant-origin rejection: a device
|
|
115
|
+
* physically cannot publish onto a foreign tenant's fanout, and a forged
|
|
116
|
+
* `originTenantId` is dropped (recorded as `rejectedCount` for assertions).
|
|
117
|
+
*/
|
|
118
|
+
class TenantScopedTransport implements PushTransport {
|
|
119
|
+
private _connected = false;
|
|
120
|
+
publishedCount = 0;
|
|
121
|
+
rejectedCount = 0;
|
|
122
|
+
|
|
123
|
+
constructor(
|
|
124
|
+
private readonly tenantId: string,
|
|
125
|
+
private readonly fanout: InMemoryFanout,
|
|
126
|
+
) {}
|
|
127
|
+
|
|
128
|
+
get connected(): boolean {
|
|
129
|
+
return this._connected;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async start(): Promise<void> {
|
|
133
|
+
this._connected = true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async publish(event: PushEvent): Promise<void> {
|
|
137
|
+
// Server-side topic-per-tenant + cross-tenant-origin rejection, modeled
|
|
138
|
+
// client-side: a forged originTenantId never lands on any fanout.
|
|
139
|
+
if (event.originTenantId !== this.tenantId) {
|
|
140
|
+
this.rejectedCount += 1;
|
|
141
|
+
throw new Error(
|
|
142
|
+
`TenantScopedTransport(${this.tenantId}): refusing cross-tenant ` +
|
|
143
|
+
`originTenantId=${event.originTenantId}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
this.publishedCount += 1;
|
|
147
|
+
this.fanout.publish(encodePushEvent(event));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async dispose(): Promise<void> {
|
|
151
|
+
this._connected = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A fake {@link SqsClientLike} backed by an in-memory per-queue buffer. Models
|
|
157
|
+
* the per-client SQS queue: `receiveMessage` drains from this queue ONLY
|
|
158
|
+
* (never any other tenant's). `seed` pre-loads a queue. `receiveMessage`
|
|
159
|
+
* resolves promptly on abort so `dispose()` never blocks. No real AWS.
|
|
160
|
+
*/
|
|
161
|
+
class FakeSqs implements SqsClientLike {
|
|
162
|
+
/** queueUrl → message buffer */
|
|
163
|
+
private readonly queues = new Map<string, SqsMessageLike[]>();
|
|
164
|
+
/** queueUrls this client was ever asked to receive from (assertion hook). */
|
|
165
|
+
readonly polledQueueUrls = new Set<string>();
|
|
166
|
+
|
|
167
|
+
seed(queueUrl: string, bodies: string[]): void {
|
|
168
|
+
const buf = this.queues.get(queueUrl) ?? [];
|
|
169
|
+
for (const body of bodies) {
|
|
170
|
+
buf.push({ Body: body, ReceiptHandle: `rh-${buf.length}-${Math.random()}` });
|
|
171
|
+
}
|
|
172
|
+
this.queues.set(queueUrl, buf);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async receiveMessage(args: {
|
|
176
|
+
queueUrl: string;
|
|
177
|
+
maxMessages: number;
|
|
178
|
+
waitTimeSeconds: number;
|
|
179
|
+
signal: AbortSignal;
|
|
180
|
+
}): Promise<{ messages: SqsMessageLike[] }> {
|
|
181
|
+
this.polledQueueUrls.add(args.queueUrl);
|
|
182
|
+
if (args.signal.aborted) return { messages: [] };
|
|
183
|
+
const buf = this.queues.get(args.queueUrl) ?? [];
|
|
184
|
+
const taken = buf.splice(0, args.maxMessages);
|
|
185
|
+
if (taken.length > 0) return { messages: taken };
|
|
186
|
+
// Real SQS long-poll posture: when the queue is empty, BLOCK until the
|
|
187
|
+
// abort signal fires (dispose) rather than busy-returning empty arrays.
|
|
188
|
+
// This keeps the receiver's poll loop from hot-spinning the event loop —
|
|
189
|
+
// exactly what a real `waitTimeSeconds` long-poll does — so the test
|
|
190
|
+
// observes "B drained nothing" without starving the runtime.
|
|
191
|
+
await new Promise<void>((resolve) => {
|
|
192
|
+
if (args.signal.aborted) return resolve();
|
|
193
|
+
args.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
194
|
+
});
|
|
195
|
+
return { messages: [] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async deleteMessage(args: {
|
|
199
|
+
queueUrl: string;
|
|
200
|
+
receiptHandle: string;
|
|
201
|
+
}): Promise<void> {
|
|
202
|
+
const buf = this.queues.get(args.queueUrl);
|
|
203
|
+
if (!buf) return;
|
|
204
|
+
const idx = buf.findIndex((m) => m.ReceiptHandle === args.receiptHandle);
|
|
205
|
+
if (idx >= 0) buf.splice(idx, 1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Build a well-formed PushEvent for a given tenant/device/path. */
|
|
210
|
+
function makeEvent(opts: {
|
|
211
|
+
tenantId: string;
|
|
212
|
+
deviceId: string;
|
|
213
|
+
relativePath: string;
|
|
214
|
+
sequenceNumber: number;
|
|
215
|
+
}): PushEvent {
|
|
216
|
+
return {
|
|
217
|
+
relativePath: opts.relativePath,
|
|
218
|
+
contentHash: ZERO_HASH,
|
|
219
|
+
mtime: "2026-05-21T12:00:00.000Z",
|
|
220
|
+
originDeviceId: opts.deviceId,
|
|
221
|
+
originTenantId: opts.tenantId,
|
|
222
|
+
sequenceNumber: opts.sequenceNumber,
|
|
223
|
+
eventTimestamp: "2026-05-21T12:00:00.000Z",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A syncFn that records each relativePath it was asked to pull. */
|
|
228
|
+
function recordingSyncFn(): {
|
|
229
|
+
fn: (ctx: PushReceiverContext) => Promise<void>;
|
|
230
|
+
pulledPaths: string[];
|
|
231
|
+
} {
|
|
232
|
+
const pulledPaths: string[] = [];
|
|
233
|
+
return {
|
|
234
|
+
pulledPaths,
|
|
235
|
+
fn: async (ctx: PushReceiverContext) => {
|
|
236
|
+
pulledPaths.push(ctx.event.relativePath);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
const TENANT_A = "tenant-us010-A";
|
|
244
|
+
const TENANT_B = "tenant-us010-B";
|
|
245
|
+
const TENANT_A_SECRET_PATH = "companies/tenantA/notes/secret.md";
|
|
246
|
+
/** PRD-specified observation window. Elapsed via fake timers, never real. */
|
|
247
|
+
const OBSERVATION_WINDOW_MS = 60_000;
|
|
248
|
+
|
|
249
|
+
// Track every disposable so a single failed assertion can't leak listeners.
|
|
250
|
+
const disposers: Array<() => Promise<void> | void> = [];
|
|
251
|
+
|
|
252
|
+
beforeEach(() => {
|
|
253
|
+
disposers.length = 0;
|
|
254
|
+
vi.useFakeTimers();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
afterEach(async () => {
|
|
258
|
+
// Run real timers for teardown so dispose() drains aren't stuck behind fakes.
|
|
259
|
+
vi.useRealTimers();
|
|
260
|
+
while (disposers.length > 0) {
|
|
261
|
+
const d = disposers.pop();
|
|
262
|
+
if (!d) continue;
|
|
263
|
+
try {
|
|
264
|
+
await d();
|
|
265
|
+
} catch {
|
|
266
|
+
/* dispose is best-effort + idempotent */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ─── Tests ─────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
describe("US-010: cross-tenant isolation (BLOCKING — failure is P0)", () => {
|
|
274
|
+
it(
|
|
275
|
+
"AC#1+AC#3 — tenant A device saves a file; over a simulated 60s window " +
|
|
276
|
+
"NO tenant-B device observes a PushEvent via push, while a second " +
|
|
277
|
+
"tenant-A device DOES (positive control proving the wiring is live)",
|
|
278
|
+
async () => {
|
|
279
|
+
// Two tenants, each with its own fanout (its SNS topic → per-client SQS).
|
|
280
|
+
const fanoutA = new InMemoryFanout();
|
|
281
|
+
const fanoutB = new InMemoryFanout();
|
|
282
|
+
|
|
283
|
+
// Both tenants are flag-enabled so dormancy can't mask a leak: if the
|
|
284
|
+
// pipeline is fully ON for BOTH tenants and tenant B STILL sees nothing,
|
|
285
|
+
// the isolation boundary — not an OFF flag — is what's holding.
|
|
286
|
+
const flagProvider = new StaticFlagProvider([TENANT_A, TENANT_B]);
|
|
287
|
+
|
|
288
|
+
// ── Tenant A: device A1 ships through a tenant-A-scoped transport ──
|
|
289
|
+
const transportA = new TenantScopedTransport(TENANT_A, fanoutA);
|
|
290
|
+
await transportA.start();
|
|
291
|
+
const emitterA1 = new PushEventEmitter({
|
|
292
|
+
originTenantId: TENANT_A,
|
|
293
|
+
originDeviceId: "device-A1",
|
|
294
|
+
transport: transportA,
|
|
295
|
+
flagProvider,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── Tenant A: device A2 receives from tenant A's fanout (control) ──
|
|
299
|
+
const a2Sync = recordingSyncFn();
|
|
300
|
+
const receiverA2 = new InMemoryPushReceiver({
|
|
301
|
+
tenantId: TENANT_A,
|
|
302
|
+
fanout: fanoutA,
|
|
303
|
+
syncFn: a2Sync.fn,
|
|
304
|
+
flagProvider,
|
|
305
|
+
});
|
|
306
|
+
disposers.push(() => receiverA2.dispose());
|
|
307
|
+
await receiverA2.start();
|
|
308
|
+
|
|
309
|
+
// ── Tenant B: BOTH devices subscribe ONLY to tenant B's fanout ──
|
|
310
|
+
const b1Sync = recordingSyncFn();
|
|
311
|
+
const receiverB1 = new InMemoryPushReceiver({
|
|
312
|
+
tenantId: TENANT_B,
|
|
313
|
+
fanout: fanoutB,
|
|
314
|
+
syncFn: b1Sync.fn,
|
|
315
|
+
flagProvider,
|
|
316
|
+
});
|
|
317
|
+
disposers.push(() => receiverB1.dispose());
|
|
318
|
+
await receiverB1.start();
|
|
319
|
+
|
|
320
|
+
const b2Sync = recordingSyncFn();
|
|
321
|
+
const receiverB2 = new InMemoryPushReceiver({
|
|
322
|
+
tenantId: TENANT_B,
|
|
323
|
+
fanout: fanoutB,
|
|
324
|
+
syncFn: b2Sync.fn,
|
|
325
|
+
flagProvider,
|
|
326
|
+
});
|
|
327
|
+
disposers.push(() => receiverB2.dispose());
|
|
328
|
+
await receiverB2.start();
|
|
329
|
+
|
|
330
|
+
// ── Act: tenant A's device A1 "saves" the file → emits a PushEvent ──
|
|
331
|
+
// emitForBatch with a single changed path. The batch maps absolutePath →
|
|
332
|
+
// relativePath; PushEventEmitter hashes/stats the file, so we drive the
|
|
333
|
+
// emit through the transport directly via a hand-built event to keep the
|
|
334
|
+
// test filesystem-free while still exercising the real transport+receiver
|
|
335
|
+
// path that carries the originTenantId.
|
|
336
|
+
const eventA = makeEvent({
|
|
337
|
+
tenantId: TENANT_A,
|
|
338
|
+
deviceId: "device-A1",
|
|
339
|
+
relativePath: TENANT_A_SECRET_PATH,
|
|
340
|
+
sequenceNumber: 1,
|
|
341
|
+
});
|
|
342
|
+
// Sanity: the emitter is enabled for tenant A (not dormant).
|
|
343
|
+
expect(emitterA1.enabled).toBe(true);
|
|
344
|
+
await transportA.publish(eventA);
|
|
345
|
+
|
|
346
|
+
// ── Advance through the FULL simulated 60s observation window ──
|
|
347
|
+
await vi.advanceTimersByTimeAsync(OBSERVATION_WINDOW_MS);
|
|
348
|
+
// Flush any microtask-scheduled receiver dispatch.
|
|
349
|
+
await Promise.resolve();
|
|
350
|
+
|
|
351
|
+
// ── AC#3 positive control: tenant-A device A2 DID receive A1's file ──
|
|
352
|
+
expect(a2Sync.pulledPaths).toEqual([TENANT_A_SECRET_PATH]);
|
|
353
|
+
expect(receiverA2.processedCount).toBe(1);
|
|
354
|
+
expect(transportA.publishedCount).toBe(1);
|
|
355
|
+
expect(transportA.rejectedCount).toBe(0);
|
|
356
|
+
|
|
357
|
+
// ── AC#1: NO tenant-B device observed ANYTHING over the 60s window ──
|
|
358
|
+
expect(b1Sync.pulledPaths).toEqual([]);
|
|
359
|
+
expect(b2Sync.pulledPaths).toEqual([]);
|
|
360
|
+
expect(receiverB1.processedCount).toBe(0);
|
|
361
|
+
expect(receiverB2.processedCount).toBe(0);
|
|
362
|
+
expect(receiverB1.dedupedCount).toBe(0);
|
|
363
|
+
expect(receiverB2.dedupedCount).toBe(0);
|
|
364
|
+
// No tenant-A bytes ever entered tenant B's fanout: a B receiver that
|
|
365
|
+
// saw a decode/dispatch would have advanced one of these counters.
|
|
366
|
+
expect(receiverB1.decodeFailureCount).toBe(0);
|
|
367
|
+
expect(receiverB2.decodeFailureCount).toBe(0);
|
|
368
|
+
},
|
|
369
|
+
20_000,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
it(
|
|
373
|
+
"AC#1 (mechanical) — a tenant-A device CANNOT publish onto tenant B: a " +
|
|
374
|
+
"tenant-B-scoped transport rejects a tenant-A-origin event, and tenant " +
|
|
375
|
+
"B's fanout stays empty",
|
|
376
|
+
async () => {
|
|
377
|
+
const fanoutB = new InMemoryFanout();
|
|
378
|
+
const transportB = new TenantScopedTransport(TENANT_B, fanoutB);
|
|
379
|
+
await transportB.start();
|
|
380
|
+
disposers.push(() => transportB.dispose());
|
|
381
|
+
|
|
382
|
+
const bReceived: string[] = [];
|
|
383
|
+
fanoutB.subscribe((raw) => bReceived.push(raw));
|
|
384
|
+
|
|
385
|
+
// A forged tenant-A event handed to tenant B's transport MUST be rejected
|
|
386
|
+
// — the transport refuses to route a foreign originTenantId onto B's
|
|
387
|
+
// fanout. This is the client-side analogue of the server's
|
|
388
|
+
// cross-tenant-push-rejected 403.
|
|
389
|
+
const forged = makeEvent({
|
|
390
|
+
tenantId: TENANT_A,
|
|
391
|
+
deviceId: "device-A1",
|
|
392
|
+
relativePath: TENANT_A_SECRET_PATH,
|
|
393
|
+
sequenceNumber: 1,
|
|
394
|
+
});
|
|
395
|
+
await expect(transportB.publish(forged)).rejects.toThrow(
|
|
396
|
+
/cross-tenant/i,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
expect(transportB.rejectedCount).toBe(1);
|
|
400
|
+
expect(transportB.publishedCount).toBe(0);
|
|
401
|
+
// Nothing reached tenant B's fanout.
|
|
402
|
+
expect(bReceived).toEqual([]);
|
|
403
|
+
},
|
|
404
|
+
20_000,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
it(
|
|
408
|
+
"AC#2 — tenant B's SQS receiver, polling ITS OWN queue, never PULLS a " +
|
|
409
|
+
"tenant-A file even when tenant A's queue is full of A's messages " +
|
|
410
|
+
"(receiver subscribes only to its own-tenant queue)",
|
|
411
|
+
async () => {
|
|
412
|
+
// This test exercises the real SqsPushReceiver poll loop, which is an
|
|
413
|
+
// async loop driven off the (now long-poll-blocking) FakeSqs — not off
|
|
414
|
+
// host timers. We run it under REAL timers so the loop's promises settle
|
|
415
|
+
// naturally; the FakeSqs blocks on empty so there is no busy-spin.
|
|
416
|
+
vi.useRealTimers();
|
|
417
|
+
// A fast injected sleep keeps any backoff path from waiting real time.
|
|
418
|
+
const fastSleep = async (): Promise<void> => Promise.resolve();
|
|
419
|
+
|
|
420
|
+
const sqs = new FakeSqs();
|
|
421
|
+
const queueUrlA = "https://sqs.local/queue/tenant-us010-A";
|
|
422
|
+
const queueUrlB = "https://sqs.local/queue/tenant-us010-B";
|
|
423
|
+
|
|
424
|
+
// Tenant A's queue is FULL of tenant-A messages. Tenant B's queue is
|
|
425
|
+
// EMPTY. If isolation held only by content-filtering (it must not), a
|
|
426
|
+
// bug that polled the wrong queue would surface here.
|
|
427
|
+
sqs.seed(queueUrlA, [
|
|
428
|
+
encodePushEvent(
|
|
429
|
+
makeEvent({
|
|
430
|
+
tenantId: TENANT_A,
|
|
431
|
+
deviceId: "device-A1",
|
|
432
|
+
relativePath: TENANT_A_SECRET_PATH,
|
|
433
|
+
sequenceNumber: 1,
|
|
434
|
+
}),
|
|
435
|
+
),
|
|
436
|
+
encodePushEvent(
|
|
437
|
+
makeEvent({
|
|
438
|
+
tenantId: TENANT_A,
|
|
439
|
+
deviceId: "device-A2",
|
|
440
|
+
relativePath: "companies/tenantA/notes/other.md",
|
|
441
|
+
sequenceNumber: 2,
|
|
442
|
+
}),
|
|
443
|
+
),
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
const bSync = recordingSyncFn();
|
|
447
|
+
const receiverB = new SqsPushReceiver({
|
|
448
|
+
tenantId: TENANT_B,
|
|
449
|
+
queueUrl: queueUrlB, // ← B subscribes ONLY to its own queue
|
|
450
|
+
sqs,
|
|
451
|
+
syncFn: bSync.fn,
|
|
452
|
+
enabled: true,
|
|
453
|
+
waitTimeSeconds: 0,
|
|
454
|
+
sleep: fastSleep,
|
|
455
|
+
});
|
|
456
|
+
disposers.push(() => receiverB.dispose());
|
|
457
|
+
|
|
458
|
+
await receiverB.start();
|
|
459
|
+
|
|
460
|
+
// Let the poll loop run a real iteration against B's (empty) queue. The
|
|
461
|
+
// first receiveMessage drains B's empty buffer then blocks on abort, so
|
|
462
|
+
// a short real delay is enough to prove B never pulled anything; the
|
|
463
|
+
// FakeSqs has no tenant-A queue exposure to B at all.
|
|
464
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
465
|
+
|
|
466
|
+
// Tenant B pulled NOTHING — its queue was empty and it never touched A's.
|
|
467
|
+
expect(bSync.pulledPaths).toEqual([]);
|
|
468
|
+
expect(receiverB.processedCount).toBe(0);
|
|
469
|
+
// The receiver only ever polled tenant B's own queue URL.
|
|
470
|
+
expect(sqs.polledQueueUrls.has(queueUrlB)).toBe(true);
|
|
471
|
+
expect(sqs.polledQueueUrls.has(queueUrlA)).toBe(false);
|
|
472
|
+
|
|
473
|
+
// Dispose under real timers (afterEach switches back), drains cleanly.
|
|
474
|
+
},
|
|
475
|
+
20_000,
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
it(
|
|
479
|
+
"AC#4 — the BLOCKING `cross-tenant-isolation` CI job references THIS test " +
|
|
480
|
+
"file; a PR that silently drops the gate fails the test it tried to skip",
|
|
481
|
+
async () => {
|
|
482
|
+
const here = fileURLToPath(import.meta.url);
|
|
483
|
+
// <repo>/test/e2e/sync/cross-tenant-isolation.test.ts → up 4 to <repo>.
|
|
484
|
+
const repoRoot = path.resolve(path.dirname(here), "..", "..", "..");
|
|
485
|
+
const workflowText = await fsReadFile(
|
|
486
|
+
path.join(repoRoot, ".github", "workflows", "ci.yml"),
|
|
487
|
+
"utf8",
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
// The explicit job id the PRD asks for (job name `cross-tenant-isolation`).
|
|
491
|
+
expect(workflowText).toContain("cross-tenant-isolation:");
|
|
492
|
+
// The job must run THIS file by path.
|
|
493
|
+
expect(workflowText).toContain(
|
|
494
|
+
"test/e2e/sync/cross-tenant-isolation.test.ts",
|
|
495
|
+
);
|
|
496
|
+
// The gate must run on PRs — pin the YAML key form so a comment-only
|
|
497
|
+
// mention can't satisfy the assertion.
|
|
498
|
+
expect(workflowText).toContain("pull_request:");
|
|
499
|
+
},
|
|
500
|
+
5_000,
|
|
501
|
+
);
|
|
502
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REAL-chokidar regression E2E for the event-push watcher scope.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists:
|
|
5
|
+
* The Phase-1 watcher tests (US-001/002/003) all drove an INJECTED stub
|
|
6
|
+
* watcher + FakeClock, so they never exercised real chokidar over a real tree
|
|
7
|
+
* with a real `.hqinclude`. That gap let a CONFIG MISMATCH ship in 5.26.0:
|
|
8
|
+
* the runner created the watcher with `personalMode: true`, whose filter
|
|
9
|
+
* excludes the `companies/` top-level -- but the menubar runs `--companies`,
|
|
10
|
+
* whose sync scope is dominated by `companies/<slug>/knowledge` (etc). Net:
|
|
11
|
+
* the watcher excluded exactly the synced paths, so a local edit never
|
|
12
|
+
* triggered an instant push (it silently fell back to the 10-min poll).
|
|
13
|
+
*
|
|
14
|
+
* This test uses a real temp tree, real chokidar, and real timers to assert:
|
|
15
|
+
* 1. With the FIXED scope (personalMode=false, as `--companies` now passes),
|
|
16
|
+
* an edit under `companies/<slug>/knowledge/` fires a debounced change.
|
|
17
|
+
* 2. With the BUGGY scope (personalMode=true), the same edit does NOT fire --
|
|
18
|
+
* pinning the exact regression so it can't silently return.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
26
|
+
|
|
27
|
+
import { TreeWatcher } from "../../src/watcher.js";
|
|
28
|
+
|
|
29
|
+
let hqRoot: string;
|
|
30
|
+
let watcher: TreeWatcher | null = null;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
hqRoot = await mkdtemp(path.join(tmpdir(), "hqcloud-watch-e2e-"));
|
|
34
|
+
// Minimal HQ-like scope: only companies/<slug>/knowledge/ is in-scope,
|
|
35
|
+
// mirroring the real HQ `.hqinclude` allow-list shape.
|
|
36
|
+
await writeFile(path.join(hqRoot, ".hqinclude"), "companies/*/knowledge/\n");
|
|
37
|
+
await mkdir(path.join(hqRoot, "companies", "acme", "knowledge"), {
|
|
38
|
+
recursive: true,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
watcher?.dispose();
|
|
44
|
+
watcher = null;
|
|
45
|
+
await rm(hqRoot, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** Resolve true if the watcher emits a debounced change within `ms`, else false. */
|
|
49
|
+
function waitForEmit(w: TreeWatcher, ms: number): Promise<boolean> {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
let settled = false;
|
|
52
|
+
const off = w.onChange(() => {
|
|
53
|
+
if (settled) return;
|
|
54
|
+
settled = true;
|
|
55
|
+
off();
|
|
56
|
+
clearTimeout(t);
|
|
57
|
+
resolve(true);
|
|
58
|
+
});
|
|
59
|
+
const t = setTimeout(() => {
|
|
60
|
+
if (settled) return;
|
|
61
|
+
settled = true;
|
|
62
|
+
off();
|
|
63
|
+
resolve(false);
|
|
64
|
+
}, ms);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("event-push watcher -- real chokidar scope (regression for 5.26.0)", () => {
|
|
69
|
+
it(
|
|
70
|
+
"FIRES on a companies/<slug>/knowledge/ edit when personalMode=false (the --companies scope)",
|
|
71
|
+
async () => {
|
|
72
|
+
watcher = new TreeWatcher({ hqRoot, debounceMs: 250, personalMode: false });
|
|
73
|
+
watcher.start();
|
|
74
|
+
// Let chokidar finish establishing watches before the write.
|
|
75
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
76
|
+
|
|
77
|
+
const emitted = waitForEmit(watcher, 4000);
|
|
78
|
+
await writeFile(
|
|
79
|
+
path.join(hqRoot, "companies", "acme", "knowledge", "note.md"),
|
|
80
|
+
"# hello\n",
|
|
81
|
+
);
|
|
82
|
+
expect(await emitted).toBe(true);
|
|
83
|
+
},
|
|
84
|
+
8000,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
it(
|
|
88
|
+
"does NOT fire on the same edit when personalMode=true (pins the 5.26.0 bug)",
|
|
89
|
+
async () => {
|
|
90
|
+
watcher = new TreeWatcher({ hqRoot, debounceMs: 250, personalMode: true });
|
|
91
|
+
watcher.start();
|
|
92
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
93
|
+
|
|
94
|
+
const emitted = waitForEmit(watcher, 2500);
|
|
95
|
+
await writeFile(
|
|
96
|
+
path.join(hqRoot, "companies", "acme", "knowledge", "note2.md"),
|
|
97
|
+
"# hello\n",
|
|
98
|
+
);
|
|
99
|
+
// personalMode excludes the companies/ top-level -> no emit. This is the
|
|
100
|
+
// bug the fix avoids by passing personalMode=false in --companies mode.
|
|
101
|
+
expect(await emitted).toBe(false);
|
|
102
|
+
},
|
|
103
|
+
8000,
|
|
104
|
+
);
|
|
105
|
+
});
|