/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { graphitiHome } from "../fs-utils.js"; import type * as IntrospectModule from "../introspect.js"; vi.mock("../introspect.js", async () => { const actual = await vi.importActual("../introspect.js"); return { ...actual, executeGraphQL: vi.fn(), }; }); const { executeGraphQL } = await import("../introspect.js"); const { clearObjectInfoCache, getObjectInfo } = await import("../object-info.js"); const auth = { alias: "test-cache", username: "u", instanceUrl: "https://test-cache.my.salesforce.com", accessToken: "t", orgId: "00D", }; const minimalObjectInfoResponse = { data: { uiapi: { objectInfos: [ { ApiName: "Account", label: "Account", labelPlural: "Accounts", createable: true, deletable: true, updateable: true, queryable: true, searchable: true, custom: false, keyPrefix: "001", nameFields: ["Name"], defaultRecordTypeId: "012000000000000AAA", recordTypeInfos: [], childRelationships: [], fields: [], }, ], }, }, }; afterEach(() => { vi.mocked(executeGraphQL).mockReset(); clearObjectInfoCache(); }); describe("object-info cache hardening", () => { function listCacheFiles(): string[] { const root = path.join(graphitiHome(), "cache", "objectInfos"); if (!fs.existsSync(root)) return []; return fs .readdirSync(root, { recursive: true, withFileTypes: true }) .filter((d) => d.isFile()) .map((d) => d.name); } it("rejects path-traversal in orgAlias when fetching ObjectInfo", async () => { vi.mocked(executeGraphQL).mockResolvedValue(minimalObjectInfoResponse); const before = listCacheFiles(); await getObjectInfo(auth, "../escaped", "Account"); const after = listCacheFiles(); // writeDiskCache silently refuses; no new files anywhere under cacheDir expect(after).toEqual(before); }); it("rejects SObject names with disallowed characters when fetching ObjectInfo", async () => { vi.mocked(executeGraphQL).mockResolvedValue(minimalObjectInfoResponse); await getObjectInfo(auth, "test-cache", "../passwd"); expect(listCacheFiles().find((n) => n.includes("passwd"))).toBeUndefined(); }); it("clearObjectInfoCache silently ignores invalid org alias", () => { expect(() => clearObjectInfoCache("../escaped")).not.toThrow(); }); it("clearObjectInfoCache accepts a valid alias", () => { expect(() => clearObjectInfoCache("test-cache")).not.toThrow(); }); it("treats cache entries with corrupt fetchedAt as stale", async () => { vi.mocked(executeGraphQL).mockResolvedValue(minimalObjectInfoResponse); // First call seeds the disk cache. await getObjectInfo(auth, "test-cache", "Account"); // Corrupt the on-disk fetchedAt to a non-parseable value. const cacheFile = path.join( graphitiHome(), "cache", "objectInfos", "test-cache", "Account.json", ); const raw = JSON.parse(fs.readFileSync(cacheFile, "utf-8")); raw.fetchedAt = "not-a-date"; fs.writeFileSync(cacheFile, JSON.stringify(raw), "utf-8"); // Clear the in-memory cache so the next call hits disk. clearObjectInfoCache(); // A naive Date.now() - new Date(undefined).getTime() yields NaN, // which compares false against CACHE_TTL_MS — without the NaN // guard the corrupt entry would read as fresh forever. With the // guard, getObjectInfo refuses the disk entry and re-fetches. await getObjectInfo(auth, "test-cache", "Account"); expect(vi.mocked(executeGraphQL)).toHaveBeenCalledTimes(2); }); });