@indigoai-us/hq-cloud 6.14.46 → 6.14.48
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/cli/share.d.ts +11 -3
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +214 -22
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +595 -28
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +9 -5
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +16 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/ignore.d.ts +38 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +55 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +50 -0
- package/dist/ignore.test.js.map +1 -1
- package/dist/journal.d.ts +56 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +49 -1
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +37 -2
- package/dist/journal.test.js.map +1 -1
- package/dist/s3.test.js +6 -0
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +670 -28
- package/src/cli/share.ts +234 -34
- package/src/cli/sync.ts +29 -15
- package/src/ignore.test.ts +55 -0
- package/src/ignore.ts +57 -0
- package/src/journal.test.ts +44 -1
- package/src/journal.ts +69 -4
- package/src/s3.test.ts +6 -0
package/src/cli/share.test.ts
CHANGED
|
@@ -2826,17 +2826,25 @@ describe("share", () => {
|
|
|
2826
2826
|
});
|
|
2827
2827
|
|
|
2828
2828
|
it(
|
|
2829
|
-
"
|
|
2830
|
-
"
|
|
2829
|
+
"vault-litter drain: a `.drift-*` marker with direction:'down' and NO delete intent tombstones " +
|
|
2830
|
+
"even under owned-only, because litter bypasses the policy gate (the May-27 .drift-* leak class)",
|
|
2831
2831
|
async () => {
|
|
2832
|
-
//
|
|
2833
|
-
// owned-only
|
|
2834
|
-
//
|
|
2835
|
-
//
|
|
2836
|
-
//
|
|
2837
|
-
//
|
|
2838
|
-
//
|
|
2839
|
-
//
|
|
2832
|
+
// Provenance: this test was written for a `prs_`-scoped coercion that
|
|
2833
|
+
// rewrote owned-only -> currency-gated. That coercion is gone (6.14.48 —
|
|
2834
|
+
// it silently defeated the rollback knob; see `createPushRunContext`).
|
|
2835
|
+
// The test still passes, and is retained, because what actually drains
|
|
2836
|
+
// this key is the vault-litter path (6.0.2): `isVaultLitterArtifact`
|
|
2837
|
+
// routes `.drift-<ts>-<pid>` keys to `litterToDelete` BEFORE any policy,
|
|
2838
|
+
// intent, ignore-filter or breaker check runs. That is the durable
|
|
2839
|
+
// guarantee worth pinning — the May-27 `personal/.obsidian/*.drift-*`
|
|
2840
|
+
// pair (journal direction "down", local rm, push silently swallowed the
|
|
2841
|
+
// delete) must drain regardless of which delete policy is in force.
|
|
2842
|
+
//
|
|
2843
|
+
// Note the inputs deliberately keep `direction: "down"`, no
|
|
2844
|
+
// `localDeleteIntent`, and an explicit `owned-only` policy: every one of
|
|
2845
|
+
// those would refuse this key on the normal path, so a regression that
|
|
2846
|
+
// removed the litter bypass would fail here rather than silently
|
|
2847
|
+
// re-stranding litter.
|
|
2840
2848
|
const stateDirPersonal = fs.mkdtempSync(
|
|
2841
2849
|
path.join(os.tmpdir(), "hq-state-prs-"),
|
|
2842
2850
|
);
|
|
@@ -2886,10 +2894,8 @@ describe("share", () => {
|
|
|
2886
2894
|
personalMode: true,
|
|
2887
2895
|
skipUnchanged: true,
|
|
2888
2896
|
propagateDeletes: true,
|
|
2889
|
-
//
|
|
2890
|
-
//
|
|
2891
|
-
// only's "don't tombstone peer-uploaded content" rule has no peer
|
|
2892
|
-
// to protect against here, so we coerce to currency-gated.
|
|
2897
|
+
// Pinned owned-only — the strictest policy. Litter must drain
|
|
2898
|
+
// anyway; that bypass is the property under test.
|
|
2893
2899
|
propagateDeletePolicy: "owned-only",
|
|
2894
2900
|
});
|
|
2895
2901
|
|
|
@@ -2995,15 +3001,14 @@ describe("share", () => {
|
|
|
2995
3001
|
});
|
|
2996
3002
|
|
|
2997
3003
|
it(
|
|
2998
|
-
"
|
|
2999
|
-
"
|
|
3004
|
+
"owned-only on a cmp_ entity refuses to tombstone a direction:'down' entry " +
|
|
3005
|
+
"(multi-user curation intact)",
|
|
3000
3006
|
async () => {
|
|
3001
|
-
//
|
|
3002
|
-
//
|
|
3003
|
-
//
|
|
3004
|
-
//
|
|
3005
|
-
//
|
|
3006
|
-
// refactors share()'s entity-typing.
|
|
3007
|
+
// owned-only's multi-user semantics: a behind machine that pulled
|
|
3008
|
+
// Alice's upload and then rm'd it locally must NOT tombstone Alice's
|
|
3009
|
+
// file. Paired with the prs_ test below, this pins owned-only as
|
|
3010
|
+
// uniformly strict across BOTH vault kinds — the property a rollback
|
|
3011
|
+
// knob has to have to be worth reaching for.
|
|
3007
3012
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3008
3013
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3009
3014
|
// No file on disk under companies/acme/peer-uploaded.md → local missing.
|
|
@@ -3044,6 +3049,118 @@ describe("share", () => {
|
|
|
3044
3049
|
},
|
|
3045
3050
|
);
|
|
3046
3051
|
|
|
3052
|
+
it(
|
|
3053
|
+
"ROLLBACK KNOB: owned-only is honored on a prs_ personal vault — an intent-less entry with a " +
|
|
3054
|
+
"matching remote etag is REFUSED, not deleted",
|
|
3055
|
+
async () => {
|
|
3056
|
+
// Regression guard for the coercion removed in 6.14.48. A `prs_`-scoped
|
|
3057
|
+
// rewrite of owned-only -> currency-gated used to sit in
|
|
3058
|
+
// `createPushRunContext`. While both policies refused intent-less
|
|
3059
|
+
// deletes it was invisible. Once currency-gated started authorizing on
|
|
3060
|
+
// ETag currency alone, that rewrite meant an operator who set
|
|
3061
|
+
// `HQ_SYNC_DELETE_POLICY=owned-only` — the documented rollback, reached
|
|
3062
|
+
// for precisely BECAUSE deletes were misbehaving — still got intent-less
|
|
3063
|
+
// etag-only deletes on their personal vault. The rollback rolled back
|
|
3064
|
+
// company vaults only.
|
|
3065
|
+
//
|
|
3066
|
+
// The inputs below are exactly the shape that DOES delete under the
|
|
3067
|
+
// default policy (see "currency-gated: propagates a delete with NO
|
|
3068
|
+
// watcher intent…"): local file absent, no `localDeleteIntent`, journal
|
|
3069
|
+
// etag matching live remote. Under owned-only it must refuse. If the
|
|
3070
|
+
// coercion ever returns, `filesDeleted` becomes 1 and this fails.
|
|
3071
|
+
const stateDirPersonal = fs.mkdtempSync(
|
|
3072
|
+
path.join(os.tmpdir(), "hq-state-prs-rollback-"),
|
|
3073
|
+
);
|
|
3074
|
+
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
3075
|
+
try {
|
|
3076
|
+
const journalPath = path.join(
|
|
3077
|
+
stateDirPersonal,
|
|
3078
|
+
"sync-journal.__hq_personal_vault__.json",
|
|
3079
|
+
);
|
|
3080
|
+
fs.writeFileSync(
|
|
3081
|
+
journalPath,
|
|
3082
|
+
JSON.stringify({
|
|
3083
|
+
version: "1",
|
|
3084
|
+
lastSync: new Date().toISOString(),
|
|
3085
|
+
files: {
|
|
3086
|
+
// direction:"up" — so the refusal cannot be attributed to
|
|
3087
|
+
// owned-only's direction filter. The ONLY thing refusing this
|
|
3088
|
+
// key is the intent requirement the rollback restores.
|
|
3089
|
+
"personal/knowledge/gone.md": {
|
|
3090
|
+
hash: "rollback-hash",
|
|
3091
|
+
size: 42,
|
|
3092
|
+
syncedAt: new Date(Date.now() - 86400000).toISOString(),
|
|
3093
|
+
direction: "up",
|
|
3094
|
+
remoteEtag: "rollback-etag",
|
|
3095
|
+
kind: "file",
|
|
3096
|
+
// NO localDeleteIntent — no watcher was running.
|
|
3097
|
+
},
|
|
3098
|
+
},
|
|
3099
|
+
}),
|
|
3100
|
+
);
|
|
3101
|
+
|
|
3102
|
+
// Live remote still matches the journal — so under currency-gated this
|
|
3103
|
+
// key is a definite `toDelete`. Without this, the beforeEach default
|
|
3104
|
+
// (null = 404) would route a regression to `toTombstone` instead and
|
|
3105
|
+
// the test would catch it only via the weaker journal assertion.
|
|
3106
|
+
//
|
|
3107
|
+
// Deliberately NOT `mockResolvedValueOnce`: on the PASSING path
|
|
3108
|
+
// owned-only refuses before any HEAD is issued, so a queued one-shot
|
|
3109
|
+
// would go unconsumed and leak into the next test (`vi.clearAllMocks`
|
|
3110
|
+
// in afterEach does not drain a `…Once` queue — it clears call
|
|
3111
|
+
// records, not queued implementations). That is the same trap already
|
|
3112
|
+
// documented in the `.drift-*` litter test above. A persistent impl
|
|
3113
|
+
// reset in `finally` cannot leak either way.
|
|
3114
|
+
vi.mocked(headRemoteFile).mockResolvedValue({
|
|
3115
|
+
lastModified: new Date(),
|
|
3116
|
+
etag: '"rollback-etag"',
|
|
3117
|
+
size: 42,
|
|
3118
|
+
});
|
|
3119
|
+
|
|
3120
|
+
const refusals: Array<{ path: string; reason: string }> = [];
|
|
3121
|
+
const result = await share({
|
|
3122
|
+
paths: [tmpDir],
|
|
3123
|
+
entityContext: makeEntityContext({
|
|
3124
|
+
uid: "prs_01HASSAANTESTUSER",
|
|
3125
|
+
slug: "__hq_personal_vault__",
|
|
3126
|
+
bucketName: "hq-vault-prs-01HASSAANTESTUSER",
|
|
3127
|
+
}),
|
|
3128
|
+
hqRoot: tmpDir,
|
|
3129
|
+
personalMode: true,
|
|
3130
|
+
skipUnchanged: true,
|
|
3131
|
+
propagateDeletes: true,
|
|
3132
|
+
propagateDeletePolicy: "owned-only",
|
|
3133
|
+
onEvent: (event) => {
|
|
3134
|
+
if (event.type === "delete-refused-stale-etag") {
|
|
3135
|
+
refusals.push({ path: event.path, reason: event.reason });
|
|
3136
|
+
}
|
|
3137
|
+
},
|
|
3138
|
+
});
|
|
3139
|
+
|
|
3140
|
+
expect(result.filesDeleted).toBe(0);
|
|
3141
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3142
|
+
// owned-only refuses on the intent gate, which sits BEFORE the HEAD
|
|
3143
|
+
// pass — so no currency check is ever issued. Asserting this also
|
|
3144
|
+
// keeps the mock above honest about never being consumed.
|
|
3145
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
3146
|
+
// Refused for want of an intent — NOT silently dropped, and not
|
|
3147
|
+
// refused for some incidental reason that would mask a regression.
|
|
3148
|
+
expect(refusals).toEqual([
|
|
3149
|
+
{ path: "personal/knowledge/gone.md", reason: "missing-delete-intent" },
|
|
3150
|
+
]);
|
|
3151
|
+
// Journal entry survives, so a later run (or a watcher-minted intent)
|
|
3152
|
+
// can still authorize the delete properly.
|
|
3153
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3154
|
+
expect(journal.files["personal/knowledge/gone.md"]).toBeDefined();
|
|
3155
|
+
} finally {
|
|
3156
|
+
vi.mocked(headRemoteFile).mockReset();
|
|
3157
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
3158
|
+
fs.rmSync(stateDirPersonal, { recursive: true, force: true });
|
|
3159
|
+
delete process.env.HQ_STATE_DIR;
|
|
3160
|
+
}
|
|
3161
|
+
},
|
|
3162
|
+
);
|
|
3163
|
+
|
|
3047
3164
|
it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
|
|
3048
3165
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3049
3166
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -3806,7 +3923,11 @@ describe("share", () => {
|
|
|
3806
3923
|
expect.objectContaining({
|
|
3807
3924
|
type: "delete-refused-stale-etag",
|
|
3808
3925
|
path: "racy.md",
|
|
3809
|
-
|
|
3926
|
+
// The file came back on disk between planning and applying. That is
|
|
3927
|
+
// now reported as what it is, rather than as an intent-version change:
|
|
3928
|
+
// the re-check is no longer nested inside the intent branch, so it
|
|
3929
|
+
// also covers etag-only candidates that carry no intent version.
|
|
3930
|
+
reason: "recreated-locally",
|
|
3810
3931
|
}),
|
|
3811
3932
|
);
|
|
3812
3933
|
});
|
|
@@ -3974,20 +4095,23 @@ describe("share", () => {
|
|
|
3974
4095
|
|
|
3975
4096
|
expect(result.filesDeleted).toBe(0);
|
|
3976
4097
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3977
|
-
// HEAD must NOT be called —
|
|
3978
|
-
//
|
|
4098
|
+
// HEAD must NOT be called — an entry with no recorded etag has nothing to
|
|
4099
|
+
// compare against, so currency-gated refuses it before any remote
|
|
4100
|
+
// operation. (Pre-etag-gated-deletes this was refused one gate earlier, for
|
|
4101
|
+
// want of a watcher intent; the outcome — nothing deleted, no remote call —
|
|
4102
|
+
// is deliberately identical.)
|
|
3979
4103
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
3980
4104
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
3981
4105
|
expect(refused).toMatchObject({
|
|
3982
|
-
journalEtag: "<
|
|
3983
|
-
reason: "
|
|
4106
|
+
journalEtag: "<legacy-no-etag>",
|
|
4107
|
+
reason: "legacy-no-etag",
|
|
3984
4108
|
});
|
|
3985
4109
|
expect(result.filesRefusedStale).toBe(1);
|
|
3986
4110
|
expect(result.pathResults).toContainEqual({
|
|
3987
4111
|
path: "legacy-no-etag.md",
|
|
3988
4112
|
status: "refused",
|
|
3989
4113
|
operation: "delete",
|
|
3990
|
-
reason: "
|
|
4114
|
+
reason: "legacy-no-etag",
|
|
3991
4115
|
});
|
|
3992
4116
|
});
|
|
3993
4117
|
|
|
@@ -4047,6 +4171,366 @@ describe("share", () => {
|
|
|
4047
4171
|
);
|
|
4048
4172
|
});
|
|
4049
4173
|
|
|
4174
|
+
// ── etag-gated deletes without a watcher ──────────────────────────────────
|
|
4175
|
+
//
|
|
4176
|
+
// A delete intent is minted ONLY by a live watcher at the instant it sees the
|
|
4177
|
+
// unlink. Three of the four ways HQ syncs never construct a watcher (outpost
|
|
4178
|
+
// and agent boxes run a one-shot runner, `hq sync now` is one-shot, and the
|
|
4179
|
+
// desktop drops the whole TreeWatcher when Instant-sync is off), so requiring
|
|
4180
|
+
// an intent made deletes structurally impossible there rather than merely
|
|
4181
|
+
// gated. currency-gated now authorizes on etag currency alone.
|
|
4182
|
+
//
|
|
4183
|
+
// The per-file question is unchanged: "has any peer touched this since we
|
|
4184
|
+
// journaled it". The whole-set question stays with the bulk-asymmetry
|
|
4185
|
+
// breaker, which these candidates still count toward — see the breaker
|
|
4186
|
+
// coverage above and policy `sync-delete-bulk-asymmetry-guard`.
|
|
4187
|
+
|
|
4188
|
+
it("currency-gated: propagates a delete with NO watcher intent when the remote etag still matches", async () => {
|
|
4189
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4190
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4191
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4192
|
+
fs.writeFileSync(
|
|
4193
|
+
journalPath,
|
|
4194
|
+
JSON.stringify({
|
|
4195
|
+
version: "1",
|
|
4196
|
+
lastSync: new Date().toISOString(),
|
|
4197
|
+
files: {
|
|
4198
|
+
"notes/gone.md": {
|
|
4199
|
+
hash: "h", size: 12, syncedAt: new Date().toISOString(),
|
|
4200
|
+
direction: "down",
|
|
4201
|
+
remoteEtag: "current-etag",
|
|
4202
|
+
kind: "file",
|
|
4203
|
+
// NO localDeleteIntent — deleted while no watcher was running.
|
|
4204
|
+
},
|
|
4205
|
+
},
|
|
4206
|
+
}),
|
|
4207
|
+
);
|
|
4208
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
4209
|
+
lastModified: new Date(),
|
|
4210
|
+
etag: '"current-etag"',
|
|
4211
|
+
size: 12,
|
|
4212
|
+
});
|
|
4213
|
+
|
|
4214
|
+
const result = await share({
|
|
4215
|
+
paths: [companyRoot],
|
|
4216
|
+
company: "acme",
|
|
4217
|
+
vaultConfig: mockConfig,
|
|
4218
|
+
hqRoot: tmpDir,
|
|
4219
|
+
skipUnchanged: true,
|
|
4220
|
+
propagateDeletes: true,
|
|
4221
|
+
propagateDeletePolicy: "currency-gated",
|
|
4222
|
+
});
|
|
4223
|
+
|
|
4224
|
+
expect(result.filesDeleted).toBe(1);
|
|
4225
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "notes/gone.md");
|
|
4226
|
+
});
|
|
4227
|
+
|
|
4228
|
+
it("currency-gated: refuses an intent-less delete when a peer has touched the remote", async () => {
|
|
4229
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4230
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4231
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4232
|
+
fs.writeFileSync(
|
|
4233
|
+
journalPath,
|
|
4234
|
+
JSON.stringify({
|
|
4235
|
+
version: "1",
|
|
4236
|
+
lastSync: new Date().toISOString(),
|
|
4237
|
+
files: {
|
|
4238
|
+
"notes/peer-edited.md": {
|
|
4239
|
+
hash: "h", size: 12, syncedAt: new Date().toISOString(),
|
|
4240
|
+
direction: "down",
|
|
4241
|
+
remoteEtag: "etag-we-journaled",
|
|
4242
|
+
kind: "file",
|
|
4243
|
+
},
|
|
4244
|
+
},
|
|
4245
|
+
}),
|
|
4246
|
+
);
|
|
4247
|
+
// A peer overwrote it since we journaled — their work must survive.
|
|
4248
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
4249
|
+
lastModified: new Date(),
|
|
4250
|
+
etag: '"a-different-etag"',
|
|
4251
|
+
size: 99,
|
|
4252
|
+
});
|
|
4253
|
+
|
|
4254
|
+
const events: Array<{ type: string; reason?: string }> = [];
|
|
4255
|
+
const result = await share({
|
|
4256
|
+
paths: [companyRoot],
|
|
4257
|
+
company: "acme",
|
|
4258
|
+
vaultConfig: mockConfig,
|
|
4259
|
+
hqRoot: tmpDir,
|
|
4260
|
+
skipUnchanged: true,
|
|
4261
|
+
propagateDeletes: true,
|
|
4262
|
+
propagateDeletePolicy: "currency-gated",
|
|
4263
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
4264
|
+
});
|
|
4265
|
+
|
|
4266
|
+
expect(result.filesDeleted).toBe(0);
|
|
4267
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4268
|
+
expect(events).toContainEqual(
|
|
4269
|
+
expect.objectContaining({ type: "delete-refused-stale-etag", reason: "stale-etag" }),
|
|
4270
|
+
);
|
|
4271
|
+
});
|
|
4272
|
+
|
|
4273
|
+
it("currency-gated: abandons an etag-only delete when the file reappears between plan and apply", async () => {
|
|
4274
|
+
// Guards the apply-time absence re-check in `executeDeletes`. That check
|
|
4275
|
+
// used to live INSIDE the intent-version branch, so it was skipped for any
|
|
4276
|
+
// item carrying `intentVersion: null`. That was harmless while null meant
|
|
4277
|
+
// only litter and decommission — but etag-only candidates now travel with
|
|
4278
|
+
// `intentVersion: null` too, and without the restructure a file recreated
|
|
4279
|
+
// during the sync would be deleted from the remote while present on disk.
|
|
4280
|
+
//
|
|
4281
|
+
// The recreate is staged from inside the delete-plan HEAD, which is the
|
|
4282
|
+
// real TOCTOU window: planning has already decided the file is absent, and
|
|
4283
|
+
// the apply loop has not yet run. The push plan is computed BEFORE the
|
|
4284
|
+
// delete plan, so the new file is not uploaded by this run — it is purely
|
|
4285
|
+
// a late arrival the delete loop must notice.
|
|
4286
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4287
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4288
|
+
const recreatedPath = path.join(companyRoot, "notes", "gone.md");
|
|
4289
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4290
|
+
fs.writeFileSync(
|
|
4291
|
+
journalPath,
|
|
4292
|
+
JSON.stringify({
|
|
4293
|
+
version: "1",
|
|
4294
|
+
lastSync: new Date().toISOString(),
|
|
4295
|
+
files: {
|
|
4296
|
+
"notes/gone.md": {
|
|
4297
|
+
hash: "h", size: 12, syncedAt: new Date().toISOString(),
|
|
4298
|
+
direction: "down",
|
|
4299
|
+
remoteEtag: "current-etag",
|
|
4300
|
+
kind: "file",
|
|
4301
|
+
// NO localDeleteIntent — this is an etag-only candidate.
|
|
4302
|
+
},
|
|
4303
|
+
},
|
|
4304
|
+
}),
|
|
4305
|
+
);
|
|
4306
|
+
vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
|
|
4307
|
+
// A pull leg (or the user) puts the file back, mid-sync.
|
|
4308
|
+
fs.mkdirSync(path.dirname(recreatedPath), { recursive: true });
|
|
4309
|
+
fs.writeFileSync(recreatedPath, "back again");
|
|
4310
|
+
return { lastModified: new Date(), etag: '"current-etag"', size: 12 };
|
|
4311
|
+
});
|
|
4312
|
+
|
|
4313
|
+
const events: Array<{ type: string; reason?: string }> = [];
|
|
4314
|
+
const result = await share({
|
|
4315
|
+
paths: [companyRoot],
|
|
4316
|
+
company: "acme",
|
|
4317
|
+
vaultConfig: mockConfig,
|
|
4318
|
+
hqRoot: tmpDir,
|
|
4319
|
+
skipUnchanged: true,
|
|
4320
|
+
propagateDeletes: true,
|
|
4321
|
+
propagateDeletePolicy: "currency-gated",
|
|
4322
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
4323
|
+
});
|
|
4324
|
+
|
|
4325
|
+
expect(result.filesDeleted).toBe(0);
|
|
4326
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4327
|
+
expect(events).toContainEqual(
|
|
4328
|
+
expect.objectContaining({
|
|
4329
|
+
type: "delete-refused-stale-etag",
|
|
4330
|
+
reason: "recreated-locally",
|
|
4331
|
+
}),
|
|
4332
|
+
);
|
|
4333
|
+
// The file that came back is still on disk — the whole point.
|
|
4334
|
+
expect(fs.existsSync(recreatedPath)).toBe(true);
|
|
4335
|
+
});
|
|
4336
|
+
|
|
4337
|
+
it("currency-gated: refuses an intent-less delete when the journal has no recorded etag", async () => {
|
|
4338
|
+
// Legacy journal entries (written before etag tracking) carry no
|
|
4339
|
+
// `remoteEtag`. Currency is the ONLY authorization an intent-less entry
|
|
4340
|
+
// has, so with nothing to compare there is nothing to authorize — refuse,
|
|
4341
|
+
// and keep the entry so a later sync that records an etag can re-evaluate.
|
|
4342
|
+
// No HEAD is issued: there is no etag to check it against.
|
|
4343
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4344
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4345
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4346
|
+
fs.writeFileSync(
|
|
4347
|
+
journalPath,
|
|
4348
|
+
JSON.stringify({
|
|
4349
|
+
version: "1",
|
|
4350
|
+
lastSync: new Date().toISOString(),
|
|
4351
|
+
files: {
|
|
4352
|
+
"notes/legacy.md": {
|
|
4353
|
+
hash: "h", size: 12, syncedAt: new Date().toISOString(),
|
|
4354
|
+
direction: "down",
|
|
4355
|
+
kind: "file",
|
|
4356
|
+
// No remoteEtag, no localDeleteIntent.
|
|
4357
|
+
},
|
|
4358
|
+
},
|
|
4359
|
+
}),
|
|
4360
|
+
);
|
|
4361
|
+
|
|
4362
|
+
const events: Array<{ type: string; reason?: string }> = [];
|
|
4363
|
+
const result = await share({
|
|
4364
|
+
paths: [companyRoot],
|
|
4365
|
+
company: "acme",
|
|
4366
|
+
vaultConfig: mockConfig,
|
|
4367
|
+
hqRoot: tmpDir,
|
|
4368
|
+
skipUnchanged: true,
|
|
4369
|
+
propagateDeletes: true,
|
|
4370
|
+
propagateDeletePolicy: "currency-gated",
|
|
4371
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
4372
|
+
});
|
|
4373
|
+
|
|
4374
|
+
expect(result.filesDeleted).toBe(0);
|
|
4375
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4376
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
4377
|
+
expect(events).toContainEqual(
|
|
4378
|
+
expect.objectContaining({
|
|
4379
|
+
type: "delete-refused-stale-etag",
|
|
4380
|
+
reason: "legacy-no-etag",
|
|
4381
|
+
}),
|
|
4382
|
+
);
|
|
4383
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
4384
|
+
expect(journal.files["notes/legacy.md"]).toBeDefined();
|
|
4385
|
+
});
|
|
4386
|
+
|
|
4387
|
+
it("owned-only still requires an intent — it is the documented rollback knob", async () => {
|
|
4388
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4389
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4390
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4391
|
+
fs.writeFileSync(
|
|
4392
|
+
journalPath,
|
|
4393
|
+
JSON.stringify({
|
|
4394
|
+
version: "1",
|
|
4395
|
+
lastSync: new Date().toISOString(),
|
|
4396
|
+
files: {
|
|
4397
|
+
"notes/gone.md": {
|
|
4398
|
+
hash: "h", size: 12, syncedAt: new Date().toISOString(),
|
|
4399
|
+
direction: "up",
|
|
4400
|
+
remoteEtag: "current-etag",
|
|
4401
|
+
kind: "file",
|
|
4402
|
+
},
|
|
4403
|
+
},
|
|
4404
|
+
}),
|
|
4405
|
+
);
|
|
4406
|
+
|
|
4407
|
+
const result = await share({
|
|
4408
|
+
paths: [companyRoot],
|
|
4409
|
+
company: "acme",
|
|
4410
|
+
vaultConfig: mockConfig,
|
|
4411
|
+
hqRoot: tmpDir,
|
|
4412
|
+
skipUnchanged: true,
|
|
4413
|
+
propagateDeletes: true,
|
|
4414
|
+
propagateDeletePolicy: "owned-only",
|
|
4415
|
+
});
|
|
4416
|
+
|
|
4417
|
+
expect(result.filesDeleted).toBe(0);
|
|
4418
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4419
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
4420
|
+
});
|
|
4421
|
+
|
|
4422
|
+
// THE load-bearing safety test for this change. An etag match cannot tell
|
|
4423
|
+
// "the user deleted this" from "the local mirror vanished" — in the steady
|
|
4424
|
+
// state nobody has overwritten anything, so a moved hqRoot / unmounted volume
|
|
4425
|
+
// / fresh clone presents mass-ENOENT with perfectly matching etags. That is
|
|
4426
|
+
// exactly how the 2026-05-25 indigo vault mass-delete destroyed 559 objects.
|
|
4427
|
+
// Relaxing the per-file gate must NOT weaken the whole-set guard.
|
|
4428
|
+
it("bulk-asymmetry breaker still refuses a mass intent-less disappearance", async () => {
|
|
4429
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4430
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4431
|
+
const files: Record<string, unknown> = {};
|
|
4432
|
+
// 40 of 100 entries missing locally = 40%, far past the 10% threshold.
|
|
4433
|
+
for (let i = 0; i < 100; i++) {
|
|
4434
|
+
const present = i >= 40;
|
|
4435
|
+
const rel = `docs/f${i}.md`;
|
|
4436
|
+
if (present) {
|
|
4437
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
4438
|
+
fs.writeFileSync(path.join(companyRoot, rel), "x");
|
|
4439
|
+
}
|
|
4440
|
+
files[rel] = {
|
|
4441
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
4442
|
+
direction: "down",
|
|
4443
|
+
remoteEtag: `etag-${i}`,
|
|
4444
|
+
kind: "file",
|
|
4445
|
+
};
|
|
4446
|
+
}
|
|
4447
|
+
fs.writeFileSync(
|
|
4448
|
+
path.join(stateDir, "sync-journal.acme.json"),
|
|
4449
|
+
JSON.stringify({ version: "1", lastSync: new Date().toISOString(), files }),
|
|
4450
|
+
);
|
|
4451
|
+
|
|
4452
|
+
const result = await share({
|
|
4453
|
+
paths: [companyRoot],
|
|
4454
|
+
company: "acme",
|
|
4455
|
+
vaultConfig: mockConfig,
|
|
4456
|
+
hqRoot: tmpDir,
|
|
4457
|
+
skipUnchanged: true,
|
|
4458
|
+
propagateDeletes: true,
|
|
4459
|
+
propagateDeletePolicy: "currency-gated",
|
|
4460
|
+
});
|
|
4461
|
+
|
|
4462
|
+
expect(result.filesDeleted).toBe(0);
|
|
4463
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4464
|
+
// All 40 absent entries are refused as one set. (No assertion on
|
|
4465
|
+
// headRemoteFile here: the push leg HEADs the 60 files that ARE present,
|
|
4466
|
+
// so a global call-count says nothing about the delete plan.)
|
|
4467
|
+
expect(result.filesRefusedStale).toBe(40);
|
|
4468
|
+
});
|
|
4469
|
+
|
|
4470
|
+
// Regression: moving a company vault from syncMode 'all' to 'shared'.
|
|
4471
|
+
// `hq sync narrow --apply` / scope-shrink calls `tombstoneEntry`, which
|
|
4472
|
+
// deliberately KEEPS hash, direction and remoteEtag so a recovery flow can
|
|
4473
|
+
// see what was pruned — and it removes the file locally. Every such entry
|
|
4474
|
+
// therefore looks exactly like an etag-current intent-less delete candidate.
|
|
4475
|
+
// Propagating those would delete OTHER members' files out of the shared
|
|
4476
|
+
// company vault because one member narrowed their own scope.
|
|
4477
|
+
//
|
|
4478
|
+
// Deliberately asserted with NO prefixSet passed: the scope filter used to be
|
|
4479
|
+
// the only thing preventing this, and that made an implicit dependency
|
|
4480
|
+
// load-bearing for a destructive operation.
|
|
4481
|
+
it("all -> shared: scope-shrink tombstones are never propagated as remote deletes", async () => {
|
|
4482
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4483
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4484
|
+
const now = new Date().toISOString();
|
|
4485
|
+
const shrunk = (reason: string) => ({
|
|
4486
|
+
hash: "h", size: 3, syncedAt: now,
|
|
4487
|
+
direction: "down",
|
|
4488
|
+
remoteEtag: "still-current",
|
|
4489
|
+
kind: "file",
|
|
4490
|
+
removedAt: now,
|
|
4491
|
+
removedReason: reason,
|
|
4492
|
+
});
|
|
4493
|
+
fs.writeFileSync(
|
|
4494
|
+
path.join(stateDir, "sync-journal.acme.json"),
|
|
4495
|
+
JSON.stringify({
|
|
4496
|
+
version: "2",
|
|
4497
|
+
lastSync: now,
|
|
4498
|
+
files: {
|
|
4499
|
+
"teammate/a.md": shrunk("scope_shrink"),
|
|
4500
|
+
"teammate/b.md": shrunk("narrow_apply"),
|
|
4501
|
+
"teammate/c.md": shrunk("manual"),
|
|
4502
|
+
},
|
|
4503
|
+
pulls: [],
|
|
4504
|
+
}),
|
|
4505
|
+
);
|
|
4506
|
+
|
|
4507
|
+
// The remote is ALIVE and still exactly what the journal recorded. Without
|
|
4508
|
+
// this the keys would 404 into `toTombstone` and the test would pass for
|
|
4509
|
+
// the wrong reason — verified by disabling the guard and watching it still
|
|
4510
|
+
// go green.
|
|
4511
|
+
vi.mocked(headRemoteFile).mockResolvedValue({
|
|
4512
|
+
lastModified: new Date(),
|
|
4513
|
+
etag: '"still-current"',
|
|
4514
|
+
size: 3,
|
|
4515
|
+
});
|
|
4516
|
+
|
|
4517
|
+
const result = await share({
|
|
4518
|
+
paths: [companyRoot],
|
|
4519
|
+
company: "acme",
|
|
4520
|
+
vaultConfig: mockConfig,
|
|
4521
|
+
hqRoot: tmpDir,
|
|
4522
|
+
skipUnchanged: true,
|
|
4523
|
+
propagateDeletes: true,
|
|
4524
|
+
propagateDeletePolicy: "currency-gated",
|
|
4525
|
+
});
|
|
4526
|
+
|
|
4527
|
+
expect(result.filesDeleted).toBe(0);
|
|
4528
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
4529
|
+
// Not refusals either — they are simply not delete candidates at all, so
|
|
4530
|
+
// they never enter the plan and never count toward the breaker ratio.
|
|
4531
|
+
expect(result.filesRefusedStale).toBe(0);
|
|
4532
|
+
});
|
|
4533
|
+
|
|
4050
4534
|
// ── Conflict-mirror exclusion (ephemeral pattern) ──────────────────────────
|
|
4051
4535
|
//
|
|
4052
4536
|
// Conflict mirrors (`*.conflict-<ISO>-<machineHash>.<ext>`) are local-only
|
|
@@ -7041,6 +7525,78 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
7041
7525
|
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
7042
7526
|
});
|
|
7043
7527
|
|
|
7528
|
+
it("never plans an upload for macOS Finder Icon\\r files — silent skip, valid files still ship", async () => {
|
|
7529
|
+
// The incident: Finder writes an `Icon\r` file into every directory it
|
|
7530
|
+
// renders a custom icon for, and a folder-level cloud-sync agent spreads
|
|
7531
|
+
// them tree-wide. A CR is a control character, so validateVaultUploadKey
|
|
7532
|
+
// throws INVALID_KEY_CONTROL_CHARS inside executeUploads; that throw lands
|
|
7533
|
+
// in the generic catch and emits type:"error". errors[] non-empty makes
|
|
7534
|
+
// the runner return PARTIAL_SYNC_EXIT, so ONE such file marks EVERY sync
|
|
7535
|
+
// pass failed forever. On the reporting machine there were 100+ per run.
|
|
7536
|
+
//
|
|
7537
|
+
// They are excluded during the walk, like .DS_Store, so they never reach
|
|
7538
|
+
// the planner at all — and, being expected noise, without a per-file
|
|
7539
|
+
// "EXCLUDED from sync" warning.
|
|
7540
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
7541
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
7542
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
|
|
7543
|
+
fs.writeFileSync(path.join(companyRoot, "Icon\r"), "finder cruft");
|
|
7544
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "Icon\r"), "finder cruft");
|
|
7545
|
+
|
|
7546
|
+
const events: Array<{ type?: string; path?: string }> = [];
|
|
7547
|
+
const result = await share({
|
|
7548
|
+
paths: [companyRoot],
|
|
7549
|
+
company: "acme",
|
|
7550
|
+
vaultConfig: mockConfig,
|
|
7551
|
+
hqRoot: tmpDir,
|
|
7552
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
7553
|
+
});
|
|
7554
|
+
|
|
7555
|
+
expect(result.aborted).toBe(false);
|
|
7556
|
+
const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
7557
|
+
expect(uploadedKeys).toEqual(["docs/good.md"]);
|
|
7558
|
+
expect(result.filesUploaded).toBe(1);
|
|
7559
|
+
// The failure class this guards: an error event is what flips the runner
|
|
7560
|
+
// pass to exit 2.
|
|
7561
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
7562
|
+
// Expected OS noise — excluded without the "review in case this is
|
|
7563
|
+
// unintended" warning that real content would earn.
|
|
7564
|
+
expect(events.some((e) => e.type === "ignore-excluded")).toBe(false);
|
|
7565
|
+
});
|
|
7566
|
+
|
|
7567
|
+
it("refuses any other control-character key up front — skip event, never an error", async () => {
|
|
7568
|
+
// Belt-and-suspenders for control-char paths that are not the Finder icon
|
|
7569
|
+
// spelling, and for callers that bypass the walker. Symmetric with the
|
|
7570
|
+
// pull leg, which has classified such keys since the same incident.
|
|
7571
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
7572
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
7573
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
|
|
7574
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "bad\u0001name.md"), "bad");
|
|
7575
|
+
|
|
7576
|
+
const events: Array<{ type?: string; path?: string; errorCode?: string }> = [];
|
|
7577
|
+
const result = await share({
|
|
7578
|
+
paths: [companyRoot],
|
|
7579
|
+
company: "acme",
|
|
7580
|
+
vaultConfig: mockConfig,
|
|
7581
|
+
hqRoot: tmpDir,
|
|
7582
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
7583
|
+
});
|
|
7584
|
+
|
|
7585
|
+
expect(result.aborted).toBe(false);
|
|
7586
|
+
const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
7587
|
+
expect(uploadedKeys).toEqual(["docs/good.md"]);
|
|
7588
|
+
|
|
7589
|
+
const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
|
|
7590
|
+
expect(skipEvents).toEqual([
|
|
7591
|
+
{
|
|
7592
|
+
type: "skip-invalid-scoped-key",
|
|
7593
|
+
path: "docs/bad\u0001name.md",
|
|
7594
|
+
errorCode: "INVALID_KEY_CONTROL_CHARS",
|
|
7595
|
+
},
|
|
7596
|
+
]);
|
|
7597
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
7598
|
+
});
|
|
7599
|
+
|
|
7044
7600
|
it("tombstones (journal drop, no remote HEAD/DELETE) a companies/ journal key in company mode instead of re-uploading", async () => {
|
|
7045
7601
|
// Delete-propagation on a poisoned journal entry: HEAD/DeleteObject on a
|
|
7046
7602
|
// companies/-prefixed key would be rejected by the presign transport's
|
|
@@ -7199,3 +7755,89 @@ describe("isForbiddenCompanyVaultKey (company-vault double-scope contract)", ()
|
|
|
7199
7755
|
expect(isForbiddenCompanyVaultKey(key, personalMode)).toBe(expected);
|
|
7200
7756
|
});
|
|
7201
7757
|
});
|
|
7758
|
+
|
|
7759
|
+
// ── delete-refusal rendering ───────────────────────────────────────────────
|
|
7760
|
+
//
|
|
7761
|
+
// Every refusal cause shares ONE event type (`delete-refused-stale-etag`),
|
|
7762
|
+
// with the real cause carried in `reason`. Before this cover, the renderer
|
|
7763
|
+
// only branched on two of the five causes and the rest fell through to the
|
|
7764
|
+
// stale-etag wording — printing a real journal etag next to a
|
|
7765
|
+
// `<not-checked>` remote for a comparison that never happened. Operators
|
|
7766
|
+
// (including a customer bug report on 2026-08-04, and the investigation into
|
|
7767
|
+
// it) read that as an etag/currency defect and chased the wrong root cause.
|
|
7768
|
+
// Pin all five so no cause can silently fall through to etag wording again.
|
|
7769
|
+
describe("defaultConsoleLogger — delete refusal wording per reason", () => {
|
|
7770
|
+
const render = (reason: string): string => {
|
|
7771
|
+
const lines: string[] = [];
|
|
7772
|
+
const spy = vi.spyOn(console, "error").mockImplementation((msg?: unknown) => {
|
|
7773
|
+
lines.push(String(msg));
|
|
7774
|
+
});
|
|
7775
|
+
try {
|
|
7776
|
+
shareTesting.defaultConsoleLogger({
|
|
7777
|
+
type: "delete-refused-stale-etag",
|
|
7778
|
+
path: "knowledge/example.md",
|
|
7779
|
+
// A REAL etag, as the engine emits whenever the entry has one — the
|
|
7780
|
+
// `<missing-delete-intent>` placeholder only appears for entries that
|
|
7781
|
+
// never had an etag, which is the rare case.
|
|
7782
|
+
journalEtag: "0156114498af2c8939c50d7ef0d3a0b1",
|
|
7783
|
+
remoteEtag: "<not-checked>",
|
|
7784
|
+
reason,
|
|
7785
|
+
} as never);
|
|
7786
|
+
} finally {
|
|
7787
|
+
spy.mockRestore();
|
|
7788
|
+
}
|
|
7789
|
+
return lines.join("\n");
|
|
7790
|
+
};
|
|
7791
|
+
|
|
7792
|
+
it("names the missing authorization instead of blaming the etag", () => {
|
|
7793
|
+
const out = render("missing-delete-intent");
|
|
7794
|
+
expect(out).toContain("no-delete-authorization");
|
|
7795
|
+
expect(out).toContain("watcher did not observe this removal");
|
|
7796
|
+
// The specific regression: neither etag is meaningful, so neither may be
|
|
7797
|
+
// shown, and the stale-etag wording must not appear.
|
|
7798
|
+
expect(out).not.toContain("stale-etag");
|
|
7799
|
+
expect(out).not.toContain("<not-checked>");
|
|
7800
|
+
expect(out).not.toContain("0156114498af2c8939c50d7ef0d3a0b1");
|
|
7801
|
+
});
|
|
7802
|
+
|
|
7803
|
+
it("names local divergence instead of blaming the etag", () => {
|
|
7804
|
+
const out = render("divergent-local");
|
|
7805
|
+
expect(out).toContain("divergent-local");
|
|
7806
|
+
expect(out).not.toContain("stale-etag");
|
|
7807
|
+
expect(out).not.toContain("<not-checked>");
|
|
7808
|
+
});
|
|
7809
|
+
|
|
7810
|
+
it("still explains legacy entries that predate etag tracking", () => {
|
|
7811
|
+
const out = render("legacy-no-etag");
|
|
7812
|
+
expect(out).toContain("no-etag-on-record");
|
|
7813
|
+
expect(out).not.toContain("stale-etag");
|
|
7814
|
+
});
|
|
7815
|
+
|
|
7816
|
+
it("still keeps bulk-asymmetry per-key lines compact", () => {
|
|
7817
|
+
const out = render("bulk-asymmetry");
|
|
7818
|
+
expect(out).toContain("bulk-asymmetry refusal");
|
|
7819
|
+
expect(out).not.toContain("stale-etag");
|
|
7820
|
+
});
|
|
7821
|
+
|
|
7822
|
+
it("still reports a genuine etag mismatch with both etags", () => {
|
|
7823
|
+
const lines: string[] = [];
|
|
7824
|
+
const spy = vi.spyOn(console, "error").mockImplementation((msg?: unknown) => {
|
|
7825
|
+
lines.push(String(msg));
|
|
7826
|
+
});
|
|
7827
|
+
try {
|
|
7828
|
+
shareTesting.defaultConsoleLogger({
|
|
7829
|
+
type: "delete-refused-stale-etag",
|
|
7830
|
+
path: "knowledge/example.md",
|
|
7831
|
+
journalEtag: "aaa111",
|
|
7832
|
+
remoteEtag: "bbb222",
|
|
7833
|
+
reason: "stale-etag",
|
|
7834
|
+
} as never);
|
|
7835
|
+
} finally {
|
|
7836
|
+
spy.mockRestore();
|
|
7837
|
+
}
|
|
7838
|
+
const out = lines.join("\n");
|
|
7839
|
+
expect(out).toContain("stale-etag");
|
|
7840
|
+
expect(out).toContain("aaa111");
|
|
7841
|
+
expect(out).toContain("bbb222");
|
|
7842
|
+
});
|
|
7843
|
+
});
|