@indigoai-us/hq-cloud 5.1.0 → 5.1.9
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/dist/bin/sync-runner.d.ts +134 -0
- package/dist/bin/sync-runner.d.ts.map +1 -0
- package/dist/bin/sync-runner.js +360 -0
- package/dist/bin/sync-runner.js.map +1 -0
- package/dist/bin/sync-runner.test.d.ts +10 -0
- package/dist/bin/sync-runner.test.d.ts.map +1 -0
- package/dist/bin/sync-runner.test.js +648 -0
- package/dist/bin/sync-runner.test.js.map +1 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/share.js +2 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +9 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +33 -10
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +15 -4
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +19 -1
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.d.ts +9 -0
- package/dist/cognito-auth.test.d.ts.map +1 -0
- package/dist/cognito-auth.test.js +113 -0
- package/dist/cognito-auth.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -0
- package/dist/context.js.map +1 -1
- package/dist/daemon-worker.d.ts +6 -1
- package/dist/daemon-worker.d.ts.map +1 -1
- package/dist/daemon-worker.js +12 -16
- package/dist/daemon-worker.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2 -0
- package/dist/daemon.js.map +1 -1
- package/dist/ignore.d.ts +13 -2
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +69 -12
- package/dist/ignore.js.map +1 -1
- package/dist/index.d.ts +24 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -134
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +20 -4
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +45 -8
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.d.ts +9 -0
- package/dist/journal.test.d.ts.map +1 -0
- package/dist/journal.test.js +114 -0
- package/dist/journal.test.js.map +1 -0
- package/dist/s3.d.ts +18 -6
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +57 -56
- package/dist/s3.js.map +1 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +59 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +72 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +160 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +11 -5
- package/dist/watcher.js.map +1 -1
- package/package.json +15 -3
- package/src/bin/sync-runner.test.ts +804 -0
- package/src/bin/sync-runner.ts +499 -0
- package/src/cli/accept.ts +97 -0
- package/src/cli/conflict.ts +119 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/invite.test.ts +247 -0
- package/src/cli/invite.ts +180 -0
- package/src/cli/promote.ts +123 -0
- package/src/cli/share.test.ts +155 -0
- package/src/cli/share.ts +212 -0
- package/src/cli/sync.test.ts +225 -0
- package/src/cli/sync.ts +225 -0
- package/src/cognito-auth.test.ts +156 -0
- package/src/cognito-auth.ts +18 -1
- package/src/context.test.ts +202 -0
- package/src/context.ts +178 -0
- package/src/daemon-worker.ts +13 -19
- package/src/daemon.ts +2 -0
- package/src/ignore.ts +76 -12
- package/src/index.ts +94 -165
- package/src/journal.test.ts +146 -0
- package/src/journal.ts +53 -11
- package/src/s3.ts +76 -66
- package/src/types.ts +37 -0
- package/src/vault-client.test.ts +563 -0
- package/src/vault-client.ts +478 -0
- package/src/watcher.ts +12 -5
- package/test/invite-flow.integration.test.ts +244 -0
- package/test/share-sync.integration.test.ts +210 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invite → Accept → Promote integration test (VLT-7 US-003).
|
|
3
|
+
*
|
|
4
|
+
* Mocks the vault-service HTTP API to test the full lifecycle round-trip:
|
|
5
|
+
* admin creates invite → invitee accepts → admin promotes to guest with paths.
|
|
6
|
+
*
|
|
7
|
+
* Uses mocked fetch (not a real vault-service) to keep the test self-contained
|
|
8
|
+
* and runnable offline. Real E2E tests against dev stage are in the e2eTests
|
|
9
|
+
* section of the PRD.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { invite } from "../src/cli/invite.js";
|
|
14
|
+
import { accept, parseToken } from "../src/cli/accept.js";
|
|
15
|
+
import { promote } from "../src/cli/promote.js";
|
|
16
|
+
import type { VaultServiceConfig } from "../src/types.js";
|
|
17
|
+
import type { Membership } from "../src/vault-client.js";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
24
|
+
return new Response(JSON.stringify(body), {
|
|
25
|
+
status,
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const VAULT_CONFIG: VaultServiceConfig = {
|
|
31
|
+
apiUrl: "https://vault.test.example.com",
|
|
32
|
+
authToken: "admin-jwt",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const INVITEE_CONFIG: VaultServiceConfig = {
|
|
36
|
+
apiUrl: "https://vault.test.example.com",
|
|
37
|
+
authToken: "invitee-jwt",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Token parsing
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
describe("parseToken", () => {
|
|
55
|
+
it("extracts token from hq:// magic link", () => {
|
|
56
|
+
expect(parseToken("hq://accept/tok_abc123")).toBe("tok_abc123");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("extracts token from https:// URL", () => {
|
|
60
|
+
expect(parseToken("https://hq.indigoai.com/accept/tok_xyz")).toBe("tok_xyz");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns raw token unchanged", () => {
|
|
64
|
+
expect(parseToken("tok_raw_token")).toBe("tok_raw_token");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("trims whitespace", () => {
|
|
68
|
+
expect(parseToken(" hq://accept/tok_abc ")).toBe("tok_abc");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Full round-trip
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe("invite → accept → promote lifecycle", () => {
|
|
77
|
+
const pendingMembership: Membership = {
|
|
78
|
+
membershipKey: "psn_invitee#cmp_acme",
|
|
79
|
+
personUid: "psn_invitee",
|
|
80
|
+
companyUid: "cmp_acme",
|
|
81
|
+
role: "member",
|
|
82
|
+
status: "pending",
|
|
83
|
+
inviteToken: "tok_secure_random_32bytes",
|
|
84
|
+
invitedBy: "psn_admin",
|
|
85
|
+
invitedAt: "2026-04-15T00:00:00Z",
|
|
86
|
+
createdAt: "2026-04-15T00:00:00Z",
|
|
87
|
+
updatedAt: "2026-04-15T00:00:00Z",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const activeMembership: Membership = {
|
|
91
|
+
...pendingMembership,
|
|
92
|
+
status: "active",
|
|
93
|
+
inviteToken: undefined,
|
|
94
|
+
acceptedAt: "2026-04-15T00:01:00Z",
|
|
95
|
+
updatedAt: "2026-04-15T00:01:00Z",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
it("admin invites → invitee accepts → admin promotes to guest with paths", async () => {
|
|
99
|
+
// --- Step 1: Admin creates invite ---
|
|
100
|
+
fetchSpy
|
|
101
|
+
// entity.findBySlug("company", "acme")
|
|
102
|
+
.mockResolvedValueOnce(
|
|
103
|
+
jsonResponse(200, { entity: { uid: "cmp_acme", slug: "acme", type: "company", status: "active" } }),
|
|
104
|
+
)
|
|
105
|
+
// createInvite
|
|
106
|
+
.mockResolvedValueOnce(
|
|
107
|
+
jsonResponse(200, {
|
|
108
|
+
membership: pendingMembership,
|
|
109
|
+
inviteToken: "tok_secure_random_32bytes",
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const inviteResult = await invite({
|
|
114
|
+
target: "alice@example.com",
|
|
115
|
+
role: "member",
|
|
116
|
+
company: "acme",
|
|
117
|
+
vaultConfig: VAULT_CONFIG,
|
|
118
|
+
callerUid: "psn_admin",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(inviteResult.magicLink).toBe("hq://accept/tok_secure_random_32bytes");
|
|
122
|
+
expect(inviteResult.membership.status).toBe("pending");
|
|
123
|
+
|
|
124
|
+
// --- Step 2: Invitee accepts ---
|
|
125
|
+
fetchSpy
|
|
126
|
+
// acceptInvite
|
|
127
|
+
.mockResolvedValueOnce(
|
|
128
|
+
jsonResponse(200, { membership: activeMembership }),
|
|
129
|
+
)
|
|
130
|
+
// entity.get for company slug resolution
|
|
131
|
+
.mockResolvedValueOnce(
|
|
132
|
+
jsonResponse(200, { entity: { uid: "cmp_acme", slug: "acme", type: "company", status: "active" } }),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const acceptResult = await accept({
|
|
136
|
+
tokenOrLink: inviteResult.magicLink,
|
|
137
|
+
callerUid: "psn_invitee",
|
|
138
|
+
vaultConfig: INVITEE_CONFIG,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(acceptResult.membership.status).toBe("active");
|
|
142
|
+
expect(acceptResult.membership.role).toBe("member");
|
|
143
|
+
expect(acceptResult.companySlug).toBe("acme");
|
|
144
|
+
|
|
145
|
+
// --- Step 3: Admin promotes member → guest with paths ---
|
|
146
|
+
fetchSpy
|
|
147
|
+
// entity.findBySlug for company resolution
|
|
148
|
+
.mockResolvedValueOnce(
|
|
149
|
+
jsonResponse(200, { entity: { uid: "cmp_acme", slug: "acme", type: "company", status: "active" } }),
|
|
150
|
+
)
|
|
151
|
+
// updateRole
|
|
152
|
+
.mockResolvedValueOnce(
|
|
153
|
+
jsonResponse(200, {
|
|
154
|
+
membership: {
|
|
155
|
+
...activeMembership,
|
|
156
|
+
role: "guest",
|
|
157
|
+
allowedPrefixes: ["docs/"],
|
|
158
|
+
updatedAt: "2026-04-15T00:02:00Z",
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const promoteResult = await promote({
|
|
164
|
+
target: "psn_invitee",
|
|
165
|
+
newRole: "guest",
|
|
166
|
+
paths: "docs/",
|
|
167
|
+
company: "acme",
|
|
168
|
+
callerUid: "psn_admin",
|
|
169
|
+
vaultConfig: VAULT_CONFIG,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(promoteResult.membership.role).toBe("guest");
|
|
173
|
+
expect(promoteResult.membership.allowedPrefixes).toEqual(["docs/"]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("double-accept returns conflict error", async () => {
|
|
177
|
+
fetchSpy.mockResolvedValueOnce(
|
|
178
|
+
jsonResponse(409, { message: "Already accepted" }),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
accept({
|
|
183
|
+
tokenOrLink: "tok_already_accepted",
|
|
184
|
+
callerUid: "psn_invitee",
|
|
185
|
+
vaultConfig: INVITEE_CONFIG,
|
|
186
|
+
}),
|
|
187
|
+
).rejects.toThrow("This invite was already accepted");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("demoting last owner returns conflict error", async () => {
|
|
191
|
+
fetchSpy
|
|
192
|
+
// entity.findBySlug
|
|
193
|
+
.mockResolvedValueOnce(
|
|
194
|
+
jsonResponse(200, { entity: { uid: "cmp_acme", slug: "acme", type: "company", status: "active" } }),
|
|
195
|
+
)
|
|
196
|
+
// updateRole — 409 because last owner
|
|
197
|
+
.mockResolvedValueOnce(
|
|
198
|
+
jsonResponse(409, { message: "Cannot remove last owner" }),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
promote({
|
|
203
|
+
target: "psn_owner",
|
|
204
|
+
newRole: "member",
|
|
205
|
+
company: "acme",
|
|
206
|
+
callerUid: "psn_owner",
|
|
207
|
+
vaultConfig: VAULT_CONFIG,
|
|
208
|
+
}),
|
|
209
|
+
).rejects.toThrow("Cannot leave company without an owner");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("non-admin invite returns permission error", async () => {
|
|
213
|
+
fetchSpy
|
|
214
|
+
.mockResolvedValueOnce(
|
|
215
|
+
jsonResponse(200, { entity: { uid: "cmp_acme", slug: "acme", type: "company", status: "active" } }),
|
|
216
|
+
)
|
|
217
|
+
.mockResolvedValueOnce(
|
|
218
|
+
jsonResponse(403, { message: "Forbidden" }),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
await expect(
|
|
222
|
+
invite({
|
|
223
|
+
target: "bob@example.com",
|
|
224
|
+
company: "acme",
|
|
225
|
+
vaultConfig: VAULT_CONFIG,
|
|
226
|
+
callerUid: "psn_member",
|
|
227
|
+
}),
|
|
228
|
+
).rejects.toThrow("Permission denied — only admins and owners can invite members");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("accept with wrong person returns permission error", async () => {
|
|
232
|
+
fetchSpy.mockResolvedValueOnce(
|
|
233
|
+
jsonResponse(403, { message: "Identity mismatch" }),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await expect(
|
|
237
|
+
accept({
|
|
238
|
+
tokenOrLink: "tok_for_someone_else",
|
|
239
|
+
callerUid: "psn_wrong",
|
|
240
|
+
vaultConfig: INVITEE_CONFIG,
|
|
241
|
+
}),
|
|
242
|
+
).rejects.toThrow("This invite was for a different person");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: share/sync lifecycle (VLT-5 US-003).
|
|
3
|
+
*
|
|
4
|
+
* Exercises the full share/sync cycle against a real dev-stage company entity
|
|
5
|
+
* using real STS vending (VLT-3). Requires:
|
|
6
|
+
* - VAULT_API_URL env var (vault-service API endpoint)
|
|
7
|
+
* - VAULT_AUTH_TOKEN env var (Cognito JWT for an active member)
|
|
8
|
+
* - VAULT_TEST_COMPANY env var (company slug with an active entity + bucket)
|
|
9
|
+
*
|
|
10
|
+
* Run: pnpm test:e2e
|
|
11
|
+
*
|
|
12
|
+
* This test:
|
|
13
|
+
* 1. M1 shares file A
|
|
14
|
+
* 2. M2 (same creds, different local dir) syncs → receives A
|
|
15
|
+
* 3. M2 shares file B
|
|
16
|
+
* 4. M1 syncs → receives B
|
|
17
|
+
* 5. M1 edits A locally + M2 pushes newer A → M1 syncs with --on-conflict=keep
|
|
18
|
+
*
|
|
19
|
+
* Cleanup: all shared files are deleted from S3 on teardown.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, afterAll } from "vitest";
|
|
23
|
+
import * as fs from "fs";
|
|
24
|
+
import * as path from "path";
|
|
25
|
+
import * as os from "os";
|
|
26
|
+
import {
|
|
27
|
+
resolveEntityContext,
|
|
28
|
+
clearContextCache,
|
|
29
|
+
deleteRemoteFile,
|
|
30
|
+
listRemoteFiles,
|
|
31
|
+
} from "../src/index.js";
|
|
32
|
+
import { share } from "../src/cli/share.js";
|
|
33
|
+
import { sync } from "../src/cli/sync.js";
|
|
34
|
+
import type { VaultServiceConfig, EntityContext } from "../src/types.js";
|
|
35
|
+
|
|
36
|
+
// Skip if env vars not set
|
|
37
|
+
const VAULT_API_URL = process.env.VAULT_API_URL;
|
|
38
|
+
const VAULT_AUTH_TOKEN = process.env.VAULT_AUTH_TOKEN;
|
|
39
|
+
const VAULT_TEST_COMPANY = process.env.VAULT_TEST_COMPANY;
|
|
40
|
+
|
|
41
|
+
const canRun = VAULT_API_URL && VAULT_AUTH_TOKEN && VAULT_TEST_COMPANY;
|
|
42
|
+
|
|
43
|
+
const TEST_PREFIX = `__integration-test-${Date.now()}`;
|
|
44
|
+
|
|
45
|
+
describe.skipIf(!canRun)("share-sync integration", () => {
|
|
46
|
+
let vaultConfig: VaultServiceConfig;
|
|
47
|
+
let m1Root: string;
|
|
48
|
+
let m2Root: string;
|
|
49
|
+
let ctx: EntityContext;
|
|
50
|
+
|
|
51
|
+
// Track files to clean up
|
|
52
|
+
const sharedKeys: string[] = [];
|
|
53
|
+
|
|
54
|
+
// Create two simulated machine roots
|
|
55
|
+
const setup = async () => {
|
|
56
|
+
vaultConfig = {
|
|
57
|
+
apiUrl: VAULT_API_URL!,
|
|
58
|
+
authToken: VAULT_AUTH_TOKEN!,
|
|
59
|
+
region: "us-east-1",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
m1Root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-integ-m1-"));
|
|
63
|
+
m2Root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-integ-m2-"));
|
|
64
|
+
|
|
65
|
+
// Resolve entity context to verify connectivity
|
|
66
|
+
clearContextCache();
|
|
67
|
+
ctx = await resolveEntityContext(VAULT_TEST_COMPANY!, vaultConfig);
|
|
68
|
+
|
|
69
|
+
console.log(`Integration test: entity=${ctx.uid}, bucket=${ctx.bucketName}`);
|
|
70
|
+
console.log(`M1 root: ${m1Root}`);
|
|
71
|
+
console.log(`M2 root: ${m2Root}`);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
// Cleanup: delete all test files from S3
|
|
76
|
+
try {
|
|
77
|
+
if (ctx) {
|
|
78
|
+
for (const key of sharedKeys) {
|
|
79
|
+
try {
|
|
80
|
+
await deleteRemoteFile(ctx, key);
|
|
81
|
+
} catch {
|
|
82
|
+
// Best-effort cleanup
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Also scan for any orphaned test files
|
|
87
|
+
const remoteFiles = await listRemoteFiles(ctx, TEST_PREFIX);
|
|
88
|
+
for (const file of remoteFiles) {
|
|
89
|
+
try {
|
|
90
|
+
await deleteRemoteFile(ctx, file.key);
|
|
91
|
+
} catch {
|
|
92
|
+
// Best-effort
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
// Clean up temp directories
|
|
98
|
+
if (m1Root) fs.rmSync(m1Root, { recursive: true, force: true });
|
|
99
|
+
if (m2Root) fs.rmSync(m2Root, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("completes the full share/sync lifecycle", async () => {
|
|
104
|
+
await setup();
|
|
105
|
+
|
|
106
|
+
// --- Step 1: M1 shares file A ---
|
|
107
|
+
const fileA = `${TEST_PREFIX}/docs/handoff.md`;
|
|
108
|
+
const fileALocal = path.join(m1Root, fileA);
|
|
109
|
+
fs.mkdirSync(path.dirname(fileALocal), { recursive: true });
|
|
110
|
+
fs.writeFileSync(fileALocal, "# Handoff from M1\n\nDiscovery notes here.");
|
|
111
|
+
sharedKeys.push(fileA);
|
|
112
|
+
|
|
113
|
+
const shareResult1 = await share({
|
|
114
|
+
paths: [fileALocal],
|
|
115
|
+
company: VAULT_TEST_COMPANY!,
|
|
116
|
+
message: "Initial handoff notes",
|
|
117
|
+
vaultConfig,
|
|
118
|
+
hqRoot: m1Root,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(shareResult1.filesUploaded).toBe(1);
|
|
122
|
+
expect(shareResult1.aborted).toBe(false);
|
|
123
|
+
console.log("Step 1 PASS: M1 shared file A");
|
|
124
|
+
|
|
125
|
+
// --- Step 2: M2 syncs → receives A ---
|
|
126
|
+
clearContextCache();
|
|
127
|
+
|
|
128
|
+
const syncResult1 = await sync({
|
|
129
|
+
company: VAULT_TEST_COMPANY!,
|
|
130
|
+
vaultConfig,
|
|
131
|
+
hqRoot: m2Root,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(syncResult1.filesDownloaded).toBeGreaterThanOrEqual(1);
|
|
135
|
+
expect(syncResult1.aborted).toBe(false);
|
|
136
|
+
|
|
137
|
+
const m2FileA = path.join(m2Root, fileA);
|
|
138
|
+
expect(fs.existsSync(m2FileA)).toBe(true);
|
|
139
|
+
expect(fs.readFileSync(m2FileA, "utf-8")).toContain("Handoff from M1");
|
|
140
|
+
console.log("Step 2 PASS: M2 synced and received file A");
|
|
141
|
+
|
|
142
|
+
// --- Step 3: M2 shares file B ---
|
|
143
|
+
const fileB = `${TEST_PREFIX}/knowledge/findings.md`;
|
|
144
|
+
const fileBLocal = path.join(m2Root, fileB);
|
|
145
|
+
fs.mkdirSync(path.dirname(fileBLocal), { recursive: true });
|
|
146
|
+
fs.writeFileSync(fileBLocal, "# Findings from M2\n\nNew discovery.");
|
|
147
|
+
sharedKeys.push(fileB);
|
|
148
|
+
|
|
149
|
+
const shareResult2 = await share({
|
|
150
|
+
paths: [fileBLocal],
|
|
151
|
+
company: VAULT_TEST_COMPANY!,
|
|
152
|
+
message: "Research findings",
|
|
153
|
+
vaultConfig,
|
|
154
|
+
hqRoot: m2Root,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(shareResult2.filesUploaded).toBe(1);
|
|
158
|
+
console.log("Step 3 PASS: M2 shared file B");
|
|
159
|
+
|
|
160
|
+
// --- Step 4: M1 syncs → receives B ---
|
|
161
|
+
clearContextCache();
|
|
162
|
+
|
|
163
|
+
const syncResult2 = await sync({
|
|
164
|
+
company: VAULT_TEST_COMPANY!,
|
|
165
|
+
vaultConfig,
|
|
166
|
+
hqRoot: m1Root,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(syncResult2.filesDownloaded).toBeGreaterThanOrEqual(1);
|
|
170
|
+
|
|
171
|
+
const m1FileB = path.join(m1Root, fileB);
|
|
172
|
+
expect(fs.existsSync(m1FileB)).toBe(true);
|
|
173
|
+
expect(fs.readFileSync(m1FileB, "utf-8")).toContain("Findings from M2");
|
|
174
|
+
console.log("Step 4 PASS: M1 synced and received file B");
|
|
175
|
+
|
|
176
|
+
// --- Step 5: Conflict — M1 edits A locally, M2 pushes newer A, M1 syncs with keep ---
|
|
177
|
+
// M1 edits locally
|
|
178
|
+
fs.writeFileSync(fileALocal, "# Handoff from M1\n\nEDITED LOCALLY by M1.");
|
|
179
|
+
|
|
180
|
+
// M2 pushes newer version
|
|
181
|
+
fs.writeFileSync(m2FileA, "# Handoff from M1\n\nUPDATED by M2.");
|
|
182
|
+
clearContextCache();
|
|
183
|
+
|
|
184
|
+
await share({
|
|
185
|
+
paths: [m2FileA],
|
|
186
|
+
company: VAULT_TEST_COMPANY!,
|
|
187
|
+
onConflict: "overwrite",
|
|
188
|
+
vaultConfig,
|
|
189
|
+
hqRoot: m2Root,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// M1 syncs with --on-conflict=keep
|
|
193
|
+
clearContextCache();
|
|
194
|
+
|
|
195
|
+
await sync({
|
|
196
|
+
company: VAULT_TEST_COMPANY!,
|
|
197
|
+
onConflict: "keep",
|
|
198
|
+
vaultConfig,
|
|
199
|
+
hqRoot: m1Root,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// M1's local version should be preserved
|
|
203
|
+
const m1Content = fs.readFileSync(fileALocal, "utf-8");
|
|
204
|
+
expect(m1Content).toContain("EDITED LOCALLY by M1");
|
|
205
|
+
expect(m1Content).not.toContain("UPDATED by M2");
|
|
206
|
+
console.log("Step 5 PASS: M1 kept local version on conflict");
|
|
207
|
+
|
|
208
|
+
console.log("\n=== All 5 lifecycle steps passed ===");
|
|
209
|
+
}, 60_000); // 60s timeout for network ops
|
|
210
|
+
});
|