Files
hapi/ios/Packages/HapiKit/Tests/HapiProtocolTests/BindLinkTests.swift
T
weishu 3363d1c75b feat(ios): pairing flow, deep link, app session wiring (A-M1d)
- HapiProtocol/Pairing/BindLink: parses the companion deeplink
  (hapicompanion://bind?hub=&code=) and the web direct-access QR
  (?hub=&token=) with URLSearchParams form-decoding semantics, in
  lockstep with the Android port (tests mirror BindLinkTest.kt).
- HapiClient/Auth/HubPairingService: normalize -> GET /health
  (reachability + protocolVersion) -> POST /api/auth -> persist
  Keychain + registry + active hub; unpair with fallback. Covered by
  PairingLogicTests through the HTTPPerforming seam.
- App layer: AppModel (@Observable @MainActor pairing state machine:
  restore, pair, switch, sign out, deep-link routing, scenePhase,
  terminal-auth-failure banner) + HubSession (per-active-hub APIClient/
  AuthManager/global SSEClient with suspend-resume and a connection
  state for the UI; store routing is TODO(M2)).
- Pairing UI: welcome flow, VisionKit QR scanner (with Simulator/
  permission fallbacks), manual entry (paste-friendly), shared confirm
  sheet with per-PairingFailure error states.
- HapiApp routes hapicompanion:// through AppModel (paired hubs switch
  with a notice, never log the token); RootView switches unpaired/
  paired and hosts the deep-link confirm sheet; HomePlaceholderView
  shows hub, connection dot, hub switcher (M2a replaces it with the
  session list).
- Info.plist: NSCameraUsageDescription; README: pairing guide + manual
  test pass.
2026-08-17 15:58:17 +08:00

150 lines
5.7 KiB
Swift

import Foundation
import HapiProtocol
import Testing
/// Mirrors the Android suite (`android/.../pairing/BindLinkTest.kt`) case for
/// case so the two ports cannot drift, plus the web direct-access form the
/// iOS parser additionally accepts.
@Suite("BindLink parsing")
struct BindLinkTests {
// MARK: - Companion deeplink form
@Test func parsesAPlainValidLink() {
let link = BindLink.parse("hapicompanion://bind?hub=http://192.168.1.20:3006&code=abc123")
#expect(link == BindLink(hubUrl: "http://192.168.1.20:3006", accessToken: "abc123"))
}
@Test func parsesURLEncodedValuesAsTheHubEmitsThem() {
// Exactly what `new URLSearchParams({hub, code}).toString()` produces
// for hub=https://demo.hapi.run and code=tok_9f8:default.
let link = BindLink.parse(
"hapicompanion://bind?hub=https%3A%2F%2Fdemo.hapi.run&code=tok_9f8%3Adefault"
)
#expect(link?.hubUrl == "https://demo.hapi.run")
#expect(link?.accessToken == "tok_9f8:default")
}
@Test func decodesPlusAsSpaceInFormEncodedValues() {
let link = BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=a+b")
#expect(link?.accessToken == "a b")
}
@Test func keepsEncodedPlusLiteral() {
let link = BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=a%2Bb")
#expect(link?.accessToken == "a+b")
}
@Test func acceptsSchemeAndHostCaseInsensitively() {
let link = BindLink.parse("HAPICOMPANION://BIND?hub=https%3A%2F%2Fh.example.com&code=x")
#expect(link?.hubUrl == "https://h.example.com")
}
@Test func ignoresExtraParamsAndKeepsFirstOccurrenceOfDuplicates() {
let link = BindLink.parse(
"hapicompanion://bind?v=1&hub=https%3A%2F%2Fa.example.com&code=first&code=second"
)
#expect(link?.hubUrl == "https://a.example.com")
#expect(link?.accessToken == "first")
}
@Test func returnsNilWhenHubParamIsMissing() {
#expect(BindLink.parse("hapicompanion://bind?code=abc123") == nil)
}
@Test func returnsNilWhenCodeParamIsMissing() {
#expect(BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com") == nil)
}
@Test func returnsNilWhenQueryIsAbsent() {
#expect(BindLink.parse("hapicompanion://bind") == nil)
}
@Test func returnsNilWhenParamsAreBlank() {
#expect(BindLink.parse("hapicompanion://bind?hub=&code=x") == nil)
#expect(BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=") == nil)
}
@Test func returnsNilForAWrongScheme() {
// https with `code=` is neither a valid deeplink nor a valid web QR
// (the web form carries `token=`).
#expect(BindLink.parse("https://bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil)
#expect(BindLink.parse("hapicompanionx://bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil)
#expect(BindLink.parse("ftp://bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil)
}
@Test func returnsNilForAWrongHost() {
#expect(BindLink.parse("hapicompanion://pair?hub=https%3A%2F%2Fh.example.com&code=x") == nil)
}
@Test func returnsNilWhenHubIsNotAnHTTPURL() {
#expect(BindLink.parse("hapicompanion://bind?hub=ftp%3A%2F%2Fh.example.com&code=x") == nil)
#expect(BindLink.parse("hapicompanion://bind?hub=not-a-url&code=x") == nil)
}
@Test func returnsNilForMalformedInput() {
#expect(BindLink.parse("") == nil)
#expect(BindLink.parse(" ") == nil)
#expect(BindLink.parse("not a uri at all") == nil)
// Scheme-relative (no authority): `bind` is a path, not a host.
#expect(BindLink.parse("hapicompanion:bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil)
}
@Test func returnsNilOnInvalidPercentEscapes() {
#expect(BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=%zz") == nil)
}
@Test func trimsSurroundingWhitespace() {
let link = BindLink.parse(" hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=x\n")
#expect(link?.hubUrl == "https://h.example.com")
#expect(link?.accessToken == "x")
}
// MARK: - Web direct-access form
@Test func parsesTheWebDirectAccessQR() {
// Exactly what `${officialWebUrl}/?${new URLSearchParams({hub, token})}`
// produces in hub/src/startHub.ts.
let link = BindLink.parse(
"https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&token=tok_9f8%3Adefault"
)
#expect(link?.hubUrl == "https://demo.hapi.run")
#expect(link?.accessToken == "tok_9f8:default")
}
@Test func acceptsAnyWebHostAndPath() {
// Self-hosted deployments serve the web app anywhere.
let link = BindLink.parse(
"http://my-server.local:8080/hapi/?hub=http%3A%2F%2F192.168.1.20%3A3006&token=abc"
)
#expect(link?.hubUrl == "http://192.168.1.20:3006")
#expect(link?.accessToken == "abc")
}
@Test func webFormRequiresTokenParamName() {
// `code=` belongs to the deeplink form only.
#expect(BindLink.parse("https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&code=abc") == nil)
}
@Test func webFormRequiresHubParam() {
#expect(BindLink.parse("https://app.hapi.run/?token=abc") == nil)
}
@Test func webFormRejectsBlankValues() {
#expect(BindLink.parse("https://app.hapi.run/?hub=&token=abc") == nil)
#expect(BindLink.parse("https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&token=") == nil)
}
@Test func webFormRequiresAnHTTPHubValue() {
#expect(BindLink.parse("https://app.hapi.run/?hub=ftp%3A%2F%2Fdemo.hapi.run&token=abc") == nil)
}
}