File size: 6,275 Bytes
6c3af4e
 
6dcf170
 
6c3af4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6dcf170
 
 
 
6c3af4e
6dcf170
6c3af4e
 
 
 
6dcf170
 
 
 
6c3af4e
 
6dcf170
 
 
 
 
 
 
 
 
 
6c3af4e
 
 
 
 
6dcf170
 
6c3af4e
 
6dcf170
6c3af4e
 
6dcf170
6c3af4e
 
 
 
 
6dcf170
 
6c3af4e
 
 
 
 
 
 
6dcf170
6c3af4e
 
6dcf170
 
 
 
6c3af4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6dcf170
 
6c3af4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
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);
  });
});