Skip to content

Commit dd3ea16

Browse files
claude[bot]claude
andauthored
fix(service-storage): the storage/test probe cleans up in the store it wrote to (#13921)
* fix(service-storage): clean up the storage/test probe in the store it wrote to The `settings.registerAction('storage', 'test', …)` handler builds a temporary adapter when the form posts values, so an operator can validate unsaved credentials, and probes that adapter instead of the persisted one. Two paths left the probe object behind. Defect 1: `let target` was declared inside the `try`, so at the `catch` the cleanup could only name `proxy` — the persisted adapter — while the probe had written to the temporary one. Deleting an absent key is a no-op on both shipped adapters, so the wrong-store delete "succeeded" and nothing looked wrong. The declaration is now resolved before the try, which makes "the cleanup names the store the upload named" true by construction, and settles the half-built-adapter question: a construction failure returns before anything is written, so no cleanup is attempted on that path. Defect 2: the content-mismatch `return` walked past the delete on the next line, after an upload that had by definition already succeeded — a guaranteed leak on the one path that only fires when the adapter is misbehaving. It now runs the same best-effort cleanup, which carries #12981 batch 7's refusal warning to this path for the first time. What the probe reports to the operator is unchanged on every path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * chore(changeset): the storage/test probe cleans up in the store it wrote to Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0fb8760 commit dd3ea16

3 files changed

Lines changed: 470 additions & 36 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
---
4+
5+
fix(service-storage): the `storage/test` probe cleans up in the store it wrote to (#13726)
6+
7+
The settings action behind the storage screen's "Test" button writes a small
8+
`__objectstack_probe__/…` object, reads it back, and deletes it. When the form
9+
posts values it builds a **temporary** adapter first, so an operator can
10+
validate credentials that are typed but not yet saved, and probes that adapter
11+
instead of the persisted one. Two paths left the probe object behind in the
12+
customer's bucket.
13+
14+
- **The failure cleanup deleted from the wrong store.** `target` was declared
15+
inside the `try`, so the `catch` could only name the persisted adapter — even
16+
when the probe had written to the temporary one, which is the whole case the
17+
temporary adapter exists for. Deleting a key that was never there is a no-op
18+
on both shipped adapters, so the wrong-store delete "succeeded" and nothing
19+
looked wrong. The adapter is now resolved before that `try`, which makes the
20+
cleanup name the store the upload named by construction.
21+
- **The content-mismatch return path cleaned up nothing.** Reaching that
22+
comparison means the upload already succeeded, so the object is definitely
23+
there — and the `return` walked straight past the delete on the next line. It
24+
now runs the same best-effort cleanup as the failure path, which also carries
25+
the "cleanup refused — here is the key it left behind" warning to this path
26+
for the first time.
27+
28+
One stray object accrued per failed test, under a name minted per call from a
29+
timestamp and a random suffix and recorded nowhere, in whichever store the probe
30+
actually wrote to — a button whose entire purpose is to be pressed repeatedly
31+
while credentials are being got right.
32+
33+
An adapter that fails to *construct* still attempts no cleanup: nothing has been
34+
written at that point, and the delete would have to name an adapter that does
35+
not exist. What the probe reports to the operator is unchanged on every path.
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13726] The `storage/test` probe cleans up in the store it WROTE to.
5+
*
6+
* The handler exists so an operator can validate credentials that are typed
7+
* into the form but not yet saved, so when the form posts values it builds a
8+
* TEMPORARY adapter and probes that instead of the persisted one. Two paths
9+
* left the probe object behind:
10+
*
11+
* 1. the failure cleanup deleted from `proxy` — the PERSISTED adapter —
12+
* while the probe had written to the temporary one. Deleting an absent
13+
* key is a no-op on both shipped adapters, so the wrong-store delete
14+
* "succeeded" and nothing looked wrong;
15+
* 2. the content-mismatch `return` walked straight past the delete on the
16+
* next line, after an upload that by definition had already succeeded —
17+
* a guaranteed leak rather than a best-effort one.
18+
*
19+
* ⚠️ Both credential cases are pinned SEPARATELY, and only one of the two
20+
* directions can catch defect 1: with no overrides `target === proxy`, so the
21+
* old code deleted from the right store by accident and a single-direction pin
22+
* passes on the defect. The case that matters is a failed probe WITH edited
23+
* credentials.
24+
*
25+
* ## How a failure is induced
26+
*
27+
* Every store below is a REAL `LocalStorageAdapter` on its own directory, with
28+
* exactly one verb overridden (`Object.create`, so every other member stays the
29+
* real one). PUT allowed / GET refused is the ordinary shape of a half-right
30+
* credential, and it is what makes the leak observable: the bytes really land
31+
* on disk, and then the probe really fails. The assertions are therefore about
32+
* the FILESYSTEM — what is left under `__objectstack_probe__/` when the handler
33+
* returns — not about a call counter that could agree with a store nobody
34+
* wrote to.
35+
*
36+
* ⚠️ Two cases below are CONTROLS, not pins, and are labelled: they are green
37+
* in both directions by construction (the pre-repair code already deleted from
38+
* the right store when there were no overrides, and already attempted no
39+
* cleanup when the adapter failed to build). They are here so the pins cannot
40+
* pass on a handler that deletes from everything, or on one that cleans up
41+
* after a store it never wrote to. ⛔ Not ablation evidence.
42+
*/
43+
44+
import { describe, it, expect } from 'vitest';
45+
import { promises as fs } from 'node:fs';
46+
import { join } from 'node:path';
47+
import { tmpdir } from 'node:os';
48+
import type { IStorageService } from '@objectstack/spec/contracts';
49+
import { LocalStorageAdapter } from './local-storage-adapter.js';
50+
import { StorageServicePlugin } from './storage-service-plugin.js';
51+
import type { SwappableStorageService } from './swappable-storage-service.js';
52+
53+
const PROBE_PREFIX = '__objectstack_probe__';
54+
const CLEANUP_HEADLINE = 'was NOT removed';
55+
const MISMATCH_MESSAGE = 'Probe download did not match upload.';
56+
57+
function makeCtx() {
58+
const services = new Map<string, unknown>();
59+
const hooks: Array<() => Promise<void> | void> = [];
60+
const logs: { info: string[]; warn: string[]; error: string[] } = { info: [], warn: [], error: [] };
61+
const ctx: any = {
62+
logger: {
63+
info: (m: string) => { logs.info.push(String(m)); },
64+
warn: (m: string) => { logs.warn.push(String(m)); },
65+
error: (m: string) => { logs.error.push(String(m)); },
66+
},
67+
_logs: logs,
68+
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
69+
getService: <T>(name: string): T => {
70+
const s = services.get(name);
71+
if (!s) throw new Error(`service '${name}' not registered`);
72+
return s as T;
73+
},
74+
hook: (event: string, fn: () => Promise<void> | void) => {
75+
if (event === 'kernel:ready') hooks.push(fn);
76+
},
77+
_flushReady: async () => { for (const h of hooks) await h(); },
78+
};
79+
return ctx;
80+
}
81+
82+
/** A settings service that keeps the registered action so a test can run it. */
83+
function makeFakeSettings() {
84+
const actions = new Map<string, (input: unknown) => Promise<any>>();
85+
return {
86+
createClient: (_ns: string) => ({}),
87+
getNamespace: async (_ns: string) => ({ values: {} }),
88+
subscribe: (_ns: string, _fn: () => void) => {},
89+
registerAction: (ns: string, id: string, fn: (input: unknown) => Promise<any>) => {
90+
actions.set(`${ns}/${id}`, fn);
91+
},
92+
_runAction: async (ns: string, id: string, input: unknown) => {
93+
const fn = actions.get(`${ns}/${id}`);
94+
if (!fn) throw new Error(`no action ${ns}/${id}`);
95+
return await fn(input);
96+
},
97+
};
98+
}
99+
100+
async function tmpRoot(prefix: string): Promise<string> {
101+
return await fs.mkdtemp(join(tmpdir(), prefix));
102+
}
103+
104+
/** A real local adapter rooted at `rootDir` — the store, not a stand-in. */
105+
function localAdapterAt(rootDir: string): IStorageService {
106+
return new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' });
107+
}
108+
109+
/**
110+
* The real store with ONE verb replaced. `Object.create` rather than a
111+
* hand-written stand-in, deliberately: every member this test does not name
112+
* stays the adapter's own, so a probe object written through the wrapper is a
113+
* real file and the assertions can read the filesystem.
114+
*/
115+
function withRefusedDownload(real: IStorageService, message: string): IStorageService {
116+
const store: IStorageService = Object.create(real);
117+
store.download = async () => { throw new Error(message); };
118+
return store;
119+
}
120+
121+
function withMangledDownload(real: IStorageService): IStorageService {
122+
const store: IStorageService = Object.create(real);
123+
store.download = async () => Buffer.from('not-what-was-uploaded', 'utf-8');
124+
return store;
125+
}
126+
127+
function withRefusedDelete(real: IStorageService, message: string): IStorageService {
128+
const store: IStorageService = Object.create(real);
129+
store.delete = async () => { throw new Error(message); };
130+
return store;
131+
}
132+
133+
/** The real store, recording every key it is ASKED to delete. */
134+
function withCountedDeletes(real: IStorageService): { store: IStorageService; deleted: string[] } {
135+
const deleted: string[] = [];
136+
const store: IStorageService = Object.create(real);
137+
store.delete = async (key: string) => { deleted.push(key); await real.delete(key); };
138+
return { store, deleted };
139+
}
140+
141+
/** Probe objects currently on disk under `rootDir`. */
142+
async function probeObjectsIn(rootDir: string): Promise<string[]> {
143+
try {
144+
return (await fs.readdir(join(rootDir, PROBE_PREFIX))).sort();
145+
} catch (err: any) {
146+
if (err?.code === 'ENOENT') return [];
147+
throw err;
148+
}
149+
}
150+
151+
/**
152+
* The factory the handler calls when the form posts values, substituted so a
153+
* test can hand it a store whose behaviour it controls.
154+
*
155+
* Named as a seam rather than reached for with `as any`: `buildAdapterFromValues`
156+
* itself is covered by its own tests (`storage-service-plugin.metrics.test.ts`
157+
* and the S3-misconfiguration case in `storage-service-plugin.test.ts`), and
158+
* what is under test HERE is which store the handler cleans up in — not how the
159+
* temporary one is constructed.
160+
*/
161+
interface AdapterFactorySeam {
162+
buildAdapterFromValues(values: Record<string, unknown>): Promise<IStorageService>;
163+
}
164+
165+
function substituteAdapterFactory(
166+
plugin: StorageServicePlugin,
167+
temporary: IStorageService,
168+
): Array<Record<string, unknown>> {
169+
const calls: Array<Record<string, unknown>> = [];
170+
const seam = plugin as unknown as AdapterFactorySeam;
171+
seam.buildAdapterFromValues = async (values: Record<string, unknown>) => {
172+
calls.push(values);
173+
return temporary;
174+
};
175+
return calls;
176+
}
177+
178+
async function bootedPlugin(persistedRoot: string) {
179+
const plugin = new StorageServicePlugin({
180+
adapter: 'local',
181+
local: { rootDir: persistedRoot },
182+
registerRoutes: false,
183+
});
184+
const ctx = makeCtx();
185+
const settings = makeFakeSettings();
186+
ctx.registerService('settings', settings);
187+
await plugin.init(ctx);
188+
await plugin.start(ctx);
189+
await ctx._flushReady();
190+
// Typed here rather than at the call site: the fake ctx is `any`, so
191+
// `ctx.getService<T>(…)` would be a type argument on an untyped call.
192+
const storage: SwappableStorageService = ctx.getService('storage');
193+
return { plugin, ctx, settings, storage };
194+
}
195+
196+
/** The shape the settings form posts when the operator edited the fields. */
197+
function editedCredentials(localRoot: string) {
198+
return { values: {}, payload: { values: { adapter: 'local', local_root: localRoot } } };
199+
}
200+
201+
describe('#13726 defect 1 — the failure cleanup names the store the probe wrote to', () => {
202+
it('a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
203+
const persistedRoot = await tmpRoot('oss-13726-persisted-');
204+
const temporaryRoot = await tmpRoot('oss-13726-temporary-');
205+
const { plugin, ctx, settings, storage } = await bootedPlugin(persistedRoot);
206+
207+
// The persisted store, watching for deletes it should never be asked for.
208+
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
209+
storage.swap(persisted.store);
210+
211+
// The store the edited credentials build: a different directory, and a GET
212+
// that is refused after the PUT has already landed the bytes.
213+
const temporary = withRefusedDownload(
214+
localAdapterAt(temporaryRoot),
215+
'download refused: GET denied for this key',
216+
);
217+
const calls = substituteAdapterFactory(plugin, temporary);
218+
219+
const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));
220+
221+
// The temporary-adapter branch really ran — without it this pin would be
222+
// measuring the no-overrides case under an overrides-shaped name.
223+
expect(calls).toHaveLength(1);
224+
expect(calls[0]).toMatchObject({ adapter: 'local', local_root: temporaryRoot });
225+
226+
// THE PIN: the store the probe wrote to holds nothing afterwards.
227+
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);
228+
229+
// …and the persisted store was neither written to nor asked to delete: the
230+
// old cleanup issued a delete here, against a key this store never held.
231+
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
232+
expect(persisted.deleted).toEqual([]);
233+
234+
// ⛔ What the operator is told is unchanged by the repair.
235+
expect(result.ok).toBe(false);
236+
expect(result.severity).toBe('error');
237+
expect(result.message).toBe('download refused: GET denied for this key');
238+
// The cleanup succeeded, so #12981's refusal line stays quiet.
239+
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
240+
});
241+
242+
// ⚠️ CONTROL, not a pin — green in BOTH directions. With no overrides
243+
// `target === proxy`, so the pre-repair `proxy.delete` was already the right
244+
// store. It is here so the pin above cannot pass on a handler that stopped
245+
// cleaning up the persisted store when it repaired the temporary one.
246+
it('CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
247+
const persistedRoot = await tmpRoot('oss-13726-persisted-only-');
248+
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);
249+
250+
storage.swap(withRefusedDownload(localAdapterAt(persistedRoot), 'download refused: GET denied'));
251+
252+
const result = await settings._runAction('storage', 'test', { values: {} });
253+
254+
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
255+
expect(result.ok).toBe(false);
256+
expect(result.message).toBe('download refused: GET denied');
257+
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
258+
});
259+
260+
// ⚠️ CONTROL, not a pin — green in both directions. It pins the judgement
261+
// this card turns on: `target` is resolved BEFORE the try whose catch cleans
262+
// up, so the catch can never see a half-built adapter or the adapter whose
263+
// construction threw. A build failure returns before anything is written, and
264+
// the handler must therefore attempt NO cleanup — not against the persisted
265+
// store (nothing was written there) and not against the adapter that failed
266+
// to construct (there is none). Uses the REAL factory, which rejects an S3
267+
// configuration with no bucket or region.
268+
it('CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere', async () => {
269+
const persistedRoot = await tmpRoot('oss-13726-nobuild-');
270+
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);
271+
272+
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
273+
storage.swap(persisted.store);
274+
275+
const result = await settings._runAction('storage', 'test', {
276+
values: {},
277+
payload: { values: { adapter: 's3', s3_bucket: '', s3_region: '' } },
278+
});
279+
280+
expect(result.ok).toBe(false);
281+
expect(result.severity).toBe('error');
282+
expect(result.message).toContain('S3 adapter requires s3_bucket and s3_region');
283+
expect(persisted.deleted).toEqual([]);
284+
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
285+
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
286+
});
287+
});
288+
289+
describe('#13726 defect 2 — the content-mismatch path cleans up', () => {
290+
it('a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
291+
const persistedRoot = await tmpRoot('oss-13726-mismatch-persisted-');
292+
const temporaryRoot = await tmpRoot('oss-13726-mismatch-temporary-');
293+
const { plugin, settings, storage } = await bootedPlugin(persistedRoot);
294+
295+
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
296+
storage.swap(persisted.store);
297+
298+
// The upload SUCCEEDS here — that is the precondition for reaching the
299+
// comparison at all — and the download answers other bytes.
300+
const temporary = withMangledDownload(localAdapterAt(temporaryRoot));
301+
substituteAdapterFactory(plugin, temporary);
302+
303+
const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));
304+
305+
// THE PIN: the upload landed, and nothing is left of it.
306+
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);
307+
expect(persisted.deleted).toEqual([]);
308+
309+
// ⛔ The message the operator reads is unchanged.
310+
expect(result.ok).toBe(false);
311+
expect(result.severity).toBe('error');
312+
expect(result.message).toBe(MISMATCH_MESSAGE);
313+
});
314+
315+
it('a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
316+
const persistedRoot = await tmpRoot('oss-13726-mismatch-only-');
317+
const { settings, storage } = await bootedPlugin(persistedRoot);
318+
319+
const counted = withCountedDeletes(localAdapterAt(persistedRoot));
320+
storage.swap(withMangledDownload(counted.store));
321+
322+
const result = await settings._runAction('storage', 'test', { values: {} });
323+
324+
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
325+
expect(counted.deleted).toHaveLength(1);
326+
expect(counted.deleted[0]).toContain(`${PROBE_PREFIX}/`);
327+
expect(result.ok).toBe(false);
328+
expect(result.message).toBe(MISMATCH_MESSAGE);
329+
});
330+
331+
// #12981 batch 7 made a REFUSED cleanup name the key it left behind. That
332+
// repair could not reach this path, because no cleanup was attempted on it.
333+
// Now that one is, the refusal is reported here too — the same line, from the
334+
// same helper — and the probe's own verdict is still the one returned.
335+
it('a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch', async () => {
336+
const persistedRoot = await tmpRoot('oss-13726-mismatch-refused-');
337+
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);
338+
339+
storage.swap(
340+
withRefusedDelete(
341+
withMangledDownload(localAdapterAt(persistedRoot)),
342+
'delete refused: bucket is read-only',
343+
),
344+
);
345+
346+
const result = await settings._runAction('storage', 'test', { values: {} });
347+
348+
const warned = ctx._logs.warn.filter((l: string) => l.includes(CLEANUP_HEADLINE));
349+
expect(warned).toHaveLength(1);
350+
expect(warned[0]).toContain(`${PROBE_PREFIX}/`);
351+
expect(warned[0]).toContain('delete refused: bucket is read-only');
352+
353+
// The object really is still there — the warning is not decorative.
354+
expect(await probeObjectsIn(persistedRoot)).toHaveLength(1);
355+
356+
// ⛔ The probe's own result is untouched by the cleanup's failure.
357+
expect(result.ok).toBe(false);
358+
expect(result.severity).toBe('error');
359+
expect(result.message).toBe(MISMATCH_MESSAGE);
360+
});
361+
});

0 commit comments

Comments
 (0)