Files
hapi/web/src/lib/remark-non-https-autolink.test.ts
T
Junmo KimandGitHub 6e32b20524 feat(web): linkify custom URI schemes with a confirm prompt (#633)
* feat(web): add UriConfirmDialog component

Add a Radix Dialog-based confirmation modal for custom URI scheme
navigation. Follows the RenameSessionDialog pattern.

- UriConfirmDialog: shows URI, scheme label, Cancel/Open/Always-allow buttons
- i18n keys: dialog.uri.{title,description,open,alwaysAllow}

* feat(web): autolink non-https URI schemes in markdown

Add a remark plugin that converts raw `scheme://...` text nodes into
link nodes for non-http(s) schemes. GFM already handles http/https;
this plugin handles the remainder (obsidian://, vscode://, slack://, etc.).

- No scheme allowlist: every `scheme://` pattern is converted; the
  sanitize layer (urlTransform) and onClick layer (classifyScheme) handle
  blocking/confirmation downstream.
- Runs before remarkStripCjkAutolink so the CJK-strip plugin sees the
  new link nodes and can trim trailing CJK punctuation from them.
- Trailing punctuation (.,;!?) stripped from matched URIs.
- Unit tests: conversion, partial-match, escape, explicit link bypass,
  code-block bypass, trailing-punct trimming.

* feat(web): linkify custom URI schemes via markdown <a> handler

Wire up 4-layer URI security policy in the markdown renderer:

1. URL sanitize (deny-only): urlTransform strips javascript:/data:/vbscript:/file:
   using classifyScheme as single source of truth (handles percent-encoding,
   case-insensitive, whitespace-prefix bypass patterns).

2. onClick intercept: custom <A> component classifies each href —
   - IANA safe (https/http/irc/ircs/mailto/xmpp): navigate directly.
   - Deny (javascript/data/vbscript/file): preventDefault silently.
   - Custom (obsidian/vscode/slack/…): preventDefault + open UriConfirmDialog.

3. UriConfirmProvider: one dialog lifted to each markdown root (MarkdownText,
   Reasoning, MarkdownRenderer). Shared isAllowed state across all <a> tags in
   the subtree — "Always allow" click updates every link in one React commit.

4. Intra-tab cross-provider sync (P7e.1): module-level schemeListeners Set so
   sibling UriConfirmProviders (MarkdownText + Reasoning in AssistantMessage)
   receive allowed-scheme updates synchronously without waiting for the window
   storage event (which only fires in other tabs). Cross-tab sync continues via
   the existing window storage event listener.

5. "Always allow" persisted to localStorage (hapi-allowed-schemes). Custom
   schemes once allowed navigate directly on subsequent clicks, no dialog gate.
   href="#" in DOM for unallowed custom schemes prevents middle-click bypass.
   Deny-scheme href="" prevents any navigation even if localStorage tampered.

Security: classifyScheme decodes percent-encoding before scheme extraction,
blocking %6Aavascript:, jav%61script:, javascript%3A (single-encoded colon)
and double-encoded variants. DENY_SCHEMES checked after localStorage lookup so
tampered allowed-list cannot promote deny schemes.

Tests: classifyScheme 6-axis security bypass, denyOnlyTransform, localStorage
roundtrip, cross-tab storage event, <A> click handler cases.

* fix(web): block control-char-spliced deny schemes in classifyScheme

Browsers silently strip ASCII control characters (\t, \n, \r) and
whitespace from URL scheme names during navigation. A scheme like
`java\nscript:alert(1)` was navigated as `javascript:` while our
literal string comparison classified it as 'custom', allowing it
past the deny list and into window.open().

Introduce normalizedScheme() that:
- applies 2 rounds of decodeURIComponent so double-encoded schemes
  (javascript%253A → javascript%3A → javascript:) are fully unwrapped
  before comparison
- strips [\x00-\x1F\x7F\s] from the extracted scheme name, matching
  the browser's own normalization

classifyScheme() now delegates to normalizedScheme() so both the
denyOnlyTransform (urlTransform) path and the <A> onClick path benefit
from the same normalization.

Tests added for \n / \t / \r / space spliced into scheme, and verify
that double-encoded colon is now caught via scheme-match (not just
the no-colon fallback).

* fix(web): preserve relative markdown links from being blocked

Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) were
silently preventDefault'd in <A>'s onClick handler. denyOnlyTransform
correctly passed them through (no colon → not a scheme URL), but the
click handler called classifyScheme(href) which returned 'deny' for
any input with no valid scheme separator — then the deny branch fired.

Add hasScheme(href): checks whether the first ':' appears before any
path/query/fragment boundary ('/', '?', '#'). When hasScheme is false
the href is treated as 'iana' so the browser or SPA router can navigate
normally with no dialog and no preventDefault.

Also wrap renderA() with <I18nProvider> so the UriConfirmDialog that
UriConfirmProvider may render does not throw outside its translation
context during tests.

Fixes a regression that broke all relative-path markdown links once the
custom-URI-scheme onClick handler was added.

* test(web): cover percent-encoded scheme control char + protocol-relative href

Round-5 internal hostile review noted two coverage gaps on the bot-fixup commits:

- `java%0Ascript:alert(1)` (percent-encoded newline in the scheme name) takes the
  same decode→strip code path as the literal `java\nscript:` case but was only
  tested literally. Add an explicit test so a future refactor that drops the
  decode-then-strip ordering would be caught.
- Protocol-relative URLs (`//host/path`) have no colon, so `hasScheme` returns
  false and `<A>` treats them as scheme-less — browsers then navigate them as
  the current origin's protocol. Existing relative-href tests covered absolute
  paths, hashes, queries, and colon-in-path, but not the protocol-relative
  variant. Add one assertion.

Also extend the `hasScheme` JSDoc to note that protocol-relative URLs are
intentionally treated as scheme-less.

* fix(web): preserve balanced parens/brackets in autolinked URIs

The trailing-punctuation strip used to drop every `)` / `]` from the end
of a matched URI, even when the URL body had an unmatched opener. So a
URI like `obsidian://open?file=Note(1)` was rendered with href
`obsidian://open?file=Note(1` plus a separate `)` text node, opening a
broken deep link.

Match the GFM autolink-literal behaviour: when the trailing character is
`)` or `]`, keep it iff the URL body has more opening counterparts than
closers (so the trailing closer balances an earlier opener and belongs
to the URL). Other trailing punctuation (`.,;!?:>'"`) and unmatched
closers still strip as before.

Add tests for the balanced cases (`Note(1)`, `Note[1]`, nested
`(a(b)c)`), the "balanced URL followed by a period" case, and a
regression test that an unmatched `).` after a URL is still stripped.
2026-05-18 10:40:32 +08:00

165 lines
8.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink'
/**
* Unit tests for remark-non-https-autolink.
*
* We test the tree-transform function directly by feeding it MDAST
* structures. The plugin converts raw "scheme://..." text in paragraph
* text nodes into link nodes, skipping http/https (handled by GFM),
* explicit markdown links, and code blocks/inline code.
*/
describe('remarkNonHttpsAutolink', () => {
const transform = remarkNonHttpsAutolink()
function makeRoot(children: any[]) { return { type: 'root', children } as any }
function makeParagraph(children: any[]) { return { type: 'paragraph', children } as any }
function makeText(value: string) { return { type: 'text', value } as any }
function makeLink(url: string, textValue: string) {
return { type: 'link', url, children: [{ type: 'text', value: textValue }] } as any
}
function makeCode(lang: string, value: string) { return { type: 'code', lang, value } as any }
function makeInlineCode(value: string) { return { type: 'inlineCode', value } as any }
// ── Basic autolink ───────────────────────────────────────────────────
it('converts obsidian:// raw URI in text to a link node', () => {
const tree = makeRoot([makeParagraph([makeText('Check obsidian://open?vault=V&file=F here')])])
transform(tree)
const para = tree.children[0]
expect(para.children[0]).toMatchObject({ type: 'text', value: 'Check ' })
expect(para.children[1]).toMatchObject({ type: 'link', url: 'obsidian://open?vault=V&file=F' })
expect(para.children[2]).toMatchObject({ type: 'text', value: ' here' })
})
it.each([
['vscode://file/path/to/file', 'Open vscode://file/path/to/file'],
['slack://channel?team=T123', 'Join slack://channel?team=T123'],
])('converts %s URI to a link node', (expectedUrl, inputText) => {
const tree = makeRoot([makeParagraph([makeText(inputText)])])
transform(tree)
const link = tree.children[0].children.find((c: any) => c.type === 'link')
expect(link?.url).toBe(expectedUrl)
})
it('sets the link text to the full URI (autolink style)', () => {
const tree = makeRoot([makeParagraph([makeText('obsidian://vault/file')])])
transform(tree)
const link = tree.children[0].children[0]
expect(link.children[0].value).toBe('obsidian://vault/file')
})
// ── http/https NOT converted (GFM handles those) ─────────────────────
it.each([
'http://example.com',
'https://example.com',
])('does NOT convert %s (GFM handles those)', (url) => {
const tree = makeRoot([makeParagraph([makeText(`Visit ${url}`)])])
transform(tree)
const links = tree.children[0].children.filter((c: any) => c.type === 'link')
expect(links.length).toBe(0)
})
// ── Existing link nodes not touched ──────────────────────────────────
it('does not modify an existing explicit markdown link node', () => {
const existingLink = makeLink('obsidian://open', 'My Note')
const tree = makeRoot([makeParagraph([existingLink])])
transform(tree)
const para = tree.children[0]
expect(para.children.length).toBe(1)
expect(para.children[0]).toMatchObject({ type: 'link', url: 'obsidian://open' })
expect(para.children[0].children[0].value).toBe('My Note')
})
// ── Code blocks / inline code not touched ────────────────────────────
it('does not linkify URIs inside code blocks', () => {
const tree = makeRoot([makeCode('sh', 'open obsidian://vault/file')])
transform(tree)
expect(tree.children[0].type).toBe('code')
expect(tree.children[0].value).toBe('open obsidian://vault/file')
})
it('does not linkify URIs inside inline code', () => {
const tree = makeRoot([makeParagraph([makeInlineCode('obsidian://vault/file')])])
transform(tree)
expect(tree.children[0].children[0].type).toBe('inlineCode')
})
// ── Trailing punctuation handling ────────────────────────────────────
it.each([
['period', 'See obsidian://open?vault=V.', 'obsidian://open?vault=V', '.'],
['comma', 'Link obsidian://open,', 'obsidian://open', ','],
['closing paren', 'See (obsidian://open)', 'obsidian://open', ')'],
])('strips trailing %s from the linked URL', (_label, inputText, expectedUrl, expectedTrailing) => {
const tree = makeRoot([makeParagraph([makeText(inputText)])])
transform(tree)
const para = tree.children[0]
const link = para.children.find((c: any) => c.type === 'link')
expect(link?.url).toBe(expectedUrl)
const trailingText = para.children[para.children.indexOf(link) + 1]
expect(trailingText?.value).toContain(expectedTrailing)
})
// ── Balanced paren/bracket preservation (GFM autolink literal behaviour) ───
it.each([
['balanced paren in URL', 'obsidian://open?file=Note(1)', 'obsidian://open?file=Note(1)'],
['balanced bracket in URL', 'obsidian://open?file=Note[1]', 'obsidian://open?file=Note[1]'],
['nested balanced parens', 'obsidian://open?q=(a(b)c)', 'obsidian://open?q=(a(b)c)'],
])('keeps %s inside the linked URL', (_label, inputText, expectedUrl) => {
const tree = makeRoot([makeParagraph([makeText(inputText)])])
transform(tree)
const link = tree.children[0].children.find((c: any) => c.type === 'link')
expect(link?.url).toBe(expectedUrl)
// No trailing text node should be emitted for a balanced URL.
expect(tree.children[0].children.length).toBe(1)
})
it('strips period after a balanced-paren URL but keeps the parens', () => {
const tree = makeRoot([makeParagraph([makeText('See obsidian://open?file=Note(1).')])])
transform(tree)
const para = tree.children[0]
const link = para.children.find((c: any) => c.type === 'link')
expect(link?.url).toBe('obsidian://open?file=Note(1)')
const trailingText = para.children[para.children.indexOf(link) + 1]
expect(trailingText?.value).toBe('.')
})
it('strips an unmatched closing paren (no opener in URL body)', () => {
const tree = makeRoot([makeParagraph([makeText('(see obsidian://x).')])])
transform(tree)
const para = tree.children[0]
const link = para.children.find((c: any) => c.type === 'link')
expect(link?.url).toBe('obsidian://x')
const trailingText = para.children[para.children.indexOf(link) + 1]
expect(trailingText?.value).toBe(').')
})
// ── Multiple URIs in one text node ───────────────────────────────────
it('converts multiple non-https URIs in the same text node', () => {
const tree = makeRoot([
makeParagraph([makeText('A: obsidian://vault/a and B: vscode://file/b done')])
])
transform(tree)
const links = tree.children[0].children.filter((c: any) => c.type === 'link')
expect(links.length).toBe(2)
expect(links[0].url).toBe('obsidian://vault/a')
expect(links[1].url).toBe('vscode://file/b')
})
// ── Scheme-only / edge cases ─────────────────────────────────────────
it('does not linkify a bare scheme without "://"', () => {
// mailto: without // is valid URI but our plugin targets scheme:// only
const tree = makeRoot([makeParagraph([makeText('mailto:user@example.com')])])
transform(tree)
const links = tree.children[0].children.filter((c: any) => c.type === 'link')
expect(links.length).toBe(0)
})
})