Honour no-store reads through Obsidian's Internal API

This commit is contained in:
vorotamoroz committed 2026-11-01 07:23:09 +00:00
1 parent 0e458e5e20
commit a7274aafd6
5 files changed
+136 -12

No files matched your search

@@ -182,6 +182,17 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
delete transformedHeaders["Host"];
delete transformedHeaders["content-length"];
delete transformedHeaders["Content-Length"];
const cacheMode = opts?.cache ?? (req instanceof Request ? req.cache : undefined);
if (cacheMode === "no-store" && ["GET", "HEAD"].includes(method.toUpperCase())) {
// requestUrl has no cache option; no-store alone can reuse an existing cached response.
for (const key of Object.keys(transformedHeaders)) {
if (["cache-control", "pragma"].includes(key.toLowerCase())) {
delete transformedHeaders[key];
}
}
transformedHeaders["cache-control"] = "no-cache, no-store";
transformedHeaders["pragma"] = "no-cache";
}
const contentType =
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
const requestParam: RequestUrlParam = {
@@ -1,6 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RequestUrlParam } from "@/deps";
const mocks = vi.hoisted(() => ({
requestUrl: vi.fn<
(request: RequestUrlParam) => Promise<{
status: number;
headers: Record<string, string>;
arrayBuffer: ArrayBuffer;
}>
>(),
platform: {
isMobile: false,
},
@@ -8,12 +16,12 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/deps.ts", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
requestUrl: mocks.requestUrl,
}));
vi.mock("@/deps", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
requestUrl: mocks.requestUrl,
}));
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
@@ -36,6 +44,8 @@ function createService(workspace: Record<string, unknown>, isMobile = false): Ob
beforeEach(() => {
mocks.platform.isMobile = false;
vi.clearAllMocks();
mocks.requestUrl.mockReset();
mocks.requestUrl.mockResolvedValue({ status: 200, headers: {}, arrayBuffer: new ArrayBuffer(0) });
});
describe("ObsidianAPIService.showWindowOnRight", () => {
@@ -65,3 +75,94 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
});
});
describe("ObsidianAPIService.nativeFetch cache control", () => {
const url = "https://couchdb.example.test/vault/f%3Ametadata";
function sentHeaders(): Headers {
expect(mocks.requestUrl).toHaveBeenCalledOnce();
return new Headers(mocks.requestUrl.mock.calls[0][0].headers);
}
it.each(["GET", "HEAD", "get", "head"])("bypasses cached responses for an explicit no-store %s", async (method) => {
const headers = {
Authorization: "Basic fixture",
"Cache-Control": "public, max-age=3600",
"cAcHe-CoNtRoL": "max-stale",
Pragma: "cache",
};
await createService({}).nativeFetch(url, { method, cache: "no-store", headers });
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
expect(sentHeaders().get("pragma")).toBe("no-cache");
expect(sentHeaders().get("authorization")).toBe("Basic fixture");
expect(headers["Cache-Control"]).toBe("public, max-age=3600");
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
});
it("uses the Request cache mode when the options do not override it", async () => {
const request = new Request(url, {
method: "HEAD",
cache: "no-store",
headers: { "X-Fixture": "request" },
});
const headers = new Headers({ "X-Other": "options" });
await createService({}).nativeFetch(request, { headers });
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
expect(sentHeaders().get("pragma")).toBe("no-cache");
expect(sentHeaders().get("x-fixture")).toBe("request");
expect(sentHeaders().get("x-other")).toBe("options");
expect(headers.has("cache-control")).toBe(false);
expect(request.headers.has("cache-control")).toBe(false);
});
it("lets the options request no-store for an otherwise cacheable Request", async () => {
await createService({}).nativeFetch(new Request(url, { cache: "force-cache" }), { cache: "no-store" });
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
});
it("lets an explicit default cache mode override a no-store Request", async () => {
await createService({}).nativeFetch(new Request(url, { cache: "no-store" }), { cache: "default" });
expect(sentHeaders().has("cache-control")).toBe(false);
expect(sentHeaders().has("pragma")).toBe(false);
});
it.each([undefined, "default", "no-cache", "reload", "force-cache"] as const)(
"preserves caller headers for cache mode %s",
async (cache) => {
const headers = { "Cache-Control": "max-age=60", Pragma: "fixture" };
await createService({}).nativeFetch(url, { cache, headers });
expect(mocks.requestUrl.mock.calls[0][0].headers).toEqual(headers);
}
);
it("does not add cache directives to an ordinary request", async () => {
await createService({}).nativeFetch(url);
expect(sentHeaders().has("cache-control")).toBe(false);
expect(sentHeaders().has("pragma")).toBe(false);
});
it.each(["POST", "PUT", "DELETE"])("preserves a no-store %s request", async (method) => {
const headers = { "Cache-Control": "no-transform" };
await createService({}).nativeFetch(url, { method, cache: "no-store", headers });
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method, headers });
expect(sentHeaders().has("pragma")).toBe(false);
});
it("uses the effective method from the options", async () => {
await createService({}).nativeFetch(new Request(url, { cache: "no-store" }), { method: "POST" });
expect(sentHeaders().has("cache-control")).toBe(false);
expect(sentHeaders().has("pragma")).toBe(false);
});
});