mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
570 lines
18 KiB
TypeScript
570 lines
18 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
BROWSER_PASSWORDS_GUEST_CHANNELS,
|
|
BROWSER_PASSWORDS_TO_GUEST_CHANNELS
|
|
} from '../shared/browser-passwords-types'
|
|
import type {
|
|
BrowserPasswordFillRequest,
|
|
BrowserPasswordGuestEnable
|
|
} from '../shared/browser-passwords-types'
|
|
import { installBrowserPasswordDetector } from './browser-password-detector'
|
|
|
|
const CHANNEL = BROWSER_PASSWORDS_GUEST_CHANNELS
|
|
|
|
type Sent = { channel: string; payload: unknown }
|
|
|
|
let sent: Sent[]
|
|
let listeners: Map<string, (payload: unknown) => void>
|
|
let rects: WeakMap<Element, DOMRect>
|
|
let teardown: (() => void)[]
|
|
|
|
function install(): void {
|
|
installBrowserPasswordDetector({
|
|
send: (channel, payload) => sent.push({ channel, payload }),
|
|
on: (channel, listener) => listeners.set(channel, listener)
|
|
})
|
|
}
|
|
|
|
function enable(payload: BrowserPasswordGuestEnable = { reportPageForms: false }): void {
|
|
listeners.get(BROWSER_PASSWORDS_TO_GUEST_CHANNELS.enable)?.(payload)
|
|
}
|
|
|
|
function fill(request: BrowserPasswordFillRequest): unknown {
|
|
listeners.get(BROWSER_PASSWORDS_TO_GUEST_CHANNELS.fill)?.(request)
|
|
return sentOn(CHANNEL.fillResult).at(-1)
|
|
}
|
|
|
|
function sentOn(channel: string): unknown[] {
|
|
return sent.filter((message) => message.channel === channel).map((message) => message.payload)
|
|
}
|
|
|
|
function readString(payload: unknown, key: string): string {
|
|
const record: Record<string, unknown> =
|
|
typeof payload === 'object' && payload !== null ? { ...payload } : {}
|
|
const value = record[key]
|
|
if (typeof value !== 'string') {
|
|
throw new Error(`payload has no string ${key}`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function trusted<E extends Event>(event: E): E {
|
|
Object.defineProperty(event, 'isTrusted', { configurable: true, value: true })
|
|
return event
|
|
}
|
|
|
|
function el<T extends Element = HTMLInputElement>(selector: string): T {
|
|
const found = document.querySelector<T>(selector)
|
|
if (!found) {
|
|
throw new Error(`missing ${selector}`)
|
|
}
|
|
return found
|
|
}
|
|
|
|
/** Gives every input its own row, so the hit test can tell them apart. */
|
|
function layout(): void {
|
|
document.querySelectorAll('input, button').forEach((element, index) => {
|
|
if (!rects.has(element)) {
|
|
rects.set(element, new DOMRect(10, 10 + index * 40, 200, 30))
|
|
}
|
|
})
|
|
}
|
|
|
|
function load(body: string): void {
|
|
document.body.innerHTML = body
|
|
layout()
|
|
}
|
|
|
|
function typeInto(input: HTMLInputElement, value: string, isTrusted = true): void {
|
|
input.value = value
|
|
const event = new Event('input', { bubbles: true, composed: true })
|
|
input.dispatchEvent(isTrusted ? trusted(event) : event)
|
|
}
|
|
|
|
function focusIn(input: HTMLInputElement, isTrusted = true): void {
|
|
const event = new FocusEvent('focusin', { bubbles: true, composed: true })
|
|
input.dispatchEvent(isTrusted ? trusted(event) : event)
|
|
}
|
|
|
|
const LOGIN_FORM = `
|
|
<form id="login" action="/session">
|
|
<input id="email" name="email" type="email">
|
|
<input id="pw" name="password" type="password">
|
|
<button id="go" type="submit">Sign in</button>
|
|
</form>`
|
|
|
|
/** Stands in for layout: `hidden` or display:none on the element or an ancestor collapses its box. */
|
|
function isCollapsed(element: Element): boolean {
|
|
for (let node: Element | null = element; node; node = node.parentElement) {
|
|
if (
|
|
node.hasAttribute('hidden') ||
|
|
(node instanceof HTMLElement && node.style.display === 'none')
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** Why: the detector has no uninstall, and the document outlives each test. */
|
|
function recordDetectorAttachments(): void {
|
|
for (const target of [document, window]) {
|
|
const add = target.addEventListener.bind(target)
|
|
vi.spyOn(target, 'addEventListener').mockImplementation((type, listener, options) => {
|
|
teardown.push(() => target.removeEventListener(type, listener, options))
|
|
add(type, listener, options)
|
|
})
|
|
}
|
|
const observe = MutationObserver.prototype.observe
|
|
vi.spyOn(MutationObserver.prototype, 'observe').mockImplementation(function (
|
|
this: MutationObserver,
|
|
target,
|
|
options
|
|
) {
|
|
teardown.push(() => this.disconnect())
|
|
observe.call(this, target, options)
|
|
})
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
|
sent = []
|
|
listeners = new Map()
|
|
rects = new WeakMap()
|
|
teardown = []
|
|
recordDetectorAttachments()
|
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
|
return isCollapsed(this)
|
|
? new DOMRect(0, 0, 0, 0)
|
|
: (rects.get(this) ?? new DOMRect(0, 0, 100, 20))
|
|
})
|
|
document.elementFromPoint = (x: number, y: number) => {
|
|
const hits = Array.from(document.querySelectorAll('input')).filter((input) => {
|
|
const box = input.getBoundingClientRect()
|
|
return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom
|
|
})
|
|
return hits.at(-1) ?? document.body
|
|
}
|
|
// Why a form submit is stopped: happy-dom would otherwise try to navigate the test window.
|
|
document.addEventListener('submit', (event) => event.preventDefault())
|
|
vi.mocked(document.addEventListener).mockClear()
|
|
})
|
|
|
|
afterEach(() => {
|
|
for (const undo of teardown) {
|
|
undo()
|
|
}
|
|
vi.useRealTimers()
|
|
vi.restoreAllMocks()
|
|
vi.unstubAllGlobals()
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('browser password detector', () => {
|
|
it('installs only its main listeners until main enables the document', async () => {
|
|
load(LOGIN_FORM)
|
|
const addEventListener = vi.mocked(document.addEventListener)
|
|
const observe = vi.mocked(MutationObserver.prototype.observe)
|
|
|
|
install()
|
|
typeInto(el('#pw'), 'secret')
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
|
|
expect([...listeners.keys()].sort()).toEqual(
|
|
Object.values(BROWSER_PASSWORDS_TO_GUEST_CHANNELS).sort()
|
|
)
|
|
expect(addEventListener).not.toHaveBeenCalled()
|
|
expect(observe).not.toHaveBeenCalled()
|
|
expect(sent).toEqual([])
|
|
|
|
enable()
|
|
expect(observe).toHaveBeenCalled()
|
|
})
|
|
|
|
it('reports no page forms unless main waits on a login that navigated here', async () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable({ reportPageForms: false })
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
document.body.insertAdjacentHTML('beforeend', '<p>unrelated</p>')
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
|
|
expect(sentOn(CHANNEL.pageForms)).toEqual([])
|
|
})
|
|
|
|
it('reports the page forms once after enable and again only when they change', async () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable({ reportPageForms: true })
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
|
|
expect(sentOn(CHANNEL.pageForms)).toEqual([
|
|
{
|
|
forms: [
|
|
{
|
|
formSignature: expect.any(String),
|
|
kind: 'login',
|
|
usernameFieldName: 'email',
|
|
passwordFieldName: 'password',
|
|
action: new URL('/session', window.location.href).toString()
|
|
}
|
|
]
|
|
}
|
|
])
|
|
|
|
document.body.insertAdjacentHTML('beforeend', '<p>unrelated</p>')
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
expect(sentOn(CHANNEL.pageForms)).toHaveLength(1)
|
|
|
|
document.body.insertAdjacentHTML(
|
|
'beforeend',
|
|
'<form id="signup"><input name="new_email"><input type="password" autocomplete="new-password"></form>'
|
|
)
|
|
layout()
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
expect(sentOn(CHANNEL.pageForms).at(-1)).toEqual({
|
|
forms: [
|
|
expect.objectContaining({ kind: 'login' }),
|
|
expect.objectContaining({ kind: 'signup' })
|
|
]
|
|
})
|
|
})
|
|
|
|
it('reports trusted typing after the debounce, with only typed values', async () => {
|
|
load(LOGIN_FORM)
|
|
el('#email').value = 'prefilled@example.com'
|
|
install()
|
|
enable()
|
|
|
|
typeInto(el('#pw'), 'se')
|
|
typeInto(el('#pw'), 'secret')
|
|
await vi.advanceTimersByTimeAsync(299)
|
|
expect(sentOn(CHANNEL.typed)).toEqual([])
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
|
|
expect(sentOn(CHANNEL.typed)).toEqual([
|
|
expect.objectContaining({
|
|
kind: 'login',
|
|
username: null,
|
|
password: 'secret',
|
|
newPassword: null
|
|
})
|
|
])
|
|
})
|
|
|
|
it('ignores typing, submits, clicks and focus the page scripted itself', async () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
|
|
typeInto(el('#email'), 'alice@example.com', false)
|
|
typeInto(el('#pw'), 'secret', false)
|
|
focusIn(el('#pw'), false)
|
|
el('#go').dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }))
|
|
el<HTMLFormElement>('#login').dispatchEvent(
|
|
new Event('submit', { bubbles: true, cancelable: true })
|
|
)
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
|
|
expect(sentOn(CHANNEL.typed)).toEqual([])
|
|
expect(sentOn(CHANNEL.submitted)).toEqual([])
|
|
expect(sentOn(CHANNEL.fieldFocused)).toEqual([])
|
|
})
|
|
|
|
it('sends one submitted per gesture, then formGone once the form is removed', async () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
typeInto(el('#email'), 'alice@example.com')
|
|
typeInto(el('#pw'), 'secret')
|
|
|
|
el('#go').dispatchEvent(trusted(new MouseEvent('click', { bubbles: true, composed: true })))
|
|
el<HTMLFormElement>('#login').dispatchEvent(
|
|
trusted(new Event('submit', { bubbles: true, cancelable: true }))
|
|
)
|
|
|
|
expect(sentOn(CHANNEL.submitted)).toEqual([
|
|
expect.objectContaining({
|
|
kind: 'login',
|
|
username: 'alice@example.com',
|
|
password: 'secret',
|
|
source: 'button'
|
|
})
|
|
])
|
|
const formSignature = readString(sentOn(CHANNEL.submitted)[0], 'formSignature')
|
|
|
|
el('#login').remove()
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
document.body.insertAdjacentHTML('beforeend', '<p>welcome</p>')
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
|
|
expect(sentOn(CHANNEL.formGone)).toEqual([{ formSignature }])
|
|
})
|
|
|
|
it('reports Enter in a credential field and formGone when its fields are hidden', async () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
typeInto(el('#pw'), 'secret')
|
|
|
|
el('#pw').dispatchEvent(trusted(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
|
|
|
|
expect(sentOn(CHANNEL.submitted)).toEqual([
|
|
expect.objectContaining({ password: 'secret', source: 'enter' })
|
|
])
|
|
expect(sentOn(CHANNEL.typed)).toHaveLength(1)
|
|
|
|
el('#login').setAttribute('hidden', '')
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
expect(sentOn(CHANNEL.formGone)).toHaveLength(1)
|
|
})
|
|
|
|
it('does not treat fields disabled during the login request as a form that went away', async () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
typeInto(el('#pw'), 'secret')
|
|
el('#pw').dispatchEvent(trusted(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
|
|
|
|
el('#email').disabled = true
|
|
el('#pw').disabled = true
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
expect(sentOn(CHANNEL.formGone)).toEqual([])
|
|
})
|
|
|
|
it('ignores a click on a button that does not submit credentials', () => {
|
|
load(
|
|
`${LOGIN_FORM.replace('</form>', '<button id="forgot" type="button">Forgot password?</button></form>')}`
|
|
)
|
|
install()
|
|
enable()
|
|
typeInto(el('#pw'), 'secret')
|
|
|
|
el('#forgot').dispatchEvent(trusted(new MouseEvent('click', { bubbles: true, composed: true })))
|
|
|
|
expect(sentOn(CHANNEL.submitted)).toEqual([])
|
|
})
|
|
|
|
it('keeps viewport invalidation alive after a picker focus transfer', async () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
focusIn(el('#pw'))
|
|
const fieldToken = readString(sentOn(CHANNEL.fieldFocused).at(-1), 'fieldToken')
|
|
el('#pw').dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }))
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
window.dispatchEvent(new Event('resize'))
|
|
expect(sentOn(CHANNEL.fieldFocused)).toHaveLength(1)
|
|
expect(sentOn(CHANNEL.fieldBlurred)).toEqual([
|
|
{ fieldToken, reason: 'focusout' },
|
|
{ fieldToken }
|
|
])
|
|
})
|
|
|
|
it('detects and captures credentials when HTTP crypto has no randomUUID', async () => {
|
|
vi.stubGlobal('crypto', { getRandomValues: crypto.getRandomValues.bind(crypto) })
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
focusIn(el('#pw'))
|
|
typeInto(el('#pw'), 'http-password')
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
expect(sentOn(CHANNEL.fieldFocused)).toEqual([expect.objectContaining({ role: 'password' })])
|
|
expect(sentOn(CHANNEL.typed)).toEqual([expect.objectContaining({ password: 'http-password' })])
|
|
})
|
|
|
|
it('discovers late nested shadow roots on interaction and observes later type changes', async () => {
|
|
load('<div id="host"></div>')
|
|
install()
|
|
enable()
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
const shadow = el<HTMLDivElement>('#host').attachShadow({ mode: 'open' })
|
|
shadow.innerHTML = '<div id="nested"></div>'
|
|
const nestedHost = shadow.querySelector('#nested')
|
|
if (!(nestedHost instanceof HTMLElement)) {
|
|
throw new Error('missing nested host')
|
|
}
|
|
const nested = nestedHost.attachShadow({ mode: 'open' })
|
|
nested.innerHTML = '<form><input name="user"><input type="password" name="password"></form>'
|
|
const pw = nested.querySelector('input[type="password"]')
|
|
if (!(pw instanceof HTMLInputElement)) {
|
|
throw new Error('missing password')
|
|
}
|
|
focusIn(pw)
|
|
typeInto(pw, 'shadow-password')
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
expect(sentOn(CHANNEL.fieldFocused)).toEqual([expect.objectContaining({ role: 'password' })])
|
|
expect(sentOn(CHANNEL.typed)).toEqual([
|
|
expect.objectContaining({ password: 'shadow-password' })
|
|
])
|
|
pw.type = 'text'
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
focusIn(pw)
|
|
expect(sentOn(CHANNEL.fieldFocused)).toHaveLength(2)
|
|
})
|
|
|
|
describe('fill', () => {
|
|
function focusedToken(input: HTMLInputElement): string {
|
|
focusIn(input)
|
|
return readString(sentOn(CHANNEL.fieldFocused).at(-1), 'fieldToken')
|
|
}
|
|
|
|
it('writes username and password so a controlled input sees them, and submits them', () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
const seen: string[] = []
|
|
document.addEventListener('input', (event) => {
|
|
if (event.target instanceof HTMLInputElement) {
|
|
seen.push(`${event.target.name}=${event.target.value}`)
|
|
}
|
|
})
|
|
const changes = vi.fn()
|
|
el('#pw').addEventListener('change', changes)
|
|
|
|
const result = fill({
|
|
requestId: 'r1',
|
|
fieldToken: focusedToken(el('#pw')),
|
|
username: 'alice@example.com',
|
|
password: 'secret'
|
|
})
|
|
|
|
expect(result).toEqual({ requestId: 'r1', ok: true })
|
|
expect(el('#email').value).toBe('alice@example.com')
|
|
expect(el('#pw').value).toBe('secret')
|
|
expect(seen).toEqual(['email=alice@example.com', 'password=secret'])
|
|
expect(changes).toHaveBeenCalledOnce()
|
|
expect(document.activeElement).toBe(el('#pw'))
|
|
|
|
el('#go').dispatchEvent(trusted(new MouseEvent('click', { bubbles: true, composed: true })))
|
|
expect(sentOn(CHANNEL.submitted)).toEqual([
|
|
expect.objectContaining({ username: 'alice@example.com', password: 'secret' })
|
|
])
|
|
})
|
|
|
|
it('fills the picked account over a different username already in the field', () => {
|
|
load(LOGIN_FORM)
|
|
el('#email').value = 'carol@example.com'
|
|
install()
|
|
enable()
|
|
|
|
fill({ requestId: 'r', fieldToken: focusedToken(el('#pw')), username: 'bob', password: 'pw' })
|
|
|
|
expect(el('#email').value).toBe('bob')
|
|
expect(el('#pw').value).toBe('pw')
|
|
})
|
|
|
|
it('refuses rather than pair a password with a different username it cannot change', () => {
|
|
load(LOGIN_FORM)
|
|
el('#email').value = 'carol@example.com'
|
|
el('#email').readOnly = true
|
|
install()
|
|
enable()
|
|
|
|
const result = fill({
|
|
requestId: 'r',
|
|
fieldToken: focusedToken(el('#pw')),
|
|
username: 'bob',
|
|
password: 'pw'
|
|
})
|
|
|
|
expect(result).toEqual({ requestId: 'r', ok: false, reason: 'username-mismatch' })
|
|
expect(el('#pw').value).toBe('')
|
|
})
|
|
|
|
it('rechecks the password box after page code reacts to the username', () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
const fieldToken = focusedToken(el('#pw'))
|
|
el('#email').addEventListener('input', () => el('#pw').setAttribute('hidden', ''))
|
|
|
|
const result = fill({ requestId: 'r', fieldToken, username: 'bob', password: 'pw' })
|
|
|
|
expect(result).toEqual({ requestId: 'r', ok: false, reason: 'not-visible' })
|
|
expect(el('#pw').value).toBe('')
|
|
})
|
|
|
|
it.each([
|
|
['hidden', (pw: HTMLInputElement) => pw.setAttribute('hidden', ''), 'not-visible'],
|
|
[
|
|
'too small',
|
|
(pw: HTMLInputElement) => rects.set(pw, new DOMRect(10, 50, 4, 4)),
|
|
'not-visible'
|
|
],
|
|
['transparent', () => (el<HTMLFormElement>('#login').style.opacity = '0.05'), 'not-visible'],
|
|
[
|
|
'visibility hidden',
|
|
(pw: HTMLInputElement) => (pw.style.visibility = 'hidden'),
|
|
'not-visible'
|
|
],
|
|
[
|
|
'covered by an overlay',
|
|
() => (document.elementFromPoint = () => document.body),
|
|
'not-visible'
|
|
],
|
|
['readonly', (pw: HTMLInputElement) => (pw.readOnly = true), 'not-editable'],
|
|
['removed', (pw: HTMLInputElement) => pw.remove(), 'field-gone']
|
|
])('refuses a password field that is %s', (_name, mutate, reason) => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
const fieldToken = focusedToken(el('#pw'))
|
|
const pw = el('#pw')
|
|
mutate(pw)
|
|
|
|
const result = fill({ requestId: 'r', fieldToken, username: 'bob', password: 'pw' })
|
|
|
|
expect(result).toEqual({ requestId: 'r', ok: false, reason })
|
|
expect(pw.value).toBe('')
|
|
expect(el('#email').value).toBe('')
|
|
})
|
|
|
|
it('refuses a token it never issued', () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
|
|
expect(
|
|
fill({ requestId: 'r', fieldToken: 'forged', username: null, password: 'pw' })
|
|
).toEqual({
|
|
requestId: 'r',
|
|
ok: false,
|
|
reason: 'field-gone'
|
|
})
|
|
})
|
|
|
|
it('fills only the username on a username-first step', () => {
|
|
load(
|
|
'<form><input id="user" name="identifier" autocomplete="username"><button>Next</button></form>'
|
|
)
|
|
install()
|
|
enable()
|
|
|
|
const result = fill({
|
|
requestId: 'r',
|
|
fieldToken: focusedToken(el('#user')),
|
|
username: 'dave',
|
|
password: 'pw'
|
|
})
|
|
|
|
expect(result).toEqual({ requestId: 'r', ok: true })
|
|
expect(el('#user').value).toBe('dave')
|
|
})
|
|
|
|
it('does not report its own focus moves as user focus', () => {
|
|
load(LOGIN_FORM)
|
|
install()
|
|
enable()
|
|
const fieldToken = focusedToken(el('#email'))
|
|
const focusedBefore = sentOn(CHANNEL.fieldFocused).length
|
|
|
|
fill({ requestId: 'r', fieldToken, username: 'bob', password: 'pw' })
|
|
|
|
expect(sentOn(CHANNEL.fieldFocused)).toHaveLength(focusedBefore)
|
|
expect(sentOn(CHANNEL.fieldBlurred)).toEqual([{ fieldToken }])
|
|
})
|
|
})
|
|
})
|