From f28c334c727bca2da26b4084a4959656ddc29a11 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 6 Oct 2026 17:55:34 -0400 Subject: [PATCH] Qualify managed V4V native login and retained playback without payments --- tests/lifecycle/v4v-native-login.cjs | 86 +++++++++++++++++++++++++ tests/lifecycle/v4v-native-playback.cjs | 58 +++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tests/lifecycle/v4v-native-login.cjs create mode 100644 tests/lifecycle/v4v-native-playback.cjs diff --git a/tests/lifecycle/v4v-native-login.cjs b/tests/lifecycle/v4v-native-login.cjs new file mode 100644 index 00000000..3bf895e1 --- /dev/null +++ b/tests/lifecycle/v4v-native-login.cjs @@ -0,0 +1,86 @@ +// Authorized, real V4V native login. No payment or relay publication permitted. +const fs = require('node:fs'); +const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '../../neode-ui/node_modules/@playwright/test'); +(async () => { + if (process.env.ALLOW_REAL_AUTH_SIGNING !== '1') throw Error('Explicit real-auth test authorization required'); + const origin = new URL(process.env.QUALIFICATION_ORIGIN).origin; + const parsed = new URL(origin); + const octets = parsed.hostname.split('.').map(Number); + const local = parsed.hostname === 'localhost' || (octets.length === 4 && octets.every(n => Number.isInteger(n) && n >= 0 && n <= 255) && (octets[0] === 127 || octets[0] === 10 || (octets[0] === 192 && octets[1] === 168) || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31))); + if (!local || !['http:', 'https:'].includes(parsed.protocol)) throw Error('Private node origin required'); + parsed.port = '7475'; const appOrigin = parsed.origin; + const browser = await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911'); + for (const width of [390, 1440]) { + const context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: 'block' }); + const proof = { width, signatures: 0, login: [], blocked: false }; + const challenges = new Set(); + try { + const cookies = JSON.parse(fs.readFileSync(process.env.QUALIFICATION_COOKIES, 'utf8')); + await context.addCookies(Object.entries(cookies).map(([name, value]) => ({ name, value, url: origin, httpOnly: name !== 'csrf_token' }))); + await context.addInitScript(() => { localStorage.setItem('neode-auth', 'true'); localStorage.setItem('neode_onboarding_complete', '1'); localStorage.setItem('neode_companion_intro_seen', 'build:54'); }); + await context.route('**/rpc/v1', async route => { + let rpc; try { rpc = route.request().postDataJSON(); } catch { return route.continue(); } + const method = rpc?.method || ''; + if (['identity.nostr-sign', 'node.nostr-sign'].includes(method)) { + const event = rpc.params?.event; + const tags = event?.tags; + const valid = event?.kind === 21236 && event.content === '' && Array.isArray(tags) && tags.length === 3 && tags.every(t => Array.isArray(t) && t.length === 2) && tags.some(t => t[0] === 'origin' && t[1] === appOrigin) && tags.some(t => t[0] === 'challenge' && challenges.has(t[1])) && tags.some(t => t[0] === 'app' && t[1] === 'v4v') && Math.abs(Date.now() / 1000 - event.created_at) < 60; + if (!valid || proof.signatures >= 2) { proof.blocked = true; return route.abort(); } + proof.signatures++; + } else if (method === 'identity.sign') { + if (!/^archipelago-identity:\d+$/.test(rpc.params?.message || '')) { proof.blocked = true; return route.abort(); } + } else if (/nostr-(encrypt|decrypt)|(?:^|[.-])(pay|send|spend|melt|withdraw|publish|transfer)(?:[.-]|$)/.test(method)) { + proof.blocked = true; return route.abort(); + } + return route.continue(); + }); + // Record the actual challenge before allowing the corresponding signature. + await context.route(appOrigin + '/api/auth/nostr/challenge', async route => { + const response = await route.fetch(); const body = await response.json(); + if (body.enabled === true && /^[0-9a-f]{64}$/.test(body.challenge)) challenges.add(body.challenge); + await route.fulfill({ response }); + }); + if (process.env.QUALIFY_PLAYBACK === '1') { + await context.addInitScript(() => { + window.__qualificationMedia = []; + const play = HTMLMediaElement.prototype.play; + HTMLMediaElement.prototype.play = function (...args) { + this.muted = true; + if (!window.__qualificationMedia.includes(this)) window.__qualificationMedia.push(this); + return play.apply(this, args); + }; + }); + await context.route(appOrigin + '/api/**', async route => { + const request = route.request(); const path = new URL(request.url()).pathname; + if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && /(?:pay|invoice|tip|purchase|checkout|melt|withdraw|spend|zap|fund|stream-credit)/i.test(path)) { + proof.blocked = true; return route.abort(); + } + return route.fallback(); + }); + } + const page = await context.newPage(); + page.on('response', response => { const u = new URL(response.url()); if (u.origin === appOrigin && u.pathname === '/api/auth/nostr/login') proof.login.push(response.status()); }); + await page.goto(origin + '/dashboard/discover', { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction(() => document.querySelector('#app')?.__vue_app__?.config.globalProperties.$pinia?._s.has('appLauncher'), undefined, { timeout: 30000 }); + await page.evaluate(() => document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('appLauncher').openSession('node-demo-v4v')); + const frame = page.frameLocator('iframe[src*="7475"]'); + const signIn = frame.getByRole('button', { name: 'Sign in with Nostr', exact: true }); + await expect(signIn).toBeVisible({ timeout: 45000 }); + await expect(frame.locator('input[type=password]')).toHaveCount(0); + await signIn.click(); + const deadline = Date.now() + 45000; let approvals = 0; + while (Date.now() < deadline && !proof.login.includes(200)) { + if (proof.blocked) throw Error('Unexpected signing or payment request blocked'); + for (const name of ['Authenticate', 'Approve']) { + const button = page.getByRole('button', { name, exact: true }); + if (await button.isVisible().catch(() => false)) { if (++approvals > 6) throw Error('Repeated consent loop'); await button.click(); } + } + await page.waitForTimeout(200); + } + expect(proof.blocked).toBe(false); expect(proof.signatures).toBeGreaterThan(0); expect(proof.login).toContain(200); + await expect(signIn).toHaveCount(0, { timeout: 15000 }); + if (process.env.QUALIFY_PLAYBACK === '1') await require('./v4v-native-playback.cjs')(page, appOrigin, proof); + console.log(JSON.stringify({ ...proof, result: 'PASS', scope: process.env.QUALIFY_PLAYBACK === '1' ? 'real managed V4V native login and bundled-song playback' : 'real managed V4V native authentication only; playback remains separately qualified' })); + } finally { await context.unrouteAll({ behavior: 'ignoreErrors' }).catch(() => {}); await context.close(); } + } +})().then(() => process.exit(0)).catch(error => { console.error(String(error.message).split(/Call [Ll]og:/)[0]); process.exit(1); }); diff --git a/tests/lifecycle/v4v-native-playback.cjs b/tests/lifecycle/v4v-native-playback.cjs new file mode 100644 index 00000000..31f62b3f --- /dev/null +++ b/tests/lifecycle/v4v-native-playback.cjs @@ -0,0 +1,58 @@ +// Invoked only after the real managed app has completed native Nostr login. +// No fixture media, intercepted success, payment or application state fabrication. +const { expect } = require(process.env.PLAYWRIGHT_MODULE || '../../neode-ui/node_modules/@playwright/test'); +module.exports = async function qualifyPlayback(page, appOrigin, proof) { + const app = page.frames().find(frame => frame.url().startsWith(appOrigin + '/')); + if (!app) throw Error('Managed V4V iframe missing after login'); + await app.waitForFunction(() => window.PulsewireLegacy?.getReactBridge?.(), undefined, { timeout: 30000 }); + const later = app.getByRole('button', { name: 'Not now', exact: true }); + if (await later.isVisible().catch(() => false)) await later.click(); + const tracks = await app.evaluate(() => { + const bridge = window.PulsewireLegacy.getReactBridge(); + return bridge.getState().releases.filter(release => { + try { + const url = new URL(bridge.helpers.releasePlayableUrl(release), location.href); + return url.origin === location.origin && url.pathname.startsWith('/media/dev-audio/'); + } catch { return false; } + }).slice(0, 2).map(release => ({ id: release.id, title: release.title })); + }); + if (tracks.length !== 2) throw Error('Need two existing bundled demo tracks for a no-payment playback check'); + await app.evaluate(async ids => { + const bridge = window.PulsewireLegacy.getReactBridge(); + if (bridge.getState().ui.shuffleEnabled) bridge.actions.toggleShuffle(); + bridge.actions.clearQueue(); + bridge.actions.play(ids[0]); + bridge.actions.queueRelease(ids[1]); + }, tracks.map(track => track.id)); + await app.waitForFunction(() => window.__qualificationMedia?.some(audio => !audio.paused && audio.readyState >= 2 && audio.currentTime > 0), undefined, { timeout: 30000 }); + const initial = await app.evaluate(() => window.__qualificationMedia.find(audio => !audio.paused).currentTime); + await page.getByRole('button', { name: 'Close', exact: true }).click(); + const bar = page.locator('.audio-player-bar'); + await expect(bar).toBeVisible(); + await expect(bar).toContainText(tracks[0].title); + await app.waitForFunction(time => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > time + 1), initial, { timeout: 15000 }); + await page.getByRole('button', { name: 'Pause audio', exact: true }).click(); + await app.waitForFunction(() => window.__qualificationMedia.every(audio => audio.paused)); + await page.getByRole('button', { name: 'Play audio', exact: true }).click(); + await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused)); + await page.getByRole('button', { name: 'Next track', exact: true }).click(); + await app.waitForFunction(id => window.PulsewireLegacy.getReactBridge().helpers.activePlayerRelease()?.id === id, tracks[1].id); + await expect(bar).toContainText(tracks[1].title); + // Previous after >3 seconds intentionally restarts the current song in V4V. + await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > 4)); + await page.getByRole('button', { name: 'Previous track', exact: true }).click(); + await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime < 3)); + await page.getByRole('button', { name: 'Shuffle', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Shuffle', exact: true })).toHaveAttribute('aria-pressed', 'true'); + expect(await app.evaluate(() => window.PulsewireLegacy.getReactBridge().getState().ui.shuffleEnabled)).toBe(true); + const artwork = await app.evaluate(() => window.PulsewireLegacy.getReactBridge().helpers.activePlayerRelease()?.cover || ''); + if (artwork) await expect(bar.locator('img')).toHaveAttribute('src', new URL(artwork, appOrigin).href); + const beforeReopen = await app.evaluate(() => window.__qualificationMedia.find(audio => !audio.paused).currentTime); + await page.getByRole('button', { name: 'Open playing app', exact: true }).click(); + if (!page.frames().includes(app)) throw Error('Reopening recreated the playback iframe'); + await expect(bar).toBeHidden(); + await app.waitForFunction(time => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime >= time), beforeReopen); + await app.evaluate(() => window.PulsewireLegacy.getReactBridge().actions.pauseAudio()); + if (proof.blocked) throw Error('Unexpected payment/signing attempted during playback'); + proof.playback = { actualDemoMedia: true, hiddenAudioContinues: true, controls: ['pause', 'play', 'next', 'previous', 'shuffle'], sameIframe: true, artworkChecked: Boolean(artwork), muted: true }; +};