507 lines
23 KiB
Swift
507 lines
23 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPersistence
|
|
import Testing
|
|
|
|
@testable import TermindRemoteInfrastructure
|
|
|
|
@Suite @MainActor
|
|
struct TailscaleProfileCleanupTests {
|
|
@Test func deletingAnInactiveProfileWithoutProErasesOnlyItsOwnState() async throws {
|
|
let first = try group("first.ts.net")
|
|
let second = try group("second.ts.net")
|
|
let library = HostLibraryModel(groups: [first, second])
|
|
let store = InMemoryTailscaleLocalStore()
|
|
let ids = [UUID(), UUID()]
|
|
try store.save(.init(groupID: first.id, profileID: ids[0]))
|
|
try store.save(.init(groupID: second.id, profileID: ids[1]))
|
|
let probe = CleanupProbe(network: try #require(first.tailscale?.network))
|
|
let model = probe.model(library, store: store, access: false)
|
|
model.start()
|
|
library.replacePersistedSnapshot(VaultSnapshot(groups: [second]))
|
|
#expect(await settles { probe.erased == [ids[0]] })
|
|
#expect(probe.created.isEmpty)
|
|
#expect(try store.load().map(\.profileID) == [ids[1]])
|
|
#expect(try store.pendingCleanupProfiles().isEmpty)
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test func failedErasureSurvivesDatabaseReopeningAndRetriesWithoutStartingANode() async throws {
|
|
let root = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let url = root.appendingPathComponent("Cleanup.store")
|
|
let group = try group("example.ts.net")
|
|
let profileID = UUID()
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
probe.failErase = true
|
|
try await seedAndFailCleanup(url, group: group, profileID: profileID, probe: probe)
|
|
probe.failErase = false
|
|
let container = try TermindPersistence.makeContainer(url: url)
|
|
let store = SwiftDataTailscaleLocalStore(context: container.mainContext)
|
|
#expect(try store.load().isEmpty)
|
|
#expect(try store.pendingCleanupProfiles() == [profileID])
|
|
let model = probe.model(HostLibraryModel(), store: store, access: false)
|
|
model.start()
|
|
#expect(await settles { probe.erased == [profileID] })
|
|
#expect(try store.pendingCleanupProfiles().isEmpty)
|
|
#expect(probe.created.isEmpty)
|
|
await model.shutdown()
|
|
}
|
|
|
|
private func seedAndFailCleanup(
|
|
_ url: URL, group: HostGroup, profileID: UUID, probe: CleanupProbe
|
|
) async throws {
|
|
let container = try TermindPersistence.makeContainer(url: url)
|
|
let store = SwiftDataTailscaleLocalStore(context: container.mainContext)
|
|
try store.save(.init(groupID: group.id, profileID: profileID))
|
|
let model = probe.model(HostLibraryModel(), store: store, access: false)
|
|
model.start()
|
|
#expect(await settles { model.error != nil })
|
|
#expect(try store.pendingCleanupProfiles() == [profileID])
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func deletingWhileFactoryIsSuspendedWaitsForTheLateNodeToClose(shutdown: Bool) async throws {
|
|
let group = try group("example.ts.net")
|
|
let library = HostLibraryModel(groups: [group])
|
|
let store = InMemoryTailscaleLocalStore()
|
|
let profileID = UUID()
|
|
try store.save(.init(groupID: group.id, profileID: profileID))
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
probe.holdFactory = true
|
|
let model = probe.model(library, store: store)
|
|
model.start()
|
|
#expect(await settles { probe.factoryContinuation != nil })
|
|
library.replacePersistedSnapshot(VaultSnapshot())
|
|
for _ in 0..<20 { await Task.yield() }
|
|
#expect(probe.erased.isEmpty)
|
|
#expect(try store.pendingCleanupProfiles() == [profileID])
|
|
if shutdown { await model.shutdown() }
|
|
probe.releaseFactory()
|
|
#expect(await settles { probe.erased == [profileID] })
|
|
#expect(probe.events == ["close:\(profileID)", "erase:\(profileID)"])
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func cancelledOrFailedLoginCleansItsCandidateWithoutTouchingAnExistingProfile(factoryFails: Bool) async throws {
|
|
let group = try group("example.ts.net")
|
|
let store = InMemoryTailscaleLocalStore()
|
|
let retained = UUID()
|
|
try store.save(.init(groupID: group.id, profileID: retained))
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
probe.holdFactory = true
|
|
probe.failFactory = factoryFails
|
|
let model = probe.model(HostLibraryModel(groups: [group]), store: store)
|
|
// Avoid starting the retained profile; beginLogin is also valid before start.
|
|
model.beginLoginAndSave(
|
|
name: group.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil)
|
|
#expect(await settles { probe.factoryContinuation != nil })
|
|
let candidate = try #require(probe.created.first)
|
|
model.cancelLogin()
|
|
probe.releaseFactory()
|
|
#expect(await settles { probe.erased == [candidate] })
|
|
#expect(try store.load().map(\.profileID) == [retained])
|
|
#expect(!probe.erased.contains(retained))
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test func reloginRetiresTheOldProfileButKeepsTheNewEnrollment() async throws {
|
|
let group = try group("example.ts.net")
|
|
let store = InMemoryTailscaleLocalStore()
|
|
let old = UUID()
|
|
try store.save(.init(groupID: group.id, profileID: old))
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
let model = probe.model(HostLibraryModel(groups: [group]), store: store)
|
|
model.start()
|
|
#expect(await settles { model.runningGroups.contains(group.id) })
|
|
model.beginLoginAndSave(
|
|
name: group.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil,
|
|
groupID: group.id)
|
|
#expect(await settles { model.loginCompletedGroupID == group.id && probe.erased.contains(old) })
|
|
let current = try #require(try store.load().first?.profileID)
|
|
#expect(current != old)
|
|
#expect(probe.erased == [old])
|
|
#expect(probe.nodes[old]?.closed == true)
|
|
#expect(probe.nodes[current]?.closed == false)
|
|
#expect(try store.pendingCleanupProfiles().isEmpty)
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test func multipleLoginsToTheSameTailnetKeepTheirProfilesAndDeletionIndependent() async throws {
|
|
let group = try group("example.ts.net")
|
|
let library = HostLibraryModel()
|
|
let store = InMemoryTailscaleLocalStore()
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
let model = probe.model(library, store: store)
|
|
model.start()
|
|
for name in ["First", "Second"] {
|
|
model.beginLoginAndSave(
|
|
name: name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil)
|
|
#expect(await settles { model.loginCompletedGroupID != nil })
|
|
}
|
|
let states = try store.load()
|
|
#expect(states.count == 2)
|
|
#expect(Set(states.map(\.profileID)).count == 2)
|
|
#expect(Set(states.map(\.groupID)).count == 2)
|
|
let first = try #require(states.first { library.group($0.groupID)?.name == "First" })
|
|
let second = try #require(states.first { library.group($0.groupID)?.name == "Second" })
|
|
#expect(model.runningGroups == [first.groupID, second.groupID])
|
|
let retained = try model.connection(for: second.groupID)
|
|
library.replacePersistedSnapshot(.init(groups: library.groups.filter { $0.id != first.groupID }))
|
|
#expect(await settles { probe.erased == [first.profileID] })
|
|
#expect(try store.load().map(\.profileID) == [second.profileID])
|
|
#expect(probe.nodes[second.profileID]?.closed == false)
|
|
#expect(try await retained.hostKeys(stableNodeID: "node").isEmpty)
|
|
#expect(model.runningGroups == [second.groupID])
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test func reloginRejectsRetainedDialersBeforeTheOldNodeHasFinishedClosing() async throws {
|
|
let first = try group("example.ts.net")
|
|
var second = first
|
|
second.id = UUID()
|
|
let library = HostLibraryModel(groups: [first, second])
|
|
let store = InMemoryTailscaleLocalStore()
|
|
let firstProfile = UUID()
|
|
let secondProfile = UUID()
|
|
try store.save(.init(groupID: first.id, profileID: firstProfile))
|
|
try store.save(.init(groupID: second.id, profileID: secondProfile))
|
|
let probe = CleanupProbe(network: try #require(first.tailscale?.network))
|
|
let model = probe.model(library, store: store)
|
|
model.start()
|
|
#expect(await settles { model.runningGroups.count == 2 })
|
|
let retained = try model.connection(for: first.id)
|
|
let independent = try model.connection(for: second.id)
|
|
let old = try #require(probe.nodes[firstProfile])
|
|
old.holdLogout = true
|
|
model.beginLoginAndSave(
|
|
name: first.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil,
|
|
groupID: first.id)
|
|
#expect(await settles { model.loginCompletedGroupID == first.id && old.logoutContinuation != nil })
|
|
#expect(!old.closed)
|
|
await #expect(throws: TailscaleConfigurationError.self) {
|
|
try await retained.open(host: "server", port: 22, timeout: 1)
|
|
}
|
|
await #expect(throws: TailscaleConfigurationError.self) { try await retained.hostKeys(stableNodeID: "node") }
|
|
let replacement = try model.connection(for: first.id)
|
|
#expect(try await replacement.open(host: "server", port: 22, timeout: 1) == 42)
|
|
#expect(try await independent.open(host: "server", port: 22, timeout: 1) == 42)
|
|
old.logoutContinuation?.resume()
|
|
old.logoutContinuation = nil
|
|
#expect(await settles { probe.erased == [firstProfile] })
|
|
#expect(probe.nodes[secondProfile]?.closed == false)
|
|
|
|
var changed = try #require(library.group(second.id))
|
|
changed.tailscale = try group("changed.ts.net").tailscale
|
|
library.replacePersistedSnapshot(.init(groups: [try #require(library.group(first.id)), changed]))
|
|
#expect(throws: TailscaleConfigurationError.self) { try model.connection(for: second.id) }
|
|
await #expect(throws: TailscaleConfigurationError.self) { try await independent.hostKeys(stableNodeID: "node") }
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test func rejectedActivationKeepsThePreviousEnrollmentAndErasesTheCandidate() async throws {
|
|
let group = try group("example.ts.net")
|
|
let store = CleanupFailingStore()
|
|
let old = UUID()
|
|
try store.save(.init(groupID: group.id, profileID: old, hostname: "saved"))
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
let model = probe.model(HostLibraryModel(groups: [group]), store: store)
|
|
model.start()
|
|
#expect(await settles { model.runningGroups.contains(group.id) })
|
|
store.failSave = true
|
|
model.beginLoginAndSave(
|
|
name: group.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil,
|
|
groupID: group.id)
|
|
#expect(await settles { probe.created.count == 2 && !probe.erased.isEmpty })
|
|
#expect(try store.load().map(\.profileID) == [old])
|
|
#expect(probe.erased == [probe.created[1]])
|
|
#expect(probe.nodes[old]?.closed == false)
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test func vaultFailureNeverAttemptsToReplaceThePreviousEnrollment() async throws {
|
|
let group = try group("example.ts.net")
|
|
let persistence = CleanupVaultStore(.init(groups: [group]))
|
|
let library = HostLibraryModel(persistence: persistence)
|
|
let store = CleanupFailingStore()
|
|
let old = UUID()
|
|
try store.save(.init(groupID: group.id, profileID: old, hostname: "saved"))
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
let model = probe.model(library, store: store)
|
|
model.start()
|
|
#expect(await settles { model.runningGroups.contains(group.id) })
|
|
let writes = store.saveAttempts
|
|
persistence.failSave = true
|
|
store.failSave = true
|
|
model.beginLoginAndSave(
|
|
name: "renamed", parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil,
|
|
groupID: group.id)
|
|
#expect(await settles { probe.created.count == 2 && !probe.erased.isEmpty })
|
|
#expect(store.saveAttempts == writes)
|
|
#expect(library.group(group.id)?.name == group.name)
|
|
#expect(try store.load().map(\.profileID) == [old])
|
|
#expect(probe.erased == [probe.created[1]])
|
|
#expect(probe.nodes[old]?.closed == false)
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test func newGroupRemainsLoginRequiredWhenLocalActivationFails() async throws {
|
|
let group = try group("example.ts.net")
|
|
let library = HostLibraryModel()
|
|
let store = CleanupFailingStore()
|
|
store.failSave = true
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
let model = probe.model(library, store: store)
|
|
model.start()
|
|
model.beginLoginAndSave(
|
|
name: group.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil)
|
|
#expect(await settles { !probe.erased.isEmpty })
|
|
#expect(library.groups.first?.tailscale?.network == group.tailscale?.network)
|
|
#expect(model.runningGroups.isEmpty)
|
|
#expect(model.error != nil)
|
|
#expect(try store.load().isEmpty)
|
|
#expect(try store.pendingCleanupProfiles().isEmpty)
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test func failedRetirementPreservesTheEnrollmentUntilItCanBeQueued() async throws {
|
|
let group = try group("example.ts.net")
|
|
let library = HostLibraryModel(groups: [group])
|
|
let store = CleanupFailingStore()
|
|
let profileID = UUID()
|
|
try store.save(.init(groupID: group.id, profileID: profileID))
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
let model = probe.model(library, store: store, access: false)
|
|
model.start()
|
|
store.failRemove = true
|
|
library.replacePersistedSnapshot(.init())
|
|
#expect(model.error != nil)
|
|
#expect(try store.load().map(\.profileID) == [profileID])
|
|
#expect(probe.erased.isEmpty)
|
|
store.failRemove = false
|
|
model.reconcile()
|
|
#expect(await settles { probe.erased == [profileID] })
|
|
#expect(try store.load().isEmpty)
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test func missingCleanupAcknowledgementRetriesAndFailedStateReadsNeverEraseProfiles() async throws {
|
|
let group = try group("example.ts.net")
|
|
let store = CleanupFailingStore()
|
|
let orphan = UUID()
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
probe.discovered = [orphan]
|
|
store.failLoad = true
|
|
let unreadable = probe.model(HostLibraryModel(), store: store, access: false)
|
|
unreadable.start()
|
|
#expect(unreadable.error != nil)
|
|
#expect(probe.erased.isEmpty)
|
|
#expect(probe.inventoryReads == 0)
|
|
await unreadable.shutdown()
|
|
|
|
store.failLoad = false
|
|
store.failComplete = true
|
|
let model = probe.model(HostLibraryModel(), store: store, access: false)
|
|
model.start()
|
|
#expect(await settles { model.error != nil })
|
|
#expect(probe.erased == [orphan])
|
|
#expect(try store.pendingCleanupProfiles() == [orphan])
|
|
store.failComplete = false
|
|
model.reconcile()
|
|
#expect(await settles { probe.erased.count == 2 })
|
|
#expect(try store.pendingCleanupProfiles().isEmpty)
|
|
await model.shutdown()
|
|
}
|
|
|
|
@Test func reopenedStoreNeverErasesAnActiveProfileFromOrphanInventory() async throws {
|
|
let root = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let url = root.appendingPathComponent("Upgrade.store")
|
|
let group = try group("example.ts.net")
|
|
let active = UUID()
|
|
let orphan = UUID()
|
|
try writeStore(url, group: group, profileID: active)
|
|
for _ in 0..<2 {
|
|
let container = try TermindPersistence.makeContainer(url: url)
|
|
let store = SwiftDataTailscaleLocalStore(context: container.mainContext)
|
|
let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext))
|
|
let probe = CleanupProbe(network: try #require(group.tailscale?.network))
|
|
probe.discovered = [active, orphan]
|
|
let model = probe.model(library, store: store, access: false)
|
|
model.start()
|
|
#expect(await settles { probe.erased == [orphan] })
|
|
#expect(try store.load().map(\.profileID) == [active])
|
|
#expect(library.group(group.id)?.tailscale == group.tailscale)
|
|
#expect(try store.pendingCleanupProfiles().isEmpty)
|
|
await model.shutdown()
|
|
}
|
|
}
|
|
|
|
@Test func profileInventoryIncludesOnlyCanonicalUUIDDirectories() throws {
|
|
let root = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let profile = UUID()
|
|
let directory = root.appendingPathComponent(profile.uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(
|
|
at: root.appendingPathComponent("unrelated"), withIntermediateDirectories: true)
|
|
try Data().write(to: root.appendingPathComponent(UUID().uuidString))
|
|
try FileManager.default.createSymbolicLink(
|
|
at: root.appendingPathComponent(UUID().uuidString), withDestinationURL: directory)
|
|
#expect(try LibTailscaleNodeSession.storedProfiles(in: root) == [profile])
|
|
#expect(try LibTailscaleNodeSession.storedProfiles(in: root.appendingPathComponent("missing")).isEmpty)
|
|
}
|
|
|
|
private func writeStore(_ url: URL, group: HostGroup, profileID: UUID) throws {
|
|
let container = try TermindPersistence.makeContainer(url: url)
|
|
_ = try HostPersistence(context: container.mainContext).save(.init(groups: [group]))
|
|
try SwiftDataTailscaleLocalStore(context: container.mainContext).save(
|
|
TailscaleLocalState(groupID: group.id, profileID: profileID, hostname: "saved"))
|
|
}
|
|
|
|
private func group(_ suffix: String) throws -> HostGroup {
|
|
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: suffix)
|
|
var group = HostGroup(name: suffix)
|
|
group.tailscale = TailscaleGroupConfiguration(network: network)
|
|
return group
|
|
}
|
|
|
|
private func temporaryDirectory() throws -> URL {
|
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
return url
|
|
}
|
|
|
|
private func settles(_ predicate: () -> Bool) async -> Bool {
|
|
for _ in 0..<200 {
|
|
if predicate() { return true }
|
|
try? await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
return predicate()
|
|
}
|
|
}
|
|
|
|
@MainActor private final class CleanupProbe {
|
|
let network: TailscaleNetwork
|
|
var created: [UUID] = []
|
|
var erased: [UUID] = []
|
|
var events: [String] = []
|
|
var nodes: [UUID: CleanupNode] = [:]
|
|
var discovered: Set<UUID> = []
|
|
var inventoryReads = 0
|
|
var failErase = false
|
|
var holdFactory = false
|
|
var failFactory = false
|
|
var factoryContinuation: CheckedContinuation<Void, Never>?
|
|
|
|
init(network: TailscaleNetwork) { self.network = network }
|
|
|
|
func model(_ library: HostLibraryModel, store: any TailscaleLocalStoring, access: Bool = true) -> TailscaleModel {
|
|
TailscaleModel(
|
|
library: library, store: store, factory: { id, _, _, _ in try await self.make(id) },
|
|
profileStorage: .init(profiles: { await self.inventory() }, erase: { try await self.erase($0) }),
|
|
hostname: { "test" }, hasAccess: { access })
|
|
}
|
|
|
|
func make(_ id: UUID) async throws -> any TailscaleNodeSession {
|
|
created.append(id)
|
|
if holdFactory { await withCheckedContinuation { factoryContinuation = $0 } }
|
|
if failFactory { throw CocoaError(.fileReadUnknown) }
|
|
let node = CleanupNode(network: network) { self.events.append("close:\(id)") }
|
|
nodes[id] = node
|
|
return node
|
|
}
|
|
func releaseFactory() {
|
|
factoryContinuation?.resume()
|
|
factoryContinuation = nil
|
|
holdFactory = false
|
|
}
|
|
func inventory() -> Set<UUID> {
|
|
inventoryReads += 1
|
|
return discovered
|
|
}
|
|
func erase(_ id: UUID) throws {
|
|
#expect(nodes[id]?.closed != false)
|
|
if failErase { throw CocoaError(.fileWriteNoPermission) }
|
|
events.append("erase:\(id)")
|
|
erased.append(id)
|
|
}
|
|
}
|
|
|
|
@MainActor private final class CleanupNode: TailscaleNodeSession {
|
|
let network: TailscaleNetwork
|
|
let onClose: () -> Void
|
|
var closed = false
|
|
var holdLogout = false
|
|
var logoutContinuation: CheckedContinuation<Void, Never>?
|
|
init(network: TailscaleNetwork, onClose: @escaping () -> Void) {
|
|
self.network = network
|
|
self.onClose = onClose
|
|
}
|
|
func status() async throws -> TailscaleNodeStatus {
|
|
.init(running: true, network: network, authURL: nil, peers: [])
|
|
}
|
|
func login() async throws {}
|
|
func setHostname(_ hostname: String) async throws {}
|
|
@MainActor func logout() async throws {
|
|
if holdLogout { await withCheckedContinuation { logoutContinuation = $0 } }
|
|
}
|
|
func close() async {
|
|
await MainActor.run {
|
|
if !closed {
|
|
closed = true
|
|
onClose()
|
|
}
|
|
}
|
|
}
|
|
func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { 42 }
|
|
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> { [] }
|
|
}
|
|
|
|
@MainActor private final class CleanupFailingStore: TailscaleLocalStoring {
|
|
let base = InMemoryTailscaleLocalStore()
|
|
var failSave = false
|
|
var failLoad = false
|
|
var failComplete = false
|
|
var failRemove = false
|
|
var saveAttempts = 0
|
|
func load() throws -> [TailscaleLocalState] {
|
|
if failLoad { throw CocoaError(.fileReadUnknown) }
|
|
return try base.load()
|
|
}
|
|
func save(_ state: TailscaleLocalState) throws {
|
|
saveAttempts += 1
|
|
if failSave { throw CocoaError(.fileWriteUnknown) }
|
|
try base.save(state)
|
|
}
|
|
func remove(groupID: UUID) throws {
|
|
if failRemove { throw CocoaError(.fileWriteUnknown) }
|
|
try base.remove(groupID: groupID)
|
|
}
|
|
func scheduleCleanup(profileID: UUID) throws { try base.scheduleCleanup(profileID: profileID) }
|
|
func pendingCleanupProfiles() throws -> Set<UUID> { try base.pendingCleanupProfiles() }
|
|
func completeCleanup(profileID: UUID) throws {
|
|
if failComplete { throw CocoaError(.fileWriteUnknown) }
|
|
try base.completeCleanup(profileID: profileID)
|
|
}
|
|
}
|
|
|
|
@MainActor private final class CleanupVaultStore: VaultPersisting {
|
|
var snapshot: VaultSnapshot
|
|
var failSave = false
|
|
init(_ snapshot: VaultSnapshot) { self.snapshot = snapshot }
|
|
func load() throws -> VaultSnapshot { snapshot }
|
|
func save(_ snapshot: VaultSnapshot, touching keys: Set<VaultRecordKey>) throws -> VaultMutationSet {
|
|
if failSave { throw CocoaError(.fileWriteUnknown) }
|
|
self.snapshot = snapshot
|
|
return .empty
|
|
}
|
|
}
|