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