diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..a0c0010 --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { readFileSync } from 'node:fs'; + +const { distill, toMarkdown } = await import('../src/distill.js'); +const { authFailure } = await import('../src/auth.js'); +const { fetchPage } = await import('../src/fetch.js'); + +const loginHtml = readFileSync(new URL('./pages/login.html', import.meta.url), 'utf8'); + +const navWithLoginLink = `News + +
${'

Real story content here.

'.repeat(20)}
+`; + +const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']; + +function listen(server) { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +function withoutProxyEnv(run) { + const prev = Object.fromEntries(PROXY_ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of PROXY_ENV_KEYS) delete process.env[k]; + return run().finally(() => { + for (const k of PROXY_ENV_KEYS) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + }); +} + +test('authFailure detects a login page with password input and supporting signals', () => { + const page = distill(loginHtml, 'https://example.com/login'); + assert.match(authFailure(page, 'https://example.com/login'), /requires login/); +}); + +test('authFailure reports expired session when auth was sent', () => { + const page = distill(loginHtml, 'https://example.com/login'); + assert.match( + authFailure(page, 'https://example.com/login', { hadAuth: true }), + /session expired or cookies are no longer valid/, + ); +}); + +test('authFailure ignores nav login links without a password field', () => { + const page = distill(navWithLoginLink, 'https://example.com/news'); + assert.equal(authFailure(page, 'https://example.com/news'), null); +}); + +test('authFailure ignores a password field without login context', () => { + const html = `Account settings +

Change your password below.

+ + + `; + const page = distill(html, 'https://example.com/settings'); + assert.equal(authFailure(page, 'https://example.com/settings'), null); +}); + +test('fetch through a proxy detects a login page end to end (offline)', async () => { + await withoutProxyEnv(async () => { + const proxy = http.createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(loginHtml); + }); + const port = await listen(proxy); + process.env.HTTP_PROXY = `http://127.0.0.1:${port}`; + try { + const { html, url } = await fetchPage('http://1.1.1.1/login'); + const page = distill(html, url); + assert.match(authFailure(page, url), /requires login/); + assert.match(authFailure(page, url, { hadAuth: true }), /session expired or cookies are no longer valid/); + } finally { + proxy.close(); + } + }); +}); + +test('auth failure gates raw output before markdown is emitted', async () => { + await withoutProxyEnv(async () => { + const proxy = http.createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(loginHtml); + }); + const port = await listen(proxy); + process.env.HTTP_PROXY = `http://127.0.0.1:${port}`; + try { + const { html, url } = await fetchPage('http://1.1.1.1/login'); + const page = distill(html, url); + assert.ok(authFailure(page, url)); + assert.match(toMarkdown(html, url), /Sign in/); + } finally { + proxy.close(); + } + }); +}); diff --git a/tests/cli-auth.test.js b/tests/cli-auth.test.js new file mode 100644 index 0000000..abb14aa --- /dev/null +++ b/tests/cli-auth.test.js @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; + +const OC_HOME = mkdtempSync(join(tmpdir(), 'oc-cli-auth-')); +process.env.OC_HOME = OC_HOME; + +const bin = new URL('../src/cli.js', import.meta.url).pathname; +const loginHtml = readFileSync(new URL('./pages/login.html', import.meta.url), 'utf8'); +const dashHtml = `Dashboard +

Welcome back

+ ${'

Secret project notes for the signed-in user.

'.repeat(20)} +`; + +const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']; + +function childEnv(envExtra = {}) { + const env = { ...process.env, OC_HOME, ...envExtra }; + for (const k of PROXY_ENV_KEYS) { + if (!(k in envExtra)) delete env[k]; + } + return env; +} + +// Sync run for cases that never touch the network (login, logout, expired jar). +function oc(args, envExtra = {}) { + return spawnSync(process.execPath, [bin, ...args], { encoding: 'utf8', env: childEnv(envExtra) }); +} + +// Async run for cases that fetch through an in-process mock proxy: spawnSync +// would block the event loop the proxy server runs on and deadlock the test. +function ocAsync(args, envExtra = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [bin, ...args], { env: childEnv(envExtra) }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + child.on('close', (status) => resolve({ status, stdout, stderr })); + }); +} + +function listen(server) { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +test('login saves a sidecar jar and logout removes it', () => { + let r = oc(['login', '--cookie', 'sid=abc', '--domain', 'example.com', '--session', 'work']); + assert.equal(r.status, 0, r.stderr); + const jarPath = join(OC_HOME, 'sessions', 'work.cookies.json'); + const saved = JSON.parse(readFileSync(jarPath, 'utf8')); + assert.equal(saved.cookies[0].value, 'abc'); + + r = oc(['logout', 'work']); + assert.equal(r.status, 0, r.stderr); + assert.throws(() => readFileSync(jarPath), /ENOENT/); +}); + +test('open with an expired jar reports session expired and clears it', () => { + const sessionsDir = join(OC_HOME, 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + const jarPath = join(sessionsDir, 'expired.cookies.json'); + writeFileSync(jarPath, JSON.stringify({ + expiresAt: new Date(Date.now() - 1000).toISOString(), + cookies: [{ name: 'sid', value: 'old', domain: 'example.com', path: '/' }], + })); + const r = oc(['open', 'example.com', '--session', 'expired']); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /session expired or cookies are no longer valid/); + assert.equal(r.stdout.trim(), ''); + assert.throws(() => readFileSync(jarPath), /ENOENT/); +}); + +test('login requires --domain', () => { + const r = oc(['login', '--cookie', 'sid=abc']); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /--domain is required/); +}); + +test('a session name that is a path is refused before any file is written', () => { + for (const bad of ['../../.ssh/id_rsa', '/tmp/leak', 'a/b']) { + const r = oc(['login', '--cookie', 'sid=abc', '--domain', 'example.com', '--session', bad]); + assert.notEqual(r.status, 0, `expected failure for ${bad}`); + assert.match(r.stderr, /invalid session name/); + } +}); + +test('open sends the jar cookies and renders authenticated content', async () => { + const proxy = http.createServer((req, res) => { + const cookie = req.headers.cookie || ''; + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(cookie.includes('sid=secret') ? dashHtml : loginHtml); + }); + const port = await listen(proxy); + const proxyUrl = `http://127.0.0.1:${port}`; + try { + let r = oc(['login', '--cookie', 'sid=secret', '--domain', '1.1.1.1', '--session', 'authed']); + assert.equal(r.status, 0, r.stderr); + + r = await ocAsync(['open', 'http://1.1.1.1/dashboard', '--session', 'authed'], { HTTP_PROXY: proxyUrl }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /Welcome back/); + assert.doesNotMatch(r.stderr, /requires login|session expired/); + } finally { + proxy.close(); + } +}); + +test('open without cookies detects a login page and fails loud', async () => { + const proxy = http.createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(loginHtml); + }); + const port = await listen(proxy); + try { + const r = await ocAsync(['open', 'http://1.1.1.1/login', '--session', 'anon'], { + HTTP_PROXY: `http://127.0.0.1:${port}`, + }); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /requires login/); + assert.equal(r.stdout.trim(), ''); + } finally { + proxy.close(); + } +}); + +test('json auth failure does not overwrite saved page state', async () => { + const sessionsDir = join(OC_HOME, 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + const sessionPath = join(sessionsDir, 'keep.json'); + writeFileSync(sessionPath, JSON.stringify({ + url: 'http://1.1.1.1/dashboard', + title: 'Dashboard', + savedAt: new Date().toISOString(), + blocks: [{ type: 'heading', text: 'Welcome back', n: 1, level: 1 }], + cursor: null, + history: ['http://1.1.1.1/dashboard'], + })); + + const proxy = http.createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(loginHtml); + }); + const port = await listen(proxy); + try { + const r = await ocAsync(['open', 'http://1.1.1.1/login', '--json', '--session', 'keep'], { + HTTP_PROXY: `http://127.0.0.1:${port}`, + }); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /requires login/); + const saved = JSON.parse(readFileSync(sessionPath, 'utf8')); + assert.equal(saved.title, 'Dashboard'); + assert.ok(existsSync(sessionPath)); + } finally { + proxy.close(); + } +}); diff --git a/tests/cookies.test.js b/tests/cookies.test.js new file mode 100644 index 0000000..3680240 --- /dev/null +++ b/tests/cookies.test.js @@ -0,0 +1,138 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, writeFileSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +process.env.OC_HOME = mkdtempSync(join(tmpdir(), 'oc-cookie-test-')); + +const { + jarFromCookieHeader, + parseExpires, + cookieHeaderFor, + parseSetCookie, + storeFromResponse, + saveCookieJar, + loadCookieJar, + clearCookieJar, + purgeExpiredJars, + isSessionExpired, + cookieJarPath, + JAR_EXPIRED, + _resetPurgeGuard, +} = await import('../src/cookies.js'); + +test('parseExpires accepts common durations', () => { + assert.equal(parseExpires('1h'), 3_600_000); + assert.equal(parseExpires('30m'), 1_800_000); + assert.equal(parseExpires('2d'), 172_800_000); +}); + +test('jarFromCookieHeader parses a Cookie header for a domain', () => { + const jar = jarFromCookieHeader('session=abc; auth=xyz', 'Example.COM'); + assert.equal(jar.cookies.length, 2); + assert.equal(jar.cookies[0].name, 'session'); + assert.equal(jar.cookies[0].value, 'abc'); + assert.equal(jar.cookies[0].domain, 'example.com'); + assert.ok(Date.parse(jar.expiresAt) > Date.now()); +}); + +test('cookieHeaderFor matches domain and path', () => { + const jar = { + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + cookies: [ + { name: 'a', value: '1', domain: 'example.com', path: '/' }, + { name: 'b', value: '2', domain: 'other.com', path: '/' }, + { name: 'c', value: '3', domain: 'example.com', path: '/app', secure: true }, + ], + }; + assert.equal(cookieHeaderFor(jar, 'https://example.com/app/home'), 'a=1; c=3'); + assert.equal(cookieHeaderFor(jar, 'http://example.com/app/home'), 'a=1'); + assert.equal(cookieHeaderFor(jar, 'https://other.com/'), 'b=2'); + assert.equal(cookieHeaderFor(jar, 'https://example.com/other'), 'a=1'); +}); + +test('parseSetCookie reads attributes and pins the cookie host-only', () => { + const c = parseSetCookie('sid=val; Path=/app; Domain=.example.com; Secure; HttpOnly; Max-Age=3600', + 'https://www.example.com/login'); + assert.equal(c.name, 'sid'); + assert.equal(c.value, 'val'); + // Domain is ignored: the cookie is scoped to the host that set it, not the + // wider domain the response asked for. + assert.equal(c.domain, 'www.example.com'); + assert.equal(c.path, '/app'); + assert.ok(c.secure); + assert.ok(c.httpOnly); + assert.ok(c.expires); +}); + +test('a response cannot widen a cookie to a public suffix and reach other sites', () => { + const jar = { expiresAt: new Date(Date.now() + 3_600_000).toISOString(), cookies: [] }; + // A page fetched under the jar tries to plant a '.com'-scoped cookie. + const next = storeFromResponse(jar, 'https://evil.example/', ['sid=x; Domain=.com; Path=/']); + assert.equal(next.cookies[0].domain, 'evil.example'); + // It is never sent to an unrelated site that merely shares the suffix. + assert.equal(cookieHeaderFor(next, 'https://bank.com/'), undefined); + assert.equal(cookieHeaderFor(next, 'https://evil.example/'), 'sid=x'); +}); + +test('storeFromResponse replaces cookies with the same name and domain', () => { + const jar = { + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + cookies: [{ name: 'sid', value: 'old', domain: 'example.com', path: '/' }], + }; + const next = storeFromResponse(jar, 'https://example.com/', ['sid=new; Path=/; Domain=example.com']); + assert.equal(next.cookies.length, 1); + assert.equal(next.cookies[0].value, 'new'); +}); + +test('session ceiling caps per-cookie expiry from Set-Cookie', () => { + const ceiling = new Date(Date.now() + 3_600_000).toISOString(); + const jar = { expiresAt: ceiling, cookies: [] }; + const next = storeFromResponse(jar, 'https://example.com/', [ + 'sid=x; Max-Age=86400; Domain=example.com; Path=/', + ]); + assert.equal(next.cookies[0].expires, ceiling); +}); + +test('saveCookieJar writes with mode 0600 and loadCookieJar reads back', () => { + clearCookieJar('work'); + const jar = jarFromCookieHeader('token=secret', 'example.com', { expiresMs: 3_600_000 }); + saveCookieJar('work', jar); + const mode = statSync(cookieJarPath('work')).mode & 0o777; + assert.equal(mode, 0o600); + const loaded = loadCookieJar('work'); + assert.equal(loaded.cookies[0].value, 'secret'); +}); + +test('loadCookieJar returns JAR_EXPIRED and clears an expired jar', () => { + clearCookieJar('expired'); + saveCookieJar('expired', { + expiresAt: new Date(Date.now() - 1000).toISOString(), + cookies: [{ name: 'a', value: 'b', domain: 'example.com', path: '/' }], + }); + _resetPurgeGuard(); + assert.equal(loadCookieJar('expired'), JAR_EXPIRED); + assert.throws(() => readFileSync(cookieJarPath('expired')), /ENOENT/); +}); + +test('purgeExpiredJars removes stale sidecar files', () => { + clearCookieJar('old'); + clearCookieJar('fresh'); + writeFileSync(cookieJarPath('old'), JSON.stringify({ + expiresAt: new Date(Date.now() - 1000).toISOString(), + cookies: [{ name: 'a', value: 'b', domain: 'example.com', path: '/' }], + })); + saveCookieJar('fresh', jarFromCookieHeader('x=1', 'example.com')); + _resetPurgeGuard(); + purgeExpiredJars(); + assert.throws(() => readFileSync(cookieJarPath('old')), /ENOENT/); + assert.ok(loadCookieJar('fresh')); +}); + +test('isSessionExpired respects the session ceiling', () => { + const jar = { expiresAt: new Date(Date.now() + 1000).toISOString(), cookies: [] }; + assert.ok(!isSessionExpired(jar)); + jar.expiresAt = new Date(Date.now() - 1000).toISOString(); + assert.ok(isSessionExpired(jar)); +}); diff --git a/tests/distill.test.js b/tests/distill.test.js index d8f99df..d2e3d21 100644 --- a/tests/distill.test.js +++ b/tests/distill.test.js @@ -519,8 +519,10 @@ test('a link-list page counts as content even with no prose on it', () => { test('every fixture page reads as content, none as a failed render', () => { // Feeds, a JSON API, and a YouTube watch page are all thin by design, which - // is exactly where this check must not cry wolf. + // is exactly where this check must not cry wolf. login.html is an auth-gate + // fixture, not a page that should distill as content. for (const name of readdirSync(PAGES)) { + if (name === 'login.html') continue; const raw = readFileSync(PAGES + name, 'utf8'); const page = distill(raw, `https://api.example.test/2.3/search/advanced?site=fixture&f=${name}`); assert.equal(contentFailure(contentTokens(page), estimateTokens(raw)), null, name); diff --git a/tests/fetch.test.js b/tests/fetch.test.js index ae5584c..4f4ad71 100644 --- a/tests/fetch.test.js +++ b/tests/fetch.test.js @@ -498,6 +498,65 @@ test('proxyGet returns the origin body through an HTTPS CONNECT tunnel', async ( } }); +// A separate cert whose SAN covers the IPv6 loopback ::1, so the tunneled +// TLS handshake to an IPv6 literal can be validated offline. +const LOCAL_CERT_V6 = `-----BEGIN CERTIFICATE----- +MIIBsjCCAVigAwIBAgIUYhesMP2mQCQ6S4SrcGJshDK0g9owCgYIKoZIzj0EAwIw +FzEVMBMGA1UEAwwMb2MtaXB2Ni10ZXN0MB4XDTI2MDgyNDE4NTM1MVoXDTM2MDgy +MTE4NTM1MVowFzEVMBMGA1UEAwwMb2MtaXB2Ni10ZXN0MFkwEwYHKoZIzj0CAQYI +KoZIzj0DAQcDQgAE26JWljo6HQCqheYsEL/xViNZpq+6NPKBlEjlvXf/WtJa2mAl +qELRtfWYJeRS+0ogeMNjYXTYME2WKHL3il88cqOBgTB/MB0GA1UdDgQWBBSx4h35 +MCnlyDhcx7ATZiWyJ/IYEzAfBgNVHSMEGDAWgBSx4h35MCnlyDhcx7ATZiWyJ/IY +EzAPBgNVHRMBAf8EBTADAQH/MCwGA1UdEQQlMCOHEAAAAAAAAAAAAAAAAAAAAAGH +BH8AAAGCCWxvY2FsaG9zdDAKBggqhkjOPQQDAgNIADBFAiAFJGgQcNAAXI5HWj02 +NYBPF1nTo3BfOoT/PY5pUsSjuAIhAP9oPp1R2+ckC9sXTOL8n1vw2qVElGDLuvES +Hi24p3Qi +-----END CERTIFICATE-----`; + +const LOCAL_KEY_V6 = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgjZ74TmqrsdfKIelm +KQFHEBF+5zD8lk8lDLuPgvz2dNGhRANCAATbolaWOjodAKqF5iwQv/FWI1mmr7o0 +8oGUSOW9d/9a0lraYCWoQtG19Zgl5FL7SiB4w2NhdNgwTZYocveKXzxy +-----END PRIVATE KEY-----`; + +test('an IPv6 literal target tunnels through a proxy with its brackets stripped', async () => { + // URL.hostname keeps the brackets ("[::1]"); before the fix they reached + // tls.connect as a DNS name and the handshake never happened. + const origin = https.createServer({ cert: LOCAL_CERT_V6, key: LOCAL_KEY_V6 }, (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('v6 tunnel'); + }); + await new Promise((r) => origin.listen(0, '::1', r)); + const originPort = origin.address().port; + + const proxy = http.createServer(); + proxy.on('connect', (req, socket) => { + const host = req.url.replace(/:\d+$/, '').replace(/^\[|\]$/g, ''); + const port = Number(req.url.slice(req.url.lastIndexOf(':') + 1)); + const dest = net.connect(port, host, () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + dest.pipe(socket); + socket.pipe(dest); + }); + dest.on('error', () => socket.destroy()); + }); + const proxyPort = await listen(proxy); + + try { + const res = await proxyGet( + `https://[::1]:${originPort}/page`, + `http://127.0.0.1:${proxyPort}`, + { 'user-agent': 'oc-test' }, + { ca: LOCAL_CERT_V6 }, + ); + assert.equal(res.status, 200); + assert.equal(await res.text(), 'v6 tunnel'); + } finally { + origin.close(); + proxy.close(); + } +}); + test('followRedirects still blocks a private hop when the transport is a proxy', async () => { // The page 302s to loopback. The proxy is also loopback, which is allowed; // the hop is not. Blocked before the second request goes out. @@ -530,3 +589,77 @@ test('proxyGet refuses a non-HTTP proxy scheme', () => { /unsupported proxy protocol \(socks5\)/, ); }); + +test('followRedirects sends jar cookies on every hop and stores Set-Cookie', () => withoutProxyEnv(async () => { + const { jarFromCookieHeader, createJarHandle } = await import('../src/cookies.js'); + const seed = jarFromCookieHeader('sid=abc', 'public.example'); + const jar = createJarHandle('test', seed); + const seen = []; + const get = (url) => { + seen.push({ url, cookie: jar.cookieHeaderFor(url) }); + const setCookie = url.includes('/two') + ? ['fresh=1; Path=/; Domain=public.example'] + : []; + return Promise.resolve({ + status: url.includes('/two') ? 200 : 302, + headers: { + get(name) { + if (name === 'location' && !url.includes('/two')) return 'https://public.example/two'; + if (name === 'set-cookie') return setCookie.join(', '); + return null; + }, + getSetCookie() { return setCookie; }, + }, + }); + }; + const { getSetCookieHeaders } = await import('../src/cookies.js'); + await followRedirects(get, 'https://public.example/one', { + onResponse: (url, res) => jar.storeFromResponse(url, getSetCookieHeaders(res)), + }); + assert.equal(seen.length, 2); + assert.equal(seen[0].cookie, 'sid=abc'); + assert.equal(seen[1].cookie, 'sid=abc'); + assert.ok(jar.toJSON().cookies.some((c) => c.name === 'fresh')); +})); + +test('proxyGet forwards a cookie header from the jar', async () => { + const seen = []; + const proxy = http.createServer((req, res) => { + seen.push({ cookie: req.headers.cookie }); + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('ok'); + }); + const port = await listen(proxy); + try { + await proxyGet('http://example.test/page', `http://127.0.0.1:${port}`, { cookie: 'a=1; b=2' }); + assert.equal(seen[0].cookie, 'a=1; b=2'); + } finally { + proxy.close(); + } +}); + +test('a proxied response exposes each Set-Cookie intact, even with a comma in Expires', async () => { + const { getSetCookieHeaders } = await import('../src/cookies.js'); + const proxy = http.createServer((req, res) => { + // Two separate Set-Cookie headers, one carrying a comma inside Expires: + // joining them into one string would make them impossible to split back. + res.writeHead(200, { + 'content-type': 'text/html', + 'set-cookie': [ + 'sid=abc; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT', + 'theme=dark; Path=/', + ], + }); + res.end('ok'); + }); + const port = await listen(proxy); + try { + const res = await proxyGet('http://example.test/page', `http://127.0.0.1:${port}`); + const headers = getSetCookieHeaders(res); + assert.equal(headers.length, 2); + assert.match(headers[0], /^sid=abc;/); + assert.match(headers[1], /^theme=dark;/); + } finally { + proxy.close(); + } +}); diff --git a/tests/pages/login.html b/tests/pages/login.html new file mode 100644 index 0000000..158b43c --- /dev/null +++ b/tests/pages/login.html @@ -0,0 +1,7 @@ +Sign in +
+ + + +
+