feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
This commit is contained in:
95
ios/App/WebTermTests/TerminalUnauthorizedTests.swift
Normal file
95
ios/App/WebTermTests/TerminalUnauthorizedTests.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// C1 · The 401 terminal state end to end through the VM (B3 added
|
||||
/// `FailureReason.unauthorized`; this pins how the terminal presents it).
|
||||
///
|
||||
/// Driven through a REAL `SessionEngine` over `TestSupport.FakeTransport`, so
|
||||
/// the assertion covers the whole path a wrong token actually takes:
|
||||
/// upgrade 401 → `TermTransportError.unauthorized` → engine terminal state →
|
||||
/// `SessionEvent.connection(.failed(.unauthorized))` → VM phase + copy.
|
||||
@MainActor
|
||||
@Suite("Terminal unauthorized (401)")
|
||||
struct TerminalUnauthorizedTests {
|
||||
@MainActor
|
||||
private struct Harness {
|
||||
let transport = FakeTransport()
|
||||
let clock = FakeClock()
|
||||
let engine: SessionEngine
|
||||
let viewModel: TerminalViewModel
|
||||
|
||||
init() throws {
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
engine = SessionEngine(
|
||||
transport: transport, clock: clock, endpoint: endpoint,
|
||||
eventsSource: { _ in [] }
|
||||
)
|
||||
viewModel = TerminalViewModel(engine: engine, events: engine.events)
|
||||
viewModel.start()
|
||||
}
|
||||
|
||||
/// Upgrade rejected with 401 → `.connecting` + `.failed(.unauthorized)`.
|
||||
func openRejected() async {
|
||||
await transport.scriptConnectFailure(TermTransportError.unauthorized)
|
||||
await engine.open(sessionId: nil, cwd: nil)
|
||||
await viewModel.waitUntilProcessed(eventCount: 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("401 升级被拒 → 终态 failed(不是 reconnecting 转圈)")
|
||||
func unauthorizedIsTerminalNotRetried() async throws {
|
||||
let harness = try Harness()
|
||||
|
||||
await harness.openRejected()
|
||||
|
||||
guard case .failed = harness.viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(harness.viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(harness.viewModel.banner == .none) // spinner cleared
|
||||
#expect(harness.viewModel.isReadOnly)
|
||||
#expect(await harness.transport.connectAttempts.count == 1) // no backoff loop
|
||||
}
|
||||
|
||||
@Test("401 话术同时给出两条补救:令牌与 ALLOWED_ORIGINS(服务端两者都回 401)")
|
||||
func unauthorizedCopyOffersBothRemedies() async throws {
|
||||
let harness = try Harness()
|
||||
|
||||
await harness.openRejected()
|
||||
|
||||
guard case .failed(let message) = harness.viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(harness.viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("访问令牌"))
|
||||
#expect(message.contains("ALLOWED_ORIGINS"))
|
||||
#expect(message == TerminalViewModel.unauthorizedMessage)
|
||||
}
|
||||
|
||||
@Test("401 横幅模型走 failed 分支(供 ReconnectBanner 渲染)")
|
||||
func unauthorizedRendersFailedBanner() async throws {
|
||||
let harness = try Harness()
|
||||
|
||||
await harness.openRejected()
|
||||
|
||||
#expect(
|
||||
harness.viewModel.bannerModel
|
||||
== .failed(message: TerminalViewModel.unauthorizedMessage)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("401 终态下输入被丢弃(read-only,不再打向已死连接)")
|
||||
func unauthorizedDropsInput() async throws {
|
||||
let harness = try Harness()
|
||||
await harness.openRejected()
|
||||
|
||||
harness.viewModel.sendInput("ls\r")
|
||||
|
||||
#expect(harness.viewModel.droppedReadOnlyInputCount == 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user