Build Markdown news site with local AI generation and web push

This commit is contained in:
spectre committed 2026-09-12 19:06:14 +02:00
1 parent ad30f12116
commit 199dec8a1e
26 files changed
+2277 -1

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { createECDH, randomBytes } from 'node:crypto';
import { Readable } from 'node:stream';
import { PushService,validateSubscription,createPushHandler } from '../scripts/push.mjs';
const old={url:'/news/old/',title:'Old story',description:'Already published.'};
const next={url:'/news/new/',title:'New story',description:'Freshly published.'};
function subscription(id='test'){return {endpoint:`https://fcm.googleapis.com/fcm/send/${id}`,keys:{p256dh:createECDH('prime256v1').generateKeys().toString('base64url'),auth:randomBytes(16).toString('base64url')}};}
async function service(t,options={}){
const directory=await fs.mkdtemp(path.join(os.tmpdir(),'truenews-push-'));
t.after(()=>fs.rm(directory,{recursive:true,force:true}));
return new PushService({directory,subject:'https://news.example',send:async()=>{},...options}).init([old]);
}
test('new stories notify once; startup backlog and article edits do not notify',async t=>{
const delivered=[];
const push=await service(t,{send:async(sub,payload,options)=>delivered.push({sub,payload:JSON.parse(payload),options})});
await push.subscribe(subscription());await push.flush();assert.equal(delivered.length,0);
await push.sync([old,next]);await push.sync([old,next]);await push.flush();
assert.equal(delivered.length,1);assert.equal(delivered[0].payload.url,next.url);
assert.equal(delivered[0].options.TTL,86400);
await push.sync([old,{...next,title:'Edited'}]);await push.flush();assert.equal(delivered.length,1);
});
test('keys, subscriptions and queued notifications survive server restarts',async t=>{
const push=await service(t);await push.subscribe(subscription());await push.sync([old,next]);
const delivered=[];
const restarted=await new PushService({directory:push.directory,subject:'https://news.example',send:async(_,payload)=>delivered.push(JSON.parse(payload))}).init([old,next]);
assert.equal(restarted.config().publicKey,push.config().publicKey);
assert.equal(restarted.state.pending.length,1);await restarted.flush();assert.equal(delivered.length,1);
assert.equal(restarted.state.pending.length,0);
});
test('transient failures retry, expired subscriptions are removed',async t=>{
let now=100000;let calls=0;
const push=await service(t,{now:()=>now,log:()=>{},send:async()=>{calls++;throw Object.assign(new Error('Temporary'),{statusCode:calls===1?503:410});}});
await push.subscribe(subscription());await push.sync([old,next]);await push.flush();
assert.equal(push.state.pending.length,1);await push.flush();assert.equal(calls,1);
now+=60001;await push.flush();assert.equal(calls,2);
assert.equal(push.state.pending.length,0);assert.equal(Object.keys(push.state.subscriptions).length,0);
});
test('unsubscribe removes queued messages and cannot remove another subscription with wrong keys',async t=>{
const push=await service(t);const sub=subscription();await push.subscribe(sub);await push.sync([old,next]);
await push.unsubscribe(subscription());assert.equal(Object.keys(push.state.subscriptions).length,1);
await push.unsubscribe(sub);assert.equal(Object.keys(push.state.subscriptions).length,0);assert.equal(push.state.pending.length,0);
});
test('new subscribers do not receive older queued stories; deleted pages are not delivered',async t=>{
const sent=[];const push=await service(t,{send:async sub=>sent.push(sub.endpoint)});
const first=subscription('first');await push.subscribe(first);await push.sync([old,next]);await push.subscribe(subscription('second'));await push.flush();assert.deepEqual(sent,[first.endpoint]);
await push.sync([old,next,{...next,url:'/removed/'}]);await push.sync([old,next]);assert.equal(push.state.pending.length,0);
});
test('subscription endpoints cannot target internal or arbitrary servers',()=>{
const sub=subscription();assert.equal(validateSubscription(sub).endpoint,sub.endpoint);
for(const endpoint of ['http://fcm.googleapis.com/send','https://localhost/send','https://127.0.0.1/','https://attacker.example/','https://fcm.googleapis.com.attacker.example/','https://user:password@fcm.googleapis.com/send','https://fcm.googleapis.com:8443/send'])assert.throws(()=>validateSubscription({...sub,endpoint}));
assert.throws(()=>validateSubscription({...sub,keys:{auth:'bad',p256dh:'bad'}}));
});
async function request(handler,{route='/api/push/subscribe',method='POST',origin='https://news.example',body=subscription(),contentType='application/json'}={}){
const req=Readable.from([JSON.stringify(body)]);req.method=method;req.headers={origin,host:'news.example','content-type':contentType};req.socket={remoteAddress:'127.0.0.1'};
let code,payload;const res={writeHead(status){code=status;},end(data){payload=JSON.parse(data);}};
await handler(req,res,route);return {code,payload};
}
test('API requires same-origin JSON writes and exposes only the public key',async t=>{
const push=await service(t);const handler=createPushHandler(push,{siteUrl:'https://news.example'});
assert.equal((await request(handler,{origin:'https://other.example'})).code,403);
assert.equal((await request(handler,{contentType:'text/plain'})).code,415);
assert.equal((await request(handler,{method:'GET'})).code,405);
assert.equal((await request(handler)).code,200);
const config=await request(handler,{route:'/api/push/config',method:'GET'});
assert.equal(config.code,200);assert.deepEqual(Object.keys(config.payload).sort(),['enabled','publicKey']);
assert.equal((await request(handler,{body:{data:'x'.repeat(9000)}})).code,413);
});
test('service worker shows notifications and opens only same-origin articles',async()=>{
const {runInNewContext}=await import('node:vm');
const listeners={};const shown=[];const opened=[];
const self={location:{origin:'https://news.example'},addEventListener:(name,handler)=>{listeners[name]=handler;},registration:{showNotification:async(title,options)=>{shown.push({title,options});}},clients:{matchAll:async()=>[],openWindow:async url=>opened.push(url)}};
runInNewContext(await fs.readFile(new URL('../public/sw.js',import.meta.url),'utf8'),{self,URL});
let pending;
listeners.push({data:{json:()=>({title:'Fresh story',url:'/news/fresh/',tag:'article-fresh'})},waitUntil:p=>{pending=p;}});await pending;
assert.equal(shown[0].options.data.url,'/news/fresh/');assert.equal(shown[0].title,'Fresh story');
listeners.notificationclick({notification:{data:{url:'/news/fresh/'},close(){}},waitUntil:p=>{pending=p;}});await pending;
assert.deepEqual(opened,['https://news.example/news/fresh/']);
listeners.notificationclick({notification:{data:{url:'https://other.example/'},close(){}},waitUntil:p=>{pending=p;}});await pending;
assert.equal(opened.length,1);
});