Files

112 lines
7.2 KiB
JavaScript

// Disposable synthetic keys/accounts only. Talks to the actual packaged server.
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync, statSync } from 'node:fs';
import { createHash, randomUUID } from 'node:crypto';
import { createServer } from 'node:http';
import Database from 'better-sqlite3';
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
const base = 'http://127.0.0.1:8095';
const key = n => new Uint8Array(32).fill(n);
const fingerprint = () => createHash('sha256').update(readFileSync('/data/config/server-nostr-key')).digest('hex');
async function call(path, method = 'GET', cookie, body, origin = base) {
const headers = {};
if (cookie) headers.cookie = cookie;
if (origin) headers.origin = origin;
if (body !== undefined) headers['content-type'] = 'application/json';
return fetch(base + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
}
async function login(n, expectedAdmin) {
const event = finalizeEvent({kind:27235, created_at:Math.floor(Date.now()/1000), content:'',
tags:[['u',base+'/api/auth/login'],['method','POST'],['nonce',randomUUID()]]}, key(n));
const r = await fetch(base+'/api/auth/login', {method:'POST', headers:{authorization:'Nostr '+Buffer.from(JSON.stringify(event)).toString('base64')}});
assert.equal(r.status,200);
const body = await r.json();
assert.equal(body.pubkey,getPublicKey(key(n)));
assert.equal(body.isAdmin,expectedAdmin);
return r.headers.getSetCookie()[0].split(';')[0];
}
// Synthetic impossible-to-redeem financial rows, only in this disposable DB.
// They qualify recovery-state preservation, not mint-side reconciliation.
function financialSnapshot() {
const db = new Database('/data/podsteadr.sqlite3', {readonly:true});
try { return JSON.stringify(['cashu_quotes','cashu_proofs','purchases','earnings'].map(table =>
db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all())); }
finally { db.close(); }
}
function seedFinancial() {
const db = new Database('/data/podsteadr.sqlite3');
try {
db.transaction(() => {
db.prepare('INSERT INTO podcasts (id,owner_pubkey,title,podcast_guid,created_at,updated_at) VALUES (?,?,?,?,?,?)')
.run('fixture-podcast',getPublicKey(key(1)),'Synthetic recovery only','fixture-guid',1,1);
db.prepare('INSERT INTO episodes (id,podcast_id,title,sha256,enclosure_url,enclosure_length,pub_date,created_at,price_sats) VALUES (?,?,?,?,?,?,?,?,?)')
.run('fixture-episode','fixture-podcast','Synthetic episode','00'.repeat(32),'http://127.0.0.1:8098/synthetic',0,1,1,100);
const ctx=JSON.stringify({episodeId:'fixture-episode',buyerPubkey:getPublicKey(key(2)),source:'producer',amountSats:100});
for (const state of ['paid','settled']) db.prepare('INSERT INTO cashu_quotes VALUES (?,?,?,?)').run('fixture-'+state,ctx,state,1);
db.prepare('INSERT INTO cashu_proofs VALUES (?,?,?,?,?)').run('fixture-proof','NOT_A_CASHU_TOKEN',100,null,1);
db.prepare('INSERT INTO purchases VALUES (?,?,?,?,?,?,?,?,?)').run('fixture-purchase','fixture-episode',getPublicKey(key(2)),getPublicKey(key(1)),0,100,'fixture-receipt','{"synthetic":true}',1);
})();
} finally { db.close(); }
return financialSnapshot();
}
async function checkFinancial(buyer, foreign, expected) {
assert.equal(financialSnapshot(),expected);
let mintCalls=0;
const mint=createServer((_req,res)=>{mintCalls++;res.writeHead(503);res.end();});
await new Promise(resolve=>mint.listen(9996,'127.0.0.1',resolve));
try {
const path='/api/podcasts/fixture-podcast/episodes/fixture-episode/purchase/';
const retries=await Promise.all(Array.from({length:3},()=>call(path+'fixture-paid/confirm','POST',buyer)));
for (const r of retries) { assert.equal(r.status,409); assert.match((await r.json()).error,/refusing to mint/); }
assert.equal((await call(path+'fixture-paid/confirm','POST',foreign)).status,404);
for (let i=0;i<2;i++) {
const r=await call(path+'fixture-settled/confirm','POST',buyer); assert.equal(r.status,200);
assert.equal((await r.json()).id,'fixture-purchase');
}
assert.equal(mintCalls,0,'Recovery must not contact mint or request another invoice');
assert.equal(financialSnapshot(),expected);
console.log('PASS synthetic paid/settled recovery: parallel refusal/reuse, exact rows preserved, zero mint calls');
} finally { await new Promise(resolve=>mint.close(resolve)); }
}
assert.equal(process.getuid(),1000);
assert.equal((await call('/api/health')).status,200);
const html = await call('/'); assert.equal(html.status,200);
const document = await html.text(); assert.match(document, /<html/);
assert.match(document, /<script[^>]+src="\/nostr-provider\.js"[^>]+data-app-id="podsteadr"[^>]+data-no-nip98/);
const provider = await call('/nostr-provider.js'); assert.equal(provider.status,200);
assert.match(provider.headers.get('content-type'), /javascript/);
assert.equal(createHash('sha256').update(Buffer.from(await provider.arrayBuffer())).digest('hex'),
readFileSync('/app/.archipelago-provider-sha256','utf8').trim(), 'Actual served provider must match canonical candidate receipt');
assert.equal((await call('/api/auth/me')).status,401);
assert.equal(statSync('/data/config/server-nostr-key').mode & 0o777,0o600);
const phase=process.env.PODSTEADR_FIXTURE_PHASE;
if (phase==='seed') {
const outsider = await login(2,false); // Must not steal the first-admin slot.
const owner = await login(1,true);
assert.equal((await call('/api/settings','PUT',outsider,{relays:[]})).status,403);
assert.equal((await call('/api/settings','PUT',owner,{relays:[]},'http://foreign.invalid')).status,403);
assert.equal((await call('/api/settings','PUT',owner,{relays:[]},null)).status,403);
const changed=await call('/api/settings','PUT',owner,{relays:[],blossom_url:'http://127.0.0.1:8098/preserved'});
assert.equal(changed.status,200);
const publicSettings=await (await call('/api/settings/public')).json();
const financial=seedFinancial();
await checkFinancial(outsider,owner,financial);
writeFileSync('/data/qualification-state.json',JSON.stringify({owner,outsider,keyHash:fingerprint(),publicKey:publicSettings.serverPubkey,financial}),{mode:0o600});
} else if (phase==='verify' || phase==='verify-preserved') {
const saved=JSON.parse(readFileSync('/data/qualification-state.json','utf8'));
assert.equal(fingerprint(),saved.keyHash);
const settings=await (await call('/api/settings/public')).json();
assert.equal(settings.serverPubkey,saved.publicKey);
assert.equal(settings.blossomUrl,'http://127.0.0.1:8098/preserved');
assert.deepEqual(settings.relays,[]);
const me=await call('/api/auth/me','GET',saved.owner); assert.equal(me.status,200); assert.equal((await me.json()).isAdmin,true);
await login(3,false); // Changed environment binding cannot silently reassign existing admin.
await login(1,true);
await checkFinancial(saved.outsider,saved.owner,saved.financial);
if (phase==='verify') {
assert.equal((await call('/api/auth/logout','POST',saved.owner)).status,200);
assert.equal((await call('/api/auth/me','GET',saved.owner)).status,401);
}
} else throw new Error('Unknown fixture phase');
console.log('PASS packaged Podsteadr '+phase+': actual HTTP, nonroot, admin/origin/session/key/settings checks');