MiniSearch / server /searchToken.test.ts
system's picture
system HF Staff
Sync from felladrin/MiniSearch@ff7e32b (part 2)
6dcf170 verified
Raw History Blame Contribute Delete
6.28 kB
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// `existsSync` is mocked so that if the read-from-file path ever comes back,
// this file fails on the assertion below rather than on a missing mock.
const mockExistsSync = vi.fn();
const mockReadFileSync = vi.fn();
const mockWriteFileSync = vi.fn();
const mockChmodSync = vi.fn();
vi.mock("node:fs", () => ({
default: {
existsSync: (...args: unknown[]) => mockExistsSync(...args),
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
chmodSync: (...args: unknown[]) => mockChmodSync(...args),
},
existsSync: (...args: unknown[]) => mockExistsSync(...args),
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
chmodSync: (...args: unknown[]) => mockChmodSync(...args),
}));
vi.mock("temp-dir", () => ({ default: "/tmp" }));
beforeEach(() => {
// The module holds the token for the life of the process, so every test
// needs its own copy of it.
vi.resetModules();
vi.clearAllMocks();
mockExistsSync.mockReset();
mockReadFileSync.mockReset();
mockWriteFileSync.mockReset();
mockChmodSync.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("searchToken", () => {
it("should generate a fresh token instead of adopting one already in the file", async () => {
// The published image used to ship a token in the temp directory, and a
// container that read it shared one token with every other container built
// from that image, and with anyone who pulled the image and read the layer.
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue("token-baked-into-the-image");
const { getSearchToken } = await import("./searchToken");
const token = getSearchToken();
expect(token).not.toBe("token-baked-into-the-image");
expect(token).toMatch(/^[0-9a-f]{64}$/);
expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
expect(token).toBe(mockWriteFileSync.mock.calls[0][1]);
});
it("should still serve a usable token when the file cannot be written", async () => {
// `getSearchToken()` runs inside the argon2 try in
// `verifyTokenAndRateLimit.ts`, so a throw out of the token module would
// read as a bad token and 401 every request with nothing in the log.
const accessError = Object.assign(new Error("EACCES: permission denied"), {
code: "EACCES",
});
mockWriteFileSync.mockImplementation(() => {
throw accessError;
});
const { getSearchToken } = await import("./searchToken");
const token = getSearchToken();
expect(token).toMatch(/^[0-9a-f]{64}$/);
expect(getSearchToken()).toBe(token);
});
it("should keep the token it generated when the file changes", async () => {
const { getSearchToken } = await import("./searchToken");
const firstToken = getSearchToken();
// Another process rewriting the file must not re-key this one: the clients
// already holding the old token have no way of being told about a new one.
mockReadFileSync.mockReturnValue("token-from-another-process");
expect(getSearchToken()).toBe(firstToken);
expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
});
it("should serve the new token after regenerating it", async () => {
const { getSearchToken, regenerateSearchToken } = await import(
"./searchToken"
);
const firstToken = getSearchToken();
regenerateSearchToken();
expect(getSearchToken()).not.toBe(firstToken);
expect(getSearchToken()).toBe(
mockWriteFileSync.mock.calls[mockWriteFileSync.mock.calls.length - 1][1],
);
});
it("regenerateSearchToken should draw the token from a cryptographic source", async () => {
mockWriteFileSync.mockReturnValue(undefined);
const mathRandomSpy = vi.spyOn(Math, "random");
const { regenerateSearchToken } = await import("./searchToken");
regenerateSearchToken();
regenerateSearchToken();
const [firstToken, secondToken] = mockWriteFileSync.mock.calls.map(
(call) => call[1] as string,
);
expect(mathRandomSpy).not.toHaveBeenCalled();
// 32 random bytes, hex-encoded
expect(firstToken).toMatch(/^[0-9a-f]{64}$/);
expect(firstToken).not.toBe(secondToken);
});
it("regenerateSearchToken should restrict the token file to its owner", async () => {
mockWriteFileSync.mockReturnValue(undefined);
mockChmodSync.mockReturnValue(undefined);
const { regenerateSearchToken } = await import("./searchToken");
regenerateSearchToken();
const [filePath, , options] = mockWriteFileSync.mock.calls[0];
expect(options).toEqual({ mode: 0o600 });
// A file left behind by an earlier build keeps its old permissions,
// which `mode` alone would not correct.
expect(mockChmodSync).toHaveBeenCalledWith(filePath, 0o600);
});
});
describe("hasSearchTokenFileChanged", () => {
it("should be false before a token has been read", async () => {
const { hasSearchTokenFileChanged } = await import("./searchToken");
expect(hasSearchTokenFileChanged()).toBe(false);
expect(mockReadFileSync).not.toHaveBeenCalled();
});
it("should be false while the file still holds this process's token", async () => {
const { getSearchToken, hasSearchTokenFileChanged } = await import(
"./searchToken"
);
const token = getSearchToken();
mockReadFileSync.mockReturnValue(token);
expect(hasSearchTokenFileChanged()).toBe(false);
});
it("should be true once another process rewrites the file", async () => {
const { getSearchToken, hasSearchTokenFileChanged } = await import(
"./searchToken"
);
getSearchToken();
mockReadFileSync.mockReturnValue("token-from-another-process");
expect(hasSearchTokenFileChanged()).toBe(true);
});
it("should be true when the file is gone", async () => {
const { getSearchToken, hasSearchTokenFileChanged } = await import(
"./searchToken"
);
getSearchToken();
mockReadFileSync.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(hasSearchTokenFileChanged()).toBe(true);
});
});