@indigoai-us/hq-cloud 6.10.2 → 6.11.1
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 +1 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +31 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +98 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +217 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +211 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +113 -0
- package/src/bin/sync-runner.ts +30 -1
- package/src/cli/sync.test.ts +241 -1
- package/src/cli/sync.ts +266 -1
- package/vitest.config.ts +22 -0
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
buildTargetedPushArgv,
|
|
22
22
|
resolvePullScope,
|
|
23
23
|
readPinnedPrefixes,
|
|
24
|
+
defaultCollectTelemetry,
|
|
24
25
|
} from "./sync-runner.js";
|
|
25
26
|
import type {
|
|
26
27
|
RunnerEvent,
|
|
@@ -3987,3 +3988,115 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
3987
3988
|
}
|
|
3988
3989
|
});
|
|
3989
3990
|
});
|
|
3991
|
+
|
|
3992
|
+
// ---------------------------------------------------------------------------
|
|
3993
|
+
// defaultCollectTelemetry — person-entity gate (Sentry HQ-4N regression)
|
|
3994
|
+
//
|
|
3995
|
+
// The telemetry passes reject SERVER-side with a captured `no-person-entity`
|
|
3996
|
+
// 4xx (and a Sentry warning) when the caller has no person entity. The gate
|
|
3997
|
+
// skips telemetry for unprovisioned callers so the premature `/v1/usage/*`
|
|
3998
|
+
// (and `/v1/skill-invocations`) calls — and therefore the warnings — stop.
|
|
3999
|
+
// `getTelemetryOptIn()` is the first call of BOTH passes, so its call count is
|
|
4000
|
+
// the differential signal for "did telemetry run".
|
|
4001
|
+
// ---------------------------------------------------------------------------
|
|
4002
|
+
|
|
4003
|
+
describe("defaultCollectTelemetry person-entity gate", () => {
|
|
4004
|
+
function makeTelemetryClient(persons: EntityInfo[]) {
|
|
4005
|
+
const calls = {
|
|
4006
|
+
listByType: 0,
|
|
4007
|
+
getTelemetryOptIn: 0,
|
|
4008
|
+
postUsage: 0,
|
|
4009
|
+
postSkillInvocations: 0,
|
|
4010
|
+
};
|
|
4011
|
+
const client = {
|
|
4012
|
+
entity: {
|
|
4013
|
+
get: async () => ({}) as unknown as EntityInfo,
|
|
4014
|
+
listByType: async (_type: string) => {
|
|
4015
|
+
calls.listByType++;
|
|
4016
|
+
return persons;
|
|
4017
|
+
},
|
|
4018
|
+
},
|
|
4019
|
+
// Telemetry surface (cast-to internally by defaultCollectTelemetry). With
|
|
4020
|
+
// opt-in disabled, the collectors return right after this one probe, so
|
|
4021
|
+
// postUsage/postSkillInvocations are never reached even when the gate
|
|
4022
|
+
// lets telemetry run — getTelemetryOptIn is the clean call-ran signal.
|
|
4023
|
+
getTelemetryOptIn: async () => {
|
|
4024
|
+
calls.getTelemetryOptIn++;
|
|
4025
|
+
return { enabled: false };
|
|
4026
|
+
},
|
|
4027
|
+
postUsage: async () => {
|
|
4028
|
+
calls.postUsage++;
|
|
4029
|
+
return { ok: true } as unknown;
|
|
4030
|
+
},
|
|
4031
|
+
postSkillInvocations: async () => {
|
|
4032
|
+
calls.postSkillInvocations++;
|
|
4033
|
+
return { written: 0, skipped: [] } as unknown;
|
|
4034
|
+
},
|
|
4035
|
+
};
|
|
4036
|
+
return { client, calls };
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
const PERSON: EntityInfo = {
|
|
4040
|
+
uid: "prs_abc123",
|
|
4041
|
+
type: "person",
|
|
4042
|
+
slug: "me",
|
|
4043
|
+
status: "active",
|
|
4044
|
+
bucketName: "hq-vault-prs_abc123",
|
|
4045
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
4046
|
+
} as unknown as EntityInfo;
|
|
4047
|
+
|
|
4048
|
+
it("skips both telemetry passes when the caller owns no person entity (no premature /v1/usage call)", async () => {
|
|
4049
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-tele-noperson-"));
|
|
4050
|
+
try {
|
|
4051
|
+
const { client, calls } = makeTelemetryClient([]); // unprovisioned caller
|
|
4052
|
+
await defaultCollectTelemetry(
|
|
4053
|
+
client as unknown as VaultClientSurface,
|
|
4054
|
+
false,
|
|
4055
|
+
hqRoot,
|
|
4056
|
+
);
|
|
4057
|
+
expect(calls.listByType).toBe(1); // orphan-safe probe ran
|
|
4058
|
+
expect(calls.getTelemetryOptIn).toBe(0); // collectors never ran
|
|
4059
|
+
expect(calls.postUsage).toBe(0);
|
|
4060
|
+
expect(calls.postSkillInvocations).toBe(0);
|
|
4061
|
+
} finally {
|
|
4062
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
4063
|
+
}
|
|
4064
|
+
});
|
|
4065
|
+
|
|
4066
|
+
it("runs telemetry when a canonical person entity exists (behaviour preserved for provisioned callers)", async () => {
|
|
4067
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-tele-person-"));
|
|
4068
|
+
try {
|
|
4069
|
+
const { client, calls } = makeTelemetryClient([PERSON]);
|
|
4070
|
+
await defaultCollectTelemetry(
|
|
4071
|
+
client as unknown as VaultClientSurface,
|
|
4072
|
+
false,
|
|
4073
|
+
hqRoot,
|
|
4074
|
+
);
|
|
4075
|
+
expect(calls.listByType).toBe(1);
|
|
4076
|
+
// Both passes reach their opt-in probe → telemetry ran as before.
|
|
4077
|
+
expect(calls.getTelemetryOptIn).toBeGreaterThan(0);
|
|
4078
|
+
} finally {
|
|
4079
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
4080
|
+
}
|
|
4081
|
+
});
|
|
4082
|
+
|
|
4083
|
+
it("fails open: a thrown person probe still lets telemetry run (no provisioned-user regression)", async () => {
|
|
4084
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-tele-throw-"));
|
|
4085
|
+
try {
|
|
4086
|
+
const { client, calls } = makeTelemetryClient([]);
|
|
4087
|
+
client.entity.listByType = async () => {
|
|
4088
|
+
calls.listByType++;
|
|
4089
|
+
throw new Error("transient network blip");
|
|
4090
|
+
};
|
|
4091
|
+
await defaultCollectTelemetry(
|
|
4092
|
+
client as unknown as VaultClientSurface,
|
|
4093
|
+
false,
|
|
4094
|
+
hqRoot,
|
|
4095
|
+
);
|
|
4096
|
+
expect(calls.listByType).toBe(1);
|
|
4097
|
+
expect(calls.getTelemetryOptIn).toBeGreaterThan(0); // fell through to telemetry
|
|
4098
|
+
} finally {
|
|
4099
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
4100
|
+
}
|
|
4101
|
+
});
|
|
4102
|
+
});
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -764,13 +764,42 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
764
764
|
// swallowed — telemetry must never abort or delay sync.
|
|
765
765
|
// ---------------------------------------------------------------------------
|
|
766
766
|
|
|
767
|
-
async function defaultCollectTelemetry(
|
|
767
|
+
export async function defaultCollectTelemetry(
|
|
768
768
|
client: VaultClientSurface,
|
|
769
769
|
clientIsStub: boolean,
|
|
770
770
|
hqRoot: string,
|
|
771
771
|
): Promise<void> {
|
|
772
772
|
if (clientIsStub) return;
|
|
773
773
|
|
|
774
|
+
// Person-entity gate (the "onboarding gate" the telemetry call-site must
|
|
775
|
+
// sit behind). Both telemetry passes below resolve the caller's `personUid`
|
|
776
|
+
// SERVER-side from the JWT and reject with a 4xx when the caller has not yet
|
|
777
|
+
// been provisioned a person entity: `getTelemetryOptIn()` (the first call of
|
|
778
|
+
// each pass) hits `GET /v1/usage/opt-in`, which 404s `no-person-entity` for
|
|
779
|
+
// an unprovisioned caller, and the skill pass's `POST /v1/skill-invocations`
|
|
780
|
+
// does the same. hq-pro logs every such reject as a Sentry *warning*, so an
|
|
781
|
+
// unprovisioned-but-signed-in identity that runs telemetry on a loop — e.g.
|
|
782
|
+
// a machine/daemon/outpost identity, or a single-company sync that fabricates
|
|
783
|
+
// its membership and never runs the onboarding gate — emits a steady stream
|
|
784
|
+
// of benign `no-person-entity` warnings (Sentry HQ-4N). The reject itself is
|
|
785
|
+
// harmless (telemetry is best-effort and the response is swallowed), but the
|
|
786
|
+
// noise is not. Probe the caller's OWN person entity with the orphan-safe
|
|
787
|
+
// `entity.listByType("person")` — `GET /entity/by-type/person` returns `200`
|
|
788
|
+
// with an empty list for an unprovisioned caller (no reject, no warning),
|
|
789
|
+
// and a non-empty result is the EXACT predicate for "the server will resolve
|
|
790
|
+
// a personUid" (same caller-owned-person union the usage handler's
|
|
791
|
+
// `getEffectivePersonUid` uses). Skip both passes when there is none. The
|
|
792
|
+
// probe is the same canonical-person check the runner already uses to pick
|
|
793
|
+
// the personal-vault target. Fail OPEN: a thrown probe (transient/network)
|
|
794
|
+
// falls through to run telemetry as before, so a provisioned user's
|
|
795
|
+
// telemetry is never dropped by a flaky probe.
|
|
796
|
+
try {
|
|
797
|
+
const persons = await client.entity.listByType("person");
|
|
798
|
+
if (pickCanonicalPersonEntity(persons) === null) return;
|
|
799
|
+
} catch {
|
|
800
|
+
// Probe failed — preserve prior behavior and let telemetry run.
|
|
801
|
+
}
|
|
802
|
+
|
|
774
803
|
// machineId: hq-cloud owns provisioning via `<hqRoot>/.hq/machine-id`
|
|
775
804
|
// (see `src/lib/machine-id.ts`). The resolver migrates forward from
|
|
776
805
|
// any legacy `~/.hq/menubar.json` value on first call, then becomes
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -69,9 +69,25 @@ const mockVendResponse = {
|
|
|
69
69
|
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
function setupFetchMock(
|
|
72
|
+
function setupFetchMock(
|
|
73
|
+
opts: { tombstones?: Array<{ key: string; deletedAt: string }> } = {},
|
|
74
|
+
) {
|
|
73
75
|
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
74
76
|
const urlStr = String(url);
|
|
77
|
+
// FILE_TOMBSTONE read surface (delete-resync). Defaults to an empty list so
|
|
78
|
+
// every legacy test exercises the no-suppression path; tests that need
|
|
79
|
+
// suppression pass `{ tombstones }`.
|
|
80
|
+
if (urlStr.includes("/v1/files/tombstones")) {
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
status: 200,
|
|
84
|
+
json: async () => ({
|
|
85
|
+
companyUid: mockEntity.uid,
|
|
86
|
+
tombstones: opts.tombstones ?? [],
|
|
87
|
+
}),
|
|
88
|
+
text: async () => "",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
75
91
|
// New per-user-namespace slug resolver (hq-pro PR 67). Returns the
|
|
76
92
|
// mockEntity's uid as the in-namespace match; the caller then
|
|
77
93
|
// re-fetches it via `/entity/{uid}`, which is matched by the
|
|
@@ -1049,6 +1065,230 @@ describe("sync", () => {
|
|
|
1049
1065
|
expect(journal.files["docs/really-deleted.md"]).toBeUndefined();
|
|
1050
1066
|
});
|
|
1051
1067
|
|
|
1068
|
+
// ── delete-resync: FILE_TOMBSTONE suppression (issue [6]) ──────────────────
|
|
1069
|
+
// These four cover the brief's regression contract: a scoped-delete tombstone
|
|
1070
|
+
// makes a delete STICK across a pull even when the object is still in the
|
|
1071
|
+
// remote LIST (a peer re-pushed it / the delete-marker hasn't propagated),
|
|
1072
|
+
// while a genuine re-create still syncs and a post-tombstone local edit is
|
|
1073
|
+
// never silently destroyed.
|
|
1074
|
+
|
|
1075
|
+
it("delete-resync: a tombstoned key present in the remote LIST is removed locally and NOT re-downloaded", async () => {
|
|
1076
|
+
// The core stays-deleted assertion. A peer ran `hq files delete docs/` (the
|
|
1077
|
+
// scoped-delete path), which wrote a FILE_TOMBSTONE. This machine still
|
|
1078
|
+
// holds the file and the object is still visible in the remote LIST (a peer
|
|
1079
|
+
// re-push, or an un-propagated delete-marker). Pre-fix the planner saw
|
|
1080
|
+
// "remote present, local present, unchanged" and KEPT it; worse, on a fresh
|
|
1081
|
+
// machine "remote present, local absent" re-DOWNLOADED it — the resurrection
|
|
1082
|
+
// loop. Now the FILE_TOMBSTONE (newer than the remote object) is
|
|
1083
|
+
// authoritative: delete locally, drop the journal entry, never download.
|
|
1084
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1085
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
1086
|
+
const sharedPath = path.join(companyRoot, "docs", "shared.md");
|
|
1087
|
+
fs.writeFileSync(sharedPath, "shared content");
|
|
1088
|
+
const crypto = await import("node:crypto");
|
|
1089
|
+
const baseline = crypto
|
|
1090
|
+
.createHash("sha256")
|
|
1091
|
+
.update("shared content")
|
|
1092
|
+
.digest("hex");
|
|
1093
|
+
const now = Date.now();
|
|
1094
|
+
fs.writeFileSync(
|
|
1095
|
+
journalPath,
|
|
1096
|
+
JSON.stringify({
|
|
1097
|
+
version: "1",
|
|
1098
|
+
lastSync: new Date(now - 120_000).toISOString(),
|
|
1099
|
+
files: {
|
|
1100
|
+
"docs/shared.md": {
|
|
1101
|
+
hash: baseline,
|
|
1102
|
+
size: 14,
|
|
1103
|
+
syncedAt: new Date(now - 120_000).toISOString(),
|
|
1104
|
+
direction: "down",
|
|
1105
|
+
remoteEtag: "remote-old",
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
}),
|
|
1109
|
+
);
|
|
1110
|
+
// Remote object is the STALE deleted version: lastModified BEFORE the
|
|
1111
|
+
// tombstone, still present in the LIST.
|
|
1112
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1113
|
+
{
|
|
1114
|
+
key: "docs/shared.md",
|
|
1115
|
+
size: 14,
|
|
1116
|
+
lastModified: new Date(now - 60_000),
|
|
1117
|
+
etag: '"remote-old"',
|
|
1118
|
+
},
|
|
1119
|
+
]);
|
|
1120
|
+
// FILE_TOMBSTONE deleted AFTER the remote object's lastModified.
|
|
1121
|
+
setupFetchMock({
|
|
1122
|
+
tombstones: [
|
|
1123
|
+
{ key: "docs/shared.md", deletedAt: new Date(now).toISOString() },
|
|
1124
|
+
],
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
const result = await sync({
|
|
1128
|
+
company: "acme",
|
|
1129
|
+
vaultConfig: mockConfig,
|
|
1130
|
+
hqRoot: tmpDir,
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// Stays deleted locally …
|
|
1134
|
+
expect(fs.existsSync(sharedPath)).toBe(false);
|
|
1135
|
+
// … and was NEVER re-downloaded (no download, no conflict-probe download).
|
|
1136
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
1137
|
+
expect(result.filesDownloaded).toBe(0);
|
|
1138
|
+
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
1139
|
+
// Journal entry dropped so it converges (no re-tombstone next run).
|
|
1140
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1141
|
+
expect(journal.files["docs/shared.md"]).toBeUndefined();
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it("delete-resync: a re-created object NEWER than the tombstone DOES sync (tombstone is not permanent)", async () => {
|
|
1145
|
+
// The tombstone must not permanently suppress a key. After a delete, a user
|
|
1146
|
+
// writes a brand-new object at the same path; its lastModified post-dates
|
|
1147
|
+
// the tombstone, so it is a genuine re-create and must download normally.
|
|
1148
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1149
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
1150
|
+
const recreatedPath = path.join(companyRoot, "docs", "recreated.md");
|
|
1151
|
+
const now = Date.now();
|
|
1152
|
+
// Empty journal + absent local: the file was deleted earlier; this is a
|
|
1153
|
+
// fresh re-create arriving from the vault.
|
|
1154
|
+
fs.writeFileSync(
|
|
1155
|
+
journalPath,
|
|
1156
|
+
JSON.stringify({ version: "1", lastSync: new Date(now).toISOString(), files: {} }),
|
|
1157
|
+
);
|
|
1158
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1159
|
+
{
|
|
1160
|
+
key: "docs/recreated.md",
|
|
1161
|
+
size: 17,
|
|
1162
|
+
lastModified: new Date(now), // newer than the tombstone below
|
|
1163
|
+
etag: '"remote-new"',
|
|
1164
|
+
},
|
|
1165
|
+
]);
|
|
1166
|
+
setupFetchMock({
|
|
1167
|
+
tombstones: [
|
|
1168
|
+
{
|
|
1169
|
+
key: "docs/recreated.md",
|
|
1170
|
+
deletedAt: new Date(now - 3_600_000).toISOString(), // an hour ago
|
|
1171
|
+
},
|
|
1172
|
+
],
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
const result = await sync({
|
|
1176
|
+
company: "acme",
|
|
1177
|
+
vaultConfig: mockConfig,
|
|
1178
|
+
hqRoot: tmpDir,
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
// Re-create wins: downloaded, present locally, not tombstoned.
|
|
1182
|
+
expect(s3Module.downloadFile).toHaveBeenCalled();
|
|
1183
|
+
expect(fs.existsSync(recreatedPath)).toBe(true);
|
|
1184
|
+
expect(result.filesDownloaded).toBeGreaterThanOrEqual(1);
|
|
1185
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it("delete-resync: a local edit made after the tombstone surfaces a CONFLICT, not a silent delete", async () => {
|
|
1189
|
+
// Safety guard: the authoritative delete must never destroy unsynced local
|
|
1190
|
+
// work. This machine edited the file locally (diverged from the synced
|
|
1191
|
+
// baseline) and only then learned of the tombstone — surface a conflict and
|
|
1192
|
+
// PRESERVE local, rather than unlinking it.
|
|
1193
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1194
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
1195
|
+
const editedPath = path.join(companyRoot, "docs", "edited.md");
|
|
1196
|
+
fs.writeFileSync(editedPath, "my local edits");
|
|
1197
|
+
const crypto = await import("node:crypto");
|
|
1198
|
+
const baseline = crypto
|
|
1199
|
+
.createHash("sha256")
|
|
1200
|
+
.update("original synced content")
|
|
1201
|
+
.digest("hex");
|
|
1202
|
+
const now = Date.now();
|
|
1203
|
+
fs.writeFileSync(
|
|
1204
|
+
journalPath,
|
|
1205
|
+
JSON.stringify({
|
|
1206
|
+
version: "1",
|
|
1207
|
+
lastSync: new Date(now - 120_000).toISOString(),
|
|
1208
|
+
files: {
|
|
1209
|
+
"docs/edited.md": {
|
|
1210
|
+
hash: baseline, // local has since diverged → localChanged
|
|
1211
|
+
size: 23,
|
|
1212
|
+
syncedAt: new Date(now - 120_000).toISOString(),
|
|
1213
|
+
direction: "down",
|
|
1214
|
+
remoteEtag: "remote-old",
|
|
1215
|
+
},
|
|
1216
|
+
},
|
|
1217
|
+
}),
|
|
1218
|
+
);
|
|
1219
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1220
|
+
{
|
|
1221
|
+
key: "docs/edited.md",
|
|
1222
|
+
size: 23,
|
|
1223
|
+
lastModified: new Date(now - 60_000),
|
|
1224
|
+
etag: '"remote-old"',
|
|
1225
|
+
},
|
|
1226
|
+
]);
|
|
1227
|
+
setupFetchMock({
|
|
1228
|
+
tombstones: [
|
|
1229
|
+
{ key: "docs/edited.md", deletedAt: new Date(now).toISOString() },
|
|
1230
|
+
],
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
const result = await sync({
|
|
1234
|
+
company: "acme",
|
|
1235
|
+
onConflict: "keep",
|
|
1236
|
+
vaultConfig: mockConfig,
|
|
1237
|
+
hqRoot: tmpDir,
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
// Conflict surfaced; local edits preserved; nothing tombstoned away.
|
|
1241
|
+
expect(result.conflicts).toBeGreaterThanOrEqual(1);
|
|
1242
|
+
expect(result.conflictPaths).toContain("docs/edited.md");
|
|
1243
|
+
expect(fs.existsSync(editedPath)).toBe(true);
|
|
1244
|
+
expect(fs.readFileSync(editedPath, "utf-8")).toBe("my local edits");
|
|
1245
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
it("delete-resync: suppression is per-key and the fetch is company-scoped (no cross-tenant bleed)", async () => {
|
|
1249
|
+
// Two guards in one: (a) a tombstone for `docs/a.md` must NOT suppress a
|
|
1250
|
+
// different key `docs/b.md` — suppression is keyed exactly; (b) the
|
|
1251
|
+
// tombstone fetch is scoped to the ACTIVE company's uid, so a sync for one
|
|
1252
|
+
// company never consults another company's tombstones (the client half of
|
|
1253
|
+
// the cross-tenant isolation the server enforces via the
|
|
1254
|
+
// FILE_TOMBSTONE#<companyUid># begins_with query).
|
|
1255
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1256
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
1257
|
+
const now = Date.now();
|
|
1258
|
+
fs.writeFileSync(
|
|
1259
|
+
journalPath,
|
|
1260
|
+
JSON.stringify({ version: "1", lastSync: new Date(now).toISOString(), files: {} }),
|
|
1261
|
+
);
|
|
1262
|
+
// a.md = stale tombstoned version (suppressed); b.md = a normal new file.
|
|
1263
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1264
|
+
{ key: "docs/a.md", size: 10, lastModified: new Date(now - 60_000), etag: '"a-old"' },
|
|
1265
|
+
{ key: "docs/b.md", size: 10, lastModified: new Date(now), etag: '"b-new"' },
|
|
1266
|
+
]);
|
|
1267
|
+
const fetchMock = setupFetchMock({
|
|
1268
|
+
tombstones: [
|
|
1269
|
+
{ key: "docs/a.md", deletedAt: new Date(now).toISOString() },
|
|
1270
|
+
],
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
const result = await sync({
|
|
1274
|
+
company: "acme",
|
|
1275
|
+
vaultConfig: mockConfig,
|
|
1276
|
+
hqRoot: tmpDir,
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
// a.md suppressed (never downloaded); b.md downloaded normally.
|
|
1280
|
+
expect(fs.existsSync(path.join(companyRoot, "docs", "a.md"))).toBe(false);
|
|
1281
|
+
expect(fs.existsSync(path.join(companyRoot, "docs", "b.md"))).toBe(true);
|
|
1282
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1283
|
+
|
|
1284
|
+
// The tombstone read was scoped to THIS company's uid.
|
|
1285
|
+
const tombstoneCall = fetchMock.mock.calls.find((c) =>
|
|
1286
|
+
String(c[0]).includes("/v1/files/tombstones"),
|
|
1287
|
+
);
|
|
1288
|
+
expect(tombstoneCall).toBeDefined();
|
|
1289
|
+
expect(String(tombstoneCall![0])).toContain(`company=${mockEntity.uid}`);
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1052
1292
|
it("does NOT tombstone keys when HEAD returns AccessDenied (Codex P1 — defensive STS skip)", async () => {
|
|
1053
1293
|
// Same Codex P1, AccessDenied branch: a guest STS session may deny
|
|
1054
1294
|
// both LIST and HEAD on out-of-scope keys. The tombstone planner
|