@pranaysahith/decap-cms-backend-gitlab 3.4.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.
@@ -0,0 +1,552 @@
1
+ jest.mock('decap-cms-core/src/backend');
2
+ import { fromJS } from 'immutable';
3
+ import { oneLine, stripIndent } from 'common-tags';
4
+ import nock from 'nock';
5
+ import { Cursor } from 'decap-cms-lib-util';
6
+
7
+ import Gitlab from '../implementation';
8
+ import AuthenticationPage from '../AuthenticationPage';
9
+
10
+ const { Backend, LocalStorageAuthStore } = jest.requireActual('decap-cms-core/src/backend');
11
+
12
+ function generateEntries(path, length) {
13
+ const entries = Array.from({ length }, (val, idx) => {
14
+ const count = idx + 1;
15
+ const id = `00${count}`.slice(-3);
16
+ const fileName = `test${id}.md`;
17
+ return { id, fileName, filePath: `${path}/${fileName}` };
18
+ });
19
+
20
+ return {
21
+ tree: entries.map(({ id, fileName, filePath }) => ({
22
+ id: `d8345753a1d935fa47a26317a503e73e1192d${id}`,
23
+ name: fileName,
24
+ type: 'blob',
25
+ path: filePath,
26
+ mode: '100644',
27
+ })),
28
+ files: entries.reduce(
29
+ (acc, { id, filePath }) => ({
30
+ ...acc,
31
+ [filePath]: stripIndent`
32
+ ---
33
+ title: test ${id}
34
+ ---
35
+ # test ${id}
36
+ `,
37
+ }),
38
+ {},
39
+ ),
40
+ };
41
+ }
42
+
43
+ const manyEntries = generateEntries('many-entries', 500);
44
+
45
+ const mockRepo = {
46
+ tree: {
47
+ '/': [
48
+ {
49
+ id: '5d0620ebdbc92068a3e866866e928cc373f18429',
50
+ name: 'content',
51
+ type: 'tree',
52
+ path: 'content',
53
+ mode: '040000',
54
+ },
55
+ ],
56
+ content: [
57
+ {
58
+ id: 'b1a200e48be54fde12b636f9563d659d44c206a5',
59
+ name: 'test1.md',
60
+ type: 'blob',
61
+ path: 'content/test1.md',
62
+ mode: '100644',
63
+ },
64
+ {
65
+ id: 'd8345753a1d935fa47a26317a503e73e1192d623',
66
+ name: 'test2.md',
67
+ type: 'blob',
68
+ path: 'content/test2.md',
69
+ mode: '100644',
70
+ },
71
+ ],
72
+ 'many-entries': manyEntries.tree,
73
+ },
74
+ files: {
75
+ 'content/test1.md': stripIndent`
76
+ ---
77
+ title: test
78
+ ---
79
+ # test
80
+ `,
81
+ 'content/test2.md': stripIndent`
82
+ ---
83
+ title: test2
84
+ ---
85
+ # test 2
86
+ `,
87
+ ...manyEntries.files,
88
+ },
89
+ };
90
+
91
+ const resp = {
92
+ user: {
93
+ success: {
94
+ id: 1,
95
+ },
96
+ },
97
+ branch: {
98
+ success: {
99
+ name: 'master',
100
+ commit: {
101
+ id: 1,
102
+ },
103
+ },
104
+ },
105
+ project: {
106
+ success: {
107
+ permissions: {
108
+ project_access: {
109
+ access_level: 30,
110
+ },
111
+ },
112
+ default_branch: 'main',
113
+ },
114
+ readOnly: {
115
+ permissions: {
116
+ project_access: {
117
+ access_level: 10,
118
+ },
119
+ },
120
+ },
121
+ },
122
+ };
123
+
124
+ describe('gitlab backend', () => {
125
+ let authStore;
126
+ let backend;
127
+ const repo = 'foo/bar';
128
+ const defaultConfig = {
129
+ backend: {
130
+ name: 'gitlab',
131
+ repo,
132
+ },
133
+ };
134
+ const collectionContentConfig = {
135
+ name: 'foo',
136
+ folder: 'content',
137
+ fields: [{ name: 'title' }],
138
+ // TODO: folder_based_collection is an internal string, we should not
139
+ // be depending on it here
140
+ type: 'folder_based_collection',
141
+ };
142
+ const collectionManyEntriesConfig = {
143
+ name: 'foo',
144
+ folder: 'many-entries',
145
+ fields: [{ name: 'title' }],
146
+ // TODO: folder_based_collection is an internal string, we should not
147
+ // be depending on it here
148
+ type: 'folder_based_collection',
149
+ };
150
+ const collectionFilesConfig = {
151
+ name: 'foo',
152
+ files: [
153
+ {
154
+ label: 'foo',
155
+ name: 'foo',
156
+ file: 'content/test1.md',
157
+ fields: [{ name: 'title' }],
158
+ },
159
+ {
160
+ label: 'bar',
161
+ name: 'bar',
162
+ file: 'content/test2.md',
163
+ fields: [{ name: 'title' }],
164
+ },
165
+ ],
166
+ type: 'file_based_collection',
167
+ };
168
+ const mockCredentials = { token: 'MOCK_TOKEN' };
169
+ const expectedRepo = encodeURIComponent(repo);
170
+ const expectedRepoUrl = `/projects/${expectedRepo}`;
171
+
172
+ function resolveBackend(config = {}) {
173
+ authStore = new LocalStorageAuthStore();
174
+ return new Backend(
175
+ {
176
+ init: (...args) => new Gitlab(...args),
177
+ },
178
+ {
179
+ backendName: 'gitlab',
180
+ config,
181
+ authStore,
182
+ },
183
+ );
184
+ }
185
+
186
+ function mockApi(backend) {
187
+ return nock(backend.implementation.apiRoot);
188
+ }
189
+
190
+ function interceptAuth(backend, { userResponse, projectResponse } = {}) {
191
+ const api = mockApi(backend);
192
+ api
193
+ .get('/user')
194
+ .query(true)
195
+ .reply(200, userResponse || resp.user.success);
196
+
197
+ api
198
+ // The `authenticate` method of the API class from netlify-cms-backend-gitlab
199
+ // calls the same endpoint twice for gettng a single project.
200
+ // First time through `this.api.hasWriteAccess()
201
+ // Second time through the method `getDefaultBranchName` from lib-util
202
+ // As a result, we need to repeat the same response twice.
203
+ // Otherwise, we'll get an error: "No match for request to
204
+ // https://gitlab.com/api/v4"
205
+
206
+ .get(expectedRepoUrl)
207
+ .times(2)
208
+ .query(true)
209
+ .reply(200, projectResponse || resp.project.success);
210
+ }
211
+
212
+ function interceptBranch(backend, { branch = 'master' } = {}) {
213
+ const api = mockApi(backend);
214
+ api
215
+ .get(`${expectedRepoUrl}/repository/branches/${encodeURIComponent(branch)}`)
216
+ .query(true)
217
+ .reply(200, resp.branch.success);
218
+ }
219
+
220
+ function parseQuery(uri) {
221
+ const query = uri.split('?')[1];
222
+ if (!query) {
223
+ return {};
224
+ }
225
+ return query.split('&').reduce((acc, q) => {
226
+ const [key, value] = q.split('=');
227
+ acc[key] = value;
228
+ return acc;
229
+ }, {});
230
+ }
231
+
232
+ function createHeaders(backend, { basePath, path, page, perPage, pageCount, totalCount }) {
233
+ const pageNum = parseInt(page, 10);
234
+ const pageCountNum = parseInt(pageCount, 10);
235
+ const url = `${backend.implementation.apiRoot}${basePath}`;
236
+
237
+ function link(linkPage) {
238
+ return `<${url}?id=${expectedRepo}&page=${linkPage}&path=${path}&per_page=${perPage}&recursive=false>`;
239
+ }
240
+
241
+ const linkHeader = oneLine`
242
+ ${link(1)}; rel="first",
243
+ ${link(pageCount)}; rel="last",
244
+ ${pageNum === 1 ? '' : `${link(pageNum - 1)}; rel="prev",`}
245
+ ${pageNum === pageCountNum ? '' : `${link(pageNum + 1)}; rel="next",`}
246
+ `.slice(0, -1);
247
+
248
+ return {
249
+ 'X-Page': page,
250
+ 'X-Total-Pages': pageCount,
251
+ 'X-Per-Page': perPage,
252
+ 'X-Total': totalCount,
253
+ Link: linkHeader,
254
+ };
255
+ }
256
+
257
+ function interceptCollection(
258
+ backend,
259
+ collection,
260
+ { verb = 'get', repeat = 1, page: expectedPage } = {},
261
+ ) {
262
+ const api = mockApi(backend);
263
+ const url = `${expectedRepoUrl}/repository/tree`;
264
+ const { folder } = collection;
265
+ const tree = mockRepo.tree[folder];
266
+ api[verb](url)
267
+ .query(({ path, page }) => {
268
+ if (path !== folder) {
269
+ return false;
270
+ }
271
+ if (expectedPage && page && parseInt(page, 10) !== parseInt(expectedPage, 10)) {
272
+ return false;
273
+ }
274
+ return true;
275
+ })
276
+ .times(repeat)
277
+ .reply(uri => {
278
+ const { page = 1, per_page = 20 } = parseQuery(uri);
279
+ const pageCount = tree.length <= per_page ? 1 : Math.round(tree.length / per_page);
280
+ const pageLastIndex = page * per_page;
281
+ const pageFirstIndex = pageLastIndex - per_page;
282
+ const resp = tree.slice(pageFirstIndex, pageLastIndex);
283
+ return [
284
+ 200,
285
+ verb === 'head' ? null : resp,
286
+ createHeaders(backend, {
287
+ basePath: url,
288
+ path: folder,
289
+ page,
290
+ perPage: per_page,
291
+ pageCount,
292
+ totalCount: tree.length,
293
+ }),
294
+ ];
295
+ });
296
+ }
297
+
298
+ function interceptFiles(backend, path) {
299
+ const api = mockApi(backend);
300
+ const url = `${expectedRepoUrl}/repository/files/${encodeURIComponent(path)}/raw`;
301
+ api.get(url).query(true).reply(200, mockRepo.files[path]);
302
+
303
+ api
304
+ .get(`${expectedRepoUrl}/repository/commits`)
305
+ .query(({ path }) => path === path)
306
+ .reply(200, [
307
+ {
308
+ author_name: 'author_name',
309
+ author_email: 'author_email',
310
+ authored_date: 'authored_date',
311
+ },
312
+ ]);
313
+ }
314
+
315
+ function sharedSetup() {
316
+ beforeEach(async () => {
317
+ backend = resolveBackend(defaultConfig);
318
+ interceptAuth(backend);
319
+ await backend.authenticate(mockCredentials);
320
+ interceptCollection(backend, collectionManyEntriesConfig, { verb: 'head' });
321
+ interceptCollection(backend, collectionContentConfig, { verb: 'head' });
322
+ });
323
+ }
324
+
325
+ it('throws if configuration does not include repo', () => {
326
+ expect(() => resolveBackend({ backend: {} })).toThrowErrorMatchingInlineSnapshot(
327
+ `"The GitLab backend needs a \\"repo\\" in the backend configuration."`,
328
+ );
329
+ });
330
+
331
+ describe('authComponent', () => {
332
+ it('returns authentication page component', () => {
333
+ backend = resolveBackend(defaultConfig);
334
+ expect(backend.authComponent()).toEqual(AuthenticationPage);
335
+ });
336
+ });
337
+
338
+ describe('authenticate', () => {
339
+ it('throws if user does not have access to project', async () => {
340
+ backend = resolveBackend(defaultConfig);
341
+ interceptAuth(backend, { projectResponse: resp.project.readOnly });
342
+ await expect(
343
+ backend.authenticate(mockCredentials),
344
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
345
+ `"Your GitLab user account does not have access to this repo."`,
346
+ );
347
+ });
348
+
349
+ it('stores and returns user object on success', async () => {
350
+ const backendName = defaultConfig.backend.name;
351
+ backend = resolveBackend(defaultConfig);
352
+ interceptAuth(backend);
353
+ const user = await backend.authenticate(mockCredentials);
354
+ expect(authStore.retrieve()).toEqual(user);
355
+ expect(user).toEqual({ ...resp.user.success, ...mockCredentials, backendName });
356
+ });
357
+ });
358
+
359
+ describe('currentUser', () => {
360
+ it('returns null if no user', async () => {
361
+ backend = resolveBackend(defaultConfig);
362
+ const user = await backend.currentUser();
363
+ expect(user).toEqual(null);
364
+ });
365
+
366
+ it('returns the stored user if exists', async () => {
367
+ const backendName = defaultConfig.backend.name;
368
+ backend = resolveBackend(defaultConfig);
369
+ interceptAuth(backend);
370
+ await backend.authenticate(mockCredentials);
371
+ const user = await backend.currentUser();
372
+ expect(user).toEqual({ ...resp.user.success, ...mockCredentials, backendName });
373
+ });
374
+ });
375
+
376
+ describe('getToken', () => {
377
+ it('returns the token for the current user', async () => {
378
+ backend = resolveBackend(defaultConfig);
379
+ interceptAuth(backend);
380
+ await backend.authenticate(mockCredentials);
381
+ const token = await backend.getToken();
382
+ expect(token).toEqual(mockCredentials.token);
383
+ });
384
+ });
385
+
386
+ describe('logout', () => {
387
+ it('sets token to null', async () => {
388
+ backend = resolveBackend(defaultConfig);
389
+ interceptAuth(backend);
390
+ await backend.authenticate(mockCredentials);
391
+ await backend.logout();
392
+ const token = await backend.getToken();
393
+ expect(token).toEqual(null);
394
+ });
395
+ });
396
+
397
+ describe('getEntry', () => {
398
+ sharedSetup();
399
+
400
+ it('returns an entry from folder collection', async () => {
401
+ const entryTree = mockRepo.tree[collectionContentConfig.folder][0];
402
+ const slug = entryTree.path.split('/').pop().replace('.md', '');
403
+
404
+ interceptFiles(backend, entryTree.path);
405
+ interceptCollection(backend, collectionContentConfig);
406
+
407
+ const entry = await backend.getEntry(
408
+ {
409
+ config: {},
410
+ integrations: fromJS([]),
411
+ entryDraft: fromJS({}),
412
+ mediaLibrary: fromJS({}),
413
+ },
414
+ fromJS(collectionContentConfig),
415
+ slug,
416
+ );
417
+
418
+ expect(entry).toEqual(expect.objectContaining({ path: entryTree.path }));
419
+ });
420
+ });
421
+
422
+ describe('listEntries', () => {
423
+ sharedSetup();
424
+
425
+ it('returns entries from folder collection', async () => {
426
+ const tree = mockRepo.tree[collectionContentConfig.folder];
427
+ tree.forEach(file => interceptFiles(backend, file.path));
428
+
429
+ interceptCollection(backend, collectionContentConfig);
430
+ const entries = await backend.listEntries(fromJS(collectionContentConfig));
431
+
432
+ expect(entries).toEqual({
433
+ cursor: expect.any(Cursor),
434
+ pagination: 1,
435
+ entries: expect.arrayContaining(
436
+ tree.map(file => expect.objectContaining({ path: file.path })),
437
+ ),
438
+ });
439
+ expect(entries.entries).toHaveLength(2);
440
+ });
441
+
442
+ it('returns all entries from folder collection', async () => {
443
+ const tree = mockRepo.tree[collectionManyEntriesConfig.folder];
444
+ interceptBranch(backend);
445
+ tree.forEach(file => interceptFiles(backend, file.path));
446
+
447
+ interceptCollection(backend, collectionManyEntriesConfig, { repeat: 5 });
448
+ const entries = await backend.listAllEntries(fromJS(collectionManyEntriesConfig));
449
+
450
+ expect(entries).toEqual(
451
+ expect.arrayContaining(tree.map(file => expect.objectContaining({ path: file.path }))),
452
+ );
453
+ expect(entries).toHaveLength(500);
454
+ }, 7000);
455
+
456
+ it('returns entries from file collection', async () => {
457
+ const { files } = collectionFilesConfig;
458
+ files.forEach(file => interceptFiles(backend, file.file));
459
+ const entries = await backend.listEntries(fromJS(collectionFilesConfig));
460
+
461
+ expect(entries).toEqual({
462
+ cursor: expect.any(Cursor),
463
+ entries: expect.arrayContaining(
464
+ files.map(file => expect.objectContaining({ path: file.file })),
465
+ ),
466
+ });
467
+ expect(entries.entries).toHaveLength(2);
468
+ });
469
+
470
+ it('returns first page from paginated folder collection tree', async () => {
471
+ const tree = mockRepo.tree[collectionManyEntriesConfig.folder];
472
+ const pageTree = tree.slice(0, 20);
473
+ pageTree.forEach(file => interceptFiles(backend, file.path));
474
+ interceptCollection(backend, collectionManyEntriesConfig, { page: 1 });
475
+ const entries = await backend.listEntries(fromJS(collectionManyEntriesConfig));
476
+
477
+ expect(entries.entries).toEqual(
478
+ expect.arrayContaining(pageTree.map(file => expect.objectContaining({ path: file.path }))),
479
+ );
480
+ expect(entries.entries).toHaveLength(20);
481
+ });
482
+ });
483
+
484
+ describe('traverseCursor', () => {
485
+ sharedSetup();
486
+
487
+ it('returns complete last page of paginated tree', async () => {
488
+ const tree = mockRepo.tree[collectionManyEntriesConfig.folder];
489
+ tree.slice(0, 20).forEach(file => interceptFiles(backend, file.path));
490
+ interceptCollection(backend, collectionManyEntriesConfig, { page: 1 });
491
+ const entries = await backend.listEntries(fromJS(collectionManyEntriesConfig));
492
+
493
+ const nextPageTree = tree.slice(20, 40);
494
+ nextPageTree.forEach(file => interceptFiles(backend, file.path));
495
+ interceptCollection(backend, collectionManyEntriesConfig, { page: 2 });
496
+ const nextPage = await backend.traverseCursor(entries.cursor, 'next');
497
+
498
+ expect(nextPage.entries).toEqual(
499
+ expect.arrayContaining(
500
+ nextPageTree.map(file => expect.objectContaining({ path: file.path })),
501
+ ),
502
+ );
503
+ expect(nextPage.entries).toHaveLength(20);
504
+
505
+ const lastPageTree = tree.slice(-20);
506
+ lastPageTree.forEach(file => interceptFiles(backend, file.path));
507
+ interceptCollection(backend, collectionManyEntriesConfig, { page: 25 });
508
+ const lastPage = await backend.traverseCursor(nextPage.cursor, 'last');
509
+ expect(lastPage.entries).toEqual(
510
+ expect.arrayContaining(
511
+ lastPageTree.map(file => expect.objectContaining({ path: file.path })),
512
+ ),
513
+ );
514
+ expect(lastPage.entries).toHaveLength(20);
515
+ });
516
+ });
517
+
518
+ describe('filterFile', () => {
519
+ it('should return true for nested file with matching depth', () => {
520
+ backend = resolveBackend(defaultConfig);
521
+
522
+ expect(
523
+ backend.implementation.filterFile(
524
+ 'content/posts',
525
+ { name: 'index.md', path: 'content/posts/dir1/dir2/index.md' },
526
+ 'md',
527
+ 3,
528
+ ),
529
+ ).toBe(true);
530
+ });
531
+
532
+ it('should return false for nested file with non matching depth', () => {
533
+ backend = resolveBackend(defaultConfig);
534
+
535
+ expect(
536
+ backend.implementation.filterFile(
537
+ 'content/posts',
538
+ { name: 'index.md', path: 'content/posts/dir1/dir2/index.md' },
539
+ 'md',
540
+ 2,
541
+ ),
542
+ ).toBe(false);
543
+ });
544
+ });
545
+
546
+ afterEach(() => {
547
+ nock.cleanAll();
548
+ authStore.logout();
549
+ backend = null;
550
+ expect(authStore.retrieve()).toEqual(null);
551
+ });
552
+ });