Files
Termind/FrameworkTests/LocalNetworkTests.swift
T
Kaede Akino d2cce7d2ce feat: make core features free and add host-based Pro limits
Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks.

Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP.

Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision.
2026-10-17 06:13:38 +08:00

283 lines
14 KiB
Swift

import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import Testing
import struct TermindDomain.Host
@testable import TermindRemoteInfrastructure
@Suite @MainActor
struct LocalNetworkTests {
@Test func validatesUnicastMACAndBuildsStandardPacket() throws {
let mac = try #require(MACAddress(" 02-ab-cd-12-34-56 "))
#expect(mac.rawValue == "02:AB:CD:12:34:56")
let packet = Array(mac.magicPacket)
#expect(packet.count == 102)
#expect(Array(packet.prefix(6)) == Array(repeating: 255, count: 6))
for offset in stride(from: 6, to: 102, by: 6) {
#expect(Array(packet[offset..<(offset + 6)]) == [2, 171, 205, 18, 52, 86])
}
for text in [
"", "00:00:00:00:00:00", "FF:FF:FF:FF:FF:FF", "01:00:5E:01:02:03",
"0:11:22:33:44:55", "00:11:22:33:44:5G", "001122334455", "00:11-22:33:44:55",
] {
#expect(MACAddress(text) == nil)
}
#expect(throws: (any Error).self) {
try JSONDecoder().decode(MACAddress.self, from: Data("\"invalid\"".utf8))
}
}
@Test func sendingIsFreeAndOnlyAutoUsesCooldown() throws {
let sender = RecordingWakeSender()
let state = WakeTestState()
let controller = WakeOnLANController(sender: sender, now: { state.clock })
let preferences = WakeOnLANPreferences(macAddress: "02:11:22:33:44:55", automaticallyWake: true)
controller.automaticallyWake(preferences)
controller.automaticallyWake(preferences)
#expect(sender.sent.count == 1)
try controller.wake(preferences)
try controller.wake(preferences)
#expect(sender.sent.count == 3)
state.clock += 3
controller.automaticallyWake(preferences)
#expect(sender.sent.count == 4)
}
@Test func automaticFailuresAndCancellationNeverRequireInteraction() async {
let sender = RecordingWakeSender(fails: true)
let access = ProAccessController.testing(status: .free)
let free = WakeOnLANController(sender: sender)
let preferences = WakeOnLANPreferences(macAddress: "02:11:22:33:44:55", automaticallyWake: true)
free.automaticallyWake(preferences)
#expect(!access.isPaywallPresented)
let controller = WakeOnLANController(sender: sender)
var failures = 0
controller.reportAutomaticFailure = { _ in failures += 1 }
controller.automaticallyWake(preferences)
#expect(failures == 1)
let task = Task { @MainActor in
await Task.yield()
controller.automaticallyWake(.init(macAddress: "02:11:22:33:44:66", automaticallyWake: true))
}
task.cancel()
await task.value
#expect(failures == 1)
}
@Test func connectionSnapshotsReadLatestWakePreferencesForTargetAndJump() async throws {
let identity = Identity(name: "Login", username: "user")
var jump = Host(name: "Jump", host: "jump.invalid", identityID: identity.id)
var target = Host(name: "Target", host: "target.invalid", identityID: identity.id)
target.jumpHostID = jump.id
let library = HostLibraryModel(hosts: [jump, target], identities: [identity])
let sender = RecordingWakeSender()
library.wakeOnLAN = WakeOnLANController(sender: sender)
let preparation = try library.prepareSSHConnection(for: target, submissions: [:])
let configuration = try await preparation.resolve(trustPolicy: .allowChallenge)
jump.wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true)
target.wakeOnLAN = .init(macAddress: "02:11:22:33:44:66", automaticallyWake: true)
library.upsert(jump)
library.upsert(target)
await configuration.beforeConnect?()
#expect(sender.sent.map(\.rawValue) == ["02:11:22:33:44:55", "02:11:22:33:44:66"])
let direct = try library.sshConnectionConfiguration(for: target, trustPolicy: .allowChallenge)
#expect(direct.beforeConnect != nil)
let snapshot = SSHAdditionalConnectionSnapshot(configuration: direct)
let additional = try library.additionalConnectionRequest(from: snapshot)
#expect(additional.configuration.beforeConnect != nil)
let resumed = try await library.resumableRouteRequest(
from: snapshot, authorizeHostKey: nil, keyboardInteractive: nil,
securityKeySigning: nil, hostKeyConstraints: [:])
#expect(resumed.configuration.beforeConnect != nil)
target.telnetEnabled = true
let telnet = try library.telnetConnectionConfiguration(for: target)
#expect(telnet.beforeConnect != nil)
}
@Test func broadcastAndProbeScopeHonorSmallSubnetsAndCapLargeSubnets() {
let wide = LocalIPv4Interface(address: 0xC0A8_1234, mask: 0xFFFF_0000)
#expect(wide.broadcast == 0xC0A8_FFFF)
#expect(wide.scanAddresses.count == 253)
#expect(wide.scanAddresses.first == "192.168.18.1")
#expect(wide.scanAddresses.last == "192.168.18.254")
#expect(!wide.scanAddresses.contains("192.168.18.52"))
let narrow = LocalIPv4Interface(address: 0xC0A8_1205, mask: 0xFFFF_FFFC)
#expect(narrow.scanAddresses == ["192.168.18.6"])
#expect(narrow.broadcast == 0xC0A8_1207)
}
@Test func probesRequireACompleteSSHIdentificationAndMergeByAddressAndPort() {
#expect(!SSHIdentification.isPresent(in: Data("HTTP/1.1 200 OK\r\n".utf8)))
#expect(!SSHIdentification.isPresent(in: Data("SSH-2.0-OpenSSH".utf8)))
#expect(SSHIdentification.isPresent(in: Data("Welcome\r\nSSH-2.0-OpenSSH_9.0\r\n".utf8)))
#expect(!SSHIdentification.isPresent(in: Data("SSH-2.0-\r\n".utf8)))
let discovered = DiscoveredLocalHost(
name: "Mac", address: "mac.local.", port: 22, source: .bonjour,
resolvedAddresses: ["192.168.1.3", "fe80::1%en0"])
let probe = DiscoveredLocalHost(name: "192.168.1.3", address: "192.168.1.3", port: 22, source: .probe)
var otherPort = discovered
otherPort.port = 2222
let merged = DiscoveredLocalHost.merged([probe, discovered, otherPort])
#expect(merged.count == 2)
#expect(merged.allSatisfy { $0.source == .bonjour })
}
@Test func prefillPreservesOtherDraftFieldsAndNeverReplacesManagedAddresses() {
var host = Host(name: "Draft", host: "before.invalid", groupID: UUID(), tags: ["Test"])
host.wakeOnLAN.macAddress = "02:11:22:33:44:55"
let groupID = host.groupID
let discovered = DiscoveredLocalHost(name: "Found", address: "192.168.1.3", port: 2222, source: .bonjour)
discovered.prefill(&host)
#expect(host.host == "192.168.1.3")
#expect(host.sshPort == 2222)
#expect(host.groupID == groupID && host.tags == ["Test"])
#expect(host.wakeOnLAN.macAddress == "02:11:22:33:44:55")
host.tailscale = .init(groupID: UUID(), stableNodeID: "node")
let managed = host
discovered.prefill(&host)
#expect(host == managed)
}
@Test func telnetDiscoveryScansPort23WithoutSSHBonjourOrBanner() {
let telnet = LocalDiscoveryProbePolicy(connectionProtocol: .telnet)
let ssh = LocalDiscoveryProbePolicy(connectionProtocol: .ssh)
#expect(telnet.port == 23)
#expect(telnet.bonjourServices.isEmpty)
#expect(telnet.acceptsConnection(received: Data()))
#expect(telnet.acceptsConnection(received: Data([255, 253, 24])))
#expect(ssh.port == 22)
#expect(ssh.bonjourServices == ["_ssh._tcp", "_sftp-ssh._tcp"])
#expect(!ssh.acceptsConnection(received: Data()))
#expect(!ssh.acceptsConnection(received: Data([255, 253, 24])))
#expect(ssh.acceptsConnection(received: Data("SSH-2.0-OpenSSH_9.0\r\n".utf8)))
#expect(LocalDiscoveryProtocol(terminalProtocol: .telnet) == .telnet)
#expect(LocalDiscoveryProtocol(terminalProtocol: .ssh) == .ssh)
#expect(LocalDiscoveryProtocol(terminalProtocol: .mosh) == nil)
#expect(LocalDiscoveryProtocol(terminalProtocol: .eternalTerminal) == nil)
}
@Test func telnetPrefillEnablesOnlyTelnetAndPreservesSSHAndOtherDraftSettings() {
var host = Host(name: "Draft", host: "old.invalid", groupID: UUID(), tags: ["Lab"])
host.sshEnabled = false
host.sshPort = 2222
host.telnetPort = 2323
host.wakeOnLAN.macAddress = "02:11:22:33:44:55"
let original = host
let discovered = DiscoveredLocalHost(
name: "Router", address: "192.168.1.1", port: 23, source: .probe, connectionProtocol: .telnet)
discovered.prefill(&host)
#expect(host.name == "Router" && host.host == "192.168.1.1")
#expect(host.telnetEnabled && host.telnetPort == 23)
#expect(!host.sshEnabled && host.sshPort == 2222)
#expect(host.preferredTerminalProtocol == .telnet)
#expect(host.id == original.id && host.groupID == original.groupID && host.tags == original.tags)
#expect(host.wakeOnLAN == original.wakeOnLAN)
host.tailscale = .init(groupID: UUID(), stableNodeID: "node")
let managed = host
discovered.prefill(&host)
#expect(host == managed)
}
@Test func discoveryDoesNotMergeDifferentProtocolsAtTheSameEndpoint() {
let ssh = DiscoveredLocalHost(name: "Router", address: "192.168.1.1", port: 23, source: .probe)
var telnet = ssh
telnet.connectionProtocol = .telnet
#expect(ssh.id != telnet.id)
#expect(DiscoveredLocalHost.merged([ssh, telnet, telnet]).count == 2)
}
@Test func remoteMACParsingRejectsMalformedAndDuplicateCandidates() {
let result = SSHHostMACAddressResolver.parse(
"en0|02:11:22:33:44:55\nen1|02-11-22-33-44-55\nlo|00:00:00:00:00:00\nbad|FF:FF:FF:FF:FF:FF\nerror\n")
#expect(result.count == 1)
#expect(result.first?.interface == "en0")
}
@Test func legacyStoreMigratesAndWakePreferencesSurviveReopening() throws {
var host = Host(name: "Host", host: "server.invalid")
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("store")
do {
let schema = Schema(versionedSchema: TermindSchemaV55.self)
let legacy = try ModelContainer(
for: schema,
configurations: ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none))
legacy.mainContext.insert(TermindSchemaV55HostModels.PersistentHost(host))
try legacy.mainContext.save()
}
do {
let container = try TermindPersistence.makeContainer(url: url)
let store = HostPersistence(context: container.mainContext)
#expect(try store.load().hosts == [host])
host.wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true)
_ = try store.save(.init(hosts: [host]))
}
let reopened = try TermindPersistence.makeContainer(url: url)
#expect(try HostPersistence(context: reopened.mainContext).load().hosts == [host])
}
@Test func wireProtectsWakePreferencesAndLegacyDomainDefaultsAreSafe() throws {
var host = Host(name: "Host", host: "server.invalid")
var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(host)) as? [String: Any])
json.removeValue(forKey: "wakeOnLAN")
let legacy = try JSONDecoder().decode(Host.self, from: JSONSerialization.data(withJSONObject: json))
#expect(legacy.wakeOnLAN == .init())
host.wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true)
let payload = VaultRecordPayload.host(host)
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope)
for version in [1, 2, 4, 5, 6] {
var old = envelope
old.formatVersion = version
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(old) }
}
}
@Test func tailscaleWakePreferencesSurviveProjectionSavingAndWireRoundtrip() throws {
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "test.ts.net"))
let peer = TailscalePeer(
stableID: "peer", name: "Server", dnsName: "server.test.ts.net", online: true,
sshHostKeys: ["ssh-ed25519 test"])
let library = HostLibraryModel(groups: [group])
library.replaceTailscalePeers([peer], groupID: group.id)
var host = try #require(library.hosts.first)
host.wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true)
#expect(library.saveTailscaleHost(host))
library.replaceTailscalePeers([peer], groupID: group.id)
#expect(library.host(host.id)?.wakeOnLAN == host.wakeOnLAN)
let payload = VaultRecordPayload.hostGroup(try #require(library.group(group.id)))
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope)
var old = envelope
old.formatVersion = 6
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(old) }
}
}
private nonisolated final class RecordingWakeSender: WakeOnLANSending, @unchecked Sendable {
private let lock = NSLock()
private var addresses: [MACAddress] = []
let fails: Bool
init(fails: Bool = false) { self.fails = fails }
var sent: [MACAddress] { lock.withLock { addresses } }
func send(to address: MACAddress) throws {
if fails { throw WakeOnLANError.noLocalNetwork }
lock.withLock { addresses.append(address) }
}
}
@MainActor
private final class WakeTestState {
var allowed = false
var clock = 10.0
}