__tests__/console-signin.e2e.test.js
Rama main · réplica de lectura del repositorio original.
// Explicit browser test: GHOST_BROWSER_E2E=1 npm test -- __tests__/console-signin.e2e.test.js
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createRequire } from 'node:module';
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import crypto from 'node:crypto';
import { chromium, devices } from 'playwright';
const require = createRequire(import.meta.url);
const express = require('express');
const { makeMobileAuth } = require('../src/mobileAuth');
const sso = require('../src/sso');
const enabled = process.env.GHOST_BROWSER_E2E === '1';
const suite = enabled ? describe : describe.skip;
suite('fresh browser authentication and native connection', () => {
let server, browser, base, accounts, dir;
const secret = 'browser-e2e-secret', owner = 'browser-test@example.test';
const mint = (claims = {}) => {
const h = Buffer.from(JSON.stringify({ alg: 'HS256' })).toString('base64url');
const b = Buffer.from(JSON.stringify({ email: owner, exp: Math.floor(Date.now() / 1000) + 120, ...claims })).toString('base64url');
return h + '.' + b + '.' + crypto.createHmac('sha256', secret).update(h + '.' + b).digest('base64url');
};
const handoff = (device, expired = false) => base + '/#leadflow=' + encodeURIComponent(JSON.stringify({ auth: mint(expired ? { exp: 1 } : {}), ...(device ? { device } : {}) }));
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'gb-browser-auth-'));
process.env.PROFILE_DIR = dir;
delete require.cache[require.resolve('../src/accounts')]; accounts = require('../src/accounts');
const record = accounts.signup(owner, 'test-only-owner-password');
const app = express(); app.use(express.json());
const mobile = makeMobileAuth({ mintDevice: input => ({ ...input, token: 'test-device-token' }) });
const signedIn = req => accounts.verifyToken(accounts.readCookie(req));
app.get('/', (_req, res) => res.type('html').send(readFileSync(new URL('../public/compose-index.html', import.meta.url))));
app.get('/js/console-auth.js', (_req, res) => res.type('js').send(readFileSync(new URL('../public/js/console-auth.js', import.meta.url))));
app.get('/console/live-overlay.js', (_req, res) => res.type('js').send(''));
app.get('/console/gbweb.js', (_req, res) => res.type('js').send('document.body.dataset.consoleStarted="true"'));
app.get('/api/auth/state', (req, res) => res.json({ signedIn: !!signedIn(req), sso: true, ssoOnly: true, hasPassword: false }));
app.post('/api/auth/sso', (req, res) => {
try { sso.verifyLeadflowToken(req.body.token, secret); const session = accounts.issue(record); accounts.setCookie(res, session.token, session.exp); res.json({ username: owner }); }
catch { res.status(401).json({ error: 'expired' }); }
});
app.post('/api/auth/logout', (_req, res) => { accounts.clearCookie(res); res.json({ ok: true }); });
app.post('/api/auth/device-code', (req, res) => {
if (!signedIn(req)) return res.sendStatus(401);
res.json(mobile.issue(owner, req.body.challenge));
});
app.post('/api/auth/device-token', (req, res) => { try { res.json(mobile.redeem(req.body)); } catch { res.sendStatus(401); } });
server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
base = 'http://127.0.0.1:' + server.address().port;
browser = await chromium.launch({ headless: true, ...(process.env.GHOST_TEST_CHROMIUM ? { executablePath: process.env.GHOST_TEST_CHROMIUM } : {}) });
}, 30000);
afterAll(async () => { await browser?.close(); await new Promise(resolve => server?.close(resolve)); if (dir) rmSync(dir, { recursive: true, force: true }); });
for (const [label, options] of [['desktop', {}], ['Android viewport', devices['Pixel 5']]]) {
it(`${label}: fresh install -> sign-in button -> handoff -> session -> reload -> sign-out`, async () => {
const context = await browser.newContext(options); const page = await context.newPage();
page.setDefaultTimeout(5000);
let step = 'fresh sign-in screen';
try {
await page.goto(base);
await page.getByRole('link', { name: 'Sign in with MyApp.Engineer' }).waitFor();
expect(await page.locator('#password-option').isVisible()).toBe(false);
expect(await page.locator('#ComposeTarget').isVisible()).toBe(false);
step = 'exchange handoff and start console'; await page.goto(handoff());
await page.waitForFunction(() => document.body.dataset.consoleStarted === 'true');
expect(new URL(page.url()).hash).toBe('');
expect((await page.evaluate(() => fetch('/api/auth/state', { credentials: 'include' }).then(r => r.json()))).signedIn).toBe(true);
step = 'reload signed-in console'; await page.reload(); await page.waitForFunction(() => document.body.dataset.consoleStarted === 'true');
step = 'sign out and reload';
await page.evaluate(() => fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })); await page.reload();
await page.getByRole('link', { name: 'Sign in with MyApp.Engineer' }).waitFor();
} catch (error) { throw new Error(step + ': ' + error.message); }
finally { await context.close(); }
}, 30000);
}
it('an expired handoff stays on a useful retry screen', async () => {
const context = await browser.newContext(); const page = await context.newPage();
try {
await page.goto(handoff(null, true));
await page.getByText('This sign-in link expired or could not be verified. Please sign in again.').waitFor();
expect(await page.locator('#password-option').isVisible()).toBe(false);
} finally { await context.close(); }
});
it('a mobile return exchanges its code once using the app verifier', async () => {
const context = await browser.newContext(devices['Pixel 5']); const page = await context.newPage();
const verifier = 'v'.repeat(43), challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
try {
await page.goto(handoff({ challenge, state: 's'.repeat(43) }));
const open = page.getByRole('link', { name: 'Open Ghost Browser Mobile' }); await open.waitFor();
const callback = new URL(await open.getAttribute('href'));
expect(callback.protocol).toBe('ghostbrowser:');
const code = callback.searchParams.get('code');
const exchange = await context.request.post(base + '/api/auth/device-token', { data: { code, verifier, deviceId: 'test-phone' } });
expect(exchange.status()).toBe(200);
expect((await exchange.json()).owner).toBe(owner);
expect((await context.request.post(base + '/api/auth/device-token', { data: { code, verifier, deviceId: 'test-phone' } })).status()).toBe(401);
} finally { await context.close(); }
}, 30000);
});