port: from the old factory (tea-mcp/src/persimmons-extension), as it was, 2026-10-14

This commit is contained in:
teahubxyz committed 2026-10-14 00:22:00 -07:00
commit 0e92a80b20
30 files changed
+1944

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {CONNECTION, VAULT_CAPABILITY} from '../connection-state.mjs';
test('page handshake is exact-origin, read-only, owner-bound and live-validated', async () => {
let hint={owner:'test-owner'}, calls=0, valid=true;
globalThis.chrome={runtime:{getManifest:()=>({version:'0.3.4'}),onMessageExternal:{addListener(){}},onConnect:{addListener(){}} },
storage:{local:{get:async()=>({[CONNECTION]:hint,[VAULT_CAPABILITY]:{owner:'test-owner',capability:'A'.repeat(43)}})}}};
globalThis.fetch=async()=>{calls++;return new Response(JSON.stringify({owner:valid?'test-owner':'other-owner',items:[]}),{headers:{'content-type':'application/json'}})};
const {connectionStatus}=await import('../connection.mjs');
const message={type:'persimmons.connection.v1',owner:'test-owner'};
const sender={origin:'https://persimmons.app',url:'https://persimmons.app/',frameId:0};
assert.deepEqual(await connectionStatus(message,sender),{connected:true,version:'0.3.4',initialized:false,unlocked:false,empty:true});
for(const bad of [{...sender,origin:'https://evil.example'},{...sender,url:'https://evil.example/'},{...sender,frameId:1}]) assert.equal(await connectionStatus(message,bad),null);
assert.equal(await connectionStatus({...message,type:'unlock'},sender),null);
assert.deepEqual(await connectionStatus({type:'persimmons.connection.v1'},sender),{connected:false,version:'0.3.4'});
assert.equal(calls,1);
assert.equal((await connectionStatus({...message,owner:'other-owner'},sender)).connected,false);
valid=false; assert.equal((await connectionStatus(message,sender)).connected,false);
hint=null; assert.equal((await connectionStatus(message,sender)).connected,false);
});
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile, readdir} from 'node:fs/promises';
import {pageTarget, metadata, publicSource, request, DEFAULT_SCOPES, BASE} from '../core.mjs';
const target = pageTarget('https://wp.phly.cloud/wp-admin/');
const response = (data, status = 200, type = 'application/json') => new Response(JSON.stringify(data), {status, headers: {'content-type': type}});
test('HTTPS origin only; no path/query exposed as origin', () => {
assert.equal(target.origin, 'https://wp.phly.cloud');
assert.deepEqual(pageTarget('https://wp.phly.cloud/private?token=example#fragment'), {origin: 'https://wp.phly.cloud'});
for (const url of ['http://example.org', 'chrome://extensions', 'file:///tmp/a', 'https://user:pass@example.org']) assert.throws(() => pageTarget(url));
});
test('My vault metadata does not pretend to attenuate provider authority', () => {
const meta = metadata({origin: target.origin, name: 'Site session', scopes: DEFAULT_SCOPES, minutes: 60}, 0);
assert.equal(meta.vault_kind, 'user');
assert.equal(meta.expires, undefined);
assert.deepEqual(meta.scopes, ['content:read', 'content:draft-write']);
assert.equal(meta.credential_type, 'api-token');
assert.throws(() => metadata({...meta, origin: target.origin, scopes: ['admin'], minutes: 60}));
});
test('public source projection retains id/name only, rejects repository context', () => {
assert.deepEqual(publicSource({id: 'ABC123', name: 'Site', vault_kind: 'user', credential: 'SENSITIVE_FIXTURE'}), {id: 'ABC123', name: 'Site'});
assert.throws(() => publicSource({id: 'ABC123', name: 'Site', vault_kind: 'repository'}));
});
test('exact create route, browser authority and safe request settings', async () => {
await request('create', {vault_kind: 'user', credential: 'SENSITIVE_FIXTURE'}, async (url, options) => {
assert.equal(url, `${BASE}/create`);
assert.equal(options.credentials, 'include');
assert.equal(options.redirect, 'error');
assert.equal(options.cache, 'no-store');
assert.equal(options.method, 'POST');
assert.equal(JSON.parse(options.body).vault_kind, 'user');
return response({id: 'ABC123', name: 'Site', vault_kind: 'user'});
});
for (const path of ['juice/create', 'juice/revoke']) await request(path, {vault_kind: 'user'}, async url => {
assert.equal(url, `${BASE}/${path}`); return response({ok: true});
});
});
test('missing Juice, rejected auth, redirects/HTML, echoed secrets and timeouts are safe', async () => {
for (const [status, expected] of [[404, /Juice unavailable/], [503, /Juice unavailable/], [401, /Sign in/], [403, /Sign in/]]) {
await assert.rejects(request('juice/create', {}, async () => response({error: 'SENSITIVE_FIXTURE'}, status)), expected);
}
await assert.rejects(request('list', undefined, async () => response('SENSITIVE_FIXTURE', 200, 'text/html')), /expected JSON/);
await assert.rejects(request('create', {}, async () => response({error: 'SENSITIVE_FIXTURE'})), /did not confirm/);
await assert.rejects(request('create', {}, async () => { throw new Error('SENSITIVE_FIXTURE'); }), /unreachable or timed out/);
let called = false;
await assert.rejects(request('create', {credential: '\\'.repeat(16384)}, async () => { called = true; }), /24 KiB/);
assert.equal(called, false);
});
test('legacy token module still has no persistence or injection; MV3 exposure is bounded', async () => {
const root = new URL('../', import.meta.url);
const manifest = JSON.parse(await readFile(new URL('manifest.json', root), 'utf8'));
assert.equal(manifest.manifest_version, 3);
assert.deepEqual(manifest.permissions, ['activeTab', 'scripting', 'storage']);
assert.deepEqual(manifest.host_permissions, ['https://teahub.xyz/*']);
assert.equal(manifest.optional_host_permissions, undefined);
assert.equal(manifest.content_scripts, undefined);
assert.deepEqual(manifest.background, {service_worker:'connection.mjs',type:'module'});
assert.equal(manifest.web_accessible_resources, undefined);
assert.deepEqual(manifest.externally_connectable, {matches:['https://persimmons.app/*']});
for (const name of await readdir(root)) {
if (!/\.(mjs|html)$/.test(name)) continue;
const source = await readFile(new URL(name, root), 'utf8');
assert.doesNotMatch(source, /console\.|chrome\.cookies|localStorage|sessionStorage|indexedDB|innerHTML|eval\(/);
if (['core.mjs', 'popup.mjs'].includes(name)) assert.doesNotMatch(source, /chrome\.scripting|chrome\.storage/);
}
const html = await readFile(new URL('popup.html', root), 'utf8');
assert.match(html, /human-popup\.mjs/);
assert.doesNotMatch(html, /juice-panel|value="content:read"|value="content:draft-write"/);
for (const scope of ['manage:users', 'billing', 'destructive']) assert.doesNotMatch(html, new RegExp(`value="${scope}" checked`));
});
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
test('homepage derives one onboarding state and never renders up-to-date copy', async () => {
const nodes = new Map();
const node = id => {
if (!nodes.has(id)) nodes.set(id,{
hidden:false,textContent:'',innerHTML:'',open:false,
addEventListener(){},append(){},appendChild(){},replaceChildren(){},style:{}
});
return nodes.get(id);
};
globalThis.document={
hidden:false,
getElementById:node,
createElement:tag=>({
tagName:tag.toUpperCase(),hidden:false,textContent:'',innerHTML:'',style:{},open:false,
setAttribute(){},append(){},appendChild(){},replaceChildren(){},showModal(){this.open=true},close(){this.open=false},
addEventListener(){},querySelector(){return {value:'',disabled:false}}
}),
body:{append(){}}
};
globalThis.window={addEventListener(){}};
globalThis.setInterval=()=>0;
let installed={connected:true,initialized:true,unlocked:true,empty:true,version:'0.4.4'};
let signedIn=true, latest='0.4.4';
globalThis.chrome={runtime:{sendMessage:(id,message,callback)=>callback(installed),lastError:null}};
globalThis.fetch=async url=>new Response(JSON.stringify(url==='/api/session'?{owner:'fixture'}:{version:latest}),{
status:url==='/api/session'&&!signedIn?401:200,headers:{'content-type':'application/json'}
});
const source=(await readFile(new URL('../../persimmons/web/app.js',import.meta.url),'utf8'))
.replace('./version.mjs',new URL('../version.mjs',import.meta.url).href);
let sequence=0;
async function render(){
await import('data:text/javascript,'+encodeURIComponent(source+'\n//'+sequence++));
await new Promise(resolve=>setImmediate(resolve));
}
await render();
assert.equal(node('extensionState').textContent,'🔓');
assert.equal(node('updateExtension').hidden,true);
assert.ok(![...nodes.values()].some(n=>n.textContent==='Up to date'));
installed={connected:true,initialized:true,unlocked:false,version:'0.4.4'}; await render();
assert.equal(node('extensionState').textContent,'🔒');
installed={connected:false,version:'0.4.3'}; await render();
assert.equal(node('onboardingHelp').textContent,'Open 🍊 and choose Link vault.');
assert.equal(node('updateExtension').hidden,false);
signedIn=false; await render();
assert.equal(node('signInLink').hidden,false);
assert.equal(node('createAccount').hidden,false);
assert.equal(node('updateExtension').hidden,true);
signedIn=true; installed=null; await render();
assert.equal(node('getExtension').hidden,false);
});
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import {readFile} from 'node:fs/promises';
import {initialize, unlock, encryptItem, decryptItem, isHttpsOrigin} from '../human-crypto.mjs';
import {EXTENSION_ID} from '../identity.mjs';
import {vaultRequest} from '../human-api.mjs';
import {VAULT_CAPABILITY} from '../connection-state.mjs';
test('deterministic unpacked ID agrees with the exact server Origin', async () => {
const manifest = JSON.parse(await readFile(new URL('../manifest.json', import.meta.url)));
const derived = createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest('hex').slice(0, 32)
.replace(/[0-9a-f]/g, c => String.fromCharCode(97 + parseInt(c, 16)));
assert.equal(derived, EXTENSION_ID);
const server = await readFile(new URL('../../tea_mcp/human_vault.py', import.meta.url), 'utf8');
assert.ok(server.includes(`EXTENSION_ORIGIN = "chrome-extension://${derived}"`));
});
test('exact HTTPS origins are data while extension origin remains the caller boundary', () => {
for (const origin of ['https://example.com','https://accounts.example.com','https://example.com:8443']) assert.equal(isHttpsOrigin(origin),true);
for (const origin of ['http://example.com','https://example.com/path','https://user@example.com','not-a-url']) assert.equal(isHttpsOrigin(origin),false);
});
test('local wrapping and authenticated ciphertext work for ordinary HTTPS sites', async () => {
const passphrase = 'disposable test passphrase';
const owner = 'fixture';
const record = await initialize(passphrase, owner);
assert.ok(!JSON.stringify(record).includes(passphrase));
const key = await unlock(passphrase, owner, record);
assert.equal(key.extractable, false);
await assert.rejects(crypto.subtle.exportKey('raw', key));
await assert.rejects(unlock('wrong passphrase', owner, record), /Unlock failed/);
const account = {username: 'DISPOSABLE_USER_SENTINEL', password: 'DISPOSABLE_PASSWORD_SENTINEL'};
const item = await encryptItem(key, owner, 'https://example.com', account);
assert.deepEqual(await decryptItem(key, owner, item), account);
assert.equal(item.username, account.username);
assert.ok(!JSON.stringify(item).includes(account.password));
const updated = await encryptItem(key, owner, item.origin, account, item.id);
assert.notEqual(updated.iv, item.iv);
assert.notEqual(updated.ciphertext, item.ciphertext);
for (const bad of [{...item,id:'0'.repeat(32)},{...item,origin:'https://other.example'},{...item,v:2}])
await assert.rejects(decryptItem(key, owner, bad), /failed authentication/);
});
test('vault HTTP has no query or keys; session failures never echo bodies', async () => {
globalThis.chrome={storage:{local:{get:async()=>({[VAULT_CAPABILITY]:{owner:'fixture',capability:'A'.repeat(43)}})}}};
await vaultRequest('GET', 'fixture', '1'.repeat(32), undefined, async (url, options) => {
assert.equal(url, 'https://teahub.xyz/tea/site/chat/persimmons/human-vault/items/' + '1'.repeat(32) + '/read');
assert.equal(options.method, 'POST');
assert.equal(options.credentials, 'omit');
assert.equal(options.headers['X-Vault-Capability'], 'A'.repeat(43));
assert.equal(options.headers['X-Vault-Owner'], 'fixture');
return new Response(JSON.stringify({owner: 'fixture'}), {headers: {'Content-Type': 'application/json'}});
});
for (const status of [401,403,500]) await assert.rejects(
vaultRequest('GET', undefined, undefined, undefined, async()=>new Response('PRIVATE_SENTINEL',{status})),
/Vault unavailable/
);
});
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {captureAccount, fillAccount} from '../human-api.mjs';
test('popup is only the human vault v0 surface', async () => {
const html = await readFile(new URL('../popup.html', import.meta.url), 'utf8');
assert.match(html, /My vault/);
assert.match(html, /Save autofilled login/);
assert.match(html, /browser autofill this site's login/);
assert.match(html, /Lock vault/);
assert.match(html, /Settings/);
assert.doesNotMatch(html, /id="juice-panel"/);
assert.doesNotMatch(html, /id="human-username"/);
assert.doesNotMatch(html, /id="human-password"/);
assert.doesNotMatch(html, /<iframe/);
assert.ok(!html.includes('src="popup.mjs"'));
});
test('linking mints a capability without a host permission prompt', async () => {
const source = await readFile(new URL('../human-popup.mjs', import.meta.url), 'utf8');
const manifest = JSON.parse(await readFile(new URL('../manifest.json', import.meta.url)));
assert.match(source, /world: 'ISOLATED'/);
assert.match(source, /fetch\('\/api\/human-vault\/link'/);
assert.match(source, /\[VAULT_CAPABILITY\]/);
assert.doesNotMatch(source, /chrome\.permissions\.request/);
assert.equal(manifest.optional_host_permissions, undefined);
assert.deepEqual(manifest.host_permissions, ['https://teahub.xyz/*']);
});
test('capture and fill require one clear filled same-origin login form', () => {
const old = {location:globalThis.location,document:globalThis.document,window:globalThis.window,HTMLInputElement:globalThis.HTMLInputElement,Event:globalThis.Event};
const makeInput = (type,value) => ({
type,value,disabled:false,readOnly:false,form:null,
getClientRects:()=>[{}],dispatchEvent(){}
});
const user=makeInput('email','person@example.com'), password=makeInput('password','secret');
const form={action:'https://example.com/login',querySelectorAll:()=>[user,password]};
user.form=form;password.form=form;
globalThis.location={protocol:'https:',origin:'https://example.com',href:'https://example.com/login'};
globalThis.window={top:null};globalThis.window.top=globalThis.window;
globalThis.document={querySelectorAll:selector=>selector==='input[type="password"]'?[password]:[user,password]};
globalThis.HTMLInputElement=function(){};
globalThis.HTMLInputElement.prototype={};
Object.defineProperty(globalThis.HTMLInputElement.prototype,'value',{set(v){this._set=v},configurable:true});
globalThis.Event=class { constructor(type,opts){this.type=type;this.opts=opts} };
try {
assert.deepEqual(captureAccount('https://example.com'),{username:'person@example.com',password:'secret'});
assert.equal(captureAccount('https://other.example'),null);
assert.equal(fillAccount('https://example.com',{username:'u',password:'p'}),true);
} finally {
Object.assign(globalThis,old);
}
});
+79
View File
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {runJuice, giteaToken, checkedScopes} from '../juice.mjs';
const sourceId = 'a'.repeat(32), juiceId = 'b'.repeat(32), oldId = 'c'.repeat(32);
const sender = {origin:'https://persimmons.app',url:'https://persimmons.app/',frameId:0};
const message = {type:'persimmons.juice.v1',action:'create',owner:'pg-2',source_id:sourceId,target:{id:1,name:'PackPack'},policy:{scopes:['read:repository']}};
const raw = 'RAW-SOURCE-NEVER-PAGE', pat = 'd'.repeat(40);
function fixture() {
const calls = [], provider = [], account = {username:'source-fixture',password:raw};
const deps = {
id:()=>juiceId, live:async()=>{}, unlocked:async()=>{},
source:async()=>({id:sourceId,origin:'https://teahub.xyz',ciphertext:'opaque'}),
use:async(owner,item,fn)=>{try{return await fn(account,()=>{});}finally{account.password='';}},
request:async(op,owner,data)=>{calls.push({op,data});return {items:[{id:oldId,source_id:sourceId,source_origin:'https://teahub.xyz',source_username:'source-fixture',scopes:['read:repository'],status:'active'}]};},
provider:async(origin,account,name,scopes)=>{provider.push({origin,name,scopes});return scopes?pat:true;}
};
return {deps,calls,provider,account};
}
test('create sends only the derived PAT to write-only backend, never to page or metadata',async()=>{
const f=fixture();const result=await runJuice(message,sender,f.deps);
assert.deepEqual(result,{ok:true,id:juiceId,status:'active'});
assert.doesNotMatch(JSON.stringify(result),new RegExp(raw+'|'+pat));
assert.deepEqual(f.calls.map(x=>x.op),['prepare','write']);
assert.equal(f.calls[1].data.pat,pat);
assert.doesNotMatch(JSON.stringify(f.calls),new RegExp(raw));
assert.doesNotMatch(JSON.stringify(f.calls[0]),new RegExp(pat));
assert.equal(f.account.password,'');
});
test('top frame, origin, owner, unlock and scope gates reject before provider mutation',async()=>{
for(const change of [{origin:'https://evil.invalid'},{url:'https://evil.invalid'},{frameId:1},{url:'invalid'}]){
const f=fixture();assert.equal(await runJuice(message,{...sender,...change},f.deps),null);assert.equal(f.calls.length,0);
}
for(const gate of ['live','unlocked']){
const f=fixture();f.deps[gate]=async()=>{throw new Error(raw)};
assert.equal((await runJuice(message,sender,f.deps)).ok,false);assert.equal(f.provider.length,0);
}
for(const scopes of [[],['all'],['write:repository'],['read:issue','write:issue'],['read:user'],['read:repository','read:repository']]) assert.throws(()=>checkedScopes(scopes));
const f=fixture();f.deps.source=async()=>({origin:'https://evil.invalid'});
assert.equal((await runJuice(message,sender,f.deps)).message,'Juice adapter not available yet.');assert.equal(f.calls.length,0);
});
test('failed or uncertain write/create compensates by deterministic source token name',async()=>{
for(const failure of ['write','provider']) {
const f=fixture();const request=f.deps.request,provider=f.deps.provider;
f.deps.request=async(op,...args)=>{if(op==='write'&&failure==='write')throw new Error(pat);return request(op,...args)};
f.deps.provider=async(...args)=>{if(args[3]&&failure==='provider'){await provider(...args);throw new Error(raw)}return provider(...args)};
const result=await runJuice(message,sender,f.deps);
assert.equal(result.ok,false);assert.equal(result.status,'revoked');
assert.equal(f.provider.at(-1).name,'persimmons-juice-'+juiceId);assert.equal(f.provider.at(-1).scopes,undefined);
assert.equal(f.calls.at(-1).data.provider_status,'revoked');assert.doesNotMatch(JSON.stringify(result),new RegExp(raw+'|'+pat));
}
});
test('unconfirmed rollback is explicit ANDON; rotation revokes old only after replacement stored',async()=>{
const f=fixture(),request=f.deps.request;
f.deps.request=async(op,...args)=>{if(op==='write')throw new Error();return request(op,...args)};
f.deps.provider=async(o,a,n,s)=>{if(!s)throw new Error();return pat};
const failed=await runJuice(message,sender,f.deps);
assert.equal(failed.status,'partial-failure');assert.match(failed.message,/ANDON/);
const r=fixture();const result=await runJuice({type:message.type,action:'rotate',owner:message.owner,target:message.target,id:oldId},sender,r.deps);
assert.equal(result.ok,true);assert.deepEqual(r.calls.map(c=>c.op),['list','prepare','write','remove','cleanup']);
assert.equal(r.provider.at(-1).name,'persimmons-juice-'+oldId);
});
test('locked revoke still removes target, records provider partial revoke truthfully',async()=>{
const f=fixture();f.deps.unlocked=async()=>{throw new Error()};
const result=await runJuice({type:message.type,action:'revoke',owner:message.owner,target:message.target,id:oldId},sender,f.deps);
assert.equal(result.status,'partial-failure');assert.match(result.message,/ANDON/);
assert.deepEqual(f.calls.map(c=>c.op),['list','remove','cleanup']);assert.equal(f.provider.length,0);
assert.equal(f.calls.at(-1).data.provider_status,'unknown');
});
test('Gitea requests are pinned Basic auth, no cookies/redirects; responses do not escape',async()=>{
const account={username:'fixture',password:raw};
for(const origin of ['https://teahub.xyz']){
const value=await giteaToken(origin,account,'persimmons-juice-'+juiceId,['read:repository'],async(url,opts)=>{
assert.equal(url,origin+'/api/v1/users/fixture/tokens');assert.equal(opts.credentials,'omit');assert.equal(opts.redirect,'error');
assert.equal(Buffer.from(opts.headers.Authorization.slice(6),'base64').toString(),'fixture:'+raw);
return Response.json({name:'persimmons-juice-'+juiceId,sha1:pat,scopes:['read:repository']},{status:201});
});assert.equal(value,pat);
}
let called=false;await assert.rejects(giteaToken('https://evil.invalid',account,'persimmons-juice-'+juiceId,['read:repository'],async()=>{called=true}));assert.equal(called,false);
});
+100
View File
@@ -0,0 +1,100 @@
import test from 'node:test';
import assert from 'node:assert/strict';
test('popup never reads cookies, saves explicit tokens, lists and revokes Juice in My vault', async () => {
class Element {
constructor(text = '', value = '') { this.textContent = text; this.value = value; this.children = []; this.disabled = false; }
addEventListener(name, listener) { this[name] = listener; }
append(...items) { this.children.push(...items); }
add(item) { this.children.push(item); }
replaceChildren(...items) { this.children = items; this.value = ''; }
querySelectorAll() { return this.children.flatMap(c => c.children || []); }
}
const ids = ['juice-panel', 'origin', 'auth', 'connect', 'save', 'squeeze', 'status', 'vault-status',
'juice-status', 'sources', 'juices', 'name', 'minutes', 'token', 'token-label', 'save-form', 'subject'];
const nodes = Object.fromEntries(ids.map(id => [id, new Element()]));
nodes.minutes.value = '60'; nodes.name.value = 'Site session';
nodes.subject.value = 'Draft assistant';
const grants = new Set();
const permissionRequests = []; const calls = [];
let grantNext = false; let cookieReads = 0; let juiceAvailable = false;
let sourceExists = false;
const publicItem = {id: 'ABC123', name: 'SITE_SESSION', vault_kind: 'user'};
globalThis.document = {getElementById: id => nodes[id],
querySelectorAll: () => [{value: 'content:read'}, {value: 'content:draft-write'}],
createElement: () => new Element(), createTextNode: text => new Element(text)};
globalThis.Option = Element;
globalThis.window = {addEventListener() {}};
globalThis.chrome = {
tabs: {query: async () => [{url: 'https://wp.phly.cloud/wp-admin/'}]},
permissions: {
contains: async ({origins}) => origins.every(origin => grants.has(origin)),
request: async ({origins}) => {
permissionRequests.push(origins);
if (grantNext) origins.forEach(origin => grants.add(origin));
return grantNext;
}
},
cookies: {getAll: async () => { cookieReads++; throw new Error('Cookie API must never be used'); }}
};
globalThis.fetch = async (url, options) => {
const path = url.split('/persimmons/')[1];
const body = options.body ? JSON.parse(options.body) : undefined;
calls.push({path, body});
let data; let status = 200;
if (path === 'list') data = {connections: sourceExists ? [publicItem] : []};
else if (path === 'create') { sourceExists = true; data = publicItem; }
else if (!juiceAvailable) { data = {error: 'SECRET_SERVER_FIXTURE'}; status = 404; }
else if (path === 'juice/list') data = {juice: [{id: 'JUICE123', vault_kind: 'user', status: 'active'}]};
else data = {ok: true};
return new Response(JSON.stringify(data), {status, headers: {'content-type': 'application/json'}});
};
const settle = async () => {
for (let i = 0; i < 100 && nodes.connect.disabled; i++) await new Promise(resolve => setImmediate(resolve));
assert.equal(nodes.connect.disabled, false, 'operation completed');
};
await import('../popup.mjs');
assert.equal(nodes.origin.textContent, 'https://wp.phly.cloud');
assert.equal(cookieReads, 0); assert.equal(calls.length, 0);
nodes.connect.onclick(); await settle();
assert.equal(calls.length, 0); assert.match(nodes.status.textContent, /not granted/);
grantNext = true;
nodes.connect.onclick(); await settle();
assert.deepEqual(calls.map(c => c.path), ['list', 'juice/list']);
assert.equal(cookieReads, 0); assert.equal(nodes.save.disabled, false);
nodes.token.value = 'SYNTHETIC_TOKEN';
nodes['save-form'].onsubmit({preventDefault() {}}); await settle();
const saved = calls.find(c => c.path === 'create').body;
assert.equal(saved.vault_kind, 'user');
assert.equal(saved.account, 'https://wp.phly.cloud');
assert.equal(saved.credential, 'SYNTHETIC_TOKEN');
assert.equal(saved.credential_type, 'api-token');
assert.deepEqual(saved.scopes, ['content:read', 'content:draft-write']);
assert.equal(saved.expires, undefined);
assert.equal(nodes.sources.value, 'ABC123');
assert.equal(nodes.token.value, ''); assert.equal(cookieReads, 0);
juiceAvailable = true;
nodes.squeeze.onclick(); await settle();
assert.equal(calls.find(c => c.path === 'juice/create').body.source_id, 'ABC123');
assert.equal(calls.find(c => c.path === 'juice/create').body.subject, 'Draft assistant');
assert.ok(Math.abs(Date.parse(calls.find(c => c.path === 'juice/create').body.expires_at) - Date.now() - 3600000) < 5000);
const revoke = nodes.juices.children[0].children[1];
revoke.onclick(); await settle();
assert.deepEqual(calls.find(c => c.path === 'juice/revoke').body, {id: 'JUICE123', vault_kind: 'user'});
const flatten = node => [node.textContent, ...node.children.map(flatten)].join(' ');
assert.doesNotMatch(Object.values(nodes).map(flatten).join(' '), /SECRET_|SYNTHETIC_TOKEN/);
calls.length = 0;
await import('../popup.mjs?reopened');
assert.equal(calls.length, 0, 'reopening does not fetch secondary Juice');
nodes['juice-panel'].open = true; nodes['juice-panel'].hidden = false;
nodes['juice-panel'].toggle(); await settle();
assert.deepEqual(calls.map(c => c.path), ['list', 'juice/list']);
assert.equal(cookieReads, 0);
assert.deepEqual(permissionRequests[0], ['https://teahub.xyz/*']);
// A failed save clears the manually entered token too.
globalThis.fetch = async () => { throw new Error('SYNTHETIC_TOKEN'); };
nodes.token.value = 'SYNTHETIC_TOKEN';
nodes['save-form'].onsubmit({preventDefault() {}}); await settle();
assert.equal(nodes.token.value, '');
assert.doesNotMatch(nodes.status.textContent, /SYNTHETIC_TOKEN/);
});
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {VaultSession} from '../vault-session.mjs';
import {updateState} from '../version.mjs';
test('vault session fails closed and honors bounded policies', () => {
let now = 1000;
const session = new VaultSession(() => now);
const key = {extractable:false};
session.set(key, 'owner-a', '5');
assert.equal(session.get('owner-a'), key);
assert.equal(session.get('owner-b'), null);
session.set(key, 'owner-a', '5');
now += 5 * 60000;
assert.equal(session.get('owner-a'), null);
session.set(key, 'owner-a', 'close');
session.closed();
assert.equal(session.get('owner-a'), null);
});
test('version UI only says something when an update is actually available', () => {
assert.equal(updateState('0.4.4','0.4.4'),'');
assert.equal(updateState('0.4.5','0.4.4'),'');
assert.equal(updateState('0.4.4','0.4.5'),'Update available');
assert.equal(updateState('0.5.9','0.6.0'),'Update available');
assert.equal(updateState('0.4.4','0.4.4.0'),'');
assert.equal(updateState('bogus','0.4.5'),'');
});
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {AUTO_LOCK} from '../vault-session.mjs';
import {VAULT_CAPABILITY} from '../connection-state.mjs';
test('worker keys stay private; trusted popup only; close, timer, auth loss and lock races fail closed', async () => {
let connect, valid = true;
const stored = {[VAULT_CAPABILITY]:{owner:'owner',capability:'A'.repeat(43)}}, timers = [];
globalThis.setInterval = () => 1; globalThis.clearInterval = () => {};
globalThis.setTimeout = callback => { timers.push(callback); return timers.length; };
globalThis.clearTimeout = () => {};
globalThis.chrome = {
runtime: {id:'fixture', getURL:path=>'chrome-extension://fixture/'+path,
getPlatformInfo:async()=>({}), onConnect:{addListener:fn=>connect=fn}},
storage:{local:{get:async key=>({[key]:stored[key]}),set:async data=>Object.assign(stored,data)}}
};
globalThis.fetch = async()=>new Response(JSON.stringify({owner:valid?'owner':'other',items:[]}),{headers:{'content-type':'application/json'}});
const {vaultUnlocked} = await import('../vault-worker.mjs');
function popup(url='chrome-extension://fixture/popup.html') {
let listener, close, disconnected = false, sequence = 0;
const replies = new Map();
connect({name:'human-vault',sender:{id:'fixture',url},disconnect:()=>disconnected=true,
onMessage:{addListener:fn=>listener=fn},onDisconnect:{addListener:fn=>close=fn},
postMessage:message=>{replies.get(message.id)?.(message);replies.delete(message.id)}});
return {get disconnected(){return disconnected}, close:()=>close(),
send:(op,data={})=>new Promise(resolve=>{const id=++sequence;replies.set(id,resolve);listener({id,op,owner:'owner',...data})})};
}
assert.equal(popup('https://persimmons.app/').disconnected,true);
assert.equal(popup('chrome-extension://fixture/other.html').disconnected,true);
let port = popup();
const unlock = () => port.send('unlock',{passphrase:'synthetic test passphrase'});
const firstUnlock = unlock();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(vaultUnlocked('owner'),false,'status during unlock does not cancel it');
assert.equal((await firstUnlock).result,true);
assert.equal(vaultUnlocked('owner'),true);
assert.equal(vaultUnlocked('other'),false);
assert.equal(vaultUnlocked('owner'),true,'another owner status cannot lock the session');
const record=stored['human-vault:https://persimmons.app:owner'];
assert.ok(record.wrappedKey); assert.equal(Object.values(stored).some(x=>x instanceof CryptoKey),false);
assert.doesNotMatch(JSON.stringify(stored),/synthetic test passphrase/);
port.close(); port=popup();assert.equal((await port.send('status')).result,false);
stored[AUTO_LOCK]='5';assert.equal((await unlock()).result,true);
port.close();port=popup();assert.equal((await port.send('status')).result,true);
const encrypted=await port.send('encrypt',{origin:'https://wp.phly.cloud',account:{username:'fixture',password:'synthetic'}});
assert.equal(encrypted.ok,true);assert.ok(encrypted.result.ciphertext);
assert.deepEqual((await port.send('decrypt',{item:encrypted.result})).result,{username:'fixture',password:'synthetic'});
timers.at(-1)();assert.equal((await port.send('status')).result,false);
assert.equal((await unlock()).result,true);
valid=false;assert.equal((await port.send('status')).ok,false);valid=true;
assert.equal((await port.send('status')).result,false);
assert.equal((await port.send('unlock',{passphrase:'wrong'})).ok,false);
const pending=unlock();await port.send('lock');assert.equal((await pending).ok,false);
// Close invalidates any pending unlock and never installs its key.
unlock();port.close();port=popup();
await new Promise(resolve=>setImmediate(resolve));
assert.equal((await port.send('status')).result,false);
});