Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 492 lines 18 kB view raw
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});