@opentermsarchive/engine 11.0.2 → 12.0.0
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/.eslintrc.yaml +3 -0
- package/config/default.json +5 -0
- package/config/test.json +4 -1
- package/package.json +3 -2
- package/scripts/reporter/duplicate/index.js +1 -1
- package/src/archivist/collection/index.test.js +1 -1
- package/src/archivist/recorder/record.js +21 -0
- package/src/archivist/recorder/repositories/git/dataMapper.js +20 -11
- package/src/archivist/recorder/repositories/git/git.js +14 -2
- package/src/archivist/recorder/repositories/git/index.js +67 -11
- package/src/archivist/recorder/repositories/git/index.test.js +282 -6
- package/src/archivist/recorder/repositories/interface.js +44 -6
- package/src/archivist/recorder/repositories/mongo/index.js +67 -4
- package/src/archivist/recorder/repositories/mongo/index.test.js +330 -13
- package/src/archivist/recorder/version.test.js +38 -0
- package/src/archivist/services/index.js +1 -1
- package/src/collection-api/routes/feed.js +253 -0
- package/src/collection-api/routes/feed.test.js +739 -0
- package/src/collection-api/routes/index.js +16 -1
- package/src/collection-api/routes/services.js +1 -2
- package/src/collection-api/routes/services.test.js +4 -41
- package/src/collection-api/routes/versions.js +78 -78
- package/src/collection-api/routes/versions.test.js +4 -4
- package/src/collection-api/server.js +2 -0
- package/src/reporter/gitlab/index.js +1 -1
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import config from 'config';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import supertest from 'supertest';
|
|
5
|
+
|
|
6
|
+
import { getCollection } from '../../archivist/collection/index.js';
|
|
7
|
+
import RepositoryFactory from '../../archivist/recorder/repositories/factory.js';
|
|
8
|
+
import Version from '../../archivist/recorder/version.js';
|
|
9
|
+
import * as Services from '../../archivist/services/index.js';
|
|
10
|
+
import { toISODateWithoutMilliseconds } from '../../archivist/utils/date.js';
|
|
11
|
+
import app from '../server.js';
|
|
12
|
+
|
|
13
|
+
import feedRouter from './feed.js';
|
|
14
|
+
|
|
15
|
+
const basePath = config.get('@opentermsarchive/engine.collection-api.basePath');
|
|
16
|
+
const request = supertest(app);
|
|
17
|
+
const storageConfig = config.get('@opentermsarchive/engine.recorder.versions.storage');
|
|
18
|
+
|
|
19
|
+
function extractTag(xml, tag) {
|
|
20
|
+
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
|
|
21
|
+
|
|
22
|
+
return match ? match[1] : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('Feed API', () => {
|
|
26
|
+
describe('GET /feed', () => {
|
|
27
|
+
let response;
|
|
28
|
+
let collection;
|
|
29
|
+
|
|
30
|
+
before(async () => {
|
|
31
|
+
collection = await getCollection();
|
|
32
|
+
response = await request.get(`${basePath}/v1/feed`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('responds with 200 status code', () => {
|
|
36
|
+
expect(response.status).to.equal(200);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('responds with Content-Type application/atom+xml', () => {
|
|
40
|
+
expect(response.headers['content-type']).to.match(/^application\/atom\+xml/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('is a valid Atom feed root', () => {
|
|
44
|
+
expect(response.text).to.match(/^<\?xml version="1\.0"/);
|
|
45
|
+
expect(response.text).to.include('<feed');
|
|
46
|
+
expect(response.text).to.include('xmlns="http://www.w3.org/2005/Atom"');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('feed-level metadata', () => {
|
|
50
|
+
it('has a title matching the collection name', () => {
|
|
51
|
+
expect(extractTag(response.text, 'title')).to.equal(collection.metadata.name);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('has a subtitle matching the collection tagline', () => {
|
|
55
|
+
expect(extractTag(response.text, 'subtitle')).to.equal(collection.metadata.tagline);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('has a tag URI id based on the collection id', () => {
|
|
59
|
+
expect(extractTag(response.text, 'id')).to.equal(`tag:opentermsarchive.org,2026:feed:${collection.metadata.id}`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('has an updated element with a valid ISO 8601 datetime', () => {
|
|
63
|
+
const updated = extractTag(response.text, 'updated');
|
|
64
|
+
|
|
65
|
+
expect(updated).to.be.a('string');
|
|
66
|
+
expect(new Date(updated).toString()).to.not.equal('Invalid Date');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('has a self link pointing to the feed endpoint', () => {
|
|
70
|
+
const selfHrefMatch = response.text.match(/<link[^>]*rel="self"[^>]*href="([^"]+)"/);
|
|
71
|
+
|
|
72
|
+
expect(selfHrefMatch).to.not.be.null;
|
|
73
|
+
expect(selfHrefMatch[1]).to.match(new RegExp(`${basePath}/v1/feed$`));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('advertises an application/atom+xml type on the self link', () => {
|
|
77
|
+
expect(response.text).to.match(/<link[^>]*rel="self"[^>]*type="application\/atom\+xml"/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('has an author matching the feed author name', () => {
|
|
81
|
+
expect(response.text).to.match(/<author>[\s\S]*<name>Open Terms Archive engine<\/name>[\s\S]*<\/author>/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('has a logo matching the collection logo', () => {
|
|
85
|
+
expect(extractTag(response.text, 'logo')).to.equal(collection.metadata.logo);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('GET /feed — entries', () => {
|
|
91
|
+
const FETCH_DATE_FIRST = new Date('2023-01-01T12:00:00Z');
|
|
92
|
+
const FETCH_DATE_CHANGE = new Date('2023-06-15T08:30:00Z');
|
|
93
|
+
const FETCH_DATE_UPGRADE = new Date('2024-02-10T16:45:00Z');
|
|
94
|
+
|
|
95
|
+
let response;
|
|
96
|
+
let repository;
|
|
97
|
+
let savedVersions;
|
|
98
|
+
|
|
99
|
+
before(async function () {
|
|
100
|
+
this.timeout(5000);
|
|
101
|
+
repository = RepositoryFactory.create(storageConfig);
|
|
102
|
+
await repository.initialize();
|
|
103
|
+
await repository.removeAll();
|
|
104
|
+
const firstRecord = await repository.save(new Version({
|
|
105
|
+
serviceId: 'service-1',
|
|
106
|
+
termsType: 'Terms of Service',
|
|
107
|
+
content: 'first content',
|
|
108
|
+
fetchDate: FETCH_DATE_FIRST,
|
|
109
|
+
snapshotIds: ['snapshot_1'],
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
const changeRecord = await repository.save(new Version({
|
|
113
|
+
serviceId: 'service-1',
|
|
114
|
+
termsType: 'Terms of Service',
|
|
115
|
+
content: 'changed content',
|
|
116
|
+
fetchDate: FETCH_DATE_CHANGE,
|
|
117
|
+
snapshotIds: ['snapshot_2'],
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const upgradeRecord = await repository.save(new Version({
|
|
121
|
+
serviceId: 'service-2',
|
|
122
|
+
termsType: 'Privacy Policy',
|
|
123
|
+
content: 'initial privacy',
|
|
124
|
+
fetchDate: new Date('2024-01-01T00:00:00Z'),
|
|
125
|
+
snapshotIds: ['snapshot_3'],
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
const technicalUpgradeRecord = await repository.save(new Version({
|
|
129
|
+
serviceId: 'service-2',
|
|
130
|
+
termsType: 'Privacy Policy',
|
|
131
|
+
content: 'upgraded privacy',
|
|
132
|
+
fetchDate: FETCH_DATE_UPGRADE,
|
|
133
|
+
snapshotIds: ['snapshot_4'],
|
|
134
|
+
isTechnicalUpgrade: true,
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
savedVersions = { firstRecord, changeRecord, upgradeRecord, technicalUpgradeRecord };
|
|
138
|
+
response = await request.get(`${basePath}/v1/feed`);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
after(() => repository.removeAll());
|
|
142
|
+
|
|
143
|
+
it('lists one entry per real-change version up to the configured limit', () => {
|
|
144
|
+
const limit = config.get('@opentermsarchive/engine.collection-api.feed.limit');
|
|
145
|
+
const entries = response.text.match(/<entry>/g) || [];
|
|
146
|
+
|
|
147
|
+
expect(entries).to.have.length(Math.min(3, limit));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('orders entries newest-first', () => {
|
|
151
|
+
const updates = [...response.text.matchAll(/<entry>[\s\S]*?<updated>([^<]+)<\/updated>[\s\S]*?<\/entry>/g)].map(match => match[1]);
|
|
152
|
+
|
|
153
|
+
expect(updates).to.deep.equal([...updates].sort().reverse());
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('entry metadata', () => {
|
|
157
|
+
let firstEntry;
|
|
158
|
+
|
|
159
|
+
before(() => {
|
|
160
|
+
[firstEntry] = response.text.match(/<entry>[\s\S]*?<\/entry>/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('has an id tag URI including storage type and record id', () => {
|
|
164
|
+
const collectionId = 'test';
|
|
165
|
+
const expected = `tag:opentermsarchive.org,2026:version:${collectionId}:${storageConfig.type}:${savedVersions.upgradeRecord.id}`;
|
|
166
|
+
|
|
167
|
+
expect(firstEntry).to.include(`<id>${expected}</id>`);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('has an alternate link to the API version endpoint', () => {
|
|
171
|
+
const href = firstEntry.match(/<link[^>]*rel="alternate"[^>]*href="([^"]+)"/)[1];
|
|
172
|
+
const expectedPathFragment = `/version/${encodeURIComponent('service-2')}/${encodeURIComponent('Privacy Policy')}/${encodeURIComponent(toISODateWithoutMilliseconds(savedVersions.upgradeRecord.fetchDate))}`;
|
|
173
|
+
|
|
174
|
+
expect(href).to.include(expectedPathFragment);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('has exactly one link per entry', () => {
|
|
178
|
+
const links = firstEntry.match(/<link\b[^>]*\/>/g) || [];
|
|
179
|
+
|
|
180
|
+
expect(links).to.have.length(1);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('has a type matching the default Version API JSON response on the alternate link', () => {
|
|
184
|
+
expect(firstEntry).to.match(/<link[^>]*rel="alternate"[^>]*type="application\/json"/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('has a title reconstructed from commit prefix + serviceId + termsType', () => {
|
|
188
|
+
const title = firstEntry.match(/<title[^>]*>([\s\S]*?)<\/title>/)[1];
|
|
189
|
+
|
|
190
|
+
expect(title).to.include('First record of');
|
|
191
|
+
expect(title).to.include('service-2');
|
|
192
|
+
expect(title).to.include('Privacy Policy');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('has an updated element matching the fetch date', () => {
|
|
196
|
+
const updated = firstEntry.match(/<updated>([^<]+)<\/updated>/)[1];
|
|
197
|
+
|
|
198
|
+
expect(new Date(updated).toISOString()).to.equal(savedVersions.upgradeRecord.fetchDate.toISOString());
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('has three categories with the expected schemes', () => {
|
|
202
|
+
const categories = [...firstEntry.matchAll(/<category([^/]*)\/>/g)].map(match => match[1]);
|
|
203
|
+
|
|
204
|
+
expect(categories).to.have.length(3);
|
|
205
|
+
|
|
206
|
+
const schemes = categories.map(attrs => attrs.match(/scheme="([^"]+)"/)[1]);
|
|
207
|
+
|
|
208
|
+
expect(schemes).to.include('tag:opentermsarchive.org,2026:scheme:service');
|
|
209
|
+
expect(schemes).to.include('tag:opentermsarchive.org,2026:scheme:terms-type');
|
|
210
|
+
expect(schemes).to.include('tag:opentermsarchive.org,2026:scheme:record-type');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('has category terms for service, terms type and record type', () => {
|
|
214
|
+
const categories = [...firstEntry.matchAll(/<category([^/]*)\/>/g)].map(match => match[1]);
|
|
215
|
+
const terms = categories.map(attrs => attrs.match(/term="([^"]+)"/)[1]);
|
|
216
|
+
|
|
217
|
+
expect(terms).to.include('service-2');
|
|
218
|
+
expect(terms).to.include('Privacy Policy');
|
|
219
|
+
expect(terms).to.include('First record');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('record-type classification', () => {
|
|
224
|
+
function findEntryById(xml, recordId) {
|
|
225
|
+
const match = [...xml.matchAll(/<entry>[\s\S]*?<\/entry>/g)].find(entry => entry[0].includes(`:${recordId}</id>`));
|
|
226
|
+
|
|
227
|
+
return match && match[0];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
it('classifies a first record as "First record"', () => {
|
|
231
|
+
const entry = findEntryById(response.text, savedVersions.upgradeRecord.id);
|
|
232
|
+
|
|
233
|
+
expect(entry).to.not.be.undefined;
|
|
234
|
+
expect(entry).to.match(/term="First record"/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('classifies a content change as "Change"', () => {
|
|
238
|
+
const entry = findEntryById(response.text, savedVersions.changeRecord.id);
|
|
239
|
+
|
|
240
|
+
expect(entry).to.not.be.undefined;
|
|
241
|
+
expect(entry).to.match(/term="Change"/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('excludes technical upgrade records from the feed', () => {
|
|
245
|
+
const entry = findEntryById(response.text, savedVersions.technicalUpgradeRecord.id);
|
|
246
|
+
|
|
247
|
+
expect(entry).to.be.undefined;
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('configurable limit', () => {
|
|
252
|
+
it('returns at most the configured number of entries', () => {
|
|
253
|
+
const limit = config.get('@opentermsarchive/engine.collection-api.feed.limit');
|
|
254
|
+
const entries = response.text.match(/<entry>/g) || [];
|
|
255
|
+
|
|
256
|
+
expect(entries.length).to.be.at.most(limit);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('GET /feed/:serviceId', () => {
|
|
262
|
+
const SERVICE = 'service_without_history';
|
|
263
|
+
const OTHER_SERVICE = 'service_with_history';
|
|
264
|
+
const TERMS = 'Terms of Service';
|
|
265
|
+
|
|
266
|
+
let repository;
|
|
267
|
+
|
|
268
|
+
before(async function () {
|
|
269
|
+
this.timeout(5000);
|
|
270
|
+
repository = RepositoryFactory.create(storageConfig);
|
|
271
|
+
await repository.initialize();
|
|
272
|
+
await repository.removeAll();
|
|
273
|
+
await repository.save(new Version({
|
|
274
|
+
serviceId: SERVICE,
|
|
275
|
+
termsType: TERMS,
|
|
276
|
+
content: 'c1',
|
|
277
|
+
fetchDate: new Date('2024-01-01T00:00:00Z'),
|
|
278
|
+
snapshotIds: ['s1'],
|
|
279
|
+
}));
|
|
280
|
+
await repository.save(new Version({
|
|
281
|
+
serviceId: SERVICE,
|
|
282
|
+
termsType: TERMS,
|
|
283
|
+
content: 'c2',
|
|
284
|
+
fetchDate: new Date('2024-02-01T00:00:00Z'),
|
|
285
|
+
snapshotIds: ['s2'],
|
|
286
|
+
}));
|
|
287
|
+
await repository.save(new Version({
|
|
288
|
+
serviceId: OTHER_SERVICE,
|
|
289
|
+
termsType: TERMS,
|
|
290
|
+
content: 'c3',
|
|
291
|
+
fetchDate: new Date('2024-03-01T00:00:00Z'),
|
|
292
|
+
snapshotIds: ['s3'],
|
|
293
|
+
}));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
after(() => repository.removeAll());
|
|
297
|
+
|
|
298
|
+
context('when the service exists and has versions', () => {
|
|
299
|
+
let response;
|
|
300
|
+
|
|
301
|
+
before(async () => {
|
|
302
|
+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent(SERVICE)}`);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('responds with 200', () => {
|
|
306
|
+
expect(response.status).to.equal(200);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('responds with Content-Type application/atom+xml', () => {
|
|
310
|
+
expect(response.headers['content-type']).to.match(/^application\/atom\+xml/);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('includes only entries for that service', () => {
|
|
314
|
+
const serviceTerms = [...response.text.matchAll(/scheme="tag:opentermsarchive.org,2026:scheme:service"[^/]*term="([^"]+)"/g)]
|
|
315
|
+
.concat([...response.text.matchAll(/term="([^"]+)"[^/]*scheme="tag:opentermsarchive.org,2026:scheme:service"/g)])
|
|
316
|
+
.map(match => match[1]);
|
|
317
|
+
|
|
318
|
+
expect(serviceTerms).to.not.be.empty;
|
|
319
|
+
|
|
320
|
+
for (const term of serviceTerms) {
|
|
321
|
+
expect(term).to.equal(SERVICE);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('has a feed id including the service id', () => {
|
|
326
|
+
expect(extractTag(response.text, 'id')).to.equal(`tag:opentermsarchive.org,2026:feed:test:${SERVICE}`);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('has a self link pointing to the service-scoped feed endpoint', () => {
|
|
330
|
+
const href = response.text.match(/<link[^>]*rel="self"[^>]*href="([^"]+)"/)[1];
|
|
331
|
+
|
|
332
|
+
expect(href).to.match(new RegExp(`/feed/${SERVICE}$`));
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
context('when the service exists but has no versions', () => {
|
|
337
|
+
let response;
|
|
338
|
+
|
|
339
|
+
before(async () => {
|
|
340
|
+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent('service_with_filters_history')}`);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('responds with 200', () => {
|
|
344
|
+
expect(response.status).to.equal(200);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('returns an empty feed (no entries)', () => {
|
|
348
|
+
expect(response.text).to.not.include('<entry>');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('uses a stable updated date so conditional GET keeps working', () => {
|
|
352
|
+
expect(extractTag(response.text, 'updated')).to.equal(new Date(0).toISOString());
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
context('when the service does not exist', () => {
|
|
357
|
+
let response;
|
|
358
|
+
|
|
359
|
+
before(async () => {
|
|
360
|
+
response = await request.get(`${basePath}/v1/feed/DoesNotExist`);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('responds with 404', () => {
|
|
364
|
+
expect(response.status).to.equal(404);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
context('when the serviceId casing does not match', () => {
|
|
369
|
+
let response;
|
|
370
|
+
|
|
371
|
+
before(async () => {
|
|
372
|
+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent(SERVICE.toUpperCase())}`);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('responds with 404', () => {
|
|
376
|
+
expect(response.status).to.equal(404);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe('XML escaping and URL encoding', () => {
|
|
382
|
+
const SERVICE = 'Service B!';
|
|
383
|
+
const TERMS = 'Privacy Policy';
|
|
384
|
+
const FETCH_DATE = new Date('2024-05-15T10:00:00Z');
|
|
385
|
+
|
|
386
|
+
let response;
|
|
387
|
+
let repository;
|
|
388
|
+
|
|
389
|
+
before(async function () {
|
|
390
|
+
this.timeout(5000);
|
|
391
|
+
repository = RepositoryFactory.create(storageConfig);
|
|
392
|
+
await repository.initialize();
|
|
393
|
+
await repository.removeAll();
|
|
394
|
+
await repository.save(new Version({
|
|
395
|
+
serviceId: SERVICE,
|
|
396
|
+
termsType: TERMS,
|
|
397
|
+
content: 'content with & and <tags>',
|
|
398
|
+
fetchDate: FETCH_DATE,
|
|
399
|
+
snapshotIds: ['s_escape'],
|
|
400
|
+
}));
|
|
401
|
+
|
|
402
|
+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent(SERVICE)}/${encodeURIComponent(TERMS)}`);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
after(() => repository.removeAll());
|
|
406
|
+
|
|
407
|
+
it('responds with 200', () => {
|
|
408
|
+
expect(response.status).to.equal(200);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('URL-encodes spaces and special characters in the self link href', () => {
|
|
412
|
+
const href = response.text.match(/<link[^>]*rel="self"[^>]*href="([^"]+)"/)[1];
|
|
413
|
+
|
|
414
|
+
expect(href).to.include('Service%20B!');
|
|
415
|
+
expect(href).to.include('Privacy%20Policy');
|
|
416
|
+
expect(href).to.not.include('Service B!');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('URL-encodes spaces and special characters in entry alternate links', () => {
|
|
420
|
+
const entry = response.text.match(/<entry>[\s\S]*?<\/entry>/)[0];
|
|
421
|
+
const href = entry.match(/<link[^>]*rel="alternate"[^>]*href="([^"]+)"/)[1];
|
|
422
|
+
|
|
423
|
+
expect(href).to.include('Service%20B!');
|
|
424
|
+
expect(href).to.include('Privacy%20Policy');
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe('GET /feed/:serviceId/:termsType', () => {
|
|
429
|
+
const SERVICE = 'service_without_history';
|
|
430
|
+
const TERMS = 'Terms of Service';
|
|
431
|
+
const UNKNOWN_TERMS = 'Imprint';
|
|
432
|
+
|
|
433
|
+
let repository;
|
|
434
|
+
|
|
435
|
+
before(async function () {
|
|
436
|
+
this.timeout(5000);
|
|
437
|
+
repository = RepositoryFactory.create(storageConfig);
|
|
438
|
+
await repository.initialize();
|
|
439
|
+
await repository.removeAll();
|
|
440
|
+
await repository.save(new Version({
|
|
441
|
+
serviceId: SERVICE,
|
|
442
|
+
termsType: TERMS,
|
|
443
|
+
content: 'first',
|
|
444
|
+
fetchDate: new Date('2024-01-01T00:00:00Z'),
|
|
445
|
+
snapshotIds: ['s1'],
|
|
446
|
+
}));
|
|
447
|
+
await repository.save(new Version({
|
|
448
|
+
serviceId: SERVICE,
|
|
449
|
+
termsType: TERMS,
|
|
450
|
+
content: 'updated',
|
|
451
|
+
fetchDate: new Date('2024-02-01T00:00:00Z'),
|
|
452
|
+
snapshotIds: ['s2'],
|
|
453
|
+
}));
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
after(() => repository.removeAll());
|
|
457
|
+
|
|
458
|
+
context('when the service and terms type match', () => {
|
|
459
|
+
let response;
|
|
460
|
+
|
|
461
|
+
before(async () => {
|
|
462
|
+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent(SERVICE)}/${encodeURIComponent(TERMS)}`);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('responds with 200', () => {
|
|
466
|
+
expect(response.status).to.equal(200);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('includes entries for the combination', () => {
|
|
470
|
+
const entries = response.text.match(/<entry>/g) || [];
|
|
471
|
+
|
|
472
|
+
expect(entries.length).to.be.at.least(1);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('entries only have the expected terms type', () => {
|
|
476
|
+
const termsTypeTerms = [...response.text.matchAll(/<category[^/]*scheme="tag:opentermsarchive.org,2026:scheme:terms-type"[^/]*term="([^"]+)"/g)]
|
|
477
|
+
.concat([...response.text.matchAll(/<category[^/]*term="([^"]+)"[^/]*scheme="tag:opentermsarchive.org,2026:scheme:terms-type"/g)])
|
|
478
|
+
.map(match => match[1]);
|
|
479
|
+
|
|
480
|
+
for (const term of termsTypeTerms) {
|
|
481
|
+
expect(term).to.equal(TERMS);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('has a feed id that includes both service and terms type', () => {
|
|
486
|
+
expect(extractTag(response.text, 'id')).to.equal(`tag:opentermsarchive.org,2026:feed:test:${SERVICE}:${TERMS}`);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('has a self link pointing to the combination endpoint', () => {
|
|
490
|
+
const href = response.text.match(/<link[^>]*rel="self"[^>]*href="([^"]+)"/)[1];
|
|
491
|
+
|
|
492
|
+
expect(href).to.match(new RegExp(`/feed/${SERVICE}/${encodeURIComponent(TERMS)}$`));
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
context('when the service exists but does not declare the terms type', () => {
|
|
497
|
+
let response;
|
|
498
|
+
|
|
499
|
+
before(async () => {
|
|
500
|
+
response = await request.get(`${basePath}/v1/feed/${encodeURIComponent(SERVICE)}/${encodeURIComponent(UNKNOWN_TERMS)}`);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('responds with 404', () => {
|
|
504
|
+
expect(response.status).to.equal(404);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
context('when the service does not exist', () => {
|
|
509
|
+
let response;
|
|
510
|
+
|
|
511
|
+
before(async () => {
|
|
512
|
+
response = await request.get(`${basePath}/v1/feed/DoesNotExist/${encodeURIComponent(TERMS)}`);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('responds with 404', () => {
|
|
516
|
+
expect(response.status).to.equal(404);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe('entry links with versionUrlTemplate configured', () => {
|
|
522
|
+
const TEMPLATE = 'https://example.test/v/%VERSION_ID';
|
|
523
|
+
|
|
524
|
+
let response;
|
|
525
|
+
let repository;
|
|
526
|
+
let savedVersion;
|
|
527
|
+
|
|
528
|
+
before(async function () {
|
|
529
|
+
this.timeout(5000);
|
|
530
|
+
repository = RepositoryFactory.create(storageConfig);
|
|
531
|
+
await repository.initialize();
|
|
532
|
+
await repository.removeAll();
|
|
533
|
+
savedVersion = await repository.save(new Version({
|
|
534
|
+
serviceId: 'service-1',
|
|
535
|
+
termsType: 'Terms of Service',
|
|
536
|
+
content: 'content',
|
|
537
|
+
fetchDate: new Date('2024-01-01T00:00:00Z'),
|
|
538
|
+
snapshotIds: ['s1'],
|
|
539
|
+
}));
|
|
540
|
+
|
|
541
|
+
const services = await Services.load();
|
|
542
|
+
const templatedApp = express();
|
|
543
|
+
|
|
544
|
+
templatedApp.use(feedRouter(services, repository, storageConfig.type, 10, TEMPLATE));
|
|
545
|
+
|
|
546
|
+
response = await supertest(templatedApp).get('/feed');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
after(() => repository.removeAll());
|
|
550
|
+
|
|
551
|
+
it('interpolates %VERSION_ID into the alternate link', () => {
|
|
552
|
+
const href = response.text.match(/<entry>[\s\S]*?<link[^>]*rel="alternate"[^>]*href="([^"]+)"/)[1];
|
|
553
|
+
|
|
554
|
+
expect(href).to.equal(`https://example.test/v/${savedVersion.id}`);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('advertises a text/html type on the alternate link', () => {
|
|
558
|
+
const entry = response.text.match(/<entry>[\s\S]*?<\/entry>/)[0];
|
|
559
|
+
|
|
560
|
+
expect(entry).to.match(/<link[^>]*rel="alternate"[^>]*type="text\/html"/);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('does not point to the API for entry links', () => {
|
|
564
|
+
const entries = response.text.match(/<entry>[\s\S]*?<\/entry>/g) || [];
|
|
565
|
+
|
|
566
|
+
for (const entry of entries) {
|
|
567
|
+
expect(entry).to.not.match(/<link[^>]*href="[^"]*\/version\//);
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('still emits exactly one link per entry', () => {
|
|
572
|
+
const entries = response.text.match(/<entry>[\s\S]*?<\/entry>/g) || [];
|
|
573
|
+
|
|
574
|
+
for (const entry of entries) {
|
|
575
|
+
const links = entry.match(/<link\b[^>]*\/>/g) || [];
|
|
576
|
+
|
|
577
|
+
expect(links).to.have.length(1);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
describe('XML escape of special characters', () => {
|
|
583
|
+
const SERVICE = 'AT&T Mobile';
|
|
584
|
+
const TERMS = 'Terms of Service';
|
|
585
|
+
|
|
586
|
+
let response;
|
|
587
|
+
let repository;
|
|
588
|
+
|
|
589
|
+
before(async function () {
|
|
590
|
+
this.timeout(5000);
|
|
591
|
+
repository = RepositoryFactory.create(storageConfig);
|
|
592
|
+
await repository.initialize();
|
|
593
|
+
await repository.removeAll();
|
|
594
|
+
await repository.save(new Version({
|
|
595
|
+
serviceId: SERVICE,
|
|
596
|
+
termsType: TERMS,
|
|
597
|
+
content: 'content',
|
|
598
|
+
fetchDate: new Date('2024-05-15T10:00:00Z'),
|
|
599
|
+
snapshotIds: ['s_xmlesc'],
|
|
600
|
+
}));
|
|
601
|
+
|
|
602
|
+
response = await request.get(`${basePath}/v1/feed`);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
after(() => repository.removeAll());
|
|
606
|
+
|
|
607
|
+
it('escapes ampersands in the entry title text', () => {
|
|
608
|
+
expect(response.text).to.match(/<title>[^<]*AT&T Mobile[^<]*<\/title>/);
|
|
609
|
+
expect(response.text).to.not.match(/<title>[^<]*AT&T Mobile/);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('escapes ampersands in the category term attribute', () => {
|
|
613
|
+
expect(response.text).to.match(/<category[^/]*term="AT&T Mobile"/);
|
|
614
|
+
expect(response.text).to.not.match(/<category[^/]*term="AT&T Mobile"/);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
context('with a versionUrlTemplate that contains XML-special characters', () => {
|
|
618
|
+
const TEMPLATE = 'https://example.test/v?ref=main&id=%VERSION_ID';
|
|
619
|
+
|
|
620
|
+
let templatedResponse;
|
|
621
|
+
let templatedRepository;
|
|
622
|
+
|
|
623
|
+
before(async function () {
|
|
624
|
+
this.timeout(5000);
|
|
625
|
+
templatedRepository = RepositoryFactory.create(storageConfig);
|
|
626
|
+
await templatedRepository.initialize();
|
|
627
|
+
await templatedRepository.removeAll();
|
|
628
|
+
await templatedRepository.save(new Version({
|
|
629
|
+
serviceId: 'service-1',
|
|
630
|
+
termsType: 'Terms of Service',
|
|
631
|
+
content: 'content',
|
|
632
|
+
fetchDate: new Date('2024-01-01T00:00:00Z'),
|
|
633
|
+
snapshotIds: ['s_tpl_escape'],
|
|
634
|
+
}));
|
|
635
|
+
|
|
636
|
+
const services = await Services.load();
|
|
637
|
+
const templatedApp = express();
|
|
638
|
+
|
|
639
|
+
templatedApp.use(feedRouter(services, templatedRepository, storageConfig.type, 10, TEMPLATE));
|
|
640
|
+
templatedResponse = await supertest(templatedApp).get('/feed');
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
after(() => templatedRepository.removeAll());
|
|
644
|
+
|
|
645
|
+
it('escapes the ampersand in the alternate link href', () => {
|
|
646
|
+
expect(templatedResponse.text).to.match(/<link[^>]*rel="alternate"[^>]*href="https:\/\/example\.test\/v\?ref=main&id=[^"]+"/);
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
describe('conditional GET via Last-Modified', () => {
|
|
652
|
+
const FETCH_DATE = new Date('2024-05-15T10:00:00Z');
|
|
653
|
+
|
|
654
|
+
let repository;
|
|
655
|
+
|
|
656
|
+
before(async function () {
|
|
657
|
+
this.timeout(5000);
|
|
658
|
+
repository = RepositoryFactory.create(storageConfig);
|
|
659
|
+
await repository.initialize();
|
|
660
|
+
await repository.removeAll();
|
|
661
|
+
await repository.save(new Version({
|
|
662
|
+
serviceId: 'service-1',
|
|
663
|
+
termsType: 'Terms of Service',
|
|
664
|
+
content: 'content',
|
|
665
|
+
fetchDate: FETCH_DATE,
|
|
666
|
+
snapshotIds: ['s1'],
|
|
667
|
+
}));
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
after(() => repository.removeAll());
|
|
671
|
+
|
|
672
|
+
it('exposes a Last-Modified header matching the latest entry fetch date', async () => {
|
|
673
|
+
const response = await request.get(`${basePath}/v1/feed`);
|
|
674
|
+
|
|
675
|
+
expect(response.headers['last-modified']).to.equal(FETCH_DATE.toUTCString());
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('returns 304 with no body when If-Modified-Since is at or after the latest entry', async () => {
|
|
679
|
+
const response = await request
|
|
680
|
+
.get(`${basePath}/v1/feed`)
|
|
681
|
+
.set('If-Modified-Since', FETCH_DATE.toUTCString());
|
|
682
|
+
|
|
683
|
+
expect(response.status).to.equal(304);
|
|
684
|
+
expect(response.text).to.be.empty;
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('returns 200 with body when If-Modified-Since is before the latest entry', async () => {
|
|
688
|
+
const earlier = new Date(FETCH_DATE.getTime() - 24 * 60 * 60 * 1000);
|
|
689
|
+
const response = await request
|
|
690
|
+
.get(`${basePath}/v1/feed`)
|
|
691
|
+
.set('If-Modified-Since', earlier.toUTCString());
|
|
692
|
+
|
|
693
|
+
expect(response.status).to.equal(200);
|
|
694
|
+
expect(response.text).to.include('<feed');
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
describe('behind a reverse proxy', () => {
|
|
699
|
+
let response;
|
|
700
|
+
let repository;
|
|
701
|
+
|
|
702
|
+
before(async function () {
|
|
703
|
+
this.timeout(5000);
|
|
704
|
+
repository = RepositoryFactory.create(storageConfig);
|
|
705
|
+
await repository.initialize();
|
|
706
|
+
await repository.removeAll();
|
|
707
|
+
await repository.save(new Version({
|
|
708
|
+
serviceId: 'service-1',
|
|
709
|
+
termsType: 'Terms of Service',
|
|
710
|
+
content: 'content',
|
|
711
|
+
fetchDate: new Date('2024-01-01T00:00:00Z'),
|
|
712
|
+
snapshotIds: ['s1'],
|
|
713
|
+
}));
|
|
714
|
+
|
|
715
|
+
response = await request
|
|
716
|
+
.get(`${basePath}/v1/feed`)
|
|
717
|
+
.set('X-Forwarded-Proto', 'https')
|
|
718
|
+
.set('X-Forwarded-Host', 'api.example.com');
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
after(() => repository.removeAll());
|
|
722
|
+
|
|
723
|
+
it('uses the forwarded protocol and host in the self link', () => {
|
|
724
|
+
const href = response.text.match(/<link[^>]*rel="self"[^>]*href="([^"]+)"/)[1];
|
|
725
|
+
|
|
726
|
+
expect(href).to.match(/^https:\/\/api\.example\.com\//);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('uses the forwarded protocol and host in entry alternate links', () => {
|
|
730
|
+
const entry = response.text.match(/<entry>[\s\S]*?<\/entry>/);
|
|
731
|
+
|
|
732
|
+
expect(entry).to.not.be.null;
|
|
733
|
+
|
|
734
|
+
const href = entry[0].match(/<link[^>]*rel="alternate"[^>]*href="([^"]+)"/)[1];
|
|
735
|
+
|
|
736
|
+
expect(href).to.match(/^https:\/\/api\.example\.com\//);
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
});
|