Full document, spreadsheet, slideshow, and diagram tooling
1import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
3import { join } from 'node:path';
4import { tmpdir } from 'node:os';
5import { randomBytes } from 'node:crypto';
6import { createApp, type ServerConfig } from '../server/app.js';
7import { signCookie, verifyCookie, generateSecret } from '../server/auth.js';
8import { buildInstanceInfo, type InstanceInfo } from '../server/config.js';
9
10function makeDist(): string {
11 const dir = mkdtempSync(join(tmpdir(), 'atmos-test-'));
12 writeFileSync(join(dir, 'index.html'), '<html><body>Landing</body></html>');
13 for (const app of ['docs', 'sheets', 'forms', 'slides', 'diagrams', 'calendar']) {
14 mkdirSync(join(dir, app));
15 writeFileSync(join(dir, app, 'index.html'), `<html><body>${app}</body></html>`);
16 }
17 mkdirSync(join(dir, 'assets'));
18 writeFileSync(join(dir, 'assets', 'main.js'), 'console.log("ok")');
19 writeFileSync(join(dir, 'favicon.svg'), '<svg/>');
20 return dir;
21}
22
23function makeConfig(overrides?: Partial<InstanceInfo>): InstanceInfo {
24 return {
25 flavor: 'public',
26 operator: null,
27 pds: null,
28 features: { sync: false, sharing: false, ai: false },
29 notice: null,
30 accessControl: null,
31 ...overrides,
32 };
33}
34
35function makeServerConfig(
36 instanceInfo: InstanceInfo,
37 distPath: string,
38 cookieSecret?: Buffer,
39): ServerConfig {
40 return {
41 instanceInfo,
42 distPath,
43 cookieSecret: cookieSecret ?? generateSecret(),
44 version: '0.1.0-test',
45 };
46}
47
48// ── Cookie signing ──────────────────────────────────────────
49
50describe('cookie signing', () => {
51 const secret = generateSecret();
52
53 it('signs and verifies a DID cookie', () => {
54 const signed = signCookie('did:plc:abc123', secret);
55 const result = verifyCookie(signed, secret);
56 expect(result).toBe('did:plc:abc123');
57 });
58
59 it('handles DIDs with multiple colons', () => {
60 const signed = signCookie('did:web:example.com', secret);
61 const result = verifyCookie(signed, secret);
62 expect(result).toBe('did:web:example.com');
63 });
64
65 it('rejects tampered signature', () => {
66 const signed = signCookie('did:plc:abc123', secret);
67 const tampered = signed.slice(0, -4) + 'dead';
68 expect(verifyCookie(tampered, secret)).toBeNull();
69 });
70
71 it('rejects tampered DID', () => {
72 const signed = signCookie('did:plc:abc123', secret);
73 const tampered = signed.replace('abc123', 'xyz789');
74 expect(verifyCookie(tampered, secret)).toBeNull();
75 });
76
77 it('rejects wrong secret', () => {
78 const signed = signCookie('did:plc:abc123', secret);
79 const wrongSecret = generateSecret();
80 expect(verifyCookie(signed, wrongSecret)).toBeNull();
81 });
82
83 it('rejects empty string', () => {
84 expect(verifyCookie('', secret)).toBeNull();
85 });
86
87 it('rejects garbage input', () => {
88 expect(verifyCookie('not-a-cookie', secret)).toBeNull();
89 });
90
91 it('rejects non-DID payload', () => {
92 const fake = `notadid:${Date.now()}:${'a'.repeat(64)}`;
93 expect(verifyCookie(fake, secret)).toBeNull();
94 });
95
96 it('rejects expired cookies (> 7 days)', () => {
97 const ts = (Date.now() - 8 * 24 * 60 * 60 * 1000).toString();
98 const payload = `did:plc:abc:${ts}`;
99 const sig = require('node:crypto')
100 .createHmac('sha256', secret)
101 .update(payload)
102 .digest('hex');
103 expect(verifyCookie(`${payload}:${sig}`, secret)).toBeNull();
104 });
105});
106
107// ── Config builder ──────────────────────────────────────────
108
109describe('buildInstanceInfo', () => {
110 const originalEnv = { ...process.env };
111
112 afterEach(() => {
113 for (const key of Object.keys(process.env)) {
114 if (key.startsWith('INSTANCE_')) delete process.env[key];
115 }
116 Object.assign(process.env, originalEnv);
117 });
118
119 it('returns defaults when no env vars set', () => {
120 delete process.env.INSTANCE_FLAVOR;
121 const info = buildInstanceInfo();
122 expect(info.flavor).toBe('public');
123 expect(info.features).toEqual({ sync: false, sharing: false, ai: false });
124 expect(info.accessControl).toBeNull();
125 });
126
127 it('parses pds-operator flavor', () => {
128 process.env.INSTANCE_FLAVOR = 'pds-operator';
129 process.env.INSTANCE_OPERATOR = 'Test Op';
130 process.env.INSTANCE_PDS = 'https://pds.example.com';
131 const info = buildInstanceInfo();
132 expect(info.flavor).toBe('pds-operator');
133 expect(info.operator).toBe('Test Op');
134 expect(info.pds).toBe('https://pds.example.com');
135 });
136
137 it('parses features', () => {
138 process.env.INSTANCE_FEATURES = 'sync,ai';
139 const info = buildInstanceInfo();
140 expect(info.features).toEqual({ sync: true, sharing: false, ai: true });
141 });
142
143 it('parses allowlist', () => {
144 process.env.INSTANCE_ACCESS_MODE = 'allowlist';
145 process.env.INSTANCE_ALLOWLIST = 'did:plc:aaa,did:plc:bbb';
146 const info = buildInstanceInfo();
147 expect(info.accessControl).toEqual({
148 mode: 'allowlist',
149 allowlist: ['did:plc:aaa', 'did:plc:bbb'],
150 });
151 });
152
153 it('parses open mode', () => {
154 process.env.INSTANCE_ACCESS_MODE = 'open';
155 const info = buildInstanceInfo();
156 expect(info.accessControl).toEqual({ mode: 'open' });
157 });
158});
159
160// ── Health endpoint ─────────────────────────────────────────
161
162describe('GET /health', () => {
163 let distPath: string;
164 beforeEach(() => { distPath = makeDist(); });
165 afterEach(() => { rmSync(distPath, { recursive: true, force: true }); });
166
167 it('returns status, version, uptime, and config info', async () => {
168 const app = createApp(makeServerConfig(makeConfig(), distPath));
169 const res = await app.request('/health');
170 expect(res.status).toBe(200);
171 const body = await res.json();
172 expect(body.status).toBe('ok');
173 expect(body.version).toBe('0.1.0-test');
174 expect(typeof body.uptime).toBe('number');
175 expect(body.flavor).toBe('public');
176 expect(body.accessMode).toBe('open');
177 });
178
179 it('reflects allowlist access mode', async () => {
180 const info = makeConfig({
181 accessControl: { mode: 'allowlist', allowlist: ['did:plc:x'] },
182 });
183 const app = createApp(makeServerConfig(info, distPath));
184 const res = await app.request('/health');
185 const body = await res.json();
186 expect(body.accessMode).toBe('allowlist');
187 });
188});
189
190// ── Instance info endpoint ──────────────────────────────────
191
192describe('GET /instance-info.json', () => {
193 let distPath: string;
194 beforeEach(() => { distPath = makeDist(); });
195 afterEach(() => { rmSync(distPath, { recursive: true, force: true }); });
196
197 it('returns the instance config', async () => {
198 const info = makeConfig({ flavor: 'pds-operator', operator: 'Test' });
199 const app = createApp(makeServerConfig(info, distPath));
200 const res = await app.request('/instance-info.json');
201 expect(res.status).toBe(200);
202 const body = await res.json();
203 expect(body.flavor).toBe('pds-operator');
204 expect(body.operator).toBe('Test');
205 });
206
207 it('sets no-cache header', async () => {
208 const app = createApp(makeServerConfig(makeConfig(), distPath));
209 const res = await app.request('/instance-info.json');
210 expect(res.headers.get('cache-control')).toBe('no-cache');
211 });
212});
213
214// ── Auth verify endpoint ────────────────────────────────────
215
216describe('POST /api/auth/verify', () => {
217 let distPath: string;
218 let secret: Buffer;
219 beforeEach(() => {
220 distPath = makeDist();
221 secret = generateSecret();
222 });
223 afterEach(() => { rmSync(distPath, { recursive: true, force: true }); });
224
225 it('sets cookie for allowed DID', async () => {
226 const info = makeConfig({
227 accessControl: { mode: 'allowlist', allowlist: ['did:plc:ok'] },
228 });
229 const app = createApp(makeServerConfig(info, distPath, secret));
230 const res = await app.request('/api/auth/verify', {
231 method: 'POST',
232 headers: { 'Content-Type': 'application/json' },
233 body: JSON.stringify({ did: 'did:plc:ok' }),
234 });
235 expect(res.status).toBe(200);
236 const cookie = res.headers.get('set-cookie');
237 expect(cookie).toContain('atmos-session=');
238 expect(cookie).toContain('HttpOnly');
239 expect(cookie).toContain('SameSite=Strict');
240 });
241
242 it('returns 403 for DID not on allowlist', async () => {
243 const info = makeConfig({
244 accessControl: { mode: 'allowlist', allowlist: ['did:plc:ok'] },
245 });
246 const app = createApp(makeServerConfig(info, distPath, secret));
247 const res = await app.request('/api/auth/verify', {
248 method: 'POST',
249 headers: { 'Content-Type': 'application/json' },
250 body: JSON.stringify({ did: 'did:plc:nope' }),
251 });
252 expect(res.status).toBe(403);
253 });
254
255 it('returns 400 for missing DID', async () => {
256 const app = createApp(makeServerConfig(makeConfig(), distPath, secret));
257 const res = await app.request('/api/auth/verify', {
258 method: 'POST',
259 headers: { 'Content-Type': 'application/json' },
260 body: JSON.stringify({}),
261 });
262 expect(res.status).toBe(400);
263 });
264
265 it('returns 400 for non-DID string', async () => {
266 const app = createApp(makeServerConfig(makeConfig(), distPath, secret));
267 const res = await app.request('/api/auth/verify', {
268 method: 'POST',
269 headers: { 'Content-Type': 'application/json' },
270 body: JSON.stringify({ did: 'not-a-did' }),
271 });
272 expect(res.status).toBe(400);
273 });
274
275 it('returns 400 for invalid JSON', async () => {
276 const app = createApp(makeServerConfig(makeConfig(), distPath, secret));
277 const res = await app.request('/api/auth/verify', {
278 method: 'POST',
279 headers: { 'Content-Type': 'application/json' },
280 body: 'not json',
281 });
282 expect(res.status).toBe(400);
283 });
284
285 it('sets cookie in open mode (any DID accepted)', async () => {
286 const info = makeConfig({ accessControl: { mode: 'open' } });
287 const app = createApp(makeServerConfig(info, distPath, secret));
288 const res = await app.request('/api/auth/verify', {
289 method: 'POST',
290 headers: { 'Content-Type': 'application/json' },
291 body: JSON.stringify({ did: 'did:plc:anyone' }),
292 });
293 expect(res.status).toBe(200);
294 expect(res.headers.get('set-cookie')).toContain('atmos-session=');
295 });
296});
297
298// ── Auth logout endpoint ────────────────────────────────────
299
300describe('POST /api/auth/logout', () => {
301 let distPath: string;
302 beforeEach(() => { distPath = makeDist(); });
303 afterEach(() => { rmSync(distPath, { recursive: true, force: true }); });
304
305 it('clears the session cookie', async () => {
306 const app = createApp(makeServerConfig(makeConfig(), distPath));
307 const res = await app.request('/api/auth/logout', { method: 'POST' });
308 expect(res.status).toBe(200);
309 const cookie = res.headers.get('set-cookie');
310 expect(cookie).toContain('atmos-session=');
311 expect(cookie).toContain('Max-Age=0');
312 });
313});
314
315// ── Route protection: allowlist mode ────────────────────────
316
317describe('route protection (allowlist mode)', () => {
318 let distPath: string;
319 let secret: Buffer;
320 let info: InstanceInfo;
321
322 beforeEach(() => {
323 distPath = makeDist();
324 secret = generateSecret();
325 info = makeConfig({
326 accessControl: { mode: 'allowlist', allowlist: ['did:plc:allowed'] },
327 });
328 });
329 afterEach(() => { rmSync(distPath, { recursive: true, force: true }); });
330
331 it('serves landing page without auth', async () => {
332 const app = createApp(makeServerConfig(info, distPath, secret));
333 const res = await app.request('/');
334 expect(res.status).toBe(200);
335 expect(await res.text()).toContain('Landing');
336 });
337
338 it('redirects /docs to / without cookie', async () => {
339 const app = createApp(makeServerConfig(info, distPath, secret));
340 const res = await app.request('/docs/abc123', { redirect: 'manual' });
341 expect(res.status).toBe(302);
342 expect(res.headers.get('location')).toBe('/');
343 });
344
345 it('redirects /sheets to / without cookie', async () => {
346 const app = createApp(makeServerConfig(info, distPath, secret));
347 const res = await app.request('/sheets/abc123', { redirect: 'manual' });
348 expect(res.status).toBe(302);
349 expect(res.headers.get('location')).toBe('/');
350 });
351
352 it('redirects /forms to / without cookie', async () => {
353 const app = createApp(makeServerConfig(info, distPath, secret));
354 const res = await app.request('/forms/abc123', { redirect: 'manual' });
355 expect(res.status).toBe(302);
356 expect(res.headers.get('location')).toBe('/');
357 });
358
359 it('serves app HTML with valid cookie', async () => {
360 const app = createApp(makeServerConfig(info, distPath, secret));
361 const cookie = signCookie('did:plc:allowed', secret);
362 const res = await app.request('/docs/abc123', {
363 headers: { Cookie: `atmos-session=${cookie}` },
364 });
365 expect(res.status).toBe(200);
366 expect(await res.text()).toContain('docs');
367 });
368
369 it('serves sheets with valid cookie', async () => {
370 const app = createApp(makeServerConfig(info, distPath, secret));
371 const cookie = signCookie('did:plc:allowed', secret);
372 const res = await app.request('/sheets/abc123', {
373 headers: { Cookie: `atmos-session=${cookie}` },
374 });
375 expect(res.status).toBe(200);
376 expect(await res.text()).toContain('sheets');
377 });
378
379 it('redirects with cookie for non-allowed DID', async () => {
380 const app = createApp(makeServerConfig(info, distPath, secret));
381 const cookie = signCookie('did:plc:intruder', secret);
382 const res = await app.request('/docs/abc123', {
383 headers: { Cookie: `atmos-session=${cookie}` },
384 redirect: 'manual',
385 });
386 expect(res.status).toBe(302);
387 });
388
389 it('redirects with tampered cookie', async () => {
390 const app = createApp(makeServerConfig(info, distPath, secret));
391 const res = await app.request('/docs/abc123', {
392 headers: { Cookie: 'atmos-session=garbage' },
393 redirect: 'manual',
394 });
395 expect(res.status).toBe(302);
396 });
397
398 it('redirects with expired cookie', async () => {
399 const app = createApp(makeServerConfig(info, distPath, secret));
400 const ts = (Date.now() - 8 * 24 * 60 * 60 * 1000).toString();
401 const payload = `did:plc:allowed:${ts}`;
402 const { createHmac } = require('node:crypto');
403 const sig = createHmac('sha256', secret).update(payload).digest('hex');
404 const cookie = `${payload}:${sig}`;
405 const res = await app.request('/docs/abc123', {
406 headers: { Cookie: `atmos-session=${cookie}` },
407 redirect: 'manual',
408 });
409 expect(res.status).toBe(302);
410 });
411});
412
413// ── Route protection: open mode ─────────────────────────────
414
415describe('route protection (open mode)', () => {
416 let distPath: string;
417
418 beforeEach(() => { distPath = makeDist(); });
419 afterEach(() => { rmSync(distPath, { recursive: true, force: true }); });
420
421 it('serves app routes without auth', async () => {
422 const info = makeConfig({ accessControl: { mode: 'open' } });
423 const app = createApp(makeServerConfig(info, distPath));
424 const res = await app.request('/docs/abc123');
425 expect(res.status).toBe(200);
426 expect(await res.text()).toContain('docs');
427 });
428
429 it('serves all app types without auth', async () => {
430 const info = makeConfig({ accessControl: { mode: 'open' } });
431 const app = createApp(makeServerConfig(info, distPath));
432 for (const name of ['docs', 'sheets', 'forms', 'diagrams', 'calendar']) {
433 const res = await app.request(`/${name}/test`);
434 expect(res.status).toBe(200);
435 }
436 });
437});
438
439// ── Route protection: self-hosted ───────────────────────────
440
441describe('route protection (self-hosted)', () => {
442 let distPath: string;
443
444 beforeEach(() => { distPath = makeDist(); });
445 afterEach(() => { rmSync(distPath, { recursive: true, force: true }); });
446
447 it('serves app routes without auth even with allowlist configured', async () => {
448 const info = makeConfig({
449 flavor: 'self-hosted',
450 accessControl: { mode: 'allowlist', allowlist: ['did:plc:only'] },
451 });
452 const app = createApp(makeServerConfig(info, distPath));
453 const res = await app.request('/docs/abc123');
454 expect(res.status).toBe(200);
455 expect(await res.text()).toContain('docs');
456 });
457});
458
459// ── Response headers ────────────────────────────────────────
460
461describe('response headers', () => {
462 let distPath: string;
463 beforeEach(() => { distPath = makeDist(); });
464 afterEach(() => { rmSync(distPath, { recursive: true, force: true }); });
465
466 it('includes X-Response-Time on all responses', async () => {
467 const app = createApp(makeServerConfig(makeConfig(), distPath));
468 const res = await app.request('/health');
469 expect(res.headers.get('x-response-time')).toMatch(/^\d+\.\d+ms$/);
470 });
471
472 it('includes X-Version on all responses', async () => {
473 const app = createApp(makeServerConfig(makeConfig(), distPath));
474 const res = await app.request('/health');
475 expect(res.headers.get('x-version')).toBe('0.1.0-test');
476 });
477});
478
479// ── Landing page fallback ───────────────────────────────────
480
481describe('landing page fallback', () => {
482 let distPath: string;
483 beforeEach(() => { distPath = makeDist(); });
484 afterEach(() => { rmSync(distPath, { recursive: true, force: true }); });
485
486 it('serves landing page for unknown routes', async () => {
487 const app = createApp(makeServerConfig(makeConfig(), distPath));
488 const res = await app.request('/unknown-path');
489 expect(res.status).toBe(200);
490 expect(await res.text()).toContain('Landing');
491 });
492});