__tests__/console-auth.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'node:module';
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
const require = createRequire(import.meta.url);
const { authenticate, boot, PLATFORM_SIGN_IN, appLink } = require('../public/js/console-auth');
const { prepareConsole } = require('../scripts/prepare-console');
const html = readFileSync(new URL('../public/compose-index.html', import.meta.url), 'utf8');
const url = (envelope) => 'https://ghost-browser.ben.my-app.engineer/#leadflow=' + encodeURIComponent(JSON.stringify(envelope));
const reply = (body, ok = true) => ({ ok, json: async () => body });
describe('the shipped console authenticates before Compose starts', () => {
it('exchanges the fragment first, strips it from history and sends cookies', async () => {
const dom = new JSDOM(html, { url: url({ auth: 'signed-token' }) });
const calls = [];
const fetch = vi.fn(async (path, options) => { calls.push({ path, options, hash: dom.window.location.hash }); return reply(path.endsWith('/sso') ? {} : { signedIn: true }); });
const state = await authenticate({ location: dom.window.location, history: dom.window.history, fetch });
expect(state.signedIn).toBe(true);
expect(calls.map(c => c.path)).toEqual(['/api/auth/sso', '/api/auth/state']);
expect(calls[0].hash).toBe('');
expect(JSON.parse(calls[0].options.body)).toEqual({ token: 'signed-token' });
expect(calls.every(c => c.options.credentials === 'include')).toBe(true);
});
it('shows a direct platform sign-in and no dead password form on a fresh browser', async () => {
const dom = new JSDOM(html, { url: 'https://ghost-browser.ben.my-app.engineer/' });
dom.window.fetch = vi.fn(async () => reply({ signedIn: false, sso: true, ssoOnly: true, hasPassword: false }));
await boot(dom.window);
expect(dom.window.document.getElementById('platform-sign-in').href).toBe(PLATFORM_SIGN_IN);
expect(dom.window.document.getElementById('password-option').hidden).toBe(true);
expect(dom.window.document.querySelector('script[src="/console/gbweb.js"]')).toBeNull();
});
it('starts the real console only after the cookie is confirmed', async () => {
const dom = new JSDOM(html, { url: url({ auth: 'signed-token' }) });
dom.window.fetch = vi.fn(async path => reply(path.endsWith('/state') ? { signedIn: true } : {}));
await boot(dom.window);
expect(dom.window.document.getElementById('auth-gate').hidden).toBe(true);
expect(dom.window.document.querySelector('script[src="/console/gbweb.js"]')).not.toBeNull();
expect(dom.window.document.getElementById('ComposeTarget').hidden).toBe(false);
expect(dom.window.document.querySelector('.mcp-link')).toBeNull();
});
it('expired handoffs show an actionable retry instead of a password form', async () => {
const dom = new JSDOM(html, { url: url({ auth: 'expired' }) });
dom.window.fetch = vi.fn(async () => reply({}, false));
await boot(dom.window);
expect(dom.window.location.hash).toBe('');
expect(dom.window.document.getElementById('auth-status').textContent).toMatch(/expired/);
expect(dom.window.document.getElementById('password-option').hidden).toBe(true);
});
it('rejects malformed handoffs and a blocked session cookie', async () => {
for (const hash of ['#leadflow=not-json', '#leadflow=%7B%7D']) {
const dom = new JSDOM(html, { url: 'https://host.test/' + hash });
const fetch = vi.fn();
await expect(authenticate({ location: dom.window.location, history: dom.window.history, fetch })).rejects.toThrow();
expect(fetch).not.toHaveBeenCalled();
expect(dom.window.location.hash).toBe('');
}
const dom = new JSDOM(html, { url: url({ auth: 'valid' }) });
await expect(authenticate({ location: dom.window.location, history: dom.window.history, fetch: async () => reply({ signedIn: false }) })).rejects.toThrow(/cookies/);
});
it('keeps a separately configured password optional', async () => {
const dom = new JSDOM(html, { url: 'https://host.test/' });
dom.window.fetch = vi.fn(async () => reply({ signedIn: false, sso: true, hasPassword: true }));
await boot(dom.window);
expect(dom.window.document.getElementById('password-option').hidden).toBe(false);
expect(dom.window.document.getElementById('password-option').open).toBe(false);
});
it('returns a mobile login with only a one-time code, not a bearer key', async () => {
const device = { challenge: 'c'.repeat(43), state: 's'.repeat(43) };
const dom = new JSDOM(html, { url: url({ auth: 'valid', device }) });
const assign = vi.fn();
const win = { document: dom.window.document, history: dom.window.history, location: { hash: dom.window.location.hash, pathname: '/', search: '', origin: dom.window.location.origin, assign },
fetch: vi.fn(async path => reply(path.endsWith('/state') ? { signedIn: true } : path.endsWith('/device-code') ? { code: 'k'.repeat(43) } : {})) };
await boot(win);
expect(assign).toHaveBeenCalledTimes(1);
const callback = new URL(assign.mock.calls[0][0]);
expect(callback.protocol).toBe('ghostbrowser:');
expect(callback.searchParams.get('code')).toBe('k'.repeat(43));
expect(callback.searchParams.get('state')).toBe(device.state);
expect(callback.searchParams.has('token')).toBe(false);
});
it('on Android opens the app by name, so the system cannot offer Chrome for the way back', async () => {
const device = { challenge: 'c'.repeat(43), state: 's'.repeat(43) };
const dom = new JSDOM(html, { url: url({ auth: 'valid', device }) });
const assign = vi.fn();
const win = { document: dom.window.document, history: dom.window.history,
navigator: { userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 Chrome/130.0 Mobile Safari/537.36' },
location: { hash: dom.window.location.hash, pathname: '/', search: '', origin: dom.window.location.origin, assign },
fetch: vi.fn(async path => reply(path.endsWith('/state') ? { signedIn: true } : path.endsWith('/device-code') ? { code: 'k'.repeat(43) } : {})) };
await boot(win);
const link = assign.mock.calls[0][0];
expect(link).toMatch(/^intent:\/\/connect\?/);
expect(link).toMatch(/#Intent;scheme=ghostbrowser;package=engineer\.myapp\.gbmobile;end$/);
const query = new URLSearchParams(link.slice('intent://connect?'.length, link.indexOf('#')));
expect(query.get('code')).toBe('k'.repeat(43));
expect(query.get('state')).toBe(device.state);
expect(dom.window.document.getElementById('platform-sign-in').getAttribute('href')).toBe(link);
});
it('keeps the plain app link off Android', () => {
expect(appLink({ navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' } }, 'a=1')).toBe('ghostbrowser://connect?a=1');
});
});
describe('console build artifact preparation', () => {
it('updates both entry points even when the Wasm artifact was preserved from an older release', () => {
const root = mkdtempSync(join(tmpdir(), 'gb-loader-'));
try {
mkdirSync(join(root, 'console')); writeFileSync(join(root, 'console', 'gbweb.js'), 'old-wasm');
writeFileSync(join(root, 'console', 'index.html'), 'old-login'); writeFileSync(join(root, 'compose-index.html'), html);
expect(prepareConsole(root)).toBe(true);
expect(readFileSync(join(root, 'console', 'index.html'), 'utf8')).toBe(html);
expect(readFileSync(join(root, 'index.html'), 'utf8')).toBe(html);
expect(readFileSync(join(root, 'console', 'gbweb.js'), 'utf8')).toBe('old-wasm');
} finally { rmSync(root, { recursive: true, force: true }); }
});
});