Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/storage-probe-cleanup-target-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): the `storage/test` probe cleans up in the store it wrote to (#13726)

The settings action behind the storage screen's "Test" button writes a small
`__objectstack_probe__/…` object, reads it back, and deletes it. When the form
posts values it builds a **temporary** adapter first, so an operator can
validate credentials that are typed but not yet saved, and probes that adapter
instead of the persisted one. Two paths left the probe object behind in the
customer's bucket.

- **The failure cleanup deleted from the wrong store.** `target` was declared
inside the `try`, so the `catch` could only name the persisted adapter — even
when the probe had written to the temporary one, which is the whole case the
temporary adapter exists for. Deleting a key that was never there is a no-op
on both shipped adapters, so the wrong-store delete "succeeded" and nothing
looked wrong. The adapter is now resolved before that `try`, which makes the
cleanup name the store the upload named by construction.
- **The content-mismatch return path cleaned up nothing.** Reaching that
comparison means the upload already succeeded, so the object is definitely
there — and the `return` walked straight past the delete on the next line. It
now runs the same best-effort cleanup as the failure path, which also carries
the "cleanup refused — here is the key it left behind" warning to this path
for the first time.

One stray object accrued per failed test, under a name minted per call from a
timestamp and a random suffix and recorded nowhere, in whichever store the probe
actually wrote to — a button whose entire purpose is to be pressed repeatedly
while credentials are being got right.

An adapter that fails to *construct* still attempts no cleanup: nothing has been
written at that point, and the delete would have to name an adapter that does
not exist. What the probe reports to the operator is unchanged on every path.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13726] The `storage/test` probe cleans up in the store it WROTE to.
*
* The handler exists so an operator can validate credentials that are typed
* into the form but not yet saved, so when the form posts values it builds a
* TEMPORARY adapter and probes that instead of the persisted one. Two paths
* left the probe object behind:
*
* 1. the failure cleanup deleted from `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;
* 2. the content-mismatch `return` walked straight past the delete on the
* next line, after an upload that by definition had already succeeded —
* a guaranteed leak rather than a best-effort one.
*
* ⚠️ Both credential cases are pinned SEPARATELY, and only one of the two
* directions can catch defect 1: with no overrides `target === proxy`, so the
* old code deleted from the right store by accident and a single-direction pin
* passes on the defect. The case that matters is a failed probe WITH edited
* credentials.
*
* ## How a failure is induced
*
* Every store below is a REAL `LocalStorageAdapter` on its own directory, with
* exactly one verb overridden (`Object.create`, so every other member stays the
* real one). PUT allowed / GET refused is the ordinary shape of a half-right
* credential, and it is what makes the leak observable: the bytes really land
* on disk, and then the probe really fails. The assertions are therefore about
* the FILESYSTEM — what is left under `__objectstack_probe__/` when the handler
* returns — not about a call counter that could agree with a store nobody
* wrote to.
*
* ⚠️ Two cases below are CONTROLS, not pins, and are labelled: they are green
* in both directions by construction (the pre-repair code already deleted from
* the right store when there were no overrides, and already attempted no
* cleanup when the adapter failed to build). They are here so the pins cannot
* pass on a handler that deletes from everything, or on one that cleans up
* after a store it never wrote to. ⛔ Not ablation evidence.
*/

import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { IStorageService } from '@objectstack/spec/contracts';
import { LocalStorageAdapter } from './local-storage-adapter.js';
import { StorageServicePlugin } from './storage-service-plugin.js';
import type { SwappableStorageService } from './swappable-storage-service.js';

const PROBE_PREFIX = '__objectstack_probe__';
const CLEANUP_HEADLINE = 'was NOT removed';
const MISMATCH_MESSAGE = 'Probe download did not match upload.';

function makeCtx() {
const services = new Map<string, unknown>();
const hooks: Array<() => Promise<void> | void> = [];
const logs: { info: string[]; warn: string[]; error: string[] } = { info: [], warn: [], error: [] };
const ctx: any = {
logger: {
info: (m: string) => { logs.info.push(String(m)); },
warn: (m: string) => { logs.warn.push(String(m)); },
error: (m: string) => { logs.error.push(String(m)); },
},
_logs: logs,
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
getService: <T>(name: string): T => {
const s = services.get(name);
if (!s) throw new Error(`service '${name}' not registered`);
return s as T;
},
hook: (event: string, fn: () => Promise<void> | void) => {
if (event === 'kernel:ready') hooks.push(fn);
},
_flushReady: async () => { for (const h of hooks) await h(); },
};
return ctx;
}

/** A settings service that keeps the registered action so a test can run it. */
function makeFakeSettings() {
const actions = new Map<string, (input: unknown) => Promise<any>>();
return {
createClient: (_ns: string) => ({}),
getNamespace: async (_ns: string) => ({ values: {} }),
subscribe: (_ns: string, _fn: () => void) => {},
registerAction: (ns: string, id: string, fn: (input: unknown) => Promise<any>) => {
actions.set(`${ns}/${id}`, fn);
},
_runAction: async (ns: string, id: string, input: unknown) => {
const fn = actions.get(`${ns}/${id}`);
if (!fn) throw new Error(`no action ${ns}/${id}`);
return await fn(input);
},
};
}

async function tmpRoot(prefix: string): Promise<string> {
return await fs.mkdtemp(join(tmpdir(), prefix));
}

/** A real local adapter rooted at `rootDir` — the store, not a stand-in. */
function localAdapterAt(rootDir: string): IStorageService {
return new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' });
}

/**
* The real store with ONE verb replaced. `Object.create` rather than a
* hand-written stand-in, deliberately: every member this test does not name
* stays the adapter's own, so a probe object written through the wrapper is a
* real file and the assertions can read the filesystem.
*/
function withRefusedDownload(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => { throw new Error(message); };
return store;
}

function withMangledDownload(real: IStorageService): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => Buffer.from('not-what-was-uploaded', 'utf-8');
return store;
}

function withRefusedDelete(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.delete = async () => { throw new Error(message); };
return store;
}

/** The real store, recording every key it is ASKED to delete. */
function withCountedDeletes(real: IStorageService): { store: IStorageService; deleted: string[] } {
const deleted: string[] = [];
const store: IStorageService = Object.create(real);
store.delete = async (key: string) => { deleted.push(key); await real.delete(key); };
return { store, deleted };
}

/** Probe objects currently on disk under `rootDir`. */
async function probeObjectsIn(rootDir: string): Promise<string[]> {
try {
return (await fs.readdir(join(rootDir, PROBE_PREFIX))).sort();
} catch (err: any) {
if (err?.code === 'ENOENT') return [];
throw err;
}
}

/**
* The factory the handler calls when the form posts values, substituted so a
* test can hand it a store whose behaviour it controls.
*
* Named as a seam rather than reached for with `as any`: `buildAdapterFromValues`
* itself is covered by its own tests (`storage-service-plugin.metrics.test.ts`
* and the S3-misconfiguration case in `storage-service-plugin.test.ts`), and
* what is under test HERE is which store the handler cleans up in — not how the
* temporary one is constructed.
*/
interface AdapterFactorySeam {
buildAdapterFromValues(values: Record<string, unknown>): Promise<IStorageService>;
}

function substituteAdapterFactory(
plugin: StorageServicePlugin,
temporary: IStorageService,
): Array<Record<string, unknown>> {
const calls: Array<Record<string, unknown>> = [];
const seam = plugin as unknown as AdapterFactorySeam;
seam.buildAdapterFromValues = async (values: Record<string, unknown>) => {
calls.push(values);
return temporary;
};
return calls;
}

async function bootedPlugin(persistedRoot: string) {
const plugin = new StorageServicePlugin({
adapter: 'local',
local: { rootDir: persistedRoot },
registerRoutes: false,
});
const ctx = makeCtx();
const settings = makeFakeSettings();
ctx.registerService('settings', settings);
await plugin.init(ctx);
await plugin.start(ctx);
await ctx._flushReady();
// Typed here rather than at the call site: the fake ctx is `any`, so
// `ctx.getService<T>(…)` would be a type argument on an untyped call.
const storage: SwappableStorageService = ctx.getService('storage');
return { plugin, ctx, settings, storage };
}

/** The shape the settings form posts when the operator edited the fields. */
function editedCredentials(localRoot: string) {
return { values: {}, payload: { values: { adapter: 'local', local_root: localRoot } } };
}

describe('#13726 defect 1 — the failure cleanup names the store the probe wrote to', () => {
it('a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-temporary-');
const { plugin, ctx, settings, storage } = await bootedPlugin(persistedRoot);

// The persisted store, watching for deletes it should never be asked for.
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The store the edited credentials build: a different directory, and a GET
// that is refused after the PUT has already landed the bytes.
const temporary = withRefusedDownload(
localAdapterAt(temporaryRoot),
'download refused: GET denied for this key',
);
const calls = substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// The temporary-adapter branch really ran — without it this pin would be
// measuring the no-overrides case under an overrides-shaped name.
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({ adapter: 'local', local_root: temporaryRoot });

// THE PIN: the store the probe wrote to holds nothing afterwards.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);

// …and the persisted store was neither written to nor asked to delete: the
// old cleanup issued a delete here, against a key this store never held.
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ What the operator is told is unchanged by the repair.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe('download refused: GET denied for this key');
// The cleanup succeeded, so #12981's refusal line stays quiet.
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in BOTH directions. With no overrides
// `target === proxy`, so the pre-repair `proxy.delete` was already the right
// store. It is here so the pin above cannot pass on a handler that stopped
// cleaning up the persisted store when it repaired the temporary one.
it('CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-only-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(withRefusedDownload(localAdapterAt(persistedRoot), 'download refused: GET denied'));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(result.ok).toBe(false);
expect(result.message).toBe('download refused: GET denied');
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in both directions. It pins the judgement
// this card turns on: `target` is resolved BEFORE the try whose catch cleans
// up, so the catch can never see a half-built adapter or the adapter whose
// construction threw. A build failure returns before anything is written, and
// the handler must therefore attempt NO cleanup — not against the persisted
// store (nothing was written there) and not against the adapter that failed
// to construct (there is none). Uses the REAL factory, which rejects an S3
// configuration with no bucket or region.
it('CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere', async () => {
const persistedRoot = await tmpRoot('oss-13726-nobuild-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

const result = await settings._runAction('storage', 'test', {
values: {},
payload: { values: { adapter: 's3', s3_bucket: '', s3_region: '' } },
});

expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toContain('S3 adapter requires s3_bucket and s3_region');
expect(persisted.deleted).toEqual([]);
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});
});

describe('#13726 defect 2 — the content-mismatch path cleans up', () => {
it('a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-mismatch-temporary-');
const { plugin, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The upload SUCCEEDS here — that is the precondition for reaching the
// comparison at all — and the download answers other bytes.
const temporary = withMangledDownload(localAdapterAt(temporaryRoot));
substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// THE PIN: the upload landed, and nothing is left of it.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ The message the operator reads is unchanged.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});

it('a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-only-');
const { settings, storage } = await bootedPlugin(persistedRoot);

const counted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(withMangledDownload(counted.store));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(counted.deleted).toHaveLength(1);
expect(counted.deleted[0]).toContain(`${PROBE_PREFIX}/`);
expect(result.ok).toBe(false);
expect(result.message).toBe(MISMATCH_MESSAGE);
});

// #12981 batch 7 made a REFUSED cleanup name the key it left behind. That
// repair could not reach this path, because no cleanup was attempted on it.
// Now that one is, the refusal is reported here too — the same line, from the
// same helper — and the probe's own verdict is still the one returned.
it('a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-refused-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(
withRefusedDelete(
withMangledDownload(localAdapterAt(persistedRoot)),
'delete refused: bucket is read-only',
),
);

const result = await settings._runAction('storage', 'test', { values: {} });

const warned = ctx._logs.warn.filter((l: string) => l.includes(CLEANUP_HEADLINE));
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(`${PROBE_PREFIX}/`);
expect(warned[0]).toContain('delete refused: bucket is read-only');

// The object really is still there — the warning is not decorative.
expect(await probeObjectsIn(persistedRoot)).toHaveLength(1);

// ⛔ The probe's own result is untouched by the cleanup's failure.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/storage-probe-cleanup-target-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): the `storage/test` probe cleans up in the store it wrote to (#13726)

The settings action behind the storage screen's "Test" button writes a small
`__objectstack_probe__/…` object, reads it back, and deletes it. When the form
posts values it builds a **temporary** adapter first, so an operator can
validate credentials that are typed but not yet saved, and probes that adapter
instead of the persisted one. Two paths left the probe object behind in the
customer's bucket.

- **The failure cleanup deleted from the wrong store.** `target` was declared
inside the `try`, so the `catch` could only name the persisted adapter — even
when the probe had written to the temporary one, which is the whole case the
temporary adapter exists for. Deleting a key that was never there is a no-op
on both shipped adapters, so the wrong-store delete "succeeded" and nothing
looked wrong. The adapter is now resolved before that `try`, which makes the
cleanup name the store the upload named by construction.
- **The content-mismatch return path cleaned up nothing.** Reaching that
comparison means the upload already succeeded, so the object is definitely
there — and the `return` walked straight past the delete on the next line. It
now runs the same best-effort cleanup as the failure path, which also carries
the "cleanup refused — here is the key it left behind" warning to this path
for the first time.

One stray object accrued per failed test, under a name minted per call from a
timestamp and a random suffix and recorded nowhere, in whichever store the probe
actually wrote to — a button whose entire purpose is to be pressed repeatedly
while credentials are being got right.

An adapter that fails to *construct* still attempts no cleanup: nothing has been
written at that point, and the delete would have to name an adapter that does
not exist. What the probe reports to the operator is unchanged on every path.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13726] The `storage/test` probe cleans up in the store it WROTE to.
*
* The handler exists so an operator can validate credentials that are typed
* into the form but not yet saved, so when the form posts values it builds a
* TEMPORARY adapter and probes that instead of the persisted one. Two paths
* left the probe object behind:
*
* 1. the failure cleanup deleted from `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;
* 2. the content-mismatch `return` walked straight past the delete on the
* next line, after an upload that by definition had already succeeded —
* a guaranteed leak rather than a best-effort one.
*
* ⚠️ Both credential cases are pinned SEPARATELY, and only one of the two
* directions can catch defect 1: with no overrides `target === proxy`, so the
* old code deleted from the right store by accident and a single-direction pin
* passes on the defect. The case that matters is a failed probe WITH edited
* credentials.
*
* ## How a failure is induced
*
* Every store below is a REAL `LocalStorageAdapter` on its own directory, with
* exactly one verb overridden (`Object.create`, so every other member stays the
* real one). PUT allowed / GET refused is the ordinary shape of a half-right
* credential, and it is what makes the leak observable: the bytes really land
* on disk, and then the probe really fails. The assertions are therefore about
* the FILESYSTEM — what is left under `__objectstack_probe__/` when the handler
* returns — not about a call counter that could agree with a store nobody
* wrote to.
*
* ⚠️ Two cases below are CONTROLS, not pins, and are labelled: they are green
* in both directions by construction (the pre-repair code already deleted from
* the right store when there were no overrides, and already attempted no
* cleanup when the adapter failed to build). They are here so the pins cannot
* pass on a handler that deletes from everything, or on one that cleans up
* after a store it never wrote to. ⛔ Not ablation evidence.
*/

import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { IStorageService } from '@objectstack/spec/contracts';
import { LocalStorageAdapter } from './local-storage-adapter.js';
import { StorageServicePlugin } from './storage-service-plugin.js';
import type { SwappableStorageService } from './swappable-storage-service.js';

const PROBE_PREFIX = '__objectstack_probe__';
const CLEANUP_HEADLINE = 'was NOT removed';
const MISMATCH_MESSAGE = 'Probe download did not match upload.';

function makeCtx() {
const services = new Map<string, unknown>();
const hooks: Array<() => Promise<void> | void> = [];
const logs: { info: string[]; warn: string[]; error: string[] } = { info: [], warn: [], error: [] };
const ctx: any = {
logger: {
info: (m: string) => { logs.info.push(String(m)); },
warn: (m: string) => { logs.warn.push(String(m)); },
error: (m: string) => { logs.error.push(String(m)); },
},
_logs: logs,
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
getService: <T>(name: string): T => {
const s = services.get(name);
if (!s) throw new Error(`service '${name}' not registered`);
return s as T;
},
hook: (event: string, fn: () => Promise<void> | void) => {
if (event === 'kernel:ready') hooks.push(fn);
},
_flushReady: async () => { for (const h of hooks) await h(); },
};
return ctx;
}

/** A settings service that keeps the registered action so a test can run it. */
function makeFakeSettings() {
const actions = new Map<string, (input: unknown) => Promise<any>>();
return {
createClient: (_ns: string) => ({}),
getNamespace: async (_ns: string) => ({ values: {} }),
subscribe: (_ns: string, _fn: () => void) => {},
registerAction: (ns: string, id: string, fn: (input: unknown) => Promise<any>) => {
actions.set(`${ns}/${id}`, fn);
},
_runAction: async (ns: string, id: string, input: unknown) => {
const fn = actions.get(`${ns}/${id}`);
if (!fn) throw new Error(`no action ${ns}/${id}`);
return await fn(input);
},
};
}

async function tmpRoot(prefix: string): Promise<string> {
return await fs.mkdtemp(join(tmpdir(), prefix));
}

/** A real local adapter rooted at `rootDir` — the store, not a stand-in. */
function localAdapterAt(rootDir: string): IStorageService {
return new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' });
}

/**
* The real store with ONE verb replaced. `Object.create` rather than a
* hand-written stand-in, deliberately: every member this test does not name
* stays the adapter's own, so a probe object written through the wrapper is a
* real file and the assertions can read the filesystem.
*/
function withRefusedDownload(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => { throw new Error(message); };
return store;
}

function withMangledDownload(real: IStorageService): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => Buffer.from('not-what-was-uploaded', 'utf-8');
return store;
}

function withRefusedDelete(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.delete = async () => { throw new Error(message); };
return store;
}

/** The real store, recording every key it is ASKED to delete. */
function withCountedDeletes(real: IStorageService): { store: IStorageService; deleted: string[] } {
const deleted: string[] = [];
const store: IStorageService = Object.create(real);
store.delete = async (key: string) => { deleted.push(key); await real.delete(key); };
return { store, deleted };
}

/** Probe objects currently on disk under `rootDir`. */
async function probeObjectsIn(rootDir: string): Promise<string[]> {
try {
return (await fs.readdir(join(rootDir, PROBE_PREFIX))).sort();
} catch (err: any) {
if (err?.code === 'ENOENT') return [];
throw err;
}
}

/**
* The factory the handler calls when the form posts values, substituted so a
* test can hand it a store whose behaviour it controls.
*
* Named as a seam rather than reached for with `as any`: `buildAdapterFromValues`
* itself is covered by its own tests (`storage-service-plugin.metrics.test.ts`
* and the S3-misconfiguration case in `storage-service-plugin.test.ts`), and
* what is under test HERE is which store the handler cleans up in — not how the
* temporary one is constructed.
*/
interface AdapterFactorySeam {
buildAdapterFromValues(values: Record<string, unknown>): Promise<IStorageService>;
}

function substituteAdapterFactory(
plugin: StorageServicePlugin,
temporary: IStorageService,
): Array<Record<string, unknown>> {
const calls: Array<Record<string, unknown>> = [];
const seam = plugin as unknown as AdapterFactorySeam;
seam.buildAdapterFromValues = async (values: Record<string, unknown>) => {
calls.push(values);
return temporary;
};
return calls;
}

async function bootedPlugin(persistedRoot: string) {
const plugin = new StorageServicePlugin({
adapter: 'local',
local: { rootDir: persistedRoot },
registerRoutes: false,
});
const ctx = makeCtx();
const settings = makeFakeSettings();
ctx.registerService('settings', settings);
await plugin.init(ctx);
await plugin.start(ctx);
await ctx._flushReady();
// Typed here rather than at the call site: the fake ctx is `any`, so
// `ctx.getService<T>(…)` would be a type argument on an untyped call.
const storage: SwappableStorageService = ctx.getService('storage');
return { plugin, ctx, settings, storage };
}

/** The shape the settings form posts when the operator edited the fields. */
function editedCredentials(localRoot: string) {
return { values: {}, payload: { values: { adapter: 'local', local_root: localRoot } } };
}

describe('#13726 defect 1 — the failure cleanup names the store the probe wrote to', () => {
it('a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-temporary-');
const { plugin, ctx, settings, storage } = await bootedPlugin(persistedRoot);

// The persisted store, watching for deletes it should never be asked for.
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The store the edited credentials build: a different directory, and a GET
// that is refused after the PUT has already landed the bytes.
const temporary = withRefusedDownload(
localAdapterAt(temporaryRoot),
'download refused: GET denied for this key',
);
const calls = substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// The temporary-adapter branch really ran — without it this pin would be
// measuring the no-overrides case under an overrides-shaped name.
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({ adapter: 'local', local_root: temporaryRoot });

// THE PIN: the store the probe wrote to holds nothing afterwards.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);

// …and the persisted store was neither written to nor asked to delete: the
// old cleanup issued a delete here, against a key this store never held.
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ What the operator is told is unchanged by the repair.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe('download refused: GET denied for this key');
// The cleanup succeeded, so #12981's refusal line stays quiet.
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in BOTH directions. With no overrides
// `target === proxy`, so the pre-repair `proxy.delete` was already the right
// store. It is here so the pin above cannot pass on a handler that stopped
// cleaning up the persisted store when it repaired the temporary one.
it('CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-only-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(withRefusedDownload(localAdapterAt(persistedRoot), 'download refused: GET denied'));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(result.ok).toBe(false);
expect(result.message).toBe('download refused: GET denied');
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in both directions. It pins the judgement
// this card turns on: `target` is resolved BEFORE the try whose catch cleans
// up, so the catch can never see a half-built adapter or the adapter whose
// construction threw. A build failure returns before anything is written, and
// the handler must therefore attempt NO cleanup — not against the persisted
// store (nothing was written there) and not against the adapter that failed
// to construct (there is none). Uses the REAL factory, which rejects an S3
// configuration with no bucket or region.
it('CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere', async () => {
const persistedRoot = await tmpRoot('oss-13726-nobuild-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

const result = await settings._runAction('storage', 'test', {
values: {},
payload: { values: { adapter: 's3', s3_bucket: '', s3_region: '' } },
});

expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toContain('S3 adapter requires s3_bucket and s3_region');
expect(persisted.deleted).toEqual([]);
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});
});

describe('#13726 defect 2 — the content-mismatch path cleans up', () => {
it('a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-mismatch-temporary-');
const { plugin, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The upload SUCCEEDS here — that is the precondition for reaching the
// comparison at all — and the download answers other bytes.
const temporary = withMangledDownload(localAdapterAt(temporaryRoot));
substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// THE PIN: the upload landed, and nothing is left of it.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ The message the operator reads is unchanged.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});

it('a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-only-');
const { settings, storage } = await bootedPlugin(persistedRoot);

const counted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(withMangledDownload(counted.store));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(counted.deleted).toHaveLength(1);
expect(counted.deleted[0]).toContain(`${PROBE_PREFIX}/`);
expect(result.ok).toBe(false);
expect(result.message).toBe(MISMATCH_MESSAGE);
});

// #12981 batch 7 made a REFUSED cleanup name the key it left behind. That
// repair could not reach this path, because no cleanup was attempted on it.
// Now that one is, the refusal is reported here too — the same line, from the
// same helper — and the probe's own verdict is still the one returned.
it('a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-refused-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(
withRefusedDelete(
withMangledDownload(localAdapterAt(persistedRoot)),
'delete refused: bucket is read-only',
),
);

const result = await settings._runAction('storage', 'test', { values: {} });

const warned = ctx._logs.warn.filter((l: string) => l.includes(CLEANUP_HEADLINE));
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(`${PROBE_PREFIX}/`);
expect(warned[0]).toContain('delete refused: bucket is read-only');

// The object really is still there — the warning is not decorative.
expect(await probeObjectsIn(persistedRoot)).toHaveLength(1);

// ⛔ The probe's own result is untouched by the cleanup's failure.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/storage-probe-cleanup-target-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): the `storage/test` probe cleans up in the store it wrote to (#13726)

The settings action behind the storage screen's "Test" button writes a small
`__objectstack_probe__/…` object, reads it back, and deletes it. When the form
posts values it builds a **temporary** adapter first, so an operator can
validate credentials that are typed but not yet saved, and probes that adapter
instead of the persisted one. Two paths left the probe object behind in the
customer's bucket.

- **The failure cleanup deleted from the wrong store.** `target` was declared
inside the `try`, so the `catch` could only name the persisted adapter — even
when the probe had written to the temporary one, which is the whole case the
temporary adapter exists for. Deleting a key that was never there is a no-op
on both shipped adapters, so the wrong-store delete "succeeded" and nothing
looked wrong. The adapter is now resolved before that `try`, which makes the
cleanup name the store the upload named by construction.
- **The content-mismatch return path cleaned up nothing.** Reaching that
comparison means the upload already succeeded, so the object is definitely
there — and the `return` walked straight past the delete on the next line. It
now runs the same best-effort cleanup as the failure path, which also carries
the "cleanup refused — here is the key it left behind" warning to this path
for the first time.

One stray object accrued per failed test, under a name minted per call from a
timestamp and a random suffix and recorded nowhere, in whichever store the probe
actually wrote to — a button whose entire purpose is to be pressed repeatedly
while credentials are being got right.

An adapter that fails to *construct* still attempts no cleanup: nothing has been
written at that point, and the delete would have to name an adapter that does
not exist. What the probe reports to the operator is unchanged on every path.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13726] The `storage/test` probe cleans up in the store it WROTE to.
*
* The handler exists so an operator can validate credentials that are typed
* into the form but not yet saved, so when the form posts values it builds a
* TEMPORARY adapter and probes that instead of the persisted one. Two paths
* left the probe object behind:
*
* 1. the failure cleanup deleted from `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;
* 2. the content-mismatch `return` walked straight past the delete on the
* next line, after an upload that by definition had already succeeded —
* a guaranteed leak rather than a best-effort one.
*
* ⚠️ Both credential cases are pinned SEPARATELY, and only one of the two
* directions can catch defect 1: with no overrides `target === proxy`, so the
* old code deleted from the right store by accident and a single-direction pin
* passes on the defect. The case that matters is a failed probe WITH edited
* credentials.
*
* ## How a failure is induced
*
* Every store below is a REAL `LocalStorageAdapter` on its own directory, with
* exactly one verb overridden (`Object.create`, so every other member stays the
* real one). PUT allowed / GET refused is the ordinary shape of a half-right
* credential, and it is what makes the leak observable: the bytes really land
* on disk, and then the probe really fails. The assertions are therefore about
* the FILESYSTEM — what is left under `__objectstack_probe__/` when the handler
* returns — not about a call counter that could agree with a store nobody
* wrote to.
*
* ⚠️ Two cases below are CONTROLS, not pins, and are labelled: they are green
* in both directions by construction (the pre-repair code already deleted from
* the right store when there were no overrides, and already attempted no
* cleanup when the adapter failed to build). They are here so the pins cannot
* pass on a handler that deletes from everything, or on one that cleans up
* after a store it never wrote to. ⛔ Not ablation evidence.
*/

import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { IStorageService } from '@objectstack/spec/contracts';
import { LocalStorageAdapter } from './local-storage-adapter.js';
import { StorageServicePlugin } from './storage-service-plugin.js';
import type { SwappableStorageService } from './swappable-storage-service.js';

const PROBE_PREFIX = '__objectstack_probe__';
const CLEANUP_HEADLINE = 'was NOT removed';
const MISMATCH_MESSAGE = 'Probe download did not match upload.';

function makeCtx() {
const services = new Map<string, unknown>();
const hooks: Array<() => Promise<void> | void> = [];
const logs: { info: string[]; warn: string[]; error: string[] } = { info: [], warn: [], error: [] };
const ctx: any = {
logger: {
info: (m: string) => { logs.info.push(String(m)); },
warn: (m: string) => { logs.warn.push(String(m)); },
error: (m: string) => { logs.error.push(String(m)); },
},
_logs: logs,
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
getService: <T>(name: string): T => {
const s = services.get(name);
if (!s) throw new Error(`service '${name}' not registered`);
return s as T;
},
hook: (event: string, fn: () => Promise<void> | void) => {
if (event === 'kernel:ready') hooks.push(fn);
},
_flushReady: async () => { for (const h of hooks) await h(); },
};
return ctx;
}

/** A settings service that keeps the registered action so a test can run it. */
function makeFakeSettings() {
const actions = new Map<string, (input: unknown) => Promise<any>>();
return {
createClient: (_ns: string) => ({}),
getNamespace: async (_ns: string) => ({ values: {} }),
subscribe: (_ns: string, _fn: () => void) => {},
registerAction: (ns: string, id: string, fn: (input: unknown) => Promise<any>) => {
actions.set(`${ns}/${id}`, fn);
},
_runAction: async (ns: string, id: string, input: unknown) => {
const fn = actions.get(`${ns}/${id}`);
if (!fn) throw new Error(`no action ${ns}/${id}`);
return await fn(input);
},
};
}

async function tmpRoot(prefix: string): Promise<string> {
return await fs.mkdtemp(join(tmpdir(), prefix));
}

/** A real local adapter rooted at `rootDir` — the store, not a stand-in. */
function localAdapterAt(rootDir: string): IStorageService {
return new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' });
}

/**
* The real store with ONE verb replaced. `Object.create` rather than a
* hand-written stand-in, deliberately: every member this test does not name
* stays the adapter's own, so a probe object written through the wrapper is a
* real file and the assertions can read the filesystem.
*/
function withRefusedDownload(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => { throw new Error(message); };
return store;
}

function withMangledDownload(real: IStorageService): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => Buffer.from('not-what-was-uploaded', 'utf-8');
return store;
}

function withRefusedDelete(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.delete = async () => { throw new Error(message); };
return store;
}

/** The real store, recording every key it is ASKED to delete. */
function withCountedDeletes(real: IStorageService): { store: IStorageService; deleted: string[] } {
const deleted: string[] = [];
const store: IStorageService = Object.create(real);
store.delete = async (key: string) => { deleted.push(key); await real.delete(key); };
return { store, deleted };
}

/** Probe objects currently on disk under `rootDir`. */
async function probeObjectsIn(rootDir: string): Promise<string[]> {
try {
return (await fs.readdir(join(rootDir, PROBE_PREFIX))).sort();
} catch (err: any) {
if (err?.code === 'ENOENT') return [];
throw err;
}
}

/**
* The factory the handler calls when the form posts values, substituted so a
* test can hand it a store whose behaviour it controls.
*
* Named as a seam rather than reached for with `as any`: `buildAdapterFromValues`
* itself is covered by its own tests (`storage-service-plugin.metrics.test.ts`
* and the S3-misconfiguration case in `storage-service-plugin.test.ts`), and
* what is under test HERE is which store the handler cleans up in — not how the
* temporary one is constructed.
*/
interface AdapterFactorySeam {
buildAdapterFromValues(values: Record<string, unknown>): Promise<IStorageService>;
}

function substituteAdapterFactory(
plugin: StorageServicePlugin,
temporary: IStorageService,
): Array<Record<string, unknown>> {
const calls: Array<Record<string, unknown>> = [];
const seam = plugin as unknown as AdapterFactorySeam;
seam.buildAdapterFromValues = async (values: Record<string, unknown>) => {
calls.push(values);
return temporary;
};
return calls;
}

async function bootedPlugin(persistedRoot: string) {
const plugin = new StorageServicePlugin({
adapter: 'local',
local: { rootDir: persistedRoot },
registerRoutes: false,
});
const ctx = makeCtx();
const settings = makeFakeSettings();
ctx.registerService('settings', settings);
await plugin.init(ctx);
await plugin.start(ctx);
await ctx._flushReady();
// Typed here rather than at the call site: the fake ctx is `any`, so
// `ctx.getService<T>(…)` would be a type argument on an untyped call.
const storage: SwappableStorageService = ctx.getService('storage');
return { plugin, ctx, settings, storage };
}

/** The shape the settings form posts when the operator edited the fields. */
function editedCredentials(localRoot: string) {
return { values: {}, payload: { values: { adapter: 'local', local_root: localRoot } } };
}

describe('#13726 defect 1 — the failure cleanup names the store the probe wrote to', () => {
it('a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-temporary-');
const { plugin, ctx, settings, storage } = await bootedPlugin(persistedRoot);

// The persisted store, watching for deletes it should never be asked for.
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The store the edited credentials build: a different directory, and a GET
// that is refused after the PUT has already landed the bytes.
const temporary = withRefusedDownload(
localAdapterAt(temporaryRoot),
'download refused: GET denied for this key',
);
const calls = substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// The temporary-adapter branch really ran — without it this pin would be
// measuring the no-overrides case under an overrides-shaped name.
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({ adapter: 'local', local_root: temporaryRoot });

// THE PIN: the store the probe wrote to holds nothing afterwards.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);

// …and the persisted store was neither written to nor asked to delete: the
// old cleanup issued a delete here, against a key this store never held.
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ What the operator is told is unchanged by the repair.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe('download refused: GET denied for this key');
// The cleanup succeeded, so #12981's refusal line stays quiet.
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in BOTH directions. With no overrides
// `target === proxy`, so the pre-repair `proxy.delete` was already the right
// store. It is here so the pin above cannot pass on a handler that stopped
// cleaning up the persisted store when it repaired the temporary one.
it('CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-only-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(withRefusedDownload(localAdapterAt(persistedRoot), 'download refused: GET denied'));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(result.ok).toBe(false);
expect(result.message).toBe('download refused: GET denied');
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in both directions. It pins the judgement
// this card turns on: `target` is resolved BEFORE the try whose catch cleans
// up, so the catch can never see a half-built adapter or the adapter whose
// construction threw. A build failure returns before anything is written, and
// the handler must therefore attempt NO cleanup — not against the persisted
// store (nothing was written there) and not against the adapter that failed
// to construct (there is none). Uses the REAL factory, which rejects an S3
// configuration with no bucket or region.
it('CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere', async () => {
const persistedRoot = await tmpRoot('oss-13726-nobuild-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

const result = await settings._runAction('storage', 'test', {
values: {},
payload: { values: { adapter: 's3', s3_bucket: '', s3_region: '' } },
});

expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toContain('S3 adapter requires s3_bucket and s3_region');
expect(persisted.deleted).toEqual([]);
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});
});

describe('#13726 defect 2 — the content-mismatch path cleans up', () => {
it('a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-mismatch-temporary-');
const { plugin, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The upload SUCCEEDS here — that is the precondition for reaching the
// comparison at all — and the download answers other bytes.
const temporary = withMangledDownload(localAdapterAt(temporaryRoot));
substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// THE PIN: the upload landed, and nothing is left of it.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ The message the operator reads is unchanged.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});

it('a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-only-');
const { settings, storage } = await bootedPlugin(persistedRoot);

const counted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(withMangledDownload(counted.store));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(counted.deleted).toHaveLength(1);
expect(counted.deleted[0]).toContain(`${PROBE_PREFIX}/`);
expect(result.ok).toBe(false);
expect(result.message).toBe(MISMATCH_MESSAGE);
});

// #12981 batch 7 made a REFUSED cleanup name the key it left behind. That
// repair could not reach this path, because no cleanup was attempted on it.
// Now that one is, the refusal is reported here too — the same line, from the
// same helper — and the probe's own verdict is still the one returned.
it('a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-refused-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(
withRefusedDelete(
withMangledDownload(localAdapterAt(persistedRoot)),
'delete refused: bucket is read-only',
),
);

const result = await settings._runAction('storage', 'test', { values: {} });

const warned = ctx._logs.warn.filter((l: string) => l.includes(CLEANUP_HEADLINE));
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(`${PROBE_PREFIX}/`);
expect(warned[0]).toContain('delete refused: bucket is read-only');

// The object really is still there — the warning is not decorative.
expect(await probeObjectsIn(persistedRoot)).toHaveLength(1);

// ⛔ The probe's own result is untouched by the cleanup's failure.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/storage-probe-cleanup-target-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): the `storage/test` probe cleans up in the store it wrote to (#13726)

The settings action behind the storage screen's "Test" button writes a small
`__objectstack_probe__/…` object, reads it back, and deletes it. When the form
posts values it builds a **temporary** adapter first, so an operator can
validate credentials that are typed but not yet saved, and probes that adapter
instead of the persisted one. Two paths left the probe object behind in the
customer's bucket.

- **The failure cleanup deleted from the wrong store.** `target` was declared
inside the `try`, so the `catch` could only name the persisted adapter — even
when the probe had written to the temporary one, which is the whole case the
temporary adapter exists for. Deleting a key that was never there is a no-op
on both shipped adapters, so the wrong-store delete "succeeded" and nothing
looked wrong. The adapter is now resolved before that `try`, which makes the
cleanup name the store the upload named by construction.
- **The content-mismatch return path cleaned up nothing.** Reaching that
comparison means the upload already succeeded, so the object is definitely
there — and the `return` walked straight past the delete on the next line. It
now runs the same best-effort cleanup as the failure path, which also carries
the "cleanup refused — here is the key it left behind" warning to this path
for the first time.

One stray object accrued per failed test, under a name minted per call from a
timestamp and a random suffix and recorded nowhere, in whichever store the probe
actually wrote to — a button whose entire purpose is to be pressed repeatedly
while credentials are being got right.

An adapter that fails to *construct* still attempts no cleanup: nothing has been
written at that point, and the delete would have to name an adapter that does
not exist. What the probe reports to the operator is unchanged on every path.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13726] The `storage/test` probe cleans up in the store it WROTE to.
*
* The handler exists so an operator can validate credentials that are typed
* into the form but not yet saved, so when the form posts values it builds a
* TEMPORARY adapter and probes that instead of the persisted one. Two paths
* left the probe object behind:
*
* 1. the failure cleanup deleted from `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;
* 2. the content-mismatch `return` walked straight past the delete on the
* next line, after an upload that by definition had already succeeded —
* a guaranteed leak rather than a best-effort one.
*
* ⚠️ Both credential cases are pinned SEPARATELY, and only one of the two
* directions can catch defect 1: with no overrides `target === proxy`, so the
* old code deleted from the right store by accident and a single-direction pin
* passes on the defect. The case that matters is a failed probe WITH edited
* credentials.
*
* ## How a failure is induced
*
* Every store below is a REAL `LocalStorageAdapter` on its own directory, with
* exactly one verb overridden (`Object.create`, so every other member stays the
* real one). PUT allowed / GET refused is the ordinary shape of a half-right
* credential, and it is what makes the leak observable: the bytes really land
* on disk, and then the probe really fails. The assertions are therefore about
* the FILESYSTEM — what is left under `__objectstack_probe__/` when the handler
* returns — not about a call counter that could agree with a store nobody
* wrote to.
*
* ⚠️ Two cases below are CONTROLS, not pins, and are labelled: they are green
* in both directions by construction (the pre-repair code already deleted from
* the right store when there were no overrides, and already attempted no
* cleanup when the adapter failed to build). They are here so the pins cannot
* pass on a handler that deletes from everything, or on one that cleans up
* after a store it never wrote to. ⛔ Not ablation evidence.
*/

import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { IStorageService } from '@objectstack/spec/contracts';
import { LocalStorageAdapter } from './local-storage-adapter.js';
import { StorageServicePlugin } from './storage-service-plugin.js';
import type { SwappableStorageService } from './swappable-storage-service.js';

const PROBE_PREFIX = '__objectstack_probe__';
const CLEANUP_HEADLINE = 'was NOT removed';
const MISMATCH_MESSAGE = 'Probe download did not match upload.';

function makeCtx() {
const services = new Map<string, unknown>();
const hooks: Array<() => Promise<void> | void> = [];
const logs: { info: string[]; warn: string[]; error: string[] } = { info: [], warn: [], error: [] };
const ctx: any = {
logger: {
info: (m: string) => { logs.info.push(String(m)); },
warn: (m: string) => { logs.warn.push(String(m)); },
error: (m: string) => { logs.error.push(String(m)); },
},
_logs: logs,
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
getService: <T>(name: string): T => {
const s = services.get(name);
if (!s) throw new Error(`service '${name}' not registered`);
return s as T;
},
hook: (event: string, fn: () => Promise<void> | void) => {
if (event === 'kernel:ready') hooks.push(fn);
},
_flushReady: async () => { for (const h of hooks) await h(); },
};
return ctx;
}

/** A settings service that keeps the registered action so a test can run it. */
function makeFakeSettings() {
const actions = new Map<string, (input: unknown) => Promise<any>>();
return {
createClient: (_ns: string) => ({}),
getNamespace: async (_ns: string) => ({ values: {} }),
subscribe: (_ns: string, _fn: () => void) => {},
registerAction: (ns: string, id: string, fn: (input: unknown) => Promise<any>) => {
actions.set(`${ns}/${id}`, fn);
},
_runAction: async (ns: string, id: string, input: unknown) => {
const fn = actions.get(`${ns}/${id}`);
if (!fn) throw new Error(`no action ${ns}/${id}`);
return await fn(input);
},
};
}

async function tmpRoot(prefix: string): Promise<string> {
return await fs.mkdtemp(join(tmpdir(), prefix));
}

/** A real local adapter rooted at `rootDir` — the store, not a stand-in. */
function localAdapterAt(rootDir: string): IStorageService {
return new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' });
}

/**
* The real store with ONE verb replaced. `Object.create` rather than a
* hand-written stand-in, deliberately: every member this test does not name
* stays the adapter's own, so a probe object written through the wrapper is a
* real file and the assertions can read the filesystem.
*/
function withRefusedDownload(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => { throw new Error(message); };
return store;
}

function withMangledDownload(real: IStorageService): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => Buffer.from('not-what-was-uploaded', 'utf-8');
return store;
}

function withRefusedDelete(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.delete = async () => { throw new Error(message); };
return store;
}

/** The real store, recording every key it is ASKED to delete. */
function withCountedDeletes(real: IStorageService): { store: IStorageService; deleted: string[] } {
const deleted: string[] = [];
const store: IStorageService = Object.create(real);
store.delete = async (key: string) => { deleted.push(key); await real.delete(key); };
return { store, deleted };
}

/** Probe objects currently on disk under `rootDir`. */
async function probeObjectsIn(rootDir: string): Promise<string[]> {
try {
return (await fs.readdir(join(rootDir, PROBE_PREFIX))).sort();
} catch (err: any) {
if (err?.code === 'ENOENT') return [];
throw err;
}
}

/**
* The factory the handler calls when the form posts values, substituted so a
* test can hand it a store whose behaviour it controls.
*
* Named as a seam rather than reached for with `as any`: `buildAdapterFromValues`
* itself is covered by its own tests (`storage-service-plugin.metrics.test.ts`
* and the S3-misconfiguration case in `storage-service-plugin.test.ts`), and
* what is under test HERE is which store the handler cleans up in — not how the
* temporary one is constructed.
*/
interface AdapterFactorySeam {
buildAdapterFromValues(values: Record<string, unknown>): Promise<IStorageService>;
}

function substituteAdapterFactory(
plugin: StorageServicePlugin,
temporary: IStorageService,
): Array<Record<string, unknown>> {
const calls: Array<Record<string, unknown>> = [];
const seam = plugin as unknown as AdapterFactorySeam;
seam.buildAdapterFromValues = async (values: Record<string, unknown>) => {
calls.push(values);
return temporary;
};
return calls;
}

async function bootedPlugin(persistedRoot: string) {
const plugin = new StorageServicePlugin({
adapter: 'local',
local: { rootDir: persistedRoot },
registerRoutes: false,
});
const ctx = makeCtx();
const settings = makeFakeSettings();
ctx.registerService('settings', settings);
await plugin.init(ctx);
await plugin.start(ctx);
await ctx._flushReady();
// Typed here rather than at the call site: the fake ctx is `any`, so
// `ctx.getService<T>(…)` would be a type argument on an untyped call.
const storage: SwappableStorageService = ctx.getService('storage');
return { plugin, ctx, settings, storage };
}

/** The shape the settings form posts when the operator edited the fields. */
function editedCredentials(localRoot: string) {
return { values: {}, payload: { values: { adapter: 'local', local_root: localRoot } } };
}

describe('#13726 defect 1 — the failure cleanup names the store the probe wrote to', () => {
it('a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-temporary-');
const { plugin, ctx, settings, storage } = await bootedPlugin(persistedRoot);

// The persisted store, watching for deletes it should never be asked for.
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The store the edited credentials build: a different directory, and a GET
// that is refused after the PUT has already landed the bytes.
const temporary = withRefusedDownload(
localAdapterAt(temporaryRoot),
'download refused: GET denied for this key',
);
const calls = substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// The temporary-adapter branch really ran — without it this pin would be
// measuring the no-overrides case under an overrides-shaped name.
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({ adapter: 'local', local_root: temporaryRoot });

// THE PIN: the store the probe wrote to holds nothing afterwards.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);

// …and the persisted store was neither written to nor asked to delete: the
// old cleanup issued a delete here, against a key this store never held.
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ What the operator is told is unchanged by the repair.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe('download refused: GET denied for this key');
// The cleanup succeeded, so #12981's refusal line stays quiet.
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in BOTH directions. With no overrides
// `target === proxy`, so the pre-repair `proxy.delete` was already the right
// store. It is here so the pin above cannot pass on a handler that stopped
// cleaning up the persisted store when it repaired the temporary one.
it('CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-only-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(withRefusedDownload(localAdapterAt(persistedRoot), 'download refused: GET denied'));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(result.ok).toBe(false);
expect(result.message).toBe('download refused: GET denied');
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in both directions. It pins the judgement
// this card turns on: `target` is resolved BEFORE the try whose catch cleans
// up, so the catch can never see a half-built adapter or the adapter whose
// construction threw. A build failure returns before anything is written, and
// the handler must therefore attempt NO cleanup — not against the persisted
// store (nothing was written there) and not against the adapter that failed
// to construct (there is none). Uses the REAL factory, which rejects an S3
// configuration with no bucket or region.
it('CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere', async () => {
const persistedRoot = await tmpRoot('oss-13726-nobuild-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

const result = await settings._runAction('storage', 'test', {
values: {},
payload: { values: { adapter: 's3', s3_bucket: '', s3_region: '' } },
});

expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toContain('S3 adapter requires s3_bucket and s3_region');
expect(persisted.deleted).toEqual([]);
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});
});

describe('#13726 defect 2 — the content-mismatch path cleans up', () => {
it('a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-mismatch-temporary-');
const { plugin, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The upload SUCCEEDS here — that is the precondition for reaching the
// comparison at all — and the download answers other bytes.
const temporary = withMangledDownload(localAdapterAt(temporaryRoot));
substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// THE PIN: the upload landed, and nothing is left of it.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ The message the operator reads is unchanged.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});

it('a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-only-');
const { settings, storage } = await bootedPlugin(persistedRoot);

const counted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(withMangledDownload(counted.store));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(counted.deleted).toHaveLength(1);
expect(counted.deleted[0]).toContain(`${PROBE_PREFIX}/`);
expect(result.ok).toBe(false);
expect(result.message).toBe(MISMATCH_MESSAGE);
});

// #12981 batch 7 made a REFUSED cleanup name the key it left behind. That
// repair could not reach this path, because no cleanup was attempted on it.
// Now that one is, the refusal is reported here too — the same line, from the
// same helper — and the probe's own verdict is still the one returned.
it('a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-refused-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(
withRefusedDelete(
withMangledDownload(localAdapterAt(persistedRoot)),
'delete refused: bucket is read-only',
),
);

const result = await settings._runAction('storage', 'test', { values: {} });

const warned = ctx._logs.warn.filter((l: string) => l.includes(CLEANUP_HEADLINE));
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(`${PROBE_PREFIX}/`);
expect(warned[0]).toContain('delete refused: bucket is read-only');

// The object really is still there — the warning is not decorative.
expect(await probeObjectsIn(persistedRoot)).toHaveLength(1);

// ⛔ The probe's own result is untouched by the cleanup's failure.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/storage-probe-cleanup-target-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): the `storage/test` probe cleans up in the store it wrote to (#13726)

The settings action behind the storage screen's "Test" button writes a small
`__objectstack_probe__/…` object, reads it back, and deletes it. When the form
posts values it builds a **temporary** adapter first, so an operator can
validate credentials that are typed but not yet saved, and probes that adapter
instead of the persisted one. Two paths left the probe object behind in the
customer's bucket.

- **The failure cleanup deleted from the wrong store.** `target` was declared
inside the `try`, so the `catch` could only name the persisted adapter — even
when the probe had written to the temporary one, which is the whole case the
temporary adapter exists for. Deleting a key that was never there is a no-op
on both shipped adapters, so the wrong-store delete "succeeded" and nothing
looked wrong. The adapter is now resolved before that `try`, which makes the
cleanup name the store the upload named by construction.
- **The content-mismatch return path cleaned up nothing.** Reaching that
comparison means the upload already succeeded, so the object is definitely
there — and the `return` walked straight past the delete on the next line. It
now runs the same best-effort cleanup as the failure path, which also carries
the "cleanup refused — here is the key it left behind" warning to this path
for the first time.

One stray object accrued per failed test, under a name minted per call from a
timestamp and a random suffix and recorded nowhere, in whichever store the probe
actually wrote to — a button whose entire purpose is to be pressed repeatedly
while credentials are being got right.

An adapter that fails to *construct* still attempts no cleanup: nothing has been
written at that point, and the delete would have to name an adapter that does
not exist. What the probe reports to the operator is unchanged on every path.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13726] The `storage/test` probe cleans up in the store it WROTE to.
*
* The handler exists so an operator can validate credentials that are typed
* into the form but not yet saved, so when the form posts values it builds a
* TEMPORARY adapter and probes that instead of the persisted one. Two paths
* left the probe object behind:
*
* 1. the failure cleanup deleted from `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;
* 2. the content-mismatch `return` walked straight past the delete on the
* next line, after an upload that by definition had already succeeded —
* a guaranteed leak rather than a best-effort one.
*
* ⚠️ Both credential cases are pinned SEPARATELY, and only one of the two
* directions can catch defect 1: with no overrides `target === proxy`, so the
* old code deleted from the right store by accident and a single-direction pin
* passes on the defect. The case that matters is a failed probe WITH edited
* credentials.
*
* ## How a failure is induced
*
* Every store below is a REAL `LocalStorageAdapter` on its own directory, with
* exactly one verb overridden (`Object.create`, so every other member stays the
* real one). PUT allowed / GET refused is the ordinary shape of a half-right
* credential, and it is what makes the leak observable: the bytes really land
* on disk, and then the probe really fails. The assertions are therefore about
* the FILESYSTEM — what is left under `__objectstack_probe__/` when the handler
* returns — not about a call counter that could agree with a store nobody
* wrote to.
*
* ⚠️ Two cases below are CONTROLS, not pins, and are labelled: they are green
* in both directions by construction (the pre-repair code already deleted from
* the right store when there were no overrides, and already attempted no
* cleanup when the adapter failed to build). They are here so the pins cannot
* pass on a handler that deletes from everything, or on one that cleans up
* after a store it never wrote to. ⛔ Not ablation evidence.
*/

import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { IStorageService } from '@objectstack/spec/contracts';
import { LocalStorageAdapter } from './local-storage-adapter.js';
import { StorageServicePlugin } from './storage-service-plugin.js';
import type { SwappableStorageService } from './swappable-storage-service.js';

const PROBE_PREFIX = '__objectstack_probe__';
const CLEANUP_HEADLINE = 'was NOT removed';
const MISMATCH_MESSAGE = 'Probe download did not match upload.';

function makeCtx() {
const services = new Map<string, unknown>();
const hooks: Array<() => Promise<void> | void> = [];
const logs: { info: string[]; warn: string[]; error: string[] } = { info: [], warn: [], error: [] };
const ctx: any = {
logger: {
info: (m: string) => { logs.info.push(String(m)); },
warn: (m: string) => { logs.warn.push(String(m)); },
error: (m: string) => { logs.error.push(String(m)); },
},
_logs: logs,
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
getService: <T>(name: string): T => {
const s = services.get(name);
if (!s) throw new Error(`service '${name}' not registered`);
return s as T;
},
hook: (event: string, fn: () => Promise<void> | void) => {
if (event === 'kernel:ready') hooks.push(fn);
},
_flushReady: async () => { for (const h of hooks) await h(); },
};
return ctx;
}

/** A settings service that keeps the registered action so a test can run it. */
function makeFakeSettings() {
const actions = new Map<string, (input: unknown) => Promise<any>>();
return {
createClient: (_ns: string) => ({}),
getNamespace: async (_ns: string) => ({ values: {} }),
subscribe: (_ns: string, _fn: () => void) => {},
registerAction: (ns: string, id: string, fn: (input: unknown) => Promise<any>) => {
actions.set(`${ns}/${id}`, fn);
},
_runAction: async (ns: string, id: string, input: unknown) => {
const fn = actions.get(`${ns}/${id}`);
if (!fn) throw new Error(`no action ${ns}/${id}`);
return await fn(input);
},
};
}

async function tmpRoot(prefix: string): Promise<string> {
return await fs.mkdtemp(join(tmpdir(), prefix));
}

/** A real local adapter rooted at `rootDir` — the store, not a stand-in. */
function localAdapterAt(rootDir: string): IStorageService {
return new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' });
}

/**
* The real store with ONE verb replaced. `Object.create` rather than a
* hand-written stand-in, deliberately: every member this test does not name
* stays the adapter's own, so a probe object written through the wrapper is a
* real file and the assertions can read the filesystem.
*/
function withRefusedDownload(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => { throw new Error(message); };
return store;
}

function withMangledDownload(real: IStorageService): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => Buffer.from('not-what-was-uploaded', 'utf-8');
return store;
}

function withRefusedDelete(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.delete = async () => { throw new Error(message); };
return store;
}

/** The real store, recording every key it is ASKED to delete. */
function withCountedDeletes(real: IStorageService): { store: IStorageService; deleted: string[] } {
const deleted: string[] = [];
const store: IStorageService = Object.create(real);
store.delete = async (key: string) => { deleted.push(key); await real.delete(key); };
return { store, deleted };
}

/** Probe objects currently on disk under `rootDir`. */
async function probeObjectsIn(rootDir: string): Promise<string[]> {
try {
return (await fs.readdir(join(rootDir, PROBE_PREFIX))).sort();
} catch (err: any) {
if (err?.code === 'ENOENT') return [];
throw err;
}
}

/**
* The factory the handler calls when the form posts values, substituted so a
* test can hand it a store whose behaviour it controls.
*
* Named as a seam rather than reached for with `as any`: `buildAdapterFromValues`
* itself is covered by its own tests (`storage-service-plugin.metrics.test.ts`
* and the S3-misconfiguration case in `storage-service-plugin.test.ts`), and
* what is under test HERE is which store the handler cleans up in — not how the
* temporary one is constructed.
*/
interface AdapterFactorySeam {
buildAdapterFromValues(values: Record<string, unknown>): Promise<IStorageService>;
}

function substituteAdapterFactory(
plugin: StorageServicePlugin,
temporary: IStorageService,
): Array<Record<string, unknown>> {
const calls: Array<Record<string, unknown>> = [];
const seam = plugin as unknown as AdapterFactorySeam;
seam.buildAdapterFromValues = async (values: Record<string, unknown>) => {
calls.push(values);
return temporary;
};
return calls;
}

async function bootedPlugin(persistedRoot: string) {
const plugin = new StorageServicePlugin({
adapter: 'local',
local: { rootDir: persistedRoot },
registerRoutes: false,
});
const ctx = makeCtx();
const settings = makeFakeSettings();
ctx.registerService('settings', settings);
await plugin.init(ctx);
await plugin.start(ctx);
await ctx._flushReady();
// Typed here rather than at the call site: the fake ctx is `any`, so
// `ctx.getService<T>(…)` would be a type argument on an untyped call.
const storage: SwappableStorageService = ctx.getService('storage');
return { plugin, ctx, settings, storage };
}

/** The shape the settings form posts when the operator edited the fields. */
function editedCredentials(localRoot: string) {
return { values: {}, payload: { values: { adapter: 'local', local_root: localRoot } } };
}

describe('#13726 defect 1 — the failure cleanup names the store the probe wrote to', () => {
it('a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-temporary-');
const { plugin, ctx, settings, storage } = await bootedPlugin(persistedRoot);

// The persisted store, watching for deletes it should never be asked for.
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The store the edited credentials build: a different directory, and a GET
// that is refused after the PUT has already landed the bytes.
const temporary = withRefusedDownload(
localAdapterAt(temporaryRoot),
'download refused: GET denied for this key',
);
const calls = substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// The temporary-adapter branch really ran — without it this pin would be
// measuring the no-overrides case under an overrides-shaped name.
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({ adapter: 'local', local_root: temporaryRoot });

// THE PIN: the store the probe wrote to holds nothing afterwards.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);

// …and the persisted store was neither written to nor asked to delete: the
// old cleanup issued a delete here, against a key this store never held.
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ What the operator is told is unchanged by the repair.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe('download refused: GET denied for this key');
// The cleanup succeeded, so #12981's refusal line stays quiet.
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in BOTH directions. With no overrides
// `target === proxy`, so the pre-repair `proxy.delete` was already the right
// store. It is here so the pin above cannot pass on a handler that stopped
// cleaning up the persisted store when it repaired the temporary one.
it('CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-only-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(withRefusedDownload(localAdapterAt(persistedRoot), 'download refused: GET denied'));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(result.ok).toBe(false);
expect(result.message).toBe('download refused: GET denied');
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in both directions. It pins the judgement
// this card turns on: `target` is resolved BEFORE the try whose catch cleans
// up, so the catch can never see a half-built adapter or the adapter whose
// construction threw. A build failure returns before anything is written, and
// the handler must therefore attempt NO cleanup — not against the persisted
// store (nothing was written there) and not against the adapter that failed
// to construct (there is none). Uses the REAL factory, which rejects an S3
// configuration with no bucket or region.
it('CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere', async () => {
const persistedRoot = await tmpRoot('oss-13726-nobuild-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

const result = await settings._runAction('storage', 'test', {
values: {},
payload: { values: { adapter: 's3', s3_bucket: '', s3_region: '' } },
});

expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toContain('S3 adapter requires s3_bucket and s3_region');
expect(persisted.deleted).toEqual([]);
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});
});

describe('#13726 defect 2 — the content-mismatch path cleans up', () => {
it('a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-mismatch-temporary-');
const { plugin, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The upload SUCCEEDS here — that is the precondition for reaching the
// comparison at all — and the download answers other bytes.
const temporary = withMangledDownload(localAdapterAt(temporaryRoot));
substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// THE PIN: the upload landed, and nothing is left of it.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ The message the operator reads is unchanged.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});

it('a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-only-');
const { settings, storage } = await bootedPlugin(persistedRoot);

const counted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(withMangledDownload(counted.store));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(counted.deleted).toHaveLength(1);
expect(counted.deleted[0]).toContain(`${PROBE_PREFIX}/`);
expect(result.ok).toBe(false);
expect(result.message).toBe(MISMATCH_MESSAGE);
});

// #12981 batch 7 made a REFUSED cleanup name the key it left behind. That
// repair could not reach this path, because no cleanup was attempted on it.
// Now that one is, the refusal is reported here too — the same line, from the
// same helper — and the probe's own verdict is still the one returned.
it('a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-refused-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(
withRefusedDelete(
withMangledDownload(localAdapterAt(persistedRoot)),
'delete refused: bucket is read-only',
),
);

const result = await settings._runAction('storage', 'test', { values: {} });

const warned = ctx._logs.warn.filter((l: string) => l.includes(CLEANUP_HEADLINE));
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(`${PROBE_PREFIX}/`);
expect(warned[0]).toContain('delete refused: bucket is read-only');

// The object really is still there — the warning is not decorative.
expect(await probeObjectsIn(persistedRoot)).toHaveLength(1);

// ⛔ The probe's own result is untouched by the cleanup's failure.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/storage-probe-cleanup-target-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): the `storage/test` probe cleans up in the store it wrote to (#13726)

The settings action behind the storage screen's "Test" button writes a small
`__objectstack_probe__/…` object, reads it back, and deletes it. When the form
posts values it builds a **temporary** adapter first, so an operator can
validate credentials that are typed but not yet saved, and probes that adapter
instead of the persisted one. Two paths left the probe object behind in the
customer's bucket.

- **The failure cleanup deleted from the wrong store.** `target` was declared
inside the `try`, so the `catch` could only name the persisted adapter — even
when the probe had written to the temporary one, which is the whole case the
temporary adapter exists for. Deleting a key that was never there is a no-op
on both shipped adapters, so the wrong-store delete "succeeded" and nothing
looked wrong. The adapter is now resolved before that `try`, which makes the
cleanup name the store the upload named by construction.
- **The content-mismatch return path cleaned up nothing.** Reaching that
comparison means the upload already succeeded, so the object is definitely
there — and the `return` walked straight past the delete on the next line. It
now runs the same best-effort cleanup as the failure path, which also carries
the "cleanup refused — here is the key it left behind" warning to this path
for the first time.

One stray object accrued per failed test, under a name minted per call from a
timestamp and a random suffix and recorded nowhere, in whichever store the probe
actually wrote to — a button whose entire purpose is to be pressed repeatedly
while credentials are being got right.

An adapter that fails to *construct* still attempts no cleanup: nothing has been
written at that point, and the delete would have to name an adapter that does
not exist. What the probe reports to the operator is unchanged on every path.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13726] The `storage/test` probe cleans up in the store it WROTE to.
*
* The handler exists so an operator can validate credentials that are typed
* into the form but not yet saved, so when the form posts values it builds a
* TEMPORARY adapter and probes that instead of the persisted one. Two paths
* left the probe object behind:
*
* 1. the failure cleanup deleted from `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;
* 2. the content-mismatch `return` walked straight past the delete on the
* next line, after an upload that by definition had already succeeded —
* a guaranteed leak rather than a best-effort one.
*
* ⚠️ Both credential cases are pinned SEPARATELY, and only one of the two
* directions can catch defect 1: with no overrides `target === proxy`, so the
* old code deleted from the right store by accident and a single-direction pin
* passes on the defect. The case that matters is a failed probe WITH edited
* credentials.
*
* ## How a failure is induced
*
* Every store below is a REAL `LocalStorageAdapter` on its own directory, with
* exactly one verb overridden (`Object.create`, so every other member stays the
* real one). PUT allowed / GET refused is the ordinary shape of a half-right
* credential, and it is what makes the leak observable: the bytes really land
* on disk, and then the probe really fails. The assertions are therefore about
* the FILESYSTEM — what is left under `__objectstack_probe__/` when the handler
* returns — not about a call counter that could agree with a store nobody
* wrote to.
*
* ⚠️ Two cases below are CONTROLS, not pins, and are labelled: they are green
* in both directions by construction (the pre-repair code already deleted from
* the right store when there were no overrides, and already attempted no
* cleanup when the adapter failed to build). They are here so the pins cannot
* pass on a handler that deletes from everything, or on one that cleans up
* after a store it never wrote to. ⛔ Not ablation evidence.
*/

import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { IStorageService } from '@objectstack/spec/contracts';
import { LocalStorageAdapter } from './local-storage-adapter.js';
import { StorageServicePlugin } from './storage-service-plugin.js';
import type { SwappableStorageService } from './swappable-storage-service.js';

const PROBE_PREFIX = '__objectstack_probe__';
const CLEANUP_HEADLINE = 'was NOT removed';
const MISMATCH_MESSAGE = 'Probe download did not match upload.';

function makeCtx() {
const services = new Map<string, unknown>();
const hooks: Array<() => Promise<void> | void> = [];
const logs: { info: string[]; warn: string[]; error: string[] } = { info: [], warn: [], error: [] };
const ctx: any = {
logger: {
info: (m: string) => { logs.info.push(String(m)); },
warn: (m: string) => { logs.warn.push(String(m)); },
error: (m: string) => { logs.error.push(String(m)); },
},
_logs: logs,
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
getService: <T>(name: string): T => {
const s = services.get(name);
if (!s) throw new Error(`service '${name}' not registered`);
return s as T;
},
hook: (event: string, fn: () => Promise<void> | void) => {
if (event === 'kernel:ready') hooks.push(fn);
},
_flushReady: async () => { for (const h of hooks) await h(); },
};
return ctx;
}

/** A settings service that keeps the registered action so a test can run it. */
function makeFakeSettings() {
const actions = new Map<string, (input: unknown) => Promise<any>>();
return {
createClient: (_ns: string) => ({}),
getNamespace: async (_ns: string) => ({ values: {} }),
subscribe: (_ns: string, _fn: () => void) => {},
registerAction: (ns: string, id: string, fn: (input: unknown) => Promise<any>) => {
actions.set(`${ns}/${id}`, fn);
},
_runAction: async (ns: string, id: string, input: unknown) => {
const fn = actions.get(`${ns}/${id}`);
if (!fn) throw new Error(`no action ${ns}/${id}`);
return await fn(input);
},
};
}

async function tmpRoot(prefix: string): Promise<string> {
return await fs.mkdtemp(join(tmpdir(), prefix));
}

/** A real local adapter rooted at `rootDir` — the store, not a stand-in. */
function localAdapterAt(rootDir: string): IStorageService {
return new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' });
}

/**
* The real store with ONE verb replaced. `Object.create` rather than a
* hand-written stand-in, deliberately: every member this test does not name
* stays the adapter's own, so a probe object written through the wrapper is a
* real file and the assertions can read the filesystem.
*/
function withRefusedDownload(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => { throw new Error(message); };
return store;
}

function withMangledDownload(real: IStorageService): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => Buffer.from('not-what-was-uploaded', 'utf-8');
return store;
}

function withRefusedDelete(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.delete = async () => { throw new Error(message); };
return store;
}

/** The real store, recording every key it is ASKED to delete. */
function withCountedDeletes(real: IStorageService): { store: IStorageService; deleted: string[] } {
const deleted: string[] = [];
const store: IStorageService = Object.create(real);
store.delete = async (key: string) => { deleted.push(key); await real.delete(key); };
return { store, deleted };
}

/** Probe objects currently on disk under `rootDir`. */
async function probeObjectsIn(rootDir: string): Promise<string[]> {
try {
return (await fs.readdir(join(rootDir, PROBE_PREFIX))).sort();
} catch (err: any) {
if (err?.code === 'ENOENT') return [];
throw err;
}
}

/**
* The factory the handler calls when the form posts values, substituted so a
* test can hand it a store whose behaviour it controls.
*
* Named as a seam rather than reached for with `as any`: `buildAdapterFromValues`
* itself is covered by its own tests (`storage-service-plugin.metrics.test.ts`
* and the S3-misconfiguration case in `storage-service-plugin.test.ts`), and
* what is under test HERE is which store the handler cleans up in — not how the
* temporary one is constructed.
*/
interface AdapterFactorySeam {
buildAdapterFromValues(values: Record<string, unknown>): Promise<IStorageService>;
}

function substituteAdapterFactory(
plugin: StorageServicePlugin,
temporary: IStorageService,
): Array<Record<string, unknown>> {
const calls: Array<Record<string, unknown>> = [];
const seam = plugin as unknown as AdapterFactorySeam;
seam.buildAdapterFromValues = async (values: Record<string, unknown>) => {
calls.push(values);
return temporary;
};
return calls;
}

async function bootedPlugin(persistedRoot: string) {
const plugin = new StorageServicePlugin({
adapter: 'local',
local: { rootDir: persistedRoot },
registerRoutes: false,
});
const ctx = makeCtx();
const settings = makeFakeSettings();
ctx.registerService('settings', settings);
await plugin.init(ctx);
await plugin.start(ctx);
await ctx._flushReady();
// Typed here rather than at the call site: the fake ctx is `any`, so
// `ctx.getService<T>(…)` would be a type argument on an untyped call.
const storage: SwappableStorageService = ctx.getService('storage');
return { plugin, ctx, settings, storage };
}

/** The shape the settings form posts when the operator edited the fields. */
function editedCredentials(localRoot: string) {
return { values: {}, payload: { values: { adapter: 'local', local_root: localRoot } } };
}

describe('#13726 defect 1 — the failure cleanup names the store the probe wrote to', () => {
it('a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-temporary-');
const { plugin, ctx, settings, storage } = await bootedPlugin(persistedRoot);

// The persisted store, watching for deletes it should never be asked for.
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The store the edited credentials build: a different directory, and a GET
// that is refused after the PUT has already landed the bytes.
const temporary = withRefusedDownload(
localAdapterAt(temporaryRoot),
'download refused: GET denied for this key',
);
const calls = substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// The temporary-adapter branch really ran — without it this pin would be
// measuring the no-overrides case under an overrides-shaped name.
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({ adapter: 'local', local_root: temporaryRoot });

// THE PIN: the store the probe wrote to holds nothing afterwards.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);

// …and the persisted store was neither written to nor asked to delete: the
// old cleanup issued a delete here, against a key this store never held.
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ What the operator is told is unchanged by the repair.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe('download refused: GET denied for this key');
// The cleanup succeeded, so #12981's refusal line stays quiet.
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in BOTH directions. With no overrides
// `target === proxy`, so the pre-repair `proxy.delete` was already the right
// store. It is here so the pin above cannot pass on a handler that stopped
// cleaning up the persisted store when it repaired the temporary one.
it('CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-only-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(withRefusedDownload(localAdapterAt(persistedRoot), 'download refused: GET denied'));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(result.ok).toBe(false);
expect(result.message).toBe('download refused: GET denied');
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in both directions. It pins the judgement
// this card turns on: `target` is resolved BEFORE the try whose catch cleans
// up, so the catch can never see a half-built adapter or the adapter whose
// construction threw. A build failure returns before anything is written, and
// the handler must therefore attempt NO cleanup — not against the persisted
// store (nothing was written there) and not against the adapter that failed
// to construct (there is none). Uses the REAL factory, which rejects an S3
// configuration with no bucket or region.
it('CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere', async () => {
const persistedRoot = await tmpRoot('oss-13726-nobuild-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

const result = await settings._runAction('storage', 'test', {
values: {},
payload: { values: { adapter: 's3', s3_bucket: '', s3_region: '' } },
});

expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toContain('S3 adapter requires s3_bucket and s3_region');
expect(persisted.deleted).toEqual([]);
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});
});

describe('#13726 defect 2 — the content-mismatch path cleans up', () => {
it('a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-mismatch-temporary-');
const { plugin, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The upload SUCCEEDS here — that is the precondition for reaching the
// comparison at all — and the download answers other bytes.
const temporary = withMangledDownload(localAdapterAt(temporaryRoot));
substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// THE PIN: the upload landed, and nothing is left of it.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ The message the operator reads is unchanged.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});

it('a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-only-');
const { settings, storage } = await bootedPlugin(persistedRoot);

const counted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(withMangledDownload(counted.store));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(counted.deleted).toHaveLength(1);
expect(counted.deleted[0]).toContain(`${PROBE_PREFIX}/`);
expect(result.ok).toBe(false);
expect(result.message).toBe(MISMATCH_MESSAGE);
});

// #12981 batch 7 made a REFUSED cleanup name the key it left behind. That
// repair could not reach this path, because no cleanup was attempted on it.
// Now that one is, the refusal is reported here too — the same line, from the
// same helper — and the probe's own verdict is still the one returned.
it('a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-refused-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(
withRefusedDelete(
withMangledDownload(localAdapterAt(persistedRoot)),
'delete refused: bucket is read-only',
),
);

const result = await settings._runAction('storage', 'test', { values: {} });

const warned = ctx._logs.warn.filter((l: string) => l.includes(CLEANUP_HEADLINE));
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(`${PROBE_PREFIX}/`);
expect(warned[0]).toContain('delete refused: bucket is read-only');

// The object really is still there — the warning is not decorative.
expect(await probeObjectsIn(persistedRoot)).toHaveLength(1);

// ⛔ The probe's own result is untouched by the cleanup's failure.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/storage-probe-cleanup-target-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): the `storage/test` probe cleans up in the store it wrote to (#13726)

The settings action behind the storage screen's "Test" button writes a small
`__objectstack_probe__/…` object, reads it back, and deletes it. When the form
posts values it builds a **temporary** adapter first, so an operator can
validate credentials that are typed but not yet saved, and probes that adapter
instead of the persisted one. Two paths left the probe object behind in the
customer's bucket.

- **The failure cleanup deleted from the wrong store.** `target` was declared
inside the `try`, so the `catch` could only name the persisted adapter — even
when the probe had written to the temporary one, which is the whole case the
temporary adapter exists for. Deleting a key that was never there is a no-op
on both shipped adapters, so the wrong-store delete "succeeded" and nothing
looked wrong. The adapter is now resolved before that `try`, which makes the
cleanup name the store the upload named by construction.
- **The content-mismatch return path cleaned up nothing.** Reaching that
comparison means the upload already succeeded, so the object is definitely
there — and the `return` walked straight past the delete on the next line. It
now runs the same best-effort cleanup as the failure path, which also carries
the "cleanup refused — here is the key it left behind" warning to this path
for the first time.

One stray object accrued per failed test, under a name minted per call from a
timestamp and a random suffix and recorded nowhere, in whichever store the probe
actually wrote to — a button whose entire purpose is to be pressed repeatedly
while credentials are being got right.

An adapter that fails to *construct* still attempts no cleanup: nothing has been
written at that point, and the delete would have to name an adapter that does
not exist. What the probe reports to the operator is unchanged on every path.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13726] The `storage/test` probe cleans up in the store it WROTE to.
*
* The handler exists so an operator can validate credentials that are typed
* into the form but not yet saved, so when the form posts values it builds a
* TEMPORARY adapter and probes that instead of the persisted one. Two paths
* left the probe object behind:
*
* 1. the failure cleanup deleted from `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;
* 2. the content-mismatch `return` walked straight past the delete on the
* next line, after an upload that by definition had already succeeded —
* a guaranteed leak rather than a best-effort one.
*
* ⚠️ Both credential cases are pinned SEPARATELY, and only one of the two
* directions can catch defect 1: with no overrides `target === proxy`, so the
* old code deleted from the right store by accident and a single-direction pin
* passes on the defect. The case that matters is a failed probe WITH edited
* credentials.
*
* ## How a failure is induced
*
* Every store below is a REAL `LocalStorageAdapter` on its own directory, with
* exactly one verb overridden (`Object.create`, so every other member stays the
* real one). PUT allowed / GET refused is the ordinary shape of a half-right
* credential, and it is what makes the leak observable: the bytes really land
* on disk, and then the probe really fails. The assertions are therefore about
* the FILESYSTEM — what is left under `__objectstack_probe__/` when the handler
* returns — not about a call counter that could agree with a store nobody
* wrote to.
*
* ⚠️ Two cases below are CONTROLS, not pins, and are labelled: they are green
* in both directions by construction (the pre-repair code already deleted from
* the right store when there were no overrides, and already attempted no
* cleanup when the adapter failed to build). They are here so the pins cannot
* pass on a handler that deletes from everything, or on one that cleans up
* after a store it never wrote to. ⛔ Not ablation evidence.
*/

import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { IStorageService } from '@objectstack/spec/contracts';
import { LocalStorageAdapter } from './local-storage-adapter.js';
import { StorageServicePlugin } from './storage-service-plugin.js';
import type { SwappableStorageService } from './swappable-storage-service.js';

const PROBE_PREFIX = '__objectstack_probe__';
const CLEANUP_HEADLINE = 'was NOT removed';
const MISMATCH_MESSAGE = 'Probe download did not match upload.';

function makeCtx() {
const services = new Map<string, unknown>();
const hooks: Array<() => Promise<void> | void> = [];
const logs: { info: string[]; warn: string[]; error: string[] } = { info: [], warn: [], error: [] };
const ctx: any = {
logger: {
info: (m: string) => { logs.info.push(String(m)); },
warn: (m: string) => { logs.warn.push(String(m)); },
error: (m: string) => { logs.error.push(String(m)); },
},
_logs: logs,
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
getService: <T>(name: string): T => {
const s = services.get(name);
if (!s) throw new Error(`service '${name}' not registered`);
return s as T;
},
hook: (event: string, fn: () => Promise<void> | void) => {
if (event === 'kernel:ready') hooks.push(fn);
},
_flushReady: async () => { for (const h of hooks) await h(); },
};
return ctx;
}

/** A settings service that keeps the registered action so a test can run it. */
function makeFakeSettings() {
const actions = new Map<string, (input: unknown) => Promise<any>>();
return {
createClient: (_ns: string) => ({}),
getNamespace: async (_ns: string) => ({ values: {} }),
subscribe: (_ns: string, _fn: () => void) => {},
registerAction: (ns: string, id: string, fn: (input: unknown) => Promise<any>) => {
actions.set(`${ns}/${id}`, fn);
},
_runAction: async (ns: string, id: string, input: unknown) => {
const fn = actions.get(`${ns}/${id}`);
if (!fn) throw new Error(`no action ${ns}/${id}`);
return await fn(input);
},
};
}

async function tmpRoot(prefix: string): Promise<string> {
return await fs.mkdtemp(join(tmpdir(), prefix));
}

/** A real local adapter rooted at `rootDir` — the store, not a stand-in. */
function localAdapterAt(rootDir: string): IStorageService {
return new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' });
}

/**
* The real store with ONE verb replaced. `Object.create` rather than a
* hand-written stand-in, deliberately: every member this test does not name
* stays the adapter's own, so a probe object written through the wrapper is a
* real file and the assertions can read the filesystem.
*/
function withRefusedDownload(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => { throw new Error(message); };
return store;
}

function withMangledDownload(real: IStorageService): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => Buffer.from('not-what-was-uploaded', 'utf-8');
return store;
}

function withRefusedDelete(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.delete = async () => { throw new Error(message); };
return store;
}

/** The real store, recording every key it is ASKED to delete. */
function withCountedDeletes(real: IStorageService): { store: IStorageService; deleted: string[] } {
const deleted: string[] = [];
const store: IStorageService = Object.create(real);
store.delete = async (key: string) => { deleted.push(key); await real.delete(key); };
return { store, deleted };
}

/** Probe objects currently on disk under `rootDir`. */
async function probeObjectsIn(rootDir: string): Promise<string[]> {
try {
return (await fs.readdir(join(rootDir, PROBE_PREFIX))).sort();
} catch (err: any) {
if (err?.code === 'ENOENT') return [];
throw err;
}
}

/**
* The factory the handler calls when the form posts values, substituted so a
* test can hand it a store whose behaviour it controls.
*
* Named as a seam rather than reached for with `as any`: `buildAdapterFromValues`
* itself is covered by its own tests (`storage-service-plugin.metrics.test.ts`
* and the S3-misconfiguration case in `storage-service-plugin.test.ts`), and
* what is under test HERE is which store the handler cleans up in — not how the
* temporary one is constructed.
*/
interface AdapterFactorySeam {
buildAdapterFromValues(values: Record<string, unknown>): Promise<IStorageService>;
}

function substituteAdapterFactory(
plugin: StorageServicePlugin,
temporary: IStorageService,
): Array<Record<string, unknown>> {
const calls: Array<Record<string, unknown>> = [];
const seam = plugin as unknown as AdapterFactorySeam;
seam.buildAdapterFromValues = async (values: Record<string, unknown>) => {
calls.push(values);
return temporary;
};
return calls;
}

async function bootedPlugin(persistedRoot: string) {
const plugin = new StorageServicePlugin({
adapter: 'local',
local: { rootDir: persistedRoot },
registerRoutes: false,
});
const ctx = makeCtx();
const settings = makeFakeSettings();
ctx.registerService('settings', settings);
await plugin.init(ctx);
await plugin.start(ctx);
await ctx._flushReady();
// Typed here rather than at the call site: the fake ctx is `any`, so
// `ctx.getService<T>(…)` would be a type argument on an untyped call.
const storage: SwappableStorageService = ctx.getService('storage');
return { plugin, ctx, settings, storage };
}

/** The shape the settings form posts when the operator edited the fields. */
function editedCredentials(localRoot: string) {
return { values: {}, payload: { values: { adapter: 'local', local_root: localRoot } } };
}

describe('#13726 defect 1 — the failure cleanup names the store the probe wrote to', () => {
it('a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-temporary-');
const { plugin, ctx, settings, storage } = await bootedPlugin(persistedRoot);

// The persisted store, watching for deletes it should never be asked for.
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The store the edited credentials build: a different directory, and a GET
// that is refused after the PUT has already landed the bytes.
const temporary = withRefusedDownload(
localAdapterAt(temporaryRoot),
'download refused: GET denied for this key',
);
const calls = substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// The temporary-adapter branch really ran — without it this pin would be
// measuring the no-overrides case under an overrides-shaped name.
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({ adapter: 'local', local_root: temporaryRoot });

// THE PIN: the store the probe wrote to holds nothing afterwards.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);

// …and the persisted store was neither written to nor asked to delete: the
// old cleanup issued a delete here, against a key this store never held.
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ What the operator is told is unchanged by the repair.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe('download refused: GET denied for this key');
// The cleanup succeeded, so #12981's refusal line stays quiet.
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in BOTH directions. With no overrides
// `target === proxy`, so the pre-repair `proxy.delete` was already the right
// store. It is here so the pin above cannot pass on a handler that stopped
// cleaning up the persisted store when it repaired the temporary one.
it('CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-only-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(withRefusedDownload(localAdapterAt(persistedRoot), 'download refused: GET denied'));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(result.ok).toBe(false);
expect(result.message).toBe('download refused: GET denied');
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in both directions. It pins the judgement
// this card turns on: `target` is resolved BEFORE the try whose catch cleans
// up, so the catch can never see a half-built adapter or the adapter whose
// construction threw. A build failure returns before anything is written, and
// the handler must therefore attempt NO cleanup — not against the persisted
// store (nothing was written there) and not against the adapter that failed
// to construct (there is none). Uses the REAL factory, which rejects an S3
// configuration with no bucket or region.
it('CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere', async () => {
const persistedRoot = await tmpRoot('oss-13726-nobuild-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

const result = await settings._runAction('storage', 'test', {
values: {},
payload: { values: { adapter: 's3', s3_bucket: '', s3_region: '' } },
});

expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toContain('S3 adapter requires s3_bucket and s3_region');
expect(persisted.deleted).toEqual([]);
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});
});

describe('#13726 defect 2 — the content-mismatch path cleans up', () => {
it('a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-mismatch-temporary-');
const { plugin, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The upload SUCCEEDS here — that is the precondition for reaching the
// comparison at all — and the download answers other bytes.
const temporary = withMangledDownload(localAdapterAt(temporaryRoot));
substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// THE PIN: the upload landed, and nothing is left of it.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ The message the operator reads is unchanged.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});

it('a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-only-');
const { settings, storage } = await bootedPlugin(persistedRoot);

const counted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(withMangledDownload(counted.store));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(counted.deleted).toHaveLength(1);
expect(counted.deleted[0]).toContain(`${PROBE_PREFIX}/`);
expect(result.ok).toBe(false);
expect(result.message).toBe(MISMATCH_MESSAGE);
});

// #12981 batch 7 made a REFUSED cleanup name the key it left behind. That
// repair could not reach this path, because no cleanup was attempted on it.
// Now that one is, the refusal is reported here too — the same line, from the
// same helper — and the probe's own verdict is still the one returned.
it('a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-refused-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(
withRefusedDelete(
withMangledDownload(localAdapterAt(persistedRoot)),
'delete refused: bucket is read-only',
),
);

const result = await settings._runAction('storage', 'test', { values: {} });

const warned = ctx._logs.warn.filter((l: string) => l.includes(CLEANUP_HEADLINE));
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(`${PROBE_PREFIX}/`);
expect(warned[0]).toContain('delete refused: bucket is read-only');

// The object really is still there — the warning is not decorative.
expect(await probeObjectsIn(persistedRoot)).toHaveLength(1);

// ⛔ The probe's own result is untouched by the cleanup's failure.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/storage-probe-cleanup-target-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): the `storage/test` probe cleans up in the store it wrote to (#13726)

The settings action behind the storage screen's "Test" button writes a small
`__objectstack_probe__/…` object, reads it back, and deletes it. When the form
posts values it builds a **temporary** adapter first, so an operator can
validate credentials that are typed but not yet saved, and probes that adapter
instead of the persisted one. Two paths left the probe object behind in the
customer's bucket.

- **The failure cleanup deleted from the wrong store.** `target` was declared
inside the `try`, so the `catch` could only name the persisted adapter — even
when the probe had written to the temporary one, which is the whole case the
temporary adapter exists for. Deleting a key that was never there is a no-op
on both shipped adapters, so the wrong-store delete "succeeded" and nothing
looked wrong. The adapter is now resolved before that `try`, which makes the
cleanup name the store the upload named by construction.
- **The content-mismatch return path cleaned up nothing.** Reaching that
comparison means the upload already succeeded, so the object is definitely
there — and the `return` walked straight past the delete on the next line. It
now runs the same best-effort cleanup as the failure path, which also carries
the "cleanup refused — here is the key it left behind" warning to this path
for the first time.

One stray object accrued per failed test, under a name minted per call from a
timestamp and a random suffix and recorded nowhere, in whichever store the probe
actually wrote to — a button whose entire purpose is to be pressed repeatedly
while credentials are being got right.

An adapter that fails to *construct* still attempts no cleanup: nothing has been
written at that point, and the delete would have to name an adapter that does
not exist. What the probe reports to the operator is unchanged on every path.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13726] The `storage/test` probe cleans up in the store it WROTE to.
*
* The handler exists so an operator can validate credentials that are typed
* into the form but not yet saved, so when the form posts values it builds a
* TEMPORARY adapter and probes that instead of the persisted one. Two paths
* left the probe object behind:
*
* 1. the failure cleanup deleted from `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;
* 2. the content-mismatch `return` walked straight past the delete on the
* next line, after an upload that by definition had already succeeded —
* a guaranteed leak rather than a best-effort one.
*
* ⚠️ Both credential cases are pinned SEPARATELY, and only one of the two
* directions can catch defect 1: with no overrides `target === proxy`, so the
* old code deleted from the right store by accident and a single-direction pin
* passes on the defect. The case that matters is a failed probe WITH edited
* credentials.
*
* ## How a failure is induced
*
* Every store below is a REAL `LocalStorageAdapter` on its own directory, with
* exactly one verb overridden (`Object.create`, so every other member stays the
* real one). PUT allowed / GET refused is the ordinary shape of a half-right
* credential, and it is what makes the leak observable: the bytes really land
* on disk, and then the probe really fails. The assertions are therefore about
* the FILESYSTEM — what is left under `__objectstack_probe__/` when the handler
* returns — not about a call counter that could agree with a store nobody
* wrote to.
*
* ⚠️ Two cases below are CONTROLS, not pins, and are labelled: they are green
* in both directions by construction (the pre-repair code already deleted from
* the right store when there were no overrides, and already attempted no
* cleanup when the adapter failed to build). They are here so the pins cannot
* pass on a handler that deletes from everything, or on one that cleans up
* after a store it never wrote to. ⛔ Not ablation evidence.
*/

import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { IStorageService } from '@objectstack/spec/contracts';
import { LocalStorageAdapter } from './local-storage-adapter.js';
import { StorageServicePlugin } from './storage-service-plugin.js';
import type { SwappableStorageService } from './swappable-storage-service.js';

const PROBE_PREFIX = '__objectstack_probe__';
const CLEANUP_HEADLINE = 'was NOT removed';
const MISMATCH_MESSAGE = 'Probe download did not match upload.';

function makeCtx() {
const services = new Map<string, unknown>();
const hooks: Array<() => Promise<void> | void> = [];
const logs: { info: string[]; warn: string[]; error: string[] } = { info: [], warn: [], error: [] };
const ctx: any = {
logger: {
info: (m: string) => { logs.info.push(String(m)); },
warn: (m: string) => { logs.warn.push(String(m)); },
error: (m: string) => { logs.error.push(String(m)); },
},
_logs: logs,
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
getService: <T>(name: string): T => {
const s = services.get(name);
if (!s) throw new Error(`service '${name}' not registered`);
return s as T;
},
hook: (event: string, fn: () => Promise<void> | void) => {
if (event === 'kernel:ready') hooks.push(fn);
},
_flushReady: async () => { for (const h of hooks) await h(); },
};
return ctx;
}

/** A settings service that keeps the registered action so a test can run it. */
function makeFakeSettings() {
const actions = new Map<string, (input: unknown) => Promise<any>>();
return {
createClient: (_ns: string) => ({}),
getNamespace: async (_ns: string) => ({ values: {} }),
subscribe: (_ns: string, _fn: () => void) => {},
registerAction: (ns: string, id: string, fn: (input: unknown) => Promise<any>) => {
actions.set(`${ns}/${id}`, fn);
},
_runAction: async (ns: string, id: string, input: unknown) => {
const fn = actions.get(`${ns}/${id}`);
if (!fn) throw new Error(`no action ${ns}/${id}`);
return await fn(input);
},
};
}

async function tmpRoot(prefix: string): Promise<string> {
return await fs.mkdtemp(join(tmpdir(), prefix));
}

/** A real local adapter rooted at `rootDir` — the store, not a stand-in. */
function localAdapterAt(rootDir: string): IStorageService {
return new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' });
}

/**
* The real store with ONE verb replaced. `Object.create` rather than a
* hand-written stand-in, deliberately: every member this test does not name
* stays the adapter's own, so a probe object written through the wrapper is a
* real file and the assertions can read the filesystem.
*/
function withRefusedDownload(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => { throw new Error(message); };
return store;
}

function withMangledDownload(real: IStorageService): IStorageService {
const store: IStorageService = Object.create(real);
store.download = async () => Buffer.from('not-what-was-uploaded', 'utf-8');
return store;
}

function withRefusedDelete(real: IStorageService, message: string): IStorageService {
const store: IStorageService = Object.create(real);
store.delete = async () => { throw new Error(message); };
return store;
}

/** The real store, recording every key it is ASKED to delete. */
function withCountedDeletes(real: IStorageService): { store: IStorageService; deleted: string[] } {
const deleted: string[] = [];
const store: IStorageService = Object.create(real);
store.delete = async (key: string) => { deleted.push(key); await real.delete(key); };
return { store, deleted };
}

/** Probe objects currently on disk under `rootDir`. */
async function probeObjectsIn(rootDir: string): Promise<string[]> {
try {
return (await fs.readdir(join(rootDir, PROBE_PREFIX))).sort();
} catch (err: any) {
if (err?.code === 'ENOENT') return [];
throw err;
}
}

/**
* The factory the handler calls when the form posts values, substituted so a
* test can hand it a store whose behaviour it controls.
*
* Named as a seam rather than reached for with `as any`: `buildAdapterFromValues`
* itself is covered by its own tests (`storage-service-plugin.metrics.test.ts`
* and the S3-misconfiguration case in `storage-service-plugin.test.ts`), and
* what is under test HERE is which store the handler cleans up in — not how the
* temporary one is constructed.
*/
interface AdapterFactorySeam {
buildAdapterFromValues(values: Record<string, unknown>): Promise<IStorageService>;
}

function substituteAdapterFactory(
plugin: StorageServicePlugin,
temporary: IStorageService,
): Array<Record<string, unknown>> {
const calls: Array<Record<string, unknown>> = [];
const seam = plugin as unknown as AdapterFactorySeam;
seam.buildAdapterFromValues = async (values: Record<string, unknown>) => {
calls.push(values);
return temporary;
};
return calls;
}

async function bootedPlugin(persistedRoot: string) {
const plugin = new StorageServicePlugin({
adapter: 'local',
local: { rootDir: persistedRoot },
registerRoutes: false,
});
const ctx = makeCtx();
const settings = makeFakeSettings();
ctx.registerService('settings', settings);
await plugin.init(ctx);
await plugin.start(ctx);
await ctx._flushReady();
// Typed here rather than at the call site: the fake ctx is `any`, so
// `ctx.getService<T>(…)` would be a type argument on an untyped call.
const storage: SwappableStorageService = ctx.getService('storage');
return { plugin, ctx, settings, storage };
}

/** The shape the settings form posts when the operator edited the fields. */
function editedCredentials(localRoot: string) {
return { values: {}, payload: { values: { adapter: 'local', local_root: localRoot } } };
}

describe('#13726 defect 1 — the failure cleanup names the store the probe wrote to', () => {
it('a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-temporary-');
const { plugin, ctx, settings, storage } = await bootedPlugin(persistedRoot);

// The persisted store, watching for deletes it should never be asked for.
const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The store the edited credentials build: a different directory, and a GET
// that is refused after the PUT has already landed the bytes.
const temporary = withRefusedDownload(
localAdapterAt(temporaryRoot),
'download refused: GET denied for this key',
);
const calls = substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// The temporary-adapter branch really ran — without it this pin would be
// measuring the no-overrides case under an overrides-shaped name.
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({ adapter: 'local', local_root: temporaryRoot });

// THE PIN: the store the probe wrote to holds nothing afterwards.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);

// …and the persisted store was neither written to nor asked to delete: the
// old cleanup issued a delete here, against a key this store never held.
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ What the operator is told is unchanged by the repair.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe('download refused: GET denied for this key');
// The cleanup succeeded, so #12981's refusal line stays quiet.
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in BOTH directions. With no overrides
// `target === proxy`, so the pre-repair `proxy.delete` was already the right
// store. It is here so the pin above cannot pass on a handler that stopped
// cleaning up the persisted store when it repaired the temporary one.
it('CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-persisted-only-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(withRefusedDownload(localAdapterAt(persistedRoot), 'download refused: GET denied'));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(result.ok).toBe(false);
expect(result.message).toBe('download refused: GET denied');
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});

// ⚠️ CONTROL, not a pin — green in both directions. It pins the judgement
// this card turns on: `target` is resolved BEFORE the try whose catch cleans
// up, so the catch can never see a half-built adapter or the adapter whose
// construction threw. A build failure returns before anything is written, and
// the handler must therefore attempt NO cleanup — not against the persisted
// store (nothing was written there) and not against the adapter that failed
// to construct (there is none). Uses the REAL factory, which rejects an S3
// configuration with no bucket or region.
it('CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere', async () => {
const persistedRoot = await tmpRoot('oss-13726-nobuild-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

const result = await settings._runAction('storage', 'test', {
values: {},
payload: { values: { adapter: 's3', s3_bucket: '', s3_region: '' } },
});

expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toContain('S3 adapter requires s3_bucket and s3_region');
expect(persisted.deleted).toEqual([]);
expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(ctx._logs.warn.join('\n')).not.toContain(CLEANUP_HEADLINE);
});
});

describe('#13726 defect 2 — the content-mismatch path cleans up', () => {
it('a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-persisted-');
const temporaryRoot = await tmpRoot('oss-13726-mismatch-temporary-');
const { plugin, settings, storage } = await bootedPlugin(persistedRoot);

const persisted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(persisted.store);

// The upload SUCCEEDS here — that is the precondition for reaching the
// comparison at all — and the download answers other bytes.
const temporary = withMangledDownload(localAdapterAt(temporaryRoot));
substituteAdapterFactory(plugin, temporary);

const result = await settings._runAction('storage', 'test', editedCredentials(temporaryRoot));

// THE PIN: the upload landed, and nothing is left of it.
expect(await probeObjectsIn(temporaryRoot)).toEqual([]);
expect(persisted.deleted).toEqual([]);

// ⛔ The message the operator reads is unchanged.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});

it('a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-only-');
const { settings, storage } = await bootedPlugin(persistedRoot);

const counted = withCountedDeletes(localAdapterAt(persistedRoot));
storage.swap(withMangledDownload(counted.store));

const result = await settings._runAction('storage', 'test', { values: {} });

expect(await probeObjectsIn(persistedRoot)).toEqual([]);
expect(counted.deleted).toHaveLength(1);
expect(counted.deleted[0]).toContain(`${PROBE_PREFIX}/`);
expect(result.ok).toBe(false);
expect(result.message).toBe(MISMATCH_MESSAGE);
});

// #12981 batch 7 made a REFUSED cleanup name the key it left behind. That
// repair could not reach this path, because no cleanup was attempted on it.
// Now that one is, the refusal is reported here too — the same line, from the
// same helper — and the probe's own verdict is still the one returned.
it('a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch', async () => {
const persistedRoot = await tmpRoot('oss-13726-mismatch-refused-');
const { ctx, settings, storage } = await bootedPlugin(persistedRoot);

storage.swap(
withRefusedDelete(
withMangledDownload(localAdapterAt(persistedRoot)),
'delete refused: bucket is read-only',
),
);

const result = await settings._runAction('storage', 'test', { values: {} });

const warned = ctx._logs.warn.filter((l: string) => l.includes(CLEANUP_HEADLINE));
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(`${PROBE_PREFIX}/`);
expect(warned[0]).toContain('delete refused: bucket is read-only');

// The object really is still there — the warning is not decorative.
expect(await probeObjectsIn(persistedRoot)).toHaveLength(1);

// ⛔ The probe's own result is untouched by the cleanup's failure.
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toBe(MISMATCH_MESSAGE);
});
});
Loading
Loading