domma-cms 1.8.32 → 1.8.34

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,816 @@
1
+ import {DATA} from './_env.js';
2
+ import {after, test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import Fastify from 'fastify';
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ import {fileURLToPath} from 'url';
8
+ import docsPlugin from '../plugin.js';
9
+ import {load as loadRoles} from '../../../cms/server/services/roles.js';
10
+
11
+ /**
12
+ * Docs - the settings route and version retention.
13
+ *
14
+ * Only the refusing paths of PUT /settings are driven here: a successful save
15
+ * persists through savePluginState into the host CMS's config/plugins.json,
16
+ * which in this harness is a real checkout that a test has no business
17
+ * writing to. The accepting path is exercised in the browser instead.
18
+ *
19
+ * Documents are written to a temp dir (tests/_env.js), removed afterwards.
20
+ */
21
+
22
+ const created = [];
23
+
24
+ after(async () => {
25
+ // Everything lives in a temp dir (tests/_env.js): remove it whole.
26
+ await fs.rm(DATA, {recursive: true, force: true});
27
+ });
28
+
29
+ const PEOPLE = [
30
+ {id: 'd-alice', name: 'Alice', email: 'alice@example.com', role: 'user'},
31
+ {id: 'd-bob', name: 'Bob', email: 'bob@example.com', role: 'user'},
32
+ {id: 'd-carol', name: 'Carol', email: 'carol@example.com', role: 'user'},
33
+ {id: 'd-admin', name: 'Ada Admin', email: 'ada@example.com', role: 'super-admin'},
34
+ {id: 'docs-test', name: 'Tester', email: 'test@example.com', role: 'super-admin'}
35
+ ];
36
+
37
+ /** Mail that goes nowhere and remembers what it was asked to send. */
38
+ const outbox = [];
39
+ const mailer = {
40
+ createTransport: async () => ({}),
41
+ sendEmail: async (t, msg) => { outbox.push(msg); return {ok: true}; }
42
+ };
43
+
44
+ /**
45
+ * @param {object} [opts]
46
+ * @param {object} [opts.user] who authenticate() says is calling
47
+ * @param {boolean} [opts.admin] whether requireAdmin lets them through
48
+ * @param {object} [opts.settings] plugin settings, as config/plugins.json would hold them
49
+ */
50
+ async function buildApp({user = {id: 'docs-test', role: 'super-admin'}, admin = true, settings = {}, perm = true} = {}) {
51
+ const app = Fastify();
52
+ const authenticate = async (req) => { req.user = {name: PEOPLE.find(p => p.id === user.id)?.name, ...user}; };
53
+ const requireAdmin = async (req, reply) => {
54
+ if (!admin) return reply.code(403).send({error: 'Admin access required'});
55
+ };
56
+ // perm: whether this user's role holds the Docs permission (1.3).
57
+ const requirePermission = () => async (req, reply) => {
58
+ if (!perm) return reply.code(403).send({error: 'Insufficient permissions'});
59
+ };
60
+ await app.register(docsPlugin, {prefix: '/api/plugins/docs', settings: {...settings},
61
+ auth: {authenticate, requireAdmin, requirePermission}, listUsers: async () => PEOPLE, mailer});
62
+ return app;
63
+ }
64
+
65
+ /** JSON body of an inject, with the status alongside. */
66
+ async function call(app, method, url, payload, headers) {
67
+ const res = await app.inject({method, url: `/api/plugins/docs${url}`, payload, headers});
68
+ let body = null;
69
+ try { body = JSON.parse(res.body); } catch { body = res.body; }
70
+ return {status: res.statusCode, body};
71
+ }
72
+
73
+ async function make(app, fields) {
74
+ const r = await call(app, 'POST', '/documents', fields);
75
+ assert.equal(r.status, 201);
76
+ created.push(r.body.id);
77
+ return r.body;
78
+ }
79
+
80
+ test('GET /settings answers the whitelisted keys, and whether this user may change them', async () => {
81
+ await loadRoles();
82
+ const app = await buildApp({settings: {maxVersions: 20, scope: 'all', smtpPassword: 'never-sent'}});
83
+ const res = await app.inject({method: 'GET', url: '/api/plugins/docs/settings'});
84
+ assert.equal(res.statusCode, 200);
85
+ const body = JSON.parse(res.body);
86
+ assert.deepEqual(Object.keys(body).sort(), ['allowSend', 'autoSaveInterval', 'canEdit', 'extensions', 'maxVersions', 'pdf', 'publicLinks', 'scope', 'suggestions', 'trashDays']);
87
+ assert.equal(body.maxVersions, 20);
88
+ assert.equal(body.scope, 'all');
89
+ assert.equal(body.canEdit, true);
90
+
91
+ const nobody = await buildApp({user: {id: 'x', role: 'no-such-role'}});
92
+ const r2 = JSON.parse((await nobody.inject({method: 'GET', url: '/api/plugins/docs/settings'})).body);
93
+ assert.equal(r2.canEdit, false);
94
+ });
95
+
96
+ test('PUT /settings refuses non-admins and bad values, before anything is saved', async () => {
97
+ const guest = await buildApp({admin: false});
98
+ const denied = await guest.inject({method: 'PUT', url: '/api/plugins/docs/settings', payload: {maxVersions: 10}});
99
+ assert.equal(denied.statusCode, 403);
100
+
101
+ const app = await buildApp();
102
+ for (const payload of [{trashDays: 'soon'}, {autoSaveInterval: -1}, {maxVersions: 'lots'}]) {
103
+ const res = await app.inject({method: 'PUT', url: '/api/plugins/docs/settings', payload});
104
+ assert.equal(res.statusCode, 400, JSON.stringify(payload));
105
+ }
106
+ // Nothing applied: the running config still answers its defaults.
107
+ const after = JSON.parse((await app.inject({method: 'GET', url: '/api/plugins/docs/settings'})).body);
108
+ assert.equal(after.autoSaveInterval, 30000);
109
+ assert.equal(after.trashDays, 30);
110
+ });
111
+
112
+ test('version history keeps the newest maxVersions and never stops recording', async () => {
113
+ // It used to stop writing versions at the cap, so a document's history
114
+ // froze at its fiftieth save and everything after was unrecoverable.
115
+ // Two people take turns, so every save keeps what it replaced (one
116
+ // person's own saves within ten minutes are merged - see the next test).
117
+ const owner = await buildApp({settings: {maxVersions: 3}});
118
+ const bob = await buildApp({user: {id: 'd-bob', role: 'user'}, settings: {maxVersions: 3}});
119
+ const doc = JSON.parse((await owner.inject({method: 'POST', url: '/api/plugins/docs/documents',
120
+ payload: {title: 'Retention', content: '<p>v0</p>'}})).body);
121
+ created.push(doc.id);
122
+ await owner.inject({method: 'PUT', url: `/api/plugins/docs/documents/${doc.id}/shares`, payload: {shares: [{userId: 'd-bob', role: 'editor'}]}});
123
+
124
+ for (let i = 1; i <= 6; i++) {
125
+ const app = i % 2 ? bob : owner;
126
+ await app.inject({method: 'PUT', url: `/api/plugins/docs/documents/${doc.id}`, payload: {content: `<p>v${i}</p>`}});
127
+ }
128
+ // A title-only save changes no content and so records nothing.
129
+ await owner.inject({method: 'PUT', url: `/api/plugins/docs/documents/${doc.id}`, payload: {title: 'Renamed'}});
130
+
131
+ const versions = JSON.parse((await owner.inject({method: 'GET', url: `/api/plugins/docs/documents/${doc.id}/versions`})).body);
132
+ assert.deepEqual(versions.map(v => v.num), [6, 5, 4]);
133
+ assert.deepEqual(versions.map(v => v.content), ['<p>v5</p>', '<p>v4</p>', '<p>v3</p>']);
134
+
135
+ // Restoring always snapshots what it replaces, within the same limit.
136
+ const restore = await owner.inject({method: 'POST', url: `/api/plugins/docs/documents/${doc.id}/versions/4/restore`});
137
+ assert.equal(JSON.parse(restore.body).content, '<p>v3</p>');
138
+ const again = JSON.parse((await owner.inject({method: 'GET', url: `/api/plugins/docs/documents/${doc.id}/versions`})).body);
139
+ assert.deepEqual(again.map(v => v.num), [7, 6, 5]);
140
+ assert.equal(again[0].content, '<p>v6</p>');
141
+ });
142
+
143
+ test('2.0: one person saving again within ten minutes adds no version (autosave does not flood history)', async () => {
144
+ const alice = await buildApp({user: {id: 'd-alice', role: 'user'}});
145
+ const doc = await make(alice, {title: 'Draft', content: '<p>start</p>'});
146
+ for (const t of ['a', 'b', 'c']) await call(alice, 'PUT', `/documents/${doc.id}`, {content: `<p>${t}</p>`});
147
+ const versions = (await call(alice, 'GET', `/documents/${doc.id}/versions`)).body;
148
+ // Creating counts as Alice's save, so even the first edit is merged into her session.
149
+ assert.equal(versions.length, 0);
150
+ const bob = await buildApp({user: {id: 'd-bob', role: 'user'}});
151
+ await call(alice, 'PUT', `/documents/${doc.id}/shares`, {shares: [{userId: 'd-bob', role: 'editor'}]});
152
+ await call(bob, 'PUT', `/documents/${doc.id}`, {content: '<p>bob</p>'});
153
+ const after = (await call(alice, 'GET', `/documents/${doc.id}/versions`)).body;
154
+ assert.deepEqual(after.map(v => v.content), ['<p>c</p>']);
155
+ });
156
+
157
+ test('1.3: the Docs permission; shared site-wide means read, not rewrite; folders must be yours', async () => {
158
+ await loadRoles();
159
+ const u = (id, role = 'user') => ({id, role});
160
+ const without = await buildApp({user: u('d-none'), perm: false});
161
+ assert.equal((await without.inject({method: 'GET', url: '/api/plugins/docs/documents'})).statusCode, 403);
162
+ assert.equal((await without.inject({method: 'POST', url: '/api/plugins/docs/documents', payload: {title: 'x'}})).statusCode, 403);
163
+
164
+ const shared = {scope: 'all'};
165
+ const alice = await buildApp({user: u('d-alice'), settings: shared});
166
+ const bob = await buildApp({user: u('d-bob'), settings: shared});
167
+ const admin = await buildApp({user: u('d-admin', 'super-admin'), settings: shared});
168
+ const doc = JSON.parse((await alice.inject({method: 'POST', url: '/api/plugins/docs/documents', payload: {title: 'Alice', content: 'hers'}})).body);
169
+ created.push(doc.id);
170
+ assert.equal((await bob.inject({method: 'GET', url: `/api/plugins/docs/documents/${doc.id}`})).statusCode, 200, 'shared: others may read');
171
+ assert.equal((await bob.inject({method: 'PUT', url: `/api/plugins/docs/documents/${doc.id}`, payload: {content: 'mine now'}})).statusCode, 403, 'but not rewrite');
172
+ assert.equal((await bob.inject({method: 'DELETE', url: `/api/plugins/docs/documents/${doc.id}`})).statusCode, 403, 'nor delete');
173
+ assert.equal((await admin.inject({method: 'PUT', url: `/api/plugins/docs/documents/${doc.id}`, payload: {title: 'Tidied'}})).statusCode, 200, 'an admin may');
174
+
175
+ // Folders: only one this user may use.
176
+ const own = await buildApp({user: u('d-carol')});
177
+ const folder = JSON.parse((await alice.inject({method: 'POST', url: '/api/plugins/docs/folders', payload: {name: 'Hers'}})).body);
178
+ const put = await own.inject({method: 'POST', url: '/api/plugins/docs/documents', payload: {title: 'x', folderId: folder.id}});
179
+ assert.equal(put.statusCode, 400, "someone else's folder, when not shared");
180
+ assert.equal((await own.inject({method: 'POST', url: '/api/plugins/docs/documents', payload: {title: 'x', folderId: 'nope'}})).statusCode, 400);
181
+ await alice.inject({method: 'DELETE', url: `/api/plugins/docs/folders/${folder.id}`});
182
+ });
183
+
184
+ // ----------------------------------------------------------------------- 2.0
185
+
186
+ const as = (id) => buildApp({user: {id, role: PEOPLE.find(p => p.id === id)?.role || 'user'}});
187
+
188
+ test('2.0: bodies are sanitised on the way in', async () => {
189
+ const app = await as('d-alice');
190
+ const doc = await make(app, {title: ' Clean ', content: '<p onclick="x()">hi<script>alert(1)</script></p><a href="javascript:alert(1)">bad</a><img src="x" onerror="y()"><p style="text-align:center;position:fixed">c</p><ul data-type="checklist"><li data-checked="true">t</li></ul>'});
191
+ assert.equal(doc.title, 'Clean');
192
+ assert.doesNotMatch(doc.content, /onclick|script|javascript|onerror|position/);
193
+ assert.match(doc.content, /text-align:center/);
194
+ assert.match(doc.content, /<li data-checked="true">/);
195
+ assert.equal(doc.format, 'html');
196
+ });
197
+
198
+ test('2.0: sharing - viewers read, editors edit, only the owner trashes; strangers see nothing', async () => {
199
+ const alice = await as('d-alice');
200
+ const bob = await as('d-bob');
201
+ const carol = await as('d-carol');
202
+ const doc = await make(alice, {title: 'Plan', content: '<p>v1</p>'});
203
+
204
+ assert.equal((await call(bob, 'GET', `/documents/${doc.id}`)).status, 404, 'private by default');
205
+
206
+ const shared = await call(alice, 'PUT', `/documents/${doc.id}/shares`, {shares: [
207
+ {userId: 'd-bob', role: 'viewer'}, {userId: 'd-carol', role: 'editor'},
208
+ {userId: 'd-alice', role: 'editor'}, {userId: 'nobody', role: 'editor'}]});
209
+ assert.equal(shared.status, 200);
210
+ assert.deepEqual(shared.body.shares.map(s => [s.userId, s.role, s.name]), [['d-bob', 'viewer', 'Bob'], ['d-carol', 'editor', 'Carol']]);
211
+
212
+ const seen = await call(bob, 'GET', `/documents/${doc.id}`);
213
+ assert.equal(seen.body.access, 'viewer');
214
+ assert.equal((await call(bob, 'PUT', `/documents/${doc.id}`, {content: '<p>bob</p>'})).status, 403);
215
+ assert.equal((await call(carol, 'PUT', `/documents/${doc.id}`, {content: '<p>carol</p>'})).status, 200);
216
+ assert.equal((await call(carol, 'DELETE', `/documents/${doc.id}`)).status, 403, 'editors do not trash');
217
+ assert.equal((await call(carol, 'PUT', `/documents/${doc.id}`, {folderId: null})).status, 403, 'nor move');
218
+
219
+ // An editor may add people but not remove another editor.
220
+ const byCarol = await call(carol, 'PUT', `/documents/${doc.id}/shares`, {shares: [{userId: 'd-bob', role: 'viewer'}]});
221
+ assert.equal(byCarol.status, 403);
222
+
223
+ // Shared with me lists it for Bob, not for Alice; Mine lists it for Alice.
224
+ const bobShared = await call(bob, 'GET', '/documents?view=shared');
225
+ assert.ok(bobShared.body.some(r => r.id === doc.id && r.ownerName === 'Alice' && r.content === undefined && r.excerpt === 'carol'));
226
+ assert.ok(!(await call(alice, 'GET', '/documents?view=shared')).body.some(r => r.id === doc.id));
227
+ assert.ok((await call(alice, 'GET', '/documents?view=mine')).body.some(r => r.id === doc.id && r.shared));
228
+
229
+ // A viewer's duplicate is their own, private, unshared copy.
230
+ const copy = await call(bob, 'POST', `/documents/${doc.id}/duplicate`);
231
+ created.push(copy.body.id);
232
+ assert.equal(copy.status, 201);
233
+ assert.equal(copy.body.userId, 'd-bob');
234
+ assert.deepEqual(copy.body.shares, []);
235
+ assert.equal(copy.body.publicLink, null);
236
+ });
237
+
238
+ test('2.0: notify emails the people newly shared with', async () => {
239
+ const alice = await as('d-alice');
240
+ const doc = await make(alice, {title: 'Notes <b>', content: '<p>x</p>'});
241
+ outbox.length = 0;
242
+ // The sharer's request claims a Host of its own: the email must never point there.
243
+ const r = await call(alice, 'PUT', `/documents/${doc.id}/shares`, {shares: [{userId: 'd-bob', role: 'viewer'}], notify: true, message: 'Have a look <3'}, {host: 'evil.example'});
244
+ assert.equal(r.body.notified, 1);
245
+ assert.equal(outbox[0].to, 'bob@example.com');
246
+ assert.match(outbox[0].html, /Notes &lt;b&gt;/);
247
+ assert.match(outbox[0].html, /Have a look &lt;3/);
248
+ assert.match(outbox[0].html, new RegExp(`#/plugins/docs/d/${doc.id}`));
249
+ assert.equal(outbox[0].html.includes('evil.example'), false, 'no link from the Host header');
250
+ assert.equal(outbox[0].text.includes('evil.example'), false);
251
+ // Saving the same list again notifies nobody.
252
+ const again = await call(alice, 'PUT', `/documents/${doc.id}/shares`, {shares: [{userId: 'd-bob', role: 'editor'}], notify: true});
253
+ assert.equal(again.body.notified, 0);
254
+ });
255
+
256
+ test('2.0: the edit lock - one session edits, others wait, the owner can take over', async () => {
257
+ const alice = await as('d-alice');
258
+ const carol = await as('d-carol');
259
+ const bob = await as('d-bob');
260
+ const doc = await make(alice, {title: 'Locked', content: '<p>a</p>'});
261
+ await call(alice, 'PUT', `/documents/${doc.id}/shares`, {shares: [{userId: 'd-carol', role: 'editor'}, {userId: 'd-bob', role: 'viewer'}]});
262
+
263
+ const s1 = 'session-carol-1';
264
+ const got = await call(carol, 'POST', `/documents/${doc.id}/lock`, {session: s1});
265
+ assert.equal(got.status, 200);
266
+ assert.equal(got.body.lock.mine, true);
267
+
268
+ const refused = await call(alice, 'POST', `/documents/${doc.id}/lock`, {session: 'session-alice-1'});
269
+ assert.equal(refused.body.ok, false);
270
+ assert.equal(refused.body.lock.name, 'Carol');
271
+ assert.equal(refused.body.canTakeOver, true);
272
+
273
+ // A save from outside the lock is refused; the holder's goes through.
274
+ assert.equal((await call(alice, 'PUT', `/documents/${doc.id}`, {content: '<p>alice</p>'}, {'x-docs-session': 'session-alice-1'})).status, 423);
275
+ assert.equal((await call(carol, 'PUT', `/documents/${doc.id}`, {content: '<p>carol</p>'}, {'x-docs-session': s1})).status, 200);
276
+
277
+ // Viewers never take the lock; they are told it is read-only.
278
+ const viewer = await call(bob, 'POST', `/documents/${doc.id}/lock`, {session: 'session-bob-1'});
279
+ assert.equal(viewer.body.readOnly, true);
280
+ assert.equal(viewer.body.lock.name, 'Carol');
281
+
282
+ // The list shows who is editing.
283
+ const row = (await call(alice, 'GET', '/documents?view=mine')).body.find(r => r.id === doc.id);
284
+ assert.equal(row.locked.name, 'Carol');
285
+
286
+ // An editor may not force; the owner may.
287
+ const s3 = 'session-carol-2';
288
+ await call(carol, 'DELETE', `/documents/${doc.id}/lock?session=${s1}`);
289
+ await call(carol, 'POST', `/documents/${doc.id}/lock`, {session: s3});
290
+ assert.equal((await call(bob, 'POST', `/documents/${doc.id}/lock`, {session: 'session-bob-1', force: true})).body.readOnly, true);
291
+ const took = await call(alice, 'POST', `/documents/${doc.id}/lock`, {session: 'session-alice-1', force: true});
292
+ assert.equal(took.status, 200);
293
+ // Carol's next heartbeat learns she lost it.
294
+ const lost = await call(carol, 'POST', `/documents/${doc.id}/lock`, {session: s3});
295
+ assert.equal(lost.body.ok, false);
296
+ assert.equal(lost.body.lock.name, 'Alice');
297
+ await call(alice, 'DELETE', `/documents/${doc.id}/lock?session=session-alice-1`);
298
+ });
299
+
300
+ test('2.0: trash, restore, delete for good; star and recent', async () => {
301
+ const alice = await as('d-alice');
302
+ const doc = await make(alice, {title: 'Binned', content: '<p>b</p>'});
303
+
304
+ const star = await call(alice, 'POST', `/documents/${doc.id}/star`, {on: true});
305
+ assert.equal(star.body.starred, true);
306
+ assert.ok((await call(alice, 'GET', '/documents?view=starred')).body.some(r => r.id === doc.id && r.starred));
307
+ await call(alice, 'GET', `/documents/${doc.id}`);
308
+ assert.equal((await call(alice, 'GET', '/documents?view=recent')).body[0].id, doc.id);
309
+
310
+ assert.equal((await call(alice, 'DELETE', `/documents/${doc.id}?permanent=1`)).status, 409, 'trash first');
311
+ assert.equal((await call(alice, 'DELETE', `/documents/${doc.id}`)).body.trashed, true);
312
+ assert.ok(!(await call(alice, 'GET', '/documents?view=mine')).body.some(r => r.id === doc.id));
313
+ assert.ok((await call(alice, 'GET', '/documents?view=trash')).body.some(r => r.id === doc.id));
314
+ assert.equal((await call(alice, 'PUT', `/documents/${doc.id}`, {content: 'x'})).status, 409);
315
+
316
+ await call(alice, 'POST', `/documents/${doc.id}/restore`);
317
+ assert.ok((await call(alice, 'GET', '/documents?view=mine')).body.some(r => r.id === doc.id));
318
+
319
+ await call(alice, 'DELETE', `/documents/${doc.id}`);
320
+ assert.equal((await call(alice, 'POST', '/trash/empty')).body.deleted >= 1, true);
321
+ assert.equal((await call(alice, 'GET', `/documents/${doc.id}`)).status, 404);
322
+ assert.ok(!(await call(alice, 'GET', '/documents?view=starred')).body.some(r => r.id === doc.id), 'forgotten');
323
+ });
324
+
325
+ test('2.0: public links - on, served sanitised and unindexed, off again; site switch', async () => {
326
+ const alice = await as('d-alice');
327
+ const doc = await make(alice, {title: 'Public <i>', content: '<h2>Hello</h2><p>World</p>'});
328
+ const on = await call(alice, 'POST', `/documents/${doc.id}/link`);
329
+ assert.equal(on.status, 200);
330
+ const token = on.body.publicLink.token;
331
+ assert.match(on.body.url, new RegExp(`/docs/s/${token}$`));
332
+ assert.equal((await call(alice, 'POST', `/documents/${doc.id}/link`)).body.publicLink.token, token, 'idempotent');
333
+
334
+ const {default: docsPublic} = await import('../plugin.public.js');
335
+ const pub = Fastify();
336
+ await pub.register(docsPublic, {settings: {}});
337
+ // The page itself renders through the CMS template; the downloads do not.
338
+ const md = await pub.inject({method: 'GET', url: `/docs/s/${token}?download=md`});
339
+ assert.equal(md.statusCode, 200);
340
+ assert.equal(md.body, '# Public <i>\n\n## Hello\n\nWorld\n');
341
+ assert.match(md.headers['x-robots-tag'], /noindex/);
342
+ assert.match(md.headers['content-disposition'], /attachment/);
343
+ const page = await pub.inject({method: 'GET', url: `/docs/s/${token}`});
344
+ assert.equal(page.statusCode, 200);
345
+ assert.match(page.body, /<h2>Hello<\/h2>/);
346
+ assert.match(page.body, /Public &lt;i&gt;/);
347
+ assert.equal((await pub.inject({method: 'GET', url: '/docs/s/not-a-real-token-at-all'})).statusCode, 404);
348
+
349
+ await call(alice, 'DELETE', `/documents/${doc.id}/link`);
350
+ assert.equal((await pub.inject({method: 'GET', url: `/docs/s/${token}`})).statusCode, 404, 'off means off');
351
+
352
+ // Switched off site-wide: no new links, and old ones stop.
353
+ const again = await call(alice, 'POST', `/documents/${doc.id}/link`);
354
+ const offSite = await buildApp({user: {id: 'd-alice', role: 'user'}, settings: {publicLinks: false}});
355
+ assert.equal((await call(offSite, 'POST', `/documents/${doc.id}/link`)).status, 403);
356
+ assert.equal((await pub.inject({method: 'GET', url: `/docs/s/${again.body.publicLink.token}`})).statusCode, 404);
357
+ await buildApp({user: {id: 'd-alice', role: 'user'}}); // back to the default for later tests
358
+ });
359
+
360
+ test('2.0: import Markdown, HTML and text', async () => {
361
+ const alice = await as('d-alice');
362
+ const md = await call(alice, 'POST', '/documents/import', {name: 'notes.md', text: '---\ntitle: x\n---\n# Meeting\n\n- [x] done\n- [ ] todo\n\n| a | b |\n|---|---|\n| 1 | 2 |\n'});
363
+ created.push(md.body.id);
364
+ assert.equal(md.status, 201);
365
+ assert.equal(md.body.title, 'Meeting');
366
+ assert.match(md.body.content, /<ul data-type="checklist">\s*<li data-checked="true">done/);
367
+ assert.match(md.body.content, /<table>/);
368
+
369
+ const html = await call(alice, 'POST', '/documents/import', {name: 'page.html', text: '<html><body><p onclick="x">Hi</p><script>bad()</script></body></html>'});
370
+ created.push(html.body.id);
371
+ assert.equal(html.body.title, 'page');
372
+ assert.equal(html.body.content, '<p>Hi</p>');
373
+
374
+ const txt = await call(alice, 'POST', '/documents/import', {name: 'plain.txt', text: 'one <two>\nline\n\npara'});
375
+ created.push(txt.body.id);
376
+ assert.equal(txt.body.content, '<p>one &lt;two&gt;<br />line</p><p>para</p>');
377
+
378
+ assert.equal((await call(alice, 'POST', '/documents/import', {name: 'x.pdf', text: 'nope'})).status, 400);
379
+ });
380
+
381
+ test('2.0: send by email - inline or as a link; limits', async () => {
382
+ const alice = await as('d-alice');
383
+ const doc = await make(alice, {title: 'Agenda', content: '<p>Item one</p>'});
384
+ outbox.length = 0;
385
+ const inline = await call(alice, 'POST', `/documents/${doc.id}/send`, {to: 'a@example.com, b@example.com', message: 'FYI', mode: 'inline'});
386
+ assert.equal(inline.body.sent, 2);
387
+ assert.match(outbox[0].html, /<p>Item one<\/p>/);
388
+ assert.equal(outbox[0].subject, 'Agenda');
389
+
390
+ const link = await call(alice, 'POST', `/documents/${doc.id}/send`, {to: ['c@example.com'], mode: 'link'});
391
+ assert.match(link.body.url, /\/docs\/s\//);
392
+ assert.match(outbox[2].html, /Read it here/);
393
+
394
+ assert.equal((await call(alice, 'POST', `/documents/${doc.id}/send`, {to: 'not-an-address'})).status, 400);
395
+ assert.equal((await call(alice, 'POST', `/documents/${doc.id}/send`, {to: Array.from({length: 11}, (_, i) => `p${i}@example.com`)})).status, 400);
396
+ const bob = await as('d-bob');
397
+ assert.equal((await call(bob, 'POST', `/documents/${doc.id}/send`, {to: 'x@example.com'})).status, 404);
398
+ });
399
+
400
+ test('2.0: people lists others who hold Docs; emails only for admins', async () => {
401
+ await loadRoles();
402
+ const alice = await as('d-alice');
403
+ const list = (await call(alice, 'GET', '/people')).body;
404
+ assert.ok(list.some(p => p.id === 'd-bob' && p.name === 'Bob' && p.email === undefined));
405
+ assert.ok(!list.some(p => p.id === 'd-alice'));
406
+ const admin = await as('d-admin');
407
+ assert.ok((await call(admin, 'GET', '/people')).body.some(p => p.id === 'd-bob' && p.email === 'bob@example.com'));
408
+ });
409
+
410
+ test('2.0 review: a save from a stale copy is refused (409), never written over newer work', async () => {
411
+ const alice = await as('d-alice');
412
+ const doc = await make(alice, {title: 'Race', content: '<p>one</p>'});
413
+ const first = await call(alice, 'PUT', `/documents/${doc.id}`, {content: '<p>two</p>', baseUpdatedAt: doc.updatedAt});
414
+ assert.equal(first.status, 200);
415
+ await new Promise(r => setTimeout(r, 5));
416
+ const stale = await call(alice, 'PUT', `/documents/${doc.id}`, {content: '<p>stale</p>', baseUpdatedAt: doc.updatedAt});
417
+ assert.equal(stale.status, 409);
418
+ assert.equal(stale.body.conflict, true);
419
+ assert.equal((await call(alice, 'GET', `/documents/${doc.id}`)).body.content, '<p>two</p>');
420
+ // A move (no text) is not a conflict.
421
+ assert.equal((await call(alice, 'PUT', `/documents/${doc.id}`, {folderId: null, baseUpdatedAt: doc.updatedAt})).status, 200);
422
+ });
423
+
424
+ test('2.0 review: concurrent share + save both land', async () => {
425
+ const alice = await as('d-alice');
426
+ const doc = await make(alice, {title: 'Both', content: '<p>x</p>'});
427
+ await Promise.all([
428
+ call(alice, 'PUT', `/documents/${doc.id}/shares`, {shares: [{userId: 'd-bob', role: 'viewer'}]}),
429
+ call(alice, 'PUT', `/documents/${doc.id}`, {content: '<p>saved</p>'})
430
+ ]);
431
+ const now = (await call(alice, 'GET', `/documents/${doc.id}`)).body;
432
+ assert.equal(now.content, '<p>saved</p>');
433
+ assert.deepEqual(now.shares.map(s => s.userId), ['d-bob']);
434
+ });
435
+
436
+ test('2.0 review: lock edges - wrong session cannot release, editor cannot force, restore needs the lock', async () => {
437
+ const alice = await as('d-alice');
438
+ const carol = await as('d-carol');
439
+ const doc = await make(alice, {title: 'Edges', content: '<p>a</p>'});
440
+ await call(alice, 'PUT', `/documents/${doc.id}/shares`, {shares: [{userId: 'd-carol', role: 'editor'}]});
441
+ await call(carol, 'PUT', `/documents/${doc.id}`, {content: '<p>b</p>'}); // another person: a version is kept
442
+ await call(alice, 'POST', `/documents/${doc.id}/lock`, {session: 'alice-session-1'});
443
+ await call(carol, 'DELETE', `/documents/${doc.id}/lock?session=not-the-session`);
444
+ assert.equal((await call(carol, 'POST', `/documents/${doc.id}/lock`, {session: 'carol-session-1'})).body.ok, false, 'still held');
445
+ assert.equal((await call(carol, 'POST', `/documents/${doc.id}/lock`, {session: 'carol-session-1', force: true})).status, 403);
446
+ const versions = (await call(alice, 'GET', `/documents/${doc.id}/versions`)).body;
447
+ assert.equal((await call(carol, 'POST', `/documents/${doc.id}/versions/${versions[0].num}/restore`, {}, {'x-docs-session': 'carol-session-1'})).status, 423);
448
+ assert.equal((await call(alice, 'POST', `/documents/${doc.id}/versions/${versions[0].num}/restore`, {}, {'x-docs-session': 'alice-session-1'})).status, 200);
449
+ await call(alice, 'DELETE', `/documents/${doc.id}/lock?session=alice-session-1`);
450
+ });
451
+
452
+ test('2.0 review: the lock expires on its own', async () => {
453
+ const {acquireLock, lockOf, LOCK_TTL_MS} = await import('../lib/store.js');
454
+ const t0 = Date.now();
455
+ assert.equal(acquireLock('ttl-doc-1', {userId: 'a', name: 'A', session: 's-aaaaaaaa'}, t0).ok, true);
456
+ assert.equal(acquireLock('ttl-doc-1', {userId: 'b', name: 'B', session: 's-bbbbbbbb'}, t0 + 1000).ok, false);
457
+ assert.equal(lockOf('ttl-doc-1', t0 + LOCK_TTL_MS + 1), null);
458
+ assert.equal(acquireLock('ttl-doc-1', {userId: 'b', name: 'B', session: 's-bbbbbbbb'}, t0 + LOCK_TTL_MS + 2).ok, true);
459
+ });
460
+
461
+ test('2.0 review: Trash belongs to the owner - sharees lose it, public page and send refuse, admins empty only their own', async () => {
462
+ const alice = await as('d-alice');
463
+ const bob = await as('d-bob');
464
+ const doc = await make(alice, {title: 'Binned share', content: '<p>x</p>'});
465
+ await call(alice, 'PUT', `/documents/${doc.id}/shares`, {shares: [{userId: 'd-bob', role: 'editor'}]});
466
+ const token = (await call(alice, 'POST', `/documents/${doc.id}/link`)).body.publicLink.token;
467
+ await call(alice, 'DELETE', `/documents/${doc.id}`);
468
+ assert.equal((await call(bob, 'GET', `/documents/${doc.id}`)).status, 404);
469
+ assert.equal((await call(bob, 'POST', `/documents/${doc.id}/duplicate`)).status, 404);
470
+ assert.equal((await call(alice, 'POST', `/documents/${doc.id}/send`, {to: 'x@example.com'})).status, 409);
471
+ const {default: docsPublic} = await import('../plugin.public.js');
472
+ const pub = Fastify();
473
+ await pub.register(docsPublic, {settings: {}});
474
+ assert.equal((await pub.inject({method: 'GET', url: `/docs/s/${token}`})).statusCode, 404);
475
+ // An admin on a site-wide install empties only their own Trash.
476
+ const admin = await buildApp({user: {id: 'd-admin', role: 'super-admin'}, settings: {scope: 'all'}});
477
+ await call(admin, 'POST', '/trash/empty');
478
+ assert.equal((await call(alice, 'GET', `/documents/${doc.id}`)).status, 200, 'still in Alice\'s Trash');
479
+ await buildApp({user: {id: 'd-alice', role: 'user'}}); // back to the default scope
480
+ });
481
+
482
+ test('2.0 review: viewers cannot send; daily email allowance; sending can be switched off', async () => {
483
+ const alice = await as('d-alice');
484
+ const bob = await as('d-bob');
485
+ const doc = await make(alice, {title: 'Mail', content: '<p>x</p>'});
486
+ await call(alice, 'PUT', `/documents/${doc.id}/shares`, {shares: [{userId: 'd-bob', role: 'viewer'}]});
487
+ assert.equal((await call(bob, 'POST', `/documents/${doc.id}/send`, {to: 'x@example.com'})).status, 403);
488
+ const carol = await as('d-carol');
489
+ const cdoc = await make(carol, {title: 'Lots', content: '<p>x</p>'});
490
+ let status = 200;
491
+ for (let i = 0; i < 6 && status === 200; i++) {
492
+ status = (await call(carol, 'POST', `/documents/${cdoc.id}/send`, {to: Array.from({length: 10}, (_, j) => `c${i}-${j}@example.com`)})).status;
493
+ }
494
+ assert.equal(status, 429, 'the 51st address today is refused');
495
+ const off = await buildApp({user: {id: 'd-alice', role: 'user'}, settings: {allowSend: false}});
496
+ assert.equal((await call(off, 'POST', `/documents/${doc.id}/send`, {to: 'x@example.com'})).status, 403);
497
+ await buildApp({user: {id: 'd-alice', role: 'user'}});
498
+ });
499
+
500
+ test('2.0 review: public page escapes the title and downloads carry a safe file name', async () => {
501
+ const alice = await as('d-alice');
502
+ const doc = await make(alice, {title: `Bob's "plan" <script>x</script>\r\nX-Evil: 1`, content: '<p>body</p>'});
503
+ const token = (await call(alice, 'POST', `/documents/${doc.id}/link`)).body.publicLink.token;
504
+ const {default: docsPublic} = await import('../plugin.public.js');
505
+ const pub = Fastify();
506
+ await pub.register(docsPublic, {settings: {}});
507
+ const page = await pub.inject({method: 'GET', url: `/docs/s/${token}`});
508
+ assert.doesNotMatch(page.body, /<script>x<\/script>/);
509
+ const dl = await pub.inject({method: 'GET', url: `/docs/s/${token}?download=txt`});
510
+ const cd = dl.headers['content-disposition'];
511
+ assert.doesNotMatch(cd, /[\r\n]/);
512
+ assert.equal(dl.headers['x-evil'], undefined);
513
+ assert.match(cd, /filename\*=UTF-8''[^'()*]*$/);
514
+ });
515
+
516
+ test('2.0 review: pre-2.0 documents - Markdown converted per document, raw HTML sanitised, templates too', async () => {
517
+ const {migrateLegacy, looksLikeMarkdown} = await import('../plugin.js');
518
+ assert.equal(looksLikeMarkdown('# Hi\n\n- a'), true);
519
+ assert.equal(looksLikeMarkdown('<p>hi</p>'), false);
520
+ const dir = path.join(DATA, 'documents');
521
+ await fs.mkdir(dir, {recursive: true});
522
+ const base = {folderId: null, tags: [], userId: 'd-alice', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z'};
523
+ await fs.writeFile(path.join(dir, 'legacy-md-0001.json'), JSON.stringify({...base, id: 'legacy-md-0001', title: 'Old md', content: '# Heading\n\n**bold** text'}));
524
+ await fs.writeFile(path.join(dir, 'legacy-html-0001.json'), JSON.stringify({...base, id: 'legacy-html-0001', title: 'Old html', content: '<p onclick="x()">hi</p><script>bad()</script>'}));
525
+ created.push('legacy-md-0001', 'legacy-html-0001');
526
+ assert.equal(await migrateLegacy(), 2);
527
+ const md = JSON.parse(await fs.readFile(path.join(dir, 'legacy-md-0001.json'), 'utf8'));
528
+ assert.match(md.content, /<h1>Heading<\/h1>/);
529
+ assert.match(md.content, /<strong>bold<\/strong>/);
530
+ assert.equal(md.format, 'html');
531
+ const html = JSON.parse(await fs.readFile(path.join(dir, 'legacy-html-0001.json'), 'utf8'));
532
+ assert.equal(html.content, '<p>hi</p>');
533
+ assert.equal(await migrateLegacy(), 0, 'once only');
534
+ await fs.access(path.join(DATA, '.backup-markdown', 'legacy-md-0001.json'));
535
+ });
536
+
537
+ test('2.7: the sidebar badge counts your own documents as an inventory - shared ones in the details, Trash left out', async () => {
538
+ const owner = await buildApp({user: {id: 'd-carol', role: 'user'}});
539
+ const before = (await call(owner, 'GET', '/count')).body;
540
+ assert.equal(before.inventory, true);
541
+ assert.equal(before.tone, 'info');
542
+ assert.ok(before.label, 'never an empty label');
543
+ const a = await make(owner, {title: 'Badge one'});
544
+ const b = await make(owner, {title: 'Badge two'});
545
+ let r = await call(owner, 'GET', '/count');
546
+ assert.equal(r.body.count, before.count + 2, 'a create clears the cached answer');
547
+ assert.equal(r.body.details.items[0].text, 'Badge two');
548
+ await call(owner, 'DELETE', `/documents/${b.id}`);
549
+ r = await call(owner, 'GET', '/count');
550
+ assert.equal(r.body.count, before.count + 1, 'Trash is left out');
551
+ assert.equal(r.body.details.rows.find(x => x.label === 'In Trash').value, String(Number(before.details.rows.find(x => x.label === 'In Trash').value) + 1));
552
+
553
+ await call(owner, 'PUT', `/documents/${a.id}/shares`, {shares: [{userId: 'd-bob', role: 'viewer'}], notify: false});
554
+ const bob = await buildApp({user: {id: 'd-bob', role: 'user'}});
555
+ const theirs = (await call(bob, 'GET', '/count')).body;
556
+ assert.ok(Number(theirs.details.rows.find(x => x.label === 'Shared with you').value) >= 1);
557
+ assert.match(theirs.label, /shared with you$/);
558
+ assert.ok(!theirs.details.items.some(i => i.text === 'Badge one'), 'only your own documents are counted');
559
+ });
560
+
561
+ test('2.7: the badge cache - a count racing a write never keeps the old answer; two people do not evict each other', async () => {
562
+ const carol = await buildApp({user: {id: 'd-carol', role: 'user'}});
563
+ const bob = await buildApp({user: {id: 'd-bob', role: 'user'}});
564
+ const start = (await call(carol, 'GET', '/count')).body.count;
565
+ // A count in flight while a create lands: the next count must see the new document.
566
+ const [, made] = await Promise.all([call(carol, 'GET', '/count'), call(carol, 'POST', '/documents', {title: 'Raced'})]);
567
+ created.push(made.body.id);
568
+ assert.equal((await call(carol, 'GET', '/count')).body.count, start + 1);
569
+ // Alternating people: each keeps their own answer.
570
+ const c1 = (await call(carol, 'GET', '/count')).body;
571
+ const b1 = (await call(bob, 'GET', '/count')).body;
572
+ assert.deepEqual((await call(carol, 'GET', '/count')).body, c1);
573
+ assert.deepEqual((await call(bob, 'GET', '/count')).body, b1);
574
+ assert.notDeepEqual(c1.details.items, b1.details.items);
575
+ });
576
+
577
+ test('2.8.1: version history is for editors and the owner - a viewer gets 403, not the text removed before sharing', async () => {
578
+ const alice = await as('d-alice');
579
+ const doc = await make(alice, {title: 'Secret draft', content: '<p>salary table</p>'});
580
+ await call(alice, 'PUT', `/documents/${doc.id}`, {content: '<p>clean</p>'});
581
+ await call(alice, 'PUT', `/documents/${doc.id}/shares`, {shares: [{userId: 'd-bob', role: 'viewer'}, {userId: 'd-carol', role: 'editor'}]});
582
+ const bob = await as('d-bob');
583
+ const carol = await as('d-carol');
584
+ assert.equal((await call(bob, 'GET', `/documents/${doc.id}`)).status, 200, 'the viewer reads the document');
585
+ assert.equal((await call(bob, 'GET', `/documents/${doc.id}/versions`)).status, 403);
586
+ const mine = await call(alice, 'GET', `/documents/${doc.id}/versions`);
587
+ assert.equal(mine.status, 200);
588
+ if (mine.body.length) {
589
+ assert.equal((await call(bob, 'GET', `/documents/${doc.id}/versions/${mine.body[0].num}`)).status, 403);
590
+ assert.equal((await call(carol, 'GET', `/documents/${doc.id}/versions/${mine.body[0].num}`)).status, 200);
591
+ }
592
+ assert.equal((await call(carol, 'GET', `/documents/${doc.id}/versions`)).status, 200);
593
+ });
594
+
595
+ test('2.10: /count answers what each Home place shows when opened - the rail numbers match the lists', async () => {
596
+ const dave = await buildApp({user: {id: 'd-alice', role: 'user'}});
597
+ const folder = (await call(dave, 'POST', '/folders', {name: 'Rail counts'})).body;
598
+ const top = await make(dave, {title: 'Top level'});
599
+ const filed = await make(dave, {title: 'Filed', folderId: folder.id});
600
+ const binned = await make(dave, {title: 'Binned'});
601
+ await call(dave, 'DELETE', `/documents/${binned.id}`);
602
+ await call(dave, 'POST', `/documents/${filed.id}/star`, {on: true});
603
+ const {places} = (await call(dave, 'GET', '/count')).body;
604
+ for (const [view, extra] of [['starred', ''], ['shared', ''], ['mine', '&folder='], ['trash', '']]) {
605
+ const list = (await call(dave, 'GET', `/documents?view=${view}${extra}`)).body;
606
+ assert.equal(places[view], list.length, `${view} matches its list`);
607
+ }
608
+ assert.ok(places.mine >= 1 && places.trash >= 1 && places.starred >= 1);
609
+ assert.ok(!(await call(dave, 'GET', '/documents?view=mine&folder=')).body.some(r => r.id === filed.id), 'filed documents are under their folder');
610
+ created.push(top.id, filed.id);
611
+ });
612
+
613
+ test('2.10: a write that does not come through a Docs route (Docs Pro, via services) still refreshes /count', async () => {
614
+ const {docsRegistry} = await import('../lib/extensions.js');
615
+ const app = await buildApp({user: {id: 'd-carol', role: 'user'}});
616
+ const before = (await call(app, 'GET', '/count')).body;
617
+ assert.deepEqual((await call(app, 'GET', '/count')).body, before, 'cached between writes');
618
+ const svc = docsRegistry().services;
619
+ const made = await svc.createDoc({title: 'From an add-on', content: '<p>x</p>'}, {id: 'd-carol', name: 'Carol', role: 'user'});
620
+ created.push(made.id);
621
+ let r = (await call(app, 'GET', '/count')).body;
622
+ assert.equal(r.count, before.count + 1, 'createDoc clears the cached answer');
623
+ await svc.writeDoc({...(await svc.readDoc(made.id)), trashedAt: new Date().toISOString()});
624
+ r = (await call(app, 'GET', '/count')).body;
625
+ assert.equal(r.count, before.count, 'writeDoc clears it too');
626
+ assert.equal(r.places.trash, before.places.trash + 1);
627
+ });
628
+
629
+ // ------------------------------------------------------------------ 2.13: PDF documents
630
+
631
+ import {makePdf} from './_pdf.js';
632
+ import {extractCapability} from '../lib/pdf.js';
633
+ import {filePath, versionFilePath} from '../lib/store.js';
634
+
635
+ const b64 = (buf) => buf.toString('base64');
636
+ const exists = (p) => fs.access(p).then(() => true, () => false);
637
+
638
+ test('2.13: a PDF uploads as a document - kept as it is, text read for search, served with safe headers', async () => {
639
+ const alice = await as('d-alice');
640
+ const pdf = makePdf({pages: ['Quarterly figures', 'Second page'], deflate: true});
641
+ const up = await call(alice, 'POST', '/documents/upload', {name: 'Q3 report.pdf', data: b64(pdf)});
642
+ assert.equal(up.status, 201, JSON.stringify(up.body));
643
+ created.push(up.body.id);
644
+ assert.equal(up.body.format, 'pdf');
645
+ assert.equal(up.body.title, 'Q3 report');
646
+ assert.equal(up.body.file.size, pdf.length);
647
+ assert.equal(up.body.file.name, 'Q3 report.pdf');
648
+ assert.match(up.body.file.sha256, /^[a-f0-9]{64}$/);
649
+ assert.ok(await exists(filePath(up.body.id)), 'the file is on disk');
650
+ if (extractCapability().available) {
651
+ assert.equal(up.body.file.pages, 2);
652
+ assert.match(up.body.content, /<p>Quarterly figures<\/p>/);
653
+ // Search finds words inside the PDF.
654
+ const found = (await call(alice, 'GET', '/documents?view=mine&q=quarterly')).body;
655
+ assert.ok(found.some(r => r.id === up.body.id));
656
+ }
657
+ // Rows say what it is.
658
+ const row = (await call(alice, 'GET', '/documents?view=mine')).body.find(r => r.id === up.body.id);
659
+ assert.equal(row.format, 'pdf');
660
+ assert.equal(row.file.size, pdf.length);
661
+
662
+ // The file comes back byte for byte, inline by default, never sniffed or cached.
663
+ const file = await alice.inject({method: 'GET', url: `/api/plugins/docs/documents/${up.body.id}/file`});
664
+ assert.equal(file.statusCode, 200);
665
+ assert.equal(file.headers['content-type'], 'application/pdf');
666
+ assert.ok(Buffer.from(file.rawPayload).equals(pdf));
667
+ assert.match(file.headers['content-disposition'], /^inline; filename="Q3 report\.pdf"/);
668
+ assert.equal(file.headers['x-content-type-options'], 'nosniff');
669
+ assert.match(file.headers['content-security-policy'], /sandbox/);
670
+ assert.match(file.headers['cache-control'], /no-store/);
671
+ const dl = await alice.inject({method: 'GET', url: `/api/plugins/docs/documents/${up.body.id}/file?download=1`});
672
+ assert.match(dl.headers['content-disposition'], /^attachment/);
673
+
674
+ // No text to edit; the title, folder and tags still change.
675
+ assert.equal((await call(alice, 'PUT', `/documents/${up.body.id}`, {content: '<p>x</p>'})).status, 400);
676
+ const renamed = await call(alice, 'PUT', `/documents/${up.body.id}`, {title: 'Q3 (final)', tags: ['finance']});
677
+ assert.equal(renamed.status, 200);
678
+ assert.equal(renamed.body.format, 'pdf', 'a title save keeps the kind');
679
+ assert.deepEqual(renamed.body.tags, ['finance']);
680
+
681
+ // Strangers get nothing; a viewer gets the file.
682
+ const bob = await as('d-bob');
683
+ assert.equal((await bob.inject({method: 'GET', url: `/api/plugins/docs/documents/${up.body.id}/file`})).statusCode, 404);
684
+ await call(alice, 'PUT', `/documents/${up.body.id}/shares`, {shares: [{userId: 'd-bob', role: 'viewer'}]});
685
+ assert.equal((await bob.inject({method: 'GET', url: `/api/plugins/docs/documents/${up.body.id}/file`})).statusCode, 200);
686
+ assert.equal((await call(bob, 'PUT', `/documents/${up.body.id}/file`, {name: 'x.pdf', data: b64(pdf)})).status, 403, 'a viewer cannot replace it');
687
+
688
+ // An HTML document has no file.
689
+ const html = await make(alice, {title: 'Words', content: '<p>w</p>'});
690
+ assert.equal((await alice.inject({method: 'GET', url: `/api/plugins/docs/documents/${html.id}/file`})).statusCode, 404);
691
+ assert.equal((await call(alice, 'PUT', `/documents/${html.id}/file`, {name: 'x.pdf', data: b64(pdf)})).status, 400);
692
+ });
693
+
694
+ test('2.13: uploads are checked - not a PDF, too large, JavaScript (plain, #-escaped or deflated), attached files', async () => {
695
+ const alice = await as('d-alice');
696
+ const refused = async (buf, name = 'x.pdf') => (await call(alice, 'POST', '/documents/upload', {name, data: b64(buf)}));
697
+ let r = await refused(Buffer.from('hello world'));
698
+ assert.equal(r.status, 400);
699
+ assert.match(r.body.error, /not a PDF/);
700
+ assert.equal((await call(alice, 'POST', '/documents/upload', {name: 'x.pdf', data: ''})).status, 400);
701
+ assert.equal((await call(alice, 'POST', '/documents/upload', {name: 'x.pdf', data: b64(makePdf()), folderId: 'no-such-folder'})).status, 400);
702
+
703
+ r = await refused(makePdf({extra: ['<< /S /JavaScript /JS (app.alert(1)) >>']}));
704
+ assert.equal(r.status, 400);
705
+ assert.match(r.body.error, /JavaScript/);
706
+ r = await refused(makePdf({extra: ['<< /S /J#61vaScript /JS (x) >>']}));
707
+ assert.match(r.body.error, /JavaScript/, 'a #-escaped name is still the name');
708
+ const zlib = await import('zlib');
709
+ const hidden = zlib.deflateSync(Buffer.from('<< /S /JavaScript /JS (x) >>'));
710
+ r = await refused(makePdf({extra: [Buffer.concat([Buffer.from(`<< /Length ${hidden.length} /Filter /FlateDecode >>\nstream\n`), hidden, Buffer.from('\nendstream')])]}));
711
+ assert.match(r.body.error, /JavaScript/, 'inside a compressed stream too');
712
+ r = await refused(makePdf({extra: ['<< /Type /Filespec /F (evil.exe) /EF << /F 9 0 R >> >>', '<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream']}));
713
+ assert.match(r.body.error, /attached/);
714
+ r = await refused(makePdf({extra: ['<< /S /Launch /F (cmd.exe) >>']}));
715
+ assert.match(r.body.error, /Launch/);
716
+ // An /OpenAction that only opens at a page is fine.
717
+ r = await refused(makePdf({catalogExtra: '/OpenAction [5 0 R /Fit]'}));
718
+ assert.equal(r.status, 201);
719
+ created.push(r.body.id);
720
+ });
721
+
722
+ test('2.13: replacing the file keeps the old one as a version with its file; restore puts it back; copies carry the file; delete removes it', async () => {
723
+ const alice = await as('d-alice');
724
+ const v1 = makePdf({pages: ['Version one']});
725
+ const v2 = makePdf({pages: ['Version two', 'more']});
726
+ const up = await call(alice, 'POST', '/documents/upload', {name: 'contract.pdf', data: b64(v1)});
727
+ created.push(up.body.id);
728
+ const id = up.body.id;
729
+
730
+ const rep = await call(alice, 'PUT', `/documents/${id}/file`, {name: 'contract-v2.pdf', data: b64(v2)});
731
+ assert.equal(rep.status, 200, JSON.stringify(rep.body));
732
+ assert.equal(rep.body.file.size, v2.length);
733
+ assert.equal(rep.body.file.name, 'contract-v2.pdf');
734
+ assert.ok(Buffer.from((await alice.inject({method: 'GET', url: `/api/plugins/docs/documents/${id}/file`})).rawPayload).equals(v2));
735
+
736
+ const versions = (await call(alice, 'GET', `/documents/${id}/versions`)).body;
737
+ assert.equal(versions.length, 1);
738
+ assert.equal(versions[0].format, 'pdf');
739
+ assert.equal(versions[0].file.size, v1.length);
740
+ assert.ok(await exists(versionFilePath(id, versions[0].num)));
741
+ const old = await alice.inject({method: 'GET', url: `/api/plugins/docs/documents/${id}/versions/${versions[0].num}/file`});
742
+ assert.equal(old.statusCode, 200);
743
+ assert.ok(Buffer.from(old.rawPayload).equals(v1));
744
+ // History is the editors': a viewer may not fetch an old file.
745
+ await call(alice, 'PUT', `/documents/${id}/shares`, {shares: [{userId: 'd-bob', role: 'viewer'}]});
746
+ const bob = await as('d-bob');
747
+ assert.equal((await bob.inject({method: 'GET', url: `/api/plugins/docs/documents/${id}/versions/${versions[0].num}/file`})).statusCode, 403);
748
+
749
+ // Restore: the current file becomes a version, the old one is the file again.
750
+ const back = await call(alice, 'POST', `/documents/${id}/versions/${versions[0].num}/restore`);
751
+ assert.equal(back.status, 200, JSON.stringify(back.body));
752
+ assert.equal(back.body.file.size, v1.length);
753
+ assert.ok(Buffer.from((await alice.inject({method: 'GET', url: `/api/plugins/docs/documents/${id}/file`})).rawPayload).equals(v1));
754
+ assert.equal((await call(alice, 'GET', `/documents/${id}/versions`)).body.length, 2);
755
+
756
+ // A copy is the file too.
757
+ const copy = await call(alice, 'POST', `/documents/${id}/duplicate`, {});
758
+ created.push(copy.body.id);
759
+ assert.equal(copy.body.format, 'pdf');
760
+ assert.ok(Buffer.from((await alice.inject({method: 'GET', url: `/api/plugins/docs/documents/${copy.body.id}/file`})).rawPayload).equals(v1));
761
+
762
+ // In Trash the file cannot be replaced; deleted for good, the file goes with the record.
763
+ await call(alice, 'DELETE', `/documents/${copy.body.id}`);
764
+ assert.equal((await call(alice, 'PUT', `/documents/${copy.body.id}/file`, {name: 'x.pdf', data: b64(v2)})).status, 409);
765
+ await call(alice, 'DELETE', `/documents/${copy.body.id}?permanent=1`);
766
+ assert.equal(await exists(filePath(copy.body.id)), false);
767
+ });
768
+
769
+ test('2.13: a PDF on its public link - the page frames the file, the file is served, downloads are the PDF; email attaches it', async () => {
770
+ const alice = await as('d-alice');
771
+ const pdf = makePdf({pages: ['Public PDF']});
772
+ const up = await call(alice, 'POST', '/documents/upload', {name: 'leaflet.pdf', data: b64(pdf)});
773
+ created.push(up.body.id);
774
+ const token = (await call(alice, 'POST', `/documents/${up.body.id}/link`)).body.publicLink.token;
775
+ const {default: docsPublic} = await import('../plugin.public.js');
776
+ const pub = Fastify();
777
+ await pub.register(docsPublic, {settings: {}});
778
+ const page = await pub.inject({method: 'GET', url: `/docs/s/${token}`});
779
+ assert.equal(page.statusCode, 200);
780
+ assert.match(page.body, new RegExp(`<iframe class="docs-public__pdf" src="/docs/s/${token}\\?file=1"`));
781
+ assert.match(page.body, /Download PDF/);
782
+ assert.doesNotMatch(page.body, /download=md/);
783
+ const file = await pub.inject({method: 'GET', url: `/docs/s/${token}?file=1`});
784
+ assert.equal(file.headers['content-type'], 'application/pdf');
785
+ assert.match(file.headers['content-disposition'], /^inline/);
786
+ assert.ok(Buffer.from(file.rawPayload).equals(pdf));
787
+ const dl = await pub.inject({method: 'GET', url: `/docs/s/${token}?download=md`});
788
+ assert.equal(dl.headers['content-type'], 'application/pdf', 'nothing but the PDF is derived from a PDF');
789
+ assert.match(dl.headers['content-disposition'], /^attachment; filename="leaflet\.pdf"/);
790
+
791
+ outbox.length = 0;
792
+ const sent = await call(alice, 'POST', `/documents/${up.body.id}/send`, {to: 'z@example.com', mode: 'inline'});
793
+ assert.equal(sent.body.sent, 1, JSON.stringify(sent.body));
794
+ assert.equal(outbox[0].attachments.length, 1);
795
+ assert.equal(outbox[0].attachments[0].filename, 'leaflet.pdf');
796
+ assert.ok(outbox[0].attachments[0].content.equals(pdf));
797
+ assert.match(outbox[0].html, /attached as a PDF/);
798
+ });
799
+
800
+ test('2.13.2 review: restoring the oldest PDF version at the history cap; POST /documents never makes a fileless PDF', async () => {
801
+ const alice = await buildApp({user: {id: 'd-alice', role: 'user'}, settings: {maxVersions: 5}});
802
+ const up = await call(alice, 'POST', '/documents/upload', {name: 'cap.pdf', data: b64(makePdf({pages: ['v0']}))});
803
+ created.push(up.body.id);
804
+ for (let i = 1; i <= 3; i++) await call(alice, 'PUT', `/documents/${up.body.id}/file`, {name: `v${i}.pdf`, data: b64(makePdf({pages: [`v${i}`]}))});
805
+ const versions = (await call(alice, 'GET', `/documents/${up.body.id}/versions`)).body;
806
+ const oldest = versions.at(-1);
807
+ const back = await call(alice, 'POST', `/documents/${up.body.id}/versions/${oldest.num}/restore`);
808
+ assert.equal(back.status, 200, JSON.stringify(back.body));
809
+ const bytes = Buffer.from((await alice.inject({method: 'GET', url: `/api/plugins/docs/documents/${up.body.id}/file`})).rawPayload);
810
+ assert.ok(bytes.equals(makePdf({pages: ['v0']})), 'the oldest file is back');
811
+
812
+ const fake = await call(alice, 'POST', '/documents', {title: 'Fake', content: '<p>x</p>', format: 'pdf', file: {pages: 9, size: 1}});
813
+ created.push(fake.body.id);
814
+ assert.equal(fake.body.format, 'html');
815
+ await buildApp({user: {id: 'd-alice', role: 'user'}});
816
+ });