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);
});
});