Draft explicit rental readiness and start with verified chunk delivery
This commit is contained in:
@@ -10,7 +10,11 @@ describe('native provider on ordinary HTTP node origins',()=>{
|
||||
const timers=new Set<unknown>();let count=0
|
||||
runInNewContext(source,{window,document:{currentScript:{hasAttribute:()=>true},readyState:'complete'},crypto:{getRandomValues:(bytes:Uint8Array)=>{bytes.fill(++count);return bytes}},Uint8Array,URL,console,
|
||||
setTimeout:(fn:unknown)=>{timers.add(fn);return fn},clearTimeout:(fn:unknown)=>timers.delete(fn)})
|
||||
const rental=window.archipelagoRental.request({title:'Film'})
|
||||
expect(window.archipelagoRental.playbackProtocol).toBe(2)
|
||||
expect(Object.getOwnPropertyDescriptor(window.archipelagoRental,'playbackProtocol')?.writable).toBe(false)
|
||||
await expect(window.archipelagoRental.request({title:'Legacy'})).rejects.toThrow('protocol 2')
|
||||
expect(parent.postMessage).not.toHaveBeenCalled()
|
||||
const rental=window.archipelagoRental.request({title:'Film'},{playbackProtocol:2})
|
||||
const registration=window.archipelagoMediaRegistration.request('resume',{intent:{requestId:'existing'}})
|
||||
const requests=parent.postMessage.mock.calls.map(([message])=>message)
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -19,5 +23,19 @@ describe('native provider on ordinary HTTP node origins',()=>{
|
||||
for(const message of requests) for(const receive of listeners) receive({source:parent,origin:'http://node.local',data:{type:message.type.replace('-request','-response'),id:message.id,result:{ok:true}}})
|
||||
await expect(rental).resolves.toEqual({ok:true});await expect(registration).resolves.toEqual({ok:true})
|
||||
expect(timers.size).toBe(0)
|
||||
parent.postMessage.mockClear()
|
||||
const stop = new AbortController()
|
||||
const closed = window.archipelagoRental.request({title:'Closed'}, {playbackProtocol:2,signal:stop.signal})
|
||||
const closedCheck = expect(closed).rejects.toThrow('Rental request closed')
|
||||
const retained = window.archipelagoRental.request({title:'Other'}, {playbackProtocol:2})
|
||||
const messages=parent.postMessage.mock.calls.map(([message])=>message)
|
||||
stop.abort();await closedCheck
|
||||
expect(parent.postMessage.mock.lastCall![0]).toMatchObject({action:'cancel',id:messages[0].id,playbackProtocol:2})
|
||||
expect(timers.size).toBe(1)
|
||||
for(const receive of listeners)receive({source:parent,origin:'http://node.local',data:{type:'archipelago-rental-response',id:messages[1].id,result:{playbackProtocol:2,handle:'d'.repeat(64)}}})
|
||||
await expect(retained).resolves.toMatchObject({playbackProtocol:2});expect(timers.size).toBe(0)
|
||||
parent.postMessage.mockClear()
|
||||
await expect(window.archipelagoRental.request({title:'Already closed'},{playbackProtocol:2,signal:stop.signal})).rejects.toThrow('closed')
|
||||
expect(parent.postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,11 +8,11 @@ const quote = { state: 'confirmation_required', network: 'mainnet', mint_url: 'h
|
||||
function fixture(consentBusy: () => boolean = () => false) {
|
||||
const child = { postMessage: vi.fn() }
|
||||
const bridge = useRentalPurchaseBridge({ consentBusy, appId: () => 'indeedhub', appUrl: () => 'https://node.test:7778/browse', frameWindow: () => child as unknown as Window })
|
||||
const send = (origin = 'https://node.test:7778', source: unknown = child) => bridge.handle({ data: { type: 'archipelago-rental-request', id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', offer }, origin, source } as MessageEvent)
|
||||
const send = (origin = 'https://node.test:7778', source: unknown = child) => bridge.handle({ data: { type: 'archipelago-rental-request', playbackProtocol: 2, id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', offer }, origin, source } as MessageEvent)
|
||||
return { bridge, child, send }
|
||||
}
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
beforeEach(() => { vi.stubGlobal('location', new URL('https://node.test')); vi.clearAllMocks(); rpc.call.mockImplementation(async ({ method }) => method === 'media.registration.context' ? installed : method === 'content.rental-purchase' ? quote : { playback_url: '/api/rental-playback/' + 'd'.repeat(64), expires_at: null }) })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() })
|
||||
beforeEach(() => { vi.stubGlobal('location', new URL('https://node.test')); vi.clearAllMocks(); rpc.call.mockImplementation(async ({ method }) => method === 'media.registration.context' ? installed : method === 'content.rental-purchase' ? quote : { handle: 'd'.repeat(64), expires_at: null }) })
|
||||
describe('native rental confirmation', () => {
|
||||
it('cannot approve a quote without its saved network and mint', async()=>{
|
||||
const f=fixture();await f.send()
|
||||
@@ -26,12 +26,12 @@ describe('native rental confirmation', () => {
|
||||
const f=fixture(); await f.send(); await f.bridge.approve(); expect(rpc.call).toHaveBeenCalledTimes(1)
|
||||
await f.bridge.review(); expect(f.bridge.phase.value).toBe('confirm')
|
||||
expect(rpc.call.mock.calls.find(([v]) => v.method==='content.rental-purchase')![0].params.consent).toBeUndefined()
|
||||
rpc.call.mockImplementation(async ({method})=>method==='media.registration.context'?installed:method==='content.rental-purchase'?{state:'entitled',operation_id:quote.operation_id}:{playback_url:'/api/rental-playback/'+'d'.repeat(64),expires_at:null})
|
||||
rpc.call.mockImplementation(async ({method})=>method==='media.registration.context'?installed:method==='content.rental-purchase'?{state:'entitled',operation_id:quote.operation_id}:{handle:'d'.repeat(64),expires_at:null})
|
||||
await f.bridge.approve()
|
||||
const calls=rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase')
|
||||
expect(calls[1]![0].params.consent).toEqual({operation_id:quote.operation_id,envelope_sha256:quote.envelope_sha256,wallet_debit_sats:10})
|
||||
expect(calls[1]![0].params.max_wallet_debit).toBe(10); expect(f.bridge.request.value).toBeNull()
|
||||
expect(f.child.postMessage.mock.lastCall![0].result.playback_url).toContain('/api/rental-playback/')
|
||||
expect(f.child.postMessage.mock.lastCall![0].result).toEqual({playbackProtocol:2,handle:'d'.repeat(64),expires_at:null,operation_id:quote.operation_id}); expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false)
|
||||
})
|
||||
it('does not dispatch after closing during installation validation', async()=>{
|
||||
const f=fixture(); await f.send(); let release!:(value:unknown)=>void
|
||||
@@ -63,7 +63,7 @@ describe('native rental confirmation', () => {
|
||||
})
|
||||
it('lease status does not open a purchase or confirm spending', async()=>{
|
||||
const f=fixture();rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{expires_at:null})
|
||||
await f.bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'status',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'status',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
expect(rpc.call.mock.calls.map(([v])=>v.method)).toEqual(['media.registration.context','content.playback-status'])
|
||||
expect(f.child.postMessage.mock.lastCall![0].result).toEqual({expires_at:null})
|
||||
expect(f.bridge.request.value).toBeNull()
|
||||
@@ -83,7 +83,7 @@ describe('native rental confirmation', () => {
|
||||
it('drops a status response after the surface closes even if its WindowProxy is reused',async()=>{
|
||||
const f=fixture();let release!:(value:unknown)=>void
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:new Promise(resolve=>{release=resolve}))
|
||||
const work=f.bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'status',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
const work=f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'status',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await vi.waitFor(()=>expect(release).toBeTypeOf('function'))
|
||||
f.bridge.cancel();release({expires_at:100});await work
|
||||
expect(f.child.postMessage).not.toHaveBeenCalled()
|
||||
@@ -91,7 +91,7 @@ describe('native rental confirmation', () => {
|
||||
it('does not dispatch when the active frame disappears during installation verification',async()=>{
|
||||
const child={postMessage:vi.fn()};let active=true
|
||||
const bridge=useRentalPurchaseBridge({appId:()=> 'indeedhub',appUrl:()=> 'https://node.test:7778/browse',frameWindow:()=>active?child as unknown as Window:null})
|
||||
await bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',offer},origin:'https://node.test:7778',source:child} as unknown as MessageEvent)
|
||||
await bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',offer},origin:'https://node.test:7778',source:child} as unknown as MessageEvent)
|
||||
let release!:(value:unknown)=>void;rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
|
||||
const work=bridge.review();active=false;bridge.cancel();release(installed);await work
|
||||
expect(rpc.call.mock.calls.some(([v])=>v.method==='content.rental-purchase')).toBe(false)
|
||||
@@ -111,3 +111,87 @@ describe('native rental confirmation', () => {
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('protocol 2 preparation and explicit Start',()=>{
|
||||
it('rejects legacy callers before a wallet request',async()=>{
|
||||
const f=fixture();await f.bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',offer},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
expect(rpc.call).not.toHaveBeenCalled();expect(f.child.postMessage.mock.lastCall![0].error).toContain('protocol 2')
|
||||
})
|
||||
it('closing while preparation waits cancels polling and never approves payment',async()=>{
|
||||
vi.useFakeTimers(); const f=fixture();await f.send()
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{state:'preparing',completed_bytes:4,total_bytes:16})
|
||||
const work=f.bridge.review();await vi.advanceTimersByTimeAsync(0)
|
||||
expect(f.bridge.phase.value).toBe('preparing');expect(f.bridge.progress.value).toContain('25%')
|
||||
f.bridge.cancel();await work;await vi.advanceTimersByTimeAsync(10000)
|
||||
const payments=rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase');expect(payments).toHaveLength(1);expect(payments[0]![0].params.consent).toBeUndefined()
|
||||
})
|
||||
it('preparation reaches confirmation without automatically accepting it',async()=>{
|
||||
vi.useFakeTimers();const f=fixture();await f.send();let polls=0
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:++polls===1?{state:'preparing',completed_bytes:0,total_bytes:16}:quote)
|
||||
const work=f.bridge.review();await vi.advanceTimersByTimeAsync(2000);await work
|
||||
expect(f.bridge.phase.value).toBe('confirm');expect(rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase').every(([v])=>v.params.consent===undefined)).toBe(true)
|
||||
})
|
||||
it('only a distinct Start action returns the local playback URL',async()=>{
|
||||
const f=fixture();const ready='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:method==='content.playback-prepare'?{state:'ready',ready_id:ready,handle:'d'.repeat(64),viewing_seconds:3600,started_at:null,expires_at:null}:{state:'started',started_at:100,expires_at:3700,playback_url:'/api/rental-playback/'+'d'.repeat(64)})
|
||||
const event=(action:string)=>({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action,handle:'d'.repeat(64),ready_id:ready},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await f.bridge.handle(event('prepare'));expect(f.child.postMessage.mock.lastCall![0].result.playback_url).toBeUndefined();expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false)
|
||||
await f.bridge.handle(event('start'));expect(f.child.postMessage.mock.lastCall![0].result.playback_url).toBe('https://node.test/api/rental-playback/'+'d'.repeat(64))
|
||||
expect(rpc.call.mock.calls.filter(([v])=>v.method==='media.registration.context')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('provider request cancellation ownership',()=>{
|
||||
it('cancels only the matching native request and cannot approve its old quote',async()=>{
|
||||
const f=fixture();await f.send();await f.bridge.review()
|
||||
const event=(id:string)=>({data:{type:'archipelago-rental-request',playbackProtocol:2,action:'cancel',id},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await f.bridge.handle(event('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'));expect(f.bridge.phase.value).toBe('confirm')
|
||||
await f.bridge.handle(event('cccccccc-cccc-4ccc-8ccc-cccccccccccc'));expect(f.bridge.request.value).toBeNull()
|
||||
const calls=rpc.call.mock.calls.length;await f.bridge.approve();expect(rpc.call).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
it('drops Start after closing during installed-frame verification',async()=>{
|
||||
const f=fixture();let release!:(value:unknown)=>void
|
||||
rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
|
||||
const work=f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'start',handle:'d'.repeat(64),ready_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
f.bridge.cancel();release(installed);await work
|
||||
expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('strict protocol responses and approved-operation recovery',()=>{
|
||||
it('rejects unknown actions rather than opening a purchase',async()=>{
|
||||
const f=fixture();await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'unexpected',offer},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
expect(rpc.call).not.toHaveBeenCalled();expect(f.bridge.request.value).toBeNull()
|
||||
})
|
||||
it('preserves approved operation when a preparing response arrives after consent',async()=>{
|
||||
const f=fixture();await f.send();await f.bridge.review()
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{state:'preparing',completed_bytes:0,total_bytes:16})
|
||||
await f.bridge.approve();expect(f.bridge.error.value).toContain('Payment was approved');expect(f.bridge.error.value).toContain('original operation');expect(f.bridge.quote.value?.operation_id).toBe(quote.operation_id)
|
||||
const calls=rpc.call.mock.calls.length;await f.bridge.approve();expect(rpc.call).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
it.each([
|
||||
{state:'ready',ready_id:'invalid',handle:'d'.repeat(64),viewing_seconds:60,started_at:null,expires_at:null},
|
||||
{state:'preparing',completed_bytes:20,total_bytes:10,viewing_seconds:60,started_at:null,expires_at:null},
|
||||
{state:'ready',ready_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',handle:'d'.repeat(64),viewing_seconds:0,started_at:null,expires_at:null},
|
||||
{state:'expired',started_at:100,expires_at:200,playback_url:'/api/rental-playback/'+'d'.repeat(64)},
|
||||
])('rejects malformed or URL-bearing non-started states',async(result)=>{
|
||||
const f=fixture();rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:result)
|
||||
await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'prepare',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
expect(f.child.postMessage.mock.lastCall![0].error).toContain('Playback state');expect(f.child.postMessage.mock.lastCall![0].result).toBeUndefined()
|
||||
})
|
||||
it('drops a completed Start response from a closed frame generation',async()=>{
|
||||
const f=fixture();let release!:(value:unknown)=>void
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:new Promise(resolve=>{release=resolve}))
|
||||
const work=f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'start',handle:'d'.repeat(64),ready_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await vi.waitFor(()=>expect(release).toBeTypeOf('function'));f.bridge.cancel();release({state:'started',started_at:100,expires_at:200,playback_url:'/api/rental-playback/'+'d'.repeat(64)});await work
|
||||
expect(f.child.postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a changed original viewing window on a recovered Start',async()=>{
|
||||
const f=fixture();const handle='d'.repeat(64),ready='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:method==='content.playback-prepare'?{state:'ready',ready_id:ready,handle,viewing_seconds:3600,started_at:100,expires_at:3700}:{state:'started',started_at:200,expires_at:3800,playback_url:'/api/rental-playback/'+handle})
|
||||
const event=(action:string)=>({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action,handle,ready_id:ready},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
|
||||
await f.bridge.handle(event('prepare'));await f.bridge.handle(event('start'))
|
||||
expect(f.child.postMessage.mock.lastCall![0].error).toContain('original viewing window');expect(f.child.postMessage.mock.lastCall![0].result).toBeUndefined()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user