@opentermsarchive/engine 16.0.0 → 16.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "16.0.0",
3
+ "version": "16.0.2",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
@@ -20,7 +20,7 @@ const fs = fsApi.promises;
20
20
  const ARCHIVE_FORMAT = 'zip'; // for supported formats, see https://www.archiverjs.com/docs/archive-formats
21
21
 
22
22
  export default async function generate({ archivePath, releaseDate }) {
23
- const versionsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.versions.storage')).initialize();
23
+ const versionsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.versions.storage'), { readOnly: true }).initialize(); // The export only reads the repository the tracker writes to
24
24
 
25
25
  const temporaryArchivePath = `${archivePath}${TEMPORARY_SUFFIX}`;
26
26
  const archive = await initializeArchive(temporaryArchivePath, path.basename(archivePath, path.extname(archivePath)));
@@ -13,6 +13,7 @@ describe('Collection', () => {
13
13
  let metadataBackup;
14
14
  let getCollection;
15
15
  let collection;
16
+ let instanceCount = 0;
16
17
 
17
18
  before(async () => {
18
19
  try {
@@ -29,7 +30,7 @@ describe('Collection', () => {
29
30
  });
30
31
 
31
32
  beforeEach(async () => {
32
- const { getCollection: reloadedGetCollection } = await import(`./index.js?t=${Date.now()}`); // Ensure a new instance is loaded for each test
33
+ const { getCollection: reloadedGetCollection } = await import(`./index.js?instance=${++instanceCount}`); // Ensure a new instance is loaded for each test, even when two tests start within the same millisecond
33
34
 
34
35
  getCollection = reloadedGetCollection;
35
36
  });
@@ -1,4 +1,5 @@
1
- import puppeteer from 'puppeteer-extra';
1
+ import puppeteer from 'puppeteer';
2
+ import { addExtra } from 'puppeteer-extra';
2
3
  import stealthPlugin from 'puppeteer-extra-plugin-stealth';
3
4
  import navigatorLanguages from 'puppeteer-extra-plugin-stealth/evasions/navigator.languages/index.js';
4
5
  import userAgentOverride from 'puppeteer-extra-plugin-stealth/evasions/user-agent-override/index.js';
@@ -39,7 +40,7 @@ export default async function fetch(url, cssSelectors, config) {
39
40
  page = await context.newPage();
40
41
  client = await page.createCDPSession();
41
42
 
42
- await configurePage(page, config);
43
+ await configurePage(page, client, config);
43
44
 
44
45
  const selectors = [].concat(cssSelectors).filter(Boolean);
45
46
 
@@ -47,7 +48,7 @@ export default async function fetch(url, cssSelectors, config) {
47
48
  let handled = null;
48
49
 
49
50
  if (!selectors.length) { // CSS selectors are specified only for HTML content and omitted when fetching a PDF
50
- ({ pdf, handled } = setupPdfInterception(client));
51
+ ({ pdf, handled } = await setupPdfInterception(client));
51
52
  }
52
53
 
53
54
  let response;
@@ -125,13 +126,13 @@ export async function launchHeadlessBrowser(language) {
125
126
  }
126
127
 
127
128
  const { locale, languages } = parseLanguage(language);
129
+ const puppeteerExtra = addExtra(puppeteer); // Fresh instance for each launch, as plugins registered on the shared instance accumulate across launches and their hooks would run once per copy on every page
128
130
  const stealth = stealthPlugin();
129
131
 
130
132
  stealth.enabledEvasions.delete('user-agent-override');
131
133
  stealth.enabledEvasions.delete('navigator.languages');
132
- puppeteer.use(stealth);
133
- puppeteer.use(userAgentOverride({ locale }));
134
- puppeteer.use(navigatorLanguages({ languages }));
134
+ puppeteerExtra.use(stealth);
135
+ puppeteerExtra.use(navigatorLanguages({ languages }));
135
136
 
136
137
  const options = {
137
138
  args: [],
@@ -156,12 +157,21 @@ export async function launchHeadlessBrowser(language) {
156
157
  options.args.push('--disable-setuid-sandbox');
157
158
  }
158
159
 
159
- browser = await puppeteer.launch(options);
160
+ const launchedBrowser = await puppeteerExtra.launch(options);
161
+
162
+ try {
163
+ launchedBrowser.userAgentOverride = await captureUserAgentOverride(launchedBrowser, locale); // Computed once since the user agent is the same for every page of the browser
164
+ } catch (error) {
165
+ await launchedBrowser.close().catch(() => {});
166
+ throw error;
167
+ }
160
168
 
161
169
  if (proxyCredentials) {
162
- browser.proxyCredentials = proxyCredentials;
170
+ launchedBrowser.proxyCredentials = proxyCredentials;
163
171
  }
164
172
 
173
+ browser = launchedBrowser; // Set only once fully configured, so that no caller gets a browser without its user agent override
174
+
165
175
  return browser;
166
176
  }
167
177
 
@@ -184,22 +194,44 @@ function isValidHttpStatus(status) {
184
194
  return (status >= 200 && status < 300) || status === 304;
185
195
  }
186
196
 
187
- async function configurePage(page, config) {
197
+ async function configurePage(page, client, config) {
188
198
  await page.setViewport({ width: 1920, height: 1080 }); // Realistic viewport to avoid detection based on default Puppeteer dimensions (800x600)
189
199
  await page.setDefaultNavigationTimeout(config.navigationTimeout);
200
+ await client.send('Network.setUserAgentOverride', browser.userAgentOverride);
190
201
 
191
202
  if (browser.proxyCredentials?.username && browser.proxyCredentials?.password) {
192
203
  await page.authenticate(browser.proxyCredentials);
193
204
  }
194
205
  }
195
206
 
196
- function setupPdfInterception(client) {
207
+ async function captureUserAgentOverride(browser, locale) { // The stealth evasion sends the override without awaiting it, so a failure would crash the process on an unhandled rejection; running it once on a stand-in page records the override for the fetcher to send itself
208
+ const evasion = userAgentOverride({ locale });
209
+ let override;
210
+
211
+ await evasion.beforeLaunch({ headless: true }); // Otherwise the evasion leaves Accept-Language to browser preferences
212
+ await evasion.onPageCreated({ // The stand-in page exposes only what the evasion reads
213
+ browser: () => browser,
214
+ _client: () => ({
215
+ send: (method, params) => {
216
+ if (method === 'Network.setUserAgentOverride') {
217
+ override = params;
218
+ }
219
+ },
220
+ }),
221
+ });
222
+
223
+ if (!override?.userAgent) {
224
+ throw new Error('Could not capture the user agent override from the stealth user-agent-override evasion');
225
+ }
226
+
227
+ return override;
228
+ }
229
+
230
+ async function setupPdfInterception(client) {
197
231
  const pdf = { content: null, status: null };
198
232
  let onHandled;
199
233
  const handled = new Promise(resolve => { onHandled = resolve; });
200
234
 
201
- client.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Response' }] }); // Intercept all responses before Chrome processes them, allowing to capture PDF content before it's handled by the PDF viewer
202
-
203
235
  client.on('Fetch.requestPaused', async ({ requestId, resourceType, responseHeaders, responseStatusCode }) => {
204
236
  try {
205
237
  const contentType = responseHeaders?.find(header => header.name.toLowerCase() === 'content-type')?.value;
@@ -234,6 +266,8 @@ function setupPdfInterception(client) {
234
266
  }
235
267
  });
236
268
 
269
+ await client.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Response' }] }); // Intercept all responses before Chrome processes them, allowing to capture PDF content before it is handled by the PDF viewer; enabled once the listener is registered so that no paused request is missed
270
+
237
271
  return { pdf, handled };
238
272
  }
239
273
 
@@ -17,7 +17,7 @@ use(chaiAsPromised);
17
17
  const dynamicHTML = '<!DOCTYPE html><html><head><title>Dynamic Page</title><script>setTimeout(() => { document.body.innerHTML += "<div class=\'dynamic\'>Loaded</div>"; }, 100);</script></head><body></body></html>';
18
18
  const delayedContentHTML = '<!DOCTYPE html><html><head><title>Delayed Content</title><script>setTimeout(() => { document.querySelector(".content").textContent = "Final content"; }, 100);</script></head><body><div class="content"></div></body></html>';
19
19
  const langEchoHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-language", navigator.language); document.body.setAttribute("data-languages", navigator.languages.join(","));</script></body></html>';
20
- const stealthProbeHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-webdriver", String(navigator.webdriver)); document.body.setAttribute("data-user-agent", navigator.userAgent); document.body.setAttribute("data-plugin-count", String(navigator.plugins.length)); document.body.setAttribute("data-viewport-width", String(window.innerWidth)); document.body.setAttribute("data-viewport-height", String(window.innerHeight)); (() => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); if (!gl) { document.body.setAttribute("data-webgl-vendor", "none"); return; } const ext = gl.getExtension("WEBGL_debug_renderer_info"); document.body.setAttribute("data-webgl-vendor", ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : ""); document.body.setAttribute("data-webgl-renderer", ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ""); })();</script></body></html>';
20
+ const stealthProbeHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-webdriver", String(navigator.webdriver)); document.body.setAttribute("data-user-agent", navigator.userAgent); document.body.setAttribute("data-platform", navigator.platform); document.body.setAttribute("data-ua-brands", navigator.userAgentData ? navigator.userAgentData.brands.map(entry => entry.brand).join(",") : ""); document.body.setAttribute("data-plugin-count", String(navigator.plugins.length)); document.body.setAttribute("data-viewport-width", String(window.innerWidth)); document.body.setAttribute("data-viewport-height", String(window.innerHeight)); (() => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); if (!gl) { document.body.setAttribute("data-webgl-vendor", "none"); return; } const ext = gl.getExtension("WEBGL_debug_renderer_info"); document.body.setAttribute("data-webgl-vendor", ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : ""); document.body.setAttribute("data-webgl-renderer", ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ""); })();</script></body></html>';
21
21
 
22
22
  describe('Full DOM Fetcher', function () {
23
23
  this.timeout(60000);
@@ -40,6 +40,11 @@ describe('Full DOM Fetcher', function () {
40
40
 
41
41
  response.writeHead(200, { 'Content-Type': 'text/html' }).write(`<!DOCTYPE html><html><body data-accept-language="${acceptLanguage}"></body></html>`);
42
42
  }
43
+ if (request.url === '/client-hints-header') {
44
+ const clientHintsBrands = request.headers['sec-ch-ua'] || '';
45
+
46
+ response.writeHead(200, { 'Content-Type': 'text/html' }).write(`<!DOCTYPE html><html><body>${clientHintsBrands}</body></html>`);
47
+ }
43
48
  if (request.url === '/lang-echo') {
44
49
  response.writeHead(200, { 'Content-Type': 'text/html' }).write(langEchoHTML);
45
50
  }
@@ -76,6 +81,16 @@ describe('Full DOM Fetcher', function () {
76
81
 
77
82
  expect(browser1).to.equal(browser2);
78
83
  });
84
+
85
+ it('registers plugin hooks once per launch', async () => {
86
+ const initialListenerCount = (await launchHeadlessBrowser('en')).listenerCount('targetcreated');
87
+
88
+ await stopHeadlessBrowser();
89
+
90
+ const relaunchedBrowser = await launchHeadlessBrowser('en');
91
+
92
+ expect(relaunchedBrowser.listenerCount('targetcreated')).to.equal(initialListenerCount);
93
+ });
79
94
  });
80
95
 
81
96
  describe('#fetch', () => {
@@ -136,6 +151,25 @@ describe('Full DOM Fetcher', function () {
136
151
  expect(content.equals(expectedPDFContent)).to.be.true;
137
152
  });
138
153
  });
154
+
155
+ context('when the user agent override cannot be applied', () => {
156
+ let browser;
157
+ let userAgentOverride;
158
+
159
+ before(async () => {
160
+ browser = await launchHeadlessBrowser('en');
161
+ ({ userAgentOverride } = browser);
162
+ browser.userAgentOverride = {}; // Rejected by Chrome since the user agent is mandatory
163
+ });
164
+
165
+ after(() => {
166
+ browser.userAgentOverride = userAgentOverride;
167
+ });
168
+
169
+ it('rejects with the protocol error', async () => {
170
+ await expect(fetch(`http://127.0.0.1:${SERVER_PORT}/dynamic`, [], config)).to.be.rejectedWith('Network.setUserAgentOverride');
171
+ });
172
+ });
139
173
  });
140
174
 
141
175
  describe('Language configuration', () => {
@@ -201,6 +235,23 @@ describe('Full DOM Fetcher', function () {
201
235
  expect(content).not.to.match(/HeadlessChrome/);
202
236
  });
203
237
 
238
+ it('removes HeadlessChrome from navigator.userAgentData brands', () => {
239
+ expect(content).to.match(/data-ua-brands="[^"]*Google Chrome[^"]*"/);
240
+ expect(content).not.to.match(/data-ua-brands="[^"]*Headless[^"]*"/);
241
+ });
242
+
243
+ it('sends the Sec-CH-UA header without HeadlessChrome', async () => {
244
+ const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/client-hints-header`, [], config);
245
+
246
+ expect(result.content).to.match(/"Google Chrome";v="\d+"/);
247
+ expect(result.content).not.to.match(/Headless/);
248
+ });
249
+
250
+ it('does not expose a Linux host through navigator.platform', () => {
251
+ expect(content).to.match(/data-platform="[^"]+"/);
252
+ expect(content).not.to.match(/data-platform="Linux/);
253
+ });
254
+
204
255
  it('uses a realistic viewport instead of Puppeteer default', () => {
205
256
  expect(content).to.match(/data-viewport-width="1920"/);
206
257
  expect(content).to.match(/data-viewport-height="1080"/);
@@ -4,15 +4,16 @@ import GitRepository from './git/index.js';
4
4
  import MongoRepository from './mongo/index.js';
5
5
 
6
6
  export default class RepositoryFactory {
7
- static create(params) {
7
+ static create(params, { readOnly = false } = {}) {
8
8
  switch (params.type) {
9
9
  case 'git':
10
10
  return new GitRepository({
11
11
  ...params.git,
12
12
  path: path.resolve(process.cwd(), params.git.path),
13
+ readOnly,
13
14
  });
14
15
  case 'mongo':
15
- return new MongoRepository(params.mongo);
16
+ return new MongoRepository({ ...params.mongo, readOnly });
16
17
  default:
17
18
  throw new Error(`Unknown storage repository configuration for type '${params.type}'`);
18
19
  }
@@ -17,14 +17,10 @@ export default class Git {
17
17
 
18
18
  async initialize() {
19
19
  if (!fsApi.existsSync(this.path)) {
20
- await fs.mkdir(this.path, { recursive: true });
20
+ await fs.mkdir(this.path, { recursive: true }); // simple-git cannot be instantiated on a missing directory
21
21
  }
22
22
 
23
- this.git = simpleGit(this.path, {
24
- trimmed: true,
25
- maxConcurrentProcesses: 1, // Concurrent runs on the same repository race the index and the commit-graph and can corrupt them
26
- });
27
-
23
+ this.#connect();
28
24
  await this.git.init();
29
25
 
30
26
  return this.git
@@ -37,6 +33,21 @@ export default class Git {
37
33
  .addConfig('gc.writeCommitGraph', false); // Prevent automatic `git gc` from also writing the commit-graph: the engine writes it explicitly (see `writeCommitGraph`/`updateCommitGraph`), and a concurrent gc write races those, which can leave a stale `commit-graph.lock` and make subsequent operations fail
38
34
  }
39
35
 
36
+ open() {
37
+ if (!fsApi.existsSync(path.join(this.path, '.git'))) {
38
+ throw new Error(`Repository ${this.path} does not exist, it has to be created by a writer such as the tracker first`); // Without this check, git would silently walk up to an enclosing repository, such as the collection one
39
+ }
40
+
41
+ this.#connect();
42
+ }
43
+
44
+ #connect() {
45
+ this.git = simpleGit(this.path, {
46
+ trimmed: true,
47
+ maxConcurrentProcesses: 1, // Concurrent runs on the same repository race the index and the commit-graph and can corrupt them
48
+ });
49
+ }
50
+
40
51
  add(filePath) {
41
52
  return this.git.add(this.relativePath(filePath));
42
53
  }
@@ -129,6 +140,10 @@ export default class Git {
129
140
  return this.git.show(options);
130
141
  }
131
142
 
143
+ showBuffer(options) {
144
+ return this.git.showBuffer(options);
145
+ }
146
+
132
147
  async cleanUp() {
133
148
  await fs.rm(path.join(this.path, '.git', 'objects', 'info', 'commit-graph.lock'), { force: true }); // Remove a leftover commit-graph lock from a previous `commit-graph write` that was killed mid-write (e.g. the process was terminated during a deploy or restart). The commit-graph is a disposable cache rebuilt by `writeCommitGraph`, so clearing a stale lock is safe and prevents every subsequent run from failing.
134
149
  await this.git.reset('hard');
@@ -144,15 +159,6 @@ export default class Git {
144
159
  ]);
145
160
  }
146
161
 
147
- restore(path, commit) {
148
- return this.git.raw([
149
- 'restore',
150
- '-s', commit, // Take the file contents from this specific commit rather than from the index
151
- '--', // Everything after is a pathspec, not a revision or option
152
- path,
153
- ]);
154
- }
155
-
156
162
  async destroyHistory() {
157
163
  await fs.rm(this.path, { recursive: true });
158
164
 
@@ -35,15 +35,22 @@ function canMatchRecordFilePath(...pathSegments) {
35
35
  }
36
36
 
37
37
  export default class GitRepository extends RepositoryInterface {
38
- constructor({ path, author, publish, snapshotIdentiferTemplate }) {
38
+ constructor({ path, author, publish, snapshotIdentiferTemplate, readOnly = false }) {
39
39
  super();
40
40
  this.path = path;
41
41
  this.needsPublication = publish;
42
+ this.readOnly = readOnly; // Readers share the repository with the tracker, so they must never touch the working tree nor the commit-graph: both race the tracker, and the commit-graph write then fails with `commit-graph.lock: File exists`
42
43
  this.git = new Git({ path: this.path, author });
43
44
  this.snapshotIdentiferTemplate = snapshotIdentiferTemplate;
44
45
  }
45
46
 
46
47
  async initialize() {
48
+ if (this.readOnly) {
49
+ this.git.open();
50
+
51
+ return this;
52
+ }
53
+
47
54
  await this.git.initialize();
48
55
  await this.git.cleanUp(); // Drop all uncommitted changes and remove all leftover files that may be present if the process was killed aggressively
49
56
  await this.git.writeCommitGraph(); // Create or replace the commit graph with a new one to ensure it's fully consistent
@@ -52,6 +59,8 @@ export default class GitRepository extends RepositoryInterface {
52
59
  }
53
60
 
54
61
  async save(record) {
62
+ this.#assertWritable('save records');
63
+
55
64
  const { serviceId, termsType, documentId, fetchDate } = record;
56
65
 
57
66
  if (record.isFirstRecord === undefined || record.isFirstRecord === null) {
@@ -75,6 +84,10 @@ export default class GitRepository extends RepositoryInterface {
75
84
  }
76
85
 
77
86
  async finalize() {
87
+ if (this.readOnly) {
88
+ return;
89
+ }
90
+
78
91
  if (this.needsPublication) {
79
92
  await this.git.pushChanges();
80
93
  }
@@ -214,31 +227,20 @@ export default class GitRepository extends RepositoryInterface {
214
227
  }
215
228
  }
216
229
 
217
- removeAll() {
218
- return this.git.destroyHistory();
230
+ async removeAll() {
231
+ this.#assertWritable('remove records');
232
+
233
+ await this.git.destroyHistory();
219
234
  }
220
235
 
221
236
  async loadRecordContent(record) {
222
237
  const relativeFilePath = DataMapper.generateFilePath(record.serviceId, record.termsType, record.documentId, record.mimeType);
223
238
 
224
- if (record.mimeType != mime.getType('pdf')) {
225
- record.content = await this.git.show(`${record.id}:${relativeFilePath}`);
226
-
227
- return;
228
- }
229
-
230
- // In case of PDF files, `git show` cannot be used as it converts PDF binary into strings that do not retain the original binary representation
231
- // It is impossible to restore the original binary data from the resulting string
232
- let pdfBuffer;
239
+ const objectPath = `${record.id}:${relativeFilePath}`;
233
240
 
234
- try {
235
- await this.git.restore(relativeFilePath, record.id); // Temporarily restore the PDF file to a specific commit
236
- pdfBuffer = await fs.readFile(`${this.path}/${relativeFilePath}`); // …read the content
237
- } finally {
238
- await this.git.restore(relativeFilePath, 'HEAD'); // …and finally restore the file to its most recent state
239
- }
240
-
241
- record.content = pdfBuffer;
241
+ record.content = record.mimeType == mime.getType('pdf')
242
+ ? await this.git.showBuffer(objectPath) // Binary content is read straight from the object database, so nothing is ever written to the working tree shared with the tracker
243
+ : await this.git.show(objectPath);
242
244
  }
243
245
 
244
246
  getDiffStats(recordId) {
@@ -308,6 +310,12 @@ export default class GitRepository extends RepositoryInterface {
308
310
  return this.git.isTracked(`${this.path}/${DataMapper.generateFilePath(serviceId, termsType, documentId)}`);
309
311
  }
310
312
 
313
+ #assertWritable(operation) {
314
+ if (this.readOnly) {
315
+ throw new Error(`Cannot ${operation} in read-only repository ${this.path}`);
316
+ }
317
+ }
318
+
311
319
  async #toDomain(commit, { deferContentLoading } = {}) {
312
320
  if (!commit) {
313
321
  return null;
@@ -2,7 +2,8 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
 
5
- import { expect } from 'chai';
5
+ import { expect, use } from 'chai';
6
+ import chaiAsPromised from 'chai-as-promised';
6
7
  import config from 'config';
7
8
  import mime from 'mime';
8
9
 
@@ -14,6 +15,8 @@ import Git from './git.js';
14
15
 
15
16
  import GitRepository from './index.js';
16
17
 
18
+ use(chaiAsPromised);
19
+
17
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
21
 
19
22
  const RECORDER_PATH = path.resolve(__dirname, '../../../../..', config.get('@opentermsarchive/engine.recorder.versions.storage.git.path'));
@@ -2063,4 +2066,75 @@ describe('GitRepository', () => {
2063
2066
  });
2064
2067
  });
2065
2068
  });
2069
+
2070
+ context('when read-only', () => {
2071
+ const UNTRACKED_FILE_PATH = `${RECORDER_PATH}/untracked-file.md`;
2072
+ const COMMIT_GRAPH_PATH = `${RECORDER_PATH}/.git/objects/info/commit-graph`;
2073
+
2074
+ let writer;
2075
+ let record;
2076
+
2077
+ before(async () => {
2078
+ writer = new GitRepository({
2079
+ ...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
2080
+ path: RECORDER_PATH,
2081
+ });
2082
+
2083
+ await writer.git.initialize(); // Bypass GitRepository#initialize, which writes the commit-graph, to check that the read-only repository does not write it either
2084
+ fs.rmSync(COMMIT_GRAPH_PATH, { force: true });
2085
+
2086
+ record = await writer.save(new Version({
2087
+ serviceId: SERVICE_PROVIDER_ID,
2088
+ termsType: TERMS_TYPE,
2089
+ content: CONTENT,
2090
+ fetchDate: FETCH_DATE,
2091
+ snapshotIds: [SNAPSHOT_ID],
2092
+ }));
2093
+
2094
+ fs.writeFileSync(UNTRACKED_FILE_PATH, CONTENT);
2095
+
2096
+ subject = new GitRepository({
2097
+ ...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
2098
+ path: RECORDER_PATH,
2099
+ readOnly: true,
2100
+ });
2101
+
2102
+ await subject.initialize();
2103
+ await subject.finalize();
2104
+ });
2105
+
2106
+ after(() => writer.removeAll());
2107
+
2108
+ it('reads the records', async () => {
2109
+ const records = await subject.findAll();
2110
+
2111
+ expect(records.map(({ id }) => id)).to.deep.equal([record.id]);
2112
+ });
2113
+
2114
+ it('leaves uncommitted changes untouched', () => {
2115
+ expect(fs.existsSync(UNTRACKED_FILE_PATH)).to.be.true;
2116
+ });
2117
+
2118
+ it('does not write the commit-graph', () => {
2119
+ expect(fs.existsSync(COMMIT_GRAPH_PATH)).to.be.false;
2120
+ });
2121
+
2122
+ it('rejects saving records', async () => {
2123
+ await expect(subject.save(record)).to.be.rejectedWith(Error, /read-only/);
2124
+ });
2125
+
2126
+ it('rejects removing records', async () => {
2127
+ await expect(subject.removeAll()).to.be.rejectedWith(Error, /read-only/);
2128
+ });
2129
+
2130
+ it('rejects opening a missing repository', async () => {
2131
+ const missingRepository = new GitRepository({
2132
+ ...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
2133
+ path: `${RECORDER_PATH}-missing`,
2134
+ readOnly: true,
2135
+ });
2136
+
2137
+ await expect(missingRepository.initialize()).to.be.rejectedWith(Error, /does not exist/);
2138
+ });
2139
+ });
2066
2140
  });
@@ -10,12 +10,13 @@ import RepositoryInterface from '../interface.js';
10
10
  import * as DataMapper from './dataMapper.js';
11
11
 
12
12
  export default class MongoRepository extends RepositoryInterface {
13
- constructor({ database: databaseName, collection: collectionName, connectionURI }) {
13
+ constructor({ database: databaseName, collection: collectionName, connectionURI, readOnly = false }) {
14
14
  super();
15
15
 
16
16
  this.client = new MongoClient(connectionURI);
17
17
  this.databaseName = databaseName;
18
18
  this.collectionName = collectionName;
19
+ this.readOnly = readOnly;
19
20
  }
20
21
 
21
22
  async initialize() {
@@ -34,6 +35,8 @@ export default class MongoRepository extends RepositoryInterface {
34
35
  }
35
36
 
36
37
  async save(record) {
38
+ this.#assertWritable('save records');
39
+
37
40
  const { serviceId, termsType, documentId } = record;
38
41
 
39
42
  if (record.isFirstRecord === undefined || record.isFirstRecord === null) {
@@ -223,8 +226,16 @@ export default class MongoRepository extends RepositoryInterface {
223
226
  }
224
227
  }
225
228
 
226
- removeAll() {
227
- return this.collection.deleteMany();
229
+ async removeAll() {
230
+ this.#assertWritable('remove records');
231
+
232
+ await this.collection.deleteMany();
233
+ }
234
+
235
+ #assertWritable(operation) {
236
+ if (this.readOnly) {
237
+ throw new Error(`Cannot ${operation} in read-only repository ${this.databaseName}.${this.collectionName}`);
238
+ }
228
239
  }
229
240
 
230
241
  async loadRecordContent(record) {
@@ -2,7 +2,8 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
 
5
- import { expect } from 'chai';
5
+ import { expect, use } from 'chai';
6
+ import chaiAsPromised from 'chai-as-promised';
6
7
  import config from 'config';
7
8
  import mime from 'mime';
8
9
  import { MongoClient, ObjectId } from 'mongodb';
@@ -12,6 +13,8 @@ import Version from '../../version.js';
12
13
 
13
14
  import MongoRepository from './index.js';
14
15
 
16
+ use(chaiAsPromised);
17
+
15
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
19
 
17
20
  const { connectionURI } = config.get('@opentermsarchive/engine.recorder.snapshots.storage.mongo');
@@ -1944,4 +1947,27 @@ describe('MongoRepository', () => {
1944
1947
  });
1945
1948
  });
1946
1949
  });
1950
+
1951
+ context('when read-only', () => {
1952
+ before(() => {
1953
+ subject = new MongoRepository({
1954
+ ...config.get('@opentermsarchive/engine.recorder.versions.storage.mongo'),
1955
+ readOnly: true,
1956
+ });
1957
+ });
1958
+
1959
+ it('rejects saving records', async () => {
1960
+ await expect(subject.save(new Version({
1961
+ serviceId: SERVICE_PROVIDER_ID,
1962
+ termsType: TERMS_TYPE,
1963
+ content: CONTENT,
1964
+ fetchDate: FETCH_DATE,
1965
+ snapshotIds: [SNAPSHOT_ID],
1966
+ }))).to.be.rejectedWith(Error, /read-only/);
1967
+ });
1968
+
1969
+ it('rejects removing records', async () => {
1970
+ await expect(subject.removeAll()).to.be.rejectedWith(Error, /read-only/);
1971
+ });
1972
+ });
1947
1973
  });
@@ -39,8 +39,8 @@ export default async function apiRouter(basePath) {
39
39
  const services = await Services.load();
40
40
  const collection = await getCollection();
41
41
  const versionsStorageConfig = config.get('@opentermsarchive/engine.recorder.versions.storage');
42
- const versionsRepository = await RepositoryFactory.create(versionsStorageConfig).initialize();
43
- const snapshotsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.snapshots.storage')).initialize();
42
+ const versionsRepository = await RepositoryFactory.create(versionsStorageConfig, { readOnly: true }).initialize(); // The API only reads the repositories the tracker writes to
43
+ const snapshotsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.snapshots.storage'), { readOnly: true }).initialize();
44
44
  const feedConfig = config.get('@opentermsarchive/engine.collection-api.feed');
45
45
  const datasetStorage = new DatasetStorage(config.get('@opentermsarchive/engine.dataset.storagePath'));
46
46