OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / test / page-context-bridge.test.js

188 lines · javascript ref:
import test from 'node:test'
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import { Script } from 'node:vm'

import bridge from '../backend/page-context-bridge.cjs'

const {
  PAGE_CONTEXT_META_NAME,
  PAGE_CONTEXT_SHIM_BODY,
  PAGE_CONTEXT_SHIM,
  PAGE_CONTEXT_SHIM_HASH,
  pageContextMeta
} = bridge

const TOKEN = 'a'.repeat(64)

function element ({ excluded = false, visible = true } = {}) {
  return {
    closest () { return excluded ? this : null },
    checkVisibility () { return visible }
  }
}

function textNode (text, opts) {
  return {
    data: text,
    nodeValue: text,
    parentElement: element(opts)
  }
}

function makePort () {
  return {
    messages: [],
    closed: 0,
    postMessage (message) { this.messages.push(message) },
    close () { this.closed++ }
  }
}

function bootShim ({
  token = TOKEN,
  title = 'Example',
  selection = 'selected text',
  nodes = [textNode('visible body')]
} = {}) {
  let messageListener = null
  const parent = {}
  const document = {
    title,
    body: {},
    querySelector (selector) {
      assert.equal(selector, `meta[name="${PAGE_CONTEXT_META_NAME}"]`)
      return token ? { content: token } : null
    },
    createTreeWalker () {
      let index = 0
      return {
        nextNode () { return nodes[index++] || null }
      }
    }
  }
  const window = {
    parent,
    addEventListener (type, listener, capture) {
      assert.equal(type, 'message')
      assert.equal(capture, true)
      messageListener = listener
    },
    getSelection () {
      return { toString: () => selection }
    },
    getComputedStyle () {
      return { display: 'block', visibility: 'visible' }
    }
  }
  new Script(PAGE_CONTEXT_SHIM_BODY).runInNewContext({
    window,
    document,
    NodeFilter: { SHOW_TEXT: 4 },
    TextEncoder,
    TextDecoder
  })
  assert.equal(typeof messageListener, 'function')
  return { listener: messageListener, parent, window }
}

function request (runtime, overrides = {}) {
  const port = overrides.port || makePort()
  runtime.listener({
    source: overrides.source === undefined ? runtime.parent : overrides.source,
    data: {
      type: 'pearbrowser:context-request',
      v: 1,
      requestId: 'request-1',
      contextToken: TOKEN,
      ...(overrides.data || {})
    },
    ports: overrides.ports || [port]
  })
  return port
}

test('page context shim is exact valid JavaScript with a matching CSP hash', () => {
  assert.equal(PAGE_CONTEXT_SHIM, `<script>${PAGE_CONTEXT_SHIM_BODY}</script>`)
  assert.doesNotThrow(() => new Script(PAGE_CONTEXT_SHIM_BODY))
  const actualHash = crypto.createHash('sha256').update(PAGE_CONTEXT_SHIM_BODY, 'utf8').digest('base64')
  assert.equal(PAGE_CONTEXT_SHIM_HASH, actualHash)
})

test('page context meta accepts only normalized 32-byte hexadecimal tokens', () => {
  assert.equal(
    pageContextMeta(' A'.trim().repeat(64)),
    `<meta name="${PAGE_CONTEXT_META_NAME}" content="${TOKEN}">`
  )
  assert.throws(() => pageContextMeta('short'), /32 bytes of hexadecimal/)
  assert.throws(() => pageContextMeta('z'.repeat(64)), /32 bytes of hexadecimal/)
})

test('page context bridge requires its parent, exact protocol, token, and one MessagePort', () => {
  const runtime = bootShim()

  const wrongSource = request(runtime, { source: {} })
  assert.deepEqual(wrongSource.messages, [])

  const wrongType = request(runtime, { data: { type: 'pearbrowser:other' } })
  assert.deepEqual(wrongType.messages, [])

  const wrongVersion = request(runtime, { data: { v: 2 } })
  assert.deepEqual(wrongVersion.messages, [])

  const wrongToken = request(runtime, { data: { contextToken: 'b'.repeat(64) } })
  assert.deepEqual(wrongToken.messages, [])
  assert.equal(wrongToken.closed, 1)

  const noPort = makePort()
  request(runtime, { port: noPort, ports: [] })
  assert.deepEqual(noPort.messages, [])

  const extraPorts = [makePort(), makePort()]
  request(runtime, { port: extraPorts[0], ports: extraPorts })
  assert.deepEqual(extraPorts[0].messages, [])
  assert.deepEqual(extraPorts[1].messages, [])
})

test('page context bridge extracts bounded UTF-8 text and omits form values', () => {
  const secret = 'PASSWORD_VALUE_MUST_NOT_LEAK'
  const runtime = bootShim({
    title: 'T'.repeat(2000),
    selection: 'é'.repeat(10000),
    nodes: [
      textNode(secret, { excluded: true }),
      textNode('😀'.repeat(10000))
    ]
  })
  const port = request(runtime)

  assert.equal(port.closed, 1)
  assert.equal(port.messages.length, 1)
  const response = port.messages[0]
  assert.equal(response.type, 'pearbrowser:context-response')
  assert.equal(response.v, 1)
  assert.equal(response.requestId, 'request-1')
  assert.ok(response.bytes.title <= 512)
  assert.ok(response.bytes.selection <= 8 * 1024)
  assert.ok(response.bytes.body <= 20 * 1024)
  assert.ok(response.bytes.total <= 24 * 1024)
  assert.equal(new TextEncoder().encode(response.context.title).byteLength, response.bytes.title)
  assert.equal(new TextEncoder().encode(response.context.selection).byteLength, response.bytes.selection)
  assert.equal(new TextEncoder().encode(response.context.body).byteLength, response.bytes.body)
  assert.equal(response.context.body.includes(secret), false)
  assert.equal(response.context.selection.endsWith('\ufffd'), false)
  assert.equal(response.context.body.endsWith('\ufffd'), false)
  assert.deepEqual({ ...response.flags }, {
    truncated: true,
    titleTruncated: true,
    selectionTruncated: true,
    bodyTruncated: true,
    nodeLimitReached: false
  })
})

test('page context bridge excludes opacity-hidden text from model context', () => {
  assert.match(PAGE_CONTEXT_SHIM_BODY, /checkOpacity: true/)
  assert.match(PAGE_CONTEXT_SHIM_BODY, /Number\(style\.opacity\) === 0/)
})