From f9b58a0e3d174375397f685305627258ff783fcf Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Mon, 29 Jun 2026 04:49:56 +0300 Subject: [PATCH] test(server): SSRF guardedFetch, decryptHeaders fail-open, yjs.util, tool-spec parity, storage delegation guardedFetch blocks loopback/private/link-local/metadata IPs and never calls fetch; decryptHeaders fails open (returns undefined, warns once, no blob leak). yjs.util setYjsMark/removeYjsMarkByAttribute/updateYjsMarkAttribute on real Y.Docs. SHARED_TOOL_SPECS<->in-app parity (name/desc/input-schema; a dropped or renamed wiring fails). Replace the tautological storage.service spec with driver-delegation checks across every public method. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server/src/collaboration/yjs.util.spec.ts | 278 ++++++++++++++++++ .../external-mcp/mcp-clients.service.spec.ts | 166 +++++++++++ .../tools/shared-tool-specs.contract.spec.ts | 124 ++++++++ .../storage/storage.service.spec.ts | 110 ++++++- 4 files changed, 669 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/collaboration/yjs.util.spec.ts create mode 100644 apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.spec.ts create mode 100644 apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts diff --git a/apps/server/src/collaboration/yjs.util.spec.ts b/apps/server/src/collaboration/yjs.util.spec.ts new file mode 100644 index 00000000..29511d6d --- /dev/null +++ b/apps/server/src/collaboration/yjs.util.spec.ts @@ -0,0 +1,278 @@ +import * as Y from 'yjs'; +import { getSchema } from '@tiptap/core'; +import { + initProseMirrorDoc, + absolutePositionToRelativePosition, + prosemirrorJSONToYDoc, +} from '@tiptap/y-tiptap'; +import { tiptapExtensions } from './collaboration.util'; +import { + setYjsMark, + removeYjsMarkByAttribute, + updateYjsMarkAttribute, + type YjsSelection, +} from './yjs.util'; + +/** + * Unit tests for the server-side Yjs mark helpers used by the collaboration + * handler to set/resolve/delete comment marks directly on the shared Y.Doc + * (collaboration.handler.ts: setCommentMark / resolveCommentMark). + * + * The fragment shape mirrors production exactly: a `default` XmlFragment whose + * children are block XmlElements (paragraph) holding XmlText runs. For setYjsMark + * the selection is a pair of Yjs RelativePosition JSONs (what the client sends); + * we synthesize them from known ProseMirror absolute positions via + * absolutePositionToRelativePosition so the marked range is deterministic. + */ + +const schema = getSchema(tiptapExtensions); + +// Build a real Y.Doc from ProseMirror JSON (same path the collab handler uses +// via TiptapTransformer) and return the doc + its `default` fragment. +function buildFromPm(pmJson: unknown) { + const ydoc = prosemirrorJSONToYDoc( + schema, + pmJson as never, + 'default', + ) as unknown as Y.Doc; + const fragment = ydoc.getXmlFragment('default'); + return { ydoc, fragment }; +} + +// Make a YjsSelection (anchor/head RelativePosition JSON) for two ProseMirror +// absolute positions in `fragment`. +function selectionFor( + fragment: Y.XmlFragment, + anchorPos: number, + headPos: number, +): YjsSelection { + const { mapping } = initProseMirrorDoc(fragment, schema); + const anchor = absolutePositionToRelativePosition( + anchorPos, + fragment as never, + mapping, + ); + const head = absolutePositionToRelativePosition( + headPos, + fragment as never, + mapping, + ); + return { + anchor: Y.relativePositionToJSON(anchor), + head: Y.relativePositionToJSON(head), + }; +} + +// The XmlText run of the i-th top-level paragraph. +function paragraphText(fragment: Y.XmlFragment, index = 0): Y.XmlText { + const para = fragment.get(index) as Y.XmlElement; + return para.get(0) as Y.XmlText; +} + +// --- raw fragment builder for the remove/update tests (no schema needed) --- +// +// removeYjsMarkByAttribute / updateYjsMarkAttribute only read item.toDelta() and +// call item.format(); they never touch the ProseMirror schema. Build the runs +// directly so we control which segment carries which comment attrs. +function buildWithComments( + segments: Array<{ + text: string; + comment?: { commentId: string; resolved: boolean }; + }>, +): { fragment: Y.XmlFragment; text: Y.XmlText } { + const ydoc = new Y.Doc(); + const fragment = ydoc.getXmlFragment('default'); + const para = new Y.XmlElement('paragraph'); + fragment.insert(0, [para]); + const text = new Y.XmlText(); + para.insert(0, [text]); + let offset = 0; + for (const seg of segments) { + text.insert(offset, seg.text); + if (seg.comment) { + text.format(offset, seg.text.length, { comment: seg.comment }); + } + offset += seg.text.length; + } + return { fragment, text }; +} + +describe('setYjsMark', () => { + it('applies the mark over exactly the selected sub-range (PM pos 1..6 = "Hello")', () => { + const { ydoc, fragment } = buildFromPm({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] }, + ], + }); + // PM pos 1 = start of the paragraph text; pos 6 = just after "Hello". + const sel = selectionFor(fragment, 1, 6); + + setYjsMark(ydoc as never, fragment, sel, 'comment', { + commentId: 'c1', + resolved: false, + }); + + // The run splits: "Hello" carries the comment mark, " world" stays clean. + expect(paragraphText(fragment).toDelta()).toEqual([ + { + insert: 'Hello', + attributes: { comment: { commentId: 'c1', resolved: false } }, + }, + { insert: ' world' }, + ]); + }); + + it('normalizes a reversed selection (head before anchor) to the same range', () => { + const { ydoc, fragment } = buildFromPm({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] }, + ], + }); + // anchor=6, head=1 — reversed; setYjsMark takes min/max so it marks "Hello". + const sel = selectionFor(fragment, 6, 1); + + setYjsMark(ydoc as never, fragment, sel, 'comment', { + commentId: 'c2', + resolved: false, + }); + + expect(paragraphText(fragment).toDelta()).toEqual([ + { + insert: 'Hello', + attributes: { comment: { commentId: 'c2', resolved: false } }, + }, + { insert: ' world' }, + ]); + }); + + it('marks across two paragraphs (range spans an element boundary)', () => { + const { ydoc, fragment } = buildFromPm({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'aaa' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'bbb' }] }, + ], + }); + // PM positions: "aaa" = 1..4; the

boundary consumes pos 4 and 5, so + // "bbb" starts at pos 6 (chars at 6,7,8). Select pos 2 (inside "aaa") to pos + // 8 (after the second "b"). + const sel = selectionFor(fragment, 2, 8); + + setYjsMark(ydoc as never, fragment, sel, 'comment', { + commentId: 'c3', + resolved: false, + }); + + // First paragraph: "a" clean, "aa" marked. + expect(paragraphText(fragment, 0).toDelta()).toEqual([ + { insert: 'a' }, + { + insert: 'aa', + attributes: { comment: { commentId: 'c3', resolved: false } }, + }, + ]); + // Second paragraph: "bb" marked, "b" clean. + expect(paragraphText(fragment, 1).toDelta()).toEqual([ + { + insert: 'bb', + attributes: { comment: { commentId: 'c3', resolved: false } }, + }, + { insert: 'b' }, + ]); + }); +}); + +describe('removeYjsMarkByAttribute', () => { + it('removes only the run whose attribute value matches, leaving others', () => { + const { fragment, text } = buildWithComments([ + { text: 'AAA', comment: { commentId: 'c1', resolved: false } }, + { text: 'BBB', comment: { commentId: 'c2', resolved: false } }, + ]); + + removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1'); + + // c1's run loses the mark; c2's run is untouched. + expect(text.toDelta()).toEqual([ + { insert: 'AAA' }, + { + insert: 'BBB', + attributes: { comment: { commentId: 'c2', resolved: false } }, + }, + ]); + }); + + it('does nothing when no run carries the requested value (no-match branch)', () => { + const { fragment, text } = buildWithComments([ + { text: 'AAA', comment: { commentId: 'c1', resolved: false } }, + ]); + const before = text.toDelta(); + + removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'does-not-exist'); + + expect(text.toDelta()).toEqual(before); + }); + + it('leaves a different mark type alone', () => { + // A run carrying only `bold` must survive a comment removal pass. + const ydoc = new Y.Doc(); + const fragment = ydoc.getXmlFragment('default'); + const para = new Y.XmlElement('paragraph'); + fragment.insert(0, [para]); + const text = new Y.XmlText(); + para.insert(0, [text]); + text.insert(0, 'XYZ'); + text.format(0, 3, { bold: true }); + + removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1'); + + expect(text.toDelta()).toEqual([ + { insert: 'XYZ', attributes: { bold: true } }, + ]); + }); +}); + +describe('updateYjsMarkAttribute', () => { + it('merges new attributes into the matching run, preserving the rest', () => { + const { fragment, text } = buildWithComments([ + { text: 'AAA', comment: { commentId: 'c1', resolved: false } }, + { text: 'BBB', comment: { commentId: 'c2', resolved: false } }, + ]); + + updateYjsMarkAttribute( + fragment, + 'comment', + { name: 'commentId', value: 'c1' }, + { resolved: true }, + ); + + // c1's run flips resolved=true (commentId preserved via merge); c2 untouched. + expect(text.toDelta()).toEqual([ + { + insert: 'AAA', + attributes: { comment: { commentId: 'c1', resolved: true } }, + }, + { + insert: 'BBB', + attributes: { comment: { commentId: 'c2', resolved: false } }, + }, + ]); + }); + + it('does nothing when no run matches (no-match branch)', () => { + const { fragment, text } = buildWithComments([ + { text: 'AAA', comment: { commentId: 'c1', resolved: false } }, + ]); + const before = text.toDelta(); + + updateYjsMarkAttribute( + fragment, + 'comment', + { name: 'commentId', value: 'nope' }, + { resolved: true }, + ); + + expect(text.toDelta()).toEqual(before); + }); +}); diff --git a/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.spec.ts b/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.spec.ts new file mode 100644 index 00000000..53ad6191 --- /dev/null +++ b/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.spec.ts @@ -0,0 +1,166 @@ +import { McpClientsService } from './mcp-clients.service'; + +/** + * Unit tests for the two security-critical surfaces of McpClientsService that the + * sibling specs (ssrf-guard / validate-resolved-addresses / lease) do NOT cover: + * + * 1. `decryptHeaders` (private) — FAIL-OPEN behavior. A decrypt/parse failure + * (e.g. APP_SECRET rotated, tampered blob) must NEVER throw and must NEVER + * log the blob: it returns `undefined` so the connect proceeds WITHOUT the + * now-unreadable auth headers (which then 401s and the server is skipped), + * rather than crashing the whole turn. + * + * 2. `this.guardedFetch` (private, bound to the SSRF-pinned dispatcher) — the + * per-request DNS-rebinding guard. A blocked host (private/loopback/metadata + * IP literal, or an unparseable URL) must REJECT before any socket is opened; + * a public host is allowed through to the real `fetch` with the pinned + * dispatcher attached. + * + * No network and no DB: the repo + secretBox deps are stubbed, and global `fetch` + * is mocked for the single allow-path assertion. + */ + +// Build the service with a SecretBoxService stub whose decryptSecret is supplied +// per-test. The repo dep is unused by the methods under test. +function buildService(decryptSecret: (blob: string) => string) { + const secretBox = { decryptSecret: jest.fn(decryptSecret) }; + const service = new McpClientsService({} as never, secretBox as never); + return { service, secretBox }; +} + +describe('McpClientsService.decryptHeaders', () => { + // Reach the private method via the as-any pattern common in these NestJS specs. + const callDecrypt = ( + service: McpClientsService, + blob: string | null, + ): Record | undefined => + ( + service as unknown as { + decryptHeaders: (b: string | null) => Record | undefined; + } + ).decryptHeaders(blob); + + it('returns undefined for a null blob without decrypting', () => { + const { service, secretBox } = buildService(() => '{}'); + expect(callDecrypt(service, null)).toBeUndefined(); + expect(secretBox.decryptSecret).not.toHaveBeenCalled(); + }); + + it('decrypts a valid blob and keeps only string-valued headers', () => { + const { service } = buildService(() => + JSON.stringify({ + Authorization: 'Bearer abc', + 'X-Api-Key': 'k', + // Non-string values must be dropped, not coerced. + count: 5, + flag: true, + nested: { a: 1 }, + }), + ); + expect(callDecrypt(service, 'cipher')).toEqual({ + Authorization: 'Bearer abc', + 'X-Api-Key': 'k', + }); + }); + + it('returns undefined when the decrypted object has no string headers', () => { + const { service } = buildService(() => JSON.stringify({ count: 5 })); + // No usable headers -> undefined (connect with no auth header), not {}. + expect(callDecrypt(service, 'cipher')).toBeUndefined(); + }); + + it('FAILS OPEN: a decrypt error returns undefined instead of throwing', () => { + const { service } = buildService(() => { + throw new Error('Failed to decrypt secret — APP_SECRET may have changed'); + }); + const warnSpy = jest + .spyOn( + (service as unknown as { logger: { warn: (...a: unknown[]) => void } }) + .logger, + 'warn', + ) + .mockImplementation(() => undefined); + + let result: unknown; + expect(() => { + result = callDecrypt(service, 'tampered-blob'); + }).not.toThrow(); + expect(result).toBeUndefined(); + // It warns (so ops sees degradation) but never logs the blob itself. + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain('tampered-blob'); + }); + + it('FAILS OPEN: malformed JSON (decrypts to non-JSON) returns undefined', () => { + const { service } = buildService(() => 'not-json{'); + jest + .spyOn( + (service as unknown as { logger: { warn: (...a: unknown[]) => void } }) + .logger, + 'warn', + ) + .mockImplementation(() => undefined); + expect(callDecrypt(service, 'cipher')).toBeUndefined(); + }); +}); + +describe('McpClientsService.guardedFetch (SSRF per-request guard)', () => { + // The bound guardedFetch closure lives on the instance as a private field. + const guardedFetchOf = (service: McpClientsService) => + (service as unknown as { guardedFetch: typeof fetch }).guardedFetch; + + let fetchSpy: jest.SpiedFunction; + + beforeEach(() => { + // Any reachable real fetch would be a network call; assert per-test that the + // blocked paths never reach it, and stub a Response for the allow path. + fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response('ok', { status: 200 })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const blocked: Array<[string, string]> = [ + ['loopback IPv4', 'http://127.0.0.1/mcp'], + ['private 10/8', 'http://10.0.0.5/mcp'], + ['private 192.168/16', 'http://192.168.1.1/mcp'], + ['cloud metadata link-local', 'http://169.254.169.254/latest/meta-data/'], + ['loopback IPv6 (bracketed)', 'http://[::1]:8080/mcp'], + ]; + + it.each(blocked)( + 'rejects a request to %s without opening a socket', + async (_label, url) => { + const { service } = buildService(() => '{}'); + await expect(guardedFetchOf(service)(url)).rejects.toThrow( + /blocked request/, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }, + ); + + it('rejects an unparseable URL as a blocked request', async () => { + const { service } = buildService(() => '{}'); + await expect( + guardedFetchOf(service)('::: not a url :::'), + ).rejects.toThrow('blocked request: invalid URL'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('allows a public IP literal and forwards through the pinned dispatcher', async () => { + const { service } = buildService(() => '{}'); + const res = await guardedFetchOf(service)('http://8.8.8.8/mcp'); + + expect(res.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + // The init MUST carry the SSRF-pinned undici dispatcher (the rebinding pin); + // dropping it would let undici do a second, unchecked DNS resolution. + const init = fetchSpy.mock.calls[0][1] as RequestInit & { + dispatcher?: unknown; + }; + expect(init.dispatcher).toBeDefined(); + }); +}); diff --git a/apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts b/apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts new file mode 100644 index 00000000..31461717 --- /dev/null +++ b/apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts @@ -0,0 +1,124 @@ +import { z } from 'zod'; +import { AiChatToolsService } from './ai-chat-tools.service'; +import * as loader from './docmost-client.loader'; +import type { DocmostClientLike } from './docmost-client.loader'; +// The real zod-agnostic registry, imported from source so the contract is checked +// against exactly what the @docmost/mcp package ships (no hand-stub). +import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs'; + +/** + * CONTRACT: SHARED_TOOL_SPECS <-> in-app tool wiring parity. + * + * `packages/mcp/src/tool-specs.ts` is the single source of truth for the tools + * that are intentionally IDENTICAL across the standalone MCP server (zod v3) and + * the in-app AI-SDK service (zod v4). The in-app service builds each one via + * `sharedTool(sharedToolSpecs., execute)`, keyed by the spec's `inAppKey`. + * + * This test fails the build if a spec is added to the registry but never wired + * in-app, if an `inAppKey` is renamed without updating the service, if the + * description drifts between the registry and the exposed tool, if the + * snake_case `mcpName` <-> camelCase `inAppKey` convention is broken, or if the + * exposed tool's input-schema keys diverge from the spec's `buildShape`. + * + * It does NOT need @docmost/mcp built: the registry is imported from TS source, + * and the ESM loader is mocked so `forUser()` never dynamically imports the + * package. + */ +describe('SHARED_TOOL_SPECS contract parity', () => { + // Empty fake client: no tool is executed here — every assertion is on tool + // presence / metadata / schema, so the client methods are never called. + const fakeClient: Partial = {}; + const tokenServiceStub = { + generateAccessToken: jest.fn().mockResolvedValue('access-token'), + generateCollabToken: jest.fn().mockResolvedValue('collab-token'), + }; + + let tools: Record; + + beforeAll(async () => { + jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({ + DocmostClient: function () { + return fakeClient as DocmostClientLike; + } as unknown as loader.DocmostClientCtor, + // Feed the service the SAME registry this test asserts against. + sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record< + string, + loader.SharedToolSpec + >, + }); + const service = new AiChatToolsService( + tokenServiceStub as never, + {} as never, + {} as never, + {} as never, + {} as never, + { asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }) } as never, + ); + tools = (await service.forUser( + { id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never, + 'session-1', + 'ws-1', + 'chat-1', + )) as unknown as Record; + }); + + afterAll(() => jest.restoreAllMocks()); + + // camelCase -> snake_case, matching the registry's mcpName convention. + const toSnake = (s: string) => + s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); + + // Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal + // above otherwise narrows to a union where some members lack buildShape. + const specEntries = Object.entries(SHARED_TOOL_SPECS) as Array< + [string, loader.SharedToolSpec] + >; + + // Sanity: the registry is non-empty, so the per-spec table below is not vacuous. + it('registry is non-empty', () => { + expect(specEntries.length).toBeGreaterThan(0); + }); + + describe.each(specEntries)('spec "%s"', (registryKey, spec) => { + it('registry key equals its inAppKey', () => { + // The service indexes the registry by property name; a key != inAppKey + // would wire the wrong (or no) tool. + expect(spec.inAppKey).toBe(registryKey); + }); + + it('mcpName is the snake_case form of inAppKey', () => { + expect(spec.mcpName).toBe(toSnake(spec.inAppKey)); + }); + + it('is exposed in-app under its inAppKey', () => { + // Fails if a spec is added to the registry but never wired in forUser(). + expect(tools[spec.inAppKey]).toBeDefined(); + }); + + it("exposed tool's description matches the registry description", () => { + const tool = tools[spec.inAppKey] as { description: string }; + expect(tool.description).toBe(spec.description); + }); + + it("exposed tool's input-schema keys match buildShape (incl. required)", () => { + const tool = tools[spec.inAppKey] as { + inputSchema: { jsonSchema: { properties?: Record; required?: string[] } }; + }; + const json = tool.inputSchema.jsonSchema; + const actualKeys = Object.keys(json.properties ?? {}).sort(); + + // Derive the spec's declared shape with THIS layer's zod (v4) — the same + // call the service makes — then compare key sets and required-ness. + const shape = spec.buildShape ? spec.buildShape(z) : {}; + const expectedKeys = Object.keys(shape).sort(); + expect(actualKeys).toEqual(expectedKeys); + + // A non-.optional() field must surface as required in the advertised schema. + const expectedRequired = Object.entries(shape) + .filter(([, field]) => !(field as z.ZodTypeAny).isOptional?.()) + .map(([k]) => k) + .sort(); + expect((json.required ?? []).slice().sort()).toEqual(expectedRequired); + }); + }); +}); diff --git a/apps/server/src/integrations/storage/storage.service.spec.ts b/apps/server/src/integrations/storage/storage.service.spec.ts index 79db48c0..fc80246e 100644 --- a/apps/server/src/integrations/storage/storage.service.spec.ts +++ b/apps/server/src/integrations/storage/storage.service.spec.ts @@ -1,18 +1,110 @@ +import { Readable } from 'stream'; import { StorageService } from './storage.service'; +import type { StorageDriver } from './interfaces'; -// Direct instantiation with a stub driver. The Test.createTestingModule form -// failed to resolve the STORAGE_DRIVER_TOKEN at compile(); this smoke test only -// needs the service to construct. -describe('StorageService', () => { +/** + * StorageService is a thin facade over the injected StorageDriver: each public + * method must forward to the driver with the SAME arguments and return/await the + * driver's result unchanged (the read paths return it; the write paths await it). + * A mock driver lets us assert that delegation exactly, with no real S3/disk IO. + */ +describe('StorageService delegation', () => { + // Every driver method is a jest mock so we can assert call args + return passing. + function buildDriver(): jest.Mocked { + return { + upload: jest.fn().mockResolvedValue(undefined), + uploadStream: jest.fn().mockResolvedValue(undefined), + copy: jest.fn().mockResolvedValue(undefined), + read: jest.fn(), + readStream: jest.fn(), + readRangeStream: jest.fn(), + exists: jest.fn(), + getUrl: jest.fn(), + getSignedUrl: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + getDriver: jest.fn(), + getDriverName: jest.fn(), + getConfig: jest.fn(), + } as unknown as jest.Mocked; + } + + let driver: jest.Mocked; let service: StorageService; beforeEach(() => { - service = new StorageService( - {} as any, // storageDriver - ); + driver = buildDriver(); + service = new StorageService(driver as unknown as StorageDriver); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('upload forwards path + content to the driver', async () => { + const buf = Buffer.from('data'); + await service.upload('a/b.png', buf); + expect(driver.upload).toHaveBeenCalledWith('a/b.png', buf); + }); + + it('uploadStream forwards path, stream and options', async () => { + const stream = Readable.from(['x']); + await service.uploadStream('a/b.bin', stream, { recreateClient: true }); + expect(driver.uploadStream).toHaveBeenCalledWith('a/b.bin', stream, { + recreateClient: true, + }); + }); + + it('copy forwards both paths', async () => { + await service.copy('from.txt', 'to.txt'); + expect(driver.copy).toHaveBeenCalledWith('from.txt', 'to.txt'); + }); + + it('read returns the driver buffer unchanged', async () => { + const buf = Buffer.from('content'); + driver.read.mockResolvedValue(buf); + await expect(service.read('f.txt')).resolves.toBe(buf); + expect(driver.read).toHaveBeenCalledWith('f.txt'); + }); + + it('readStream returns the driver stream unchanged', async () => { + const stream = Readable.from(['y']); + driver.readStream.mockResolvedValue(stream); + await expect(service.readStream('f.bin')).resolves.toBe(stream); + expect(driver.readStream).toHaveBeenCalledWith('f.bin'); + }); + + it('readRangeStream forwards the range object and returns the stream', async () => { + const stream = Readable.from(['z']); + driver.readRangeStream.mockResolvedValue(stream); + const range = { start: 0, end: 99 }; + await expect(service.readRangeStream('f.bin', range)).resolves.toBe(stream); + expect(driver.readRangeStream).toHaveBeenCalledWith('f.bin', range); + }); + + it('exists returns the driver boolean', async () => { + driver.exists.mockResolvedValue(false); + await expect(service.exists('missing')).resolves.toBe(false); + expect(driver.exists).toHaveBeenCalledWith('missing'); + }); + + it('getSignedUrl forwards path + expiry and returns the signed url', async () => { + driver.getSignedUrl.mockResolvedValue('https://signed/url'); + await expect(service.getSignedUrl('f.png', 600)).resolves.toBe( + 'https://signed/url', + ); + expect(driver.getSignedUrl).toHaveBeenCalledWith('f.png', 600); + }); + + it('getUrl returns the driver url synchronously', () => { + driver.getUrl.mockReturnValue('https://cdn/f.png'); + expect(service.getUrl('f.png')).toBe('https://cdn/f.png'); + expect(driver.getUrl).toHaveBeenCalledWith('f.png'); + }); + + it('delete forwards the path', async () => { + await service.delete('old.txt'); + expect(driver.delete).toHaveBeenCalledWith('old.txt'); + }); + + it('getDriverName returns the driver name', () => { + driver.getDriverName.mockReturnValue('s3'); + expect(service.getDriverName()).toBe('s3'); + expect(driver.getDriverName).toHaveBeenCalledTimes(1); }); });