Archived
port: from the old factory (tea-mcp/src/persimmons-extension), as it was, 2026-10-14
This commit is contained in:
commit
0e92a80b20
30 files changed
+1944
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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`));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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'),'');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user