Files
Qiu Yuzhou f9e95474e5 refactor: adopt age configuration storage
Replace the custom encrypted container with AgeSwift 0.1.0 so standard
age tools can inspect sensitive configuration independently of the app.

Use independent Keychain X25519 identities for catalog.age and
proxy-listening.age. Preserve atomic commits and confirmed recovery;
reject historical NG2 formats without migration or cleanup.
2026-10-21 14:31:32 +08:00

311 lines
13 KiB
Swift

import Foundation
import Testing
@testable import ShadowsocksX_NG2
@MainActor
struct ServerFormLoadingTests {
@Test func absentServerCannotLoadAnEditForm() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let workflow = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(
fileStore: makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.age")),
runtime: FakeCatalogRuntime()))
#expect(try workflow.serverEditForm(for: .fresh()) == nil)
}
@Test
func appearingAgainAndUpdatingPresentationDoNotReloadManualDraft() {
let fields = ServerFormFields()
let id = NodeID(rawValue: "manual")
var reads = 0
let load = { (_: NodeID) -> ServerEditForm? in
reads += 1
return Self.form()
}
fields.showServer(id, load: load)
fields.remark = "未保存名称"
fields.showServer(id, load: load)
fields.updatePresentation(ServerFormPresentation(isEditable: true, plugin: Self.form().plugin))
#expect(reads == 1)
#expect(fields.remark == "未保存名称")
#expect(fields.hasChanges)
fields.reloadServer(load: load)
#expect(reads == 2)
#expect(fields.remark == "服务器")
#expect(!fields.hasChanges)
}
@Test
func resetFailurePreservesManualDraftButBlocksSavingUntilReloadSucceeds() {
let fields = ServerFormFields()
fields.showServer(NodeID(rawValue: "manual")) { _ in Self.form() }
fields.password = "edited password"
fields.reloadServer { _ in throw ServerFormLoadError.credentialsUnavailable }
#expect(fields.password == "edited password")
#expect(fields.hasLoadedServer)
#expect(!fields.canSaveServer)
#expect(fields.loadFailure == .credentialsUnavailable)
fields.reloadServer { _ in Self.form(password: "latest") }
#expect(fields.password == "latest")
#expect(fields.canSaveServer)
#expect(fields.loadFailure == nil)
#expect(!fields.hasChanges)
}
@Test
func failedSelectionNeverShowsPreviousServerAndLateSaveDoesNotResetNewDraft() {
let fields = ServerFormFields()
let first = NodeID(rawValue: "first")
let second = NodeID(rawValue: "second")
fields.showServer(first) { _ in Self.form() }
fields.showServer(second) { _ in throw ServerFormLoadError.credentialsUnavailable }
#expect(fields.password.isEmpty)
#expect(fields.address.isEmpty)
#expect(!fields.hasLoadedServer)
#expect(!fields.canSaveServer)
fields.reloadServer { _ in Self.form(password: "second password") }
fields.password = "unsaved second password"
fields.didSaveServer(first) { _ in
Issue.record("保存其他服务器不应装载当前草稿")
return Self.form()
}
#expect(fields.password == "unsaved second password")
fields.didSaveServer(second) { _ in Self.form(password: "saved second password") }
#expect(fields.password == "saved second password")
#expect(!fields.hasChanges)
}
@Test
func onlyAffectedReadOnlyServerReloadsAndFailedReloadClearsOldFields() {
let fields = ServerFormFields()
let id = NodeID(rawValue: "subscription")
fields.showServer(id) { _ in Self.form(isEditable: false) }
fields.subscriptionDidRefresh(affectedServers: [NodeID(rawValue: "other")]) { _ in
Issue.record("其他订阅不应触发装载")
return nil
}
#expect(fields.password == "pw")
fields.subscriptionDidRefresh(affectedServers: [id]) { _ in
throw ServerFormLoadError.credentialsUnavailable
}
#expect(fields.password.isEmpty)
#expect(fields.portText.isEmpty)
#expect(fields.pluginOptions.composedString == "")
#expect(!fields.hasLoadedServer)
#expect(fields.loadFailure == .credentialsUnavailable)
fields.updatePresentation(
ServerFormPresentation(isEditable: false, plugin: Self.form().plugin))
fields.subscriptionDidRefresh(affectedServers: [id]) { _ in
Self.form(isEditable: false, password: "new password")
}
#expect(fields.password == "new password")
#expect(fields.loadFailure == nil)
fields.showServer(NodeID(rawValue: "manual")) { _ in Self.form() }
fields.password = "manual draft"
fields.subscriptionDidRefresh(affectedServers: [fields.serverID!]) { _ in
Issue.record("手动草稿不应自动装载")
return nil
}
#expect(fields.password == "manual draft")
}
@Test func presentationOmitsSecretsAndEditFormReadsCompleteRecord() async throws {
try await Self.withWorkflow { workflow in
let program = ManagedPluginCatalog.plugins[0].program
let id = try await workflow.createServer(
ServerEditDraft(
address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm",
password: "pw", remark: "服务器", plugin: .named(program: program),
pluginOptions: "mode=websocket"), into: nil)
let presentation = try #require(workflow.serverFormPresentation(for: id))
#expect(presentation.isEditable)
#expect(presentation.plugin.optionsPresent)
#expect(presentation.plugin.options.isEmpty)
let form = try #require(try workflow.serverEditForm(for: id))
#expect(form.password == "pw")
#expect(form.plugin.options == "mode=websocket")
}
}
@Test
func successfulRefreshReloadsSecretChangesAndFailureDoesNotReload() async throws {
let fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.flat(serverCount: 1)))
try await Self.withWorkflow(fetcher: fetcher) { workflow in
let summary = try await workflow.createSubscription(urlString: "https://example.com/sub.json")
let server = try #require(workflow.tree.node(withID: summary.groupID)?.childNodes.first)
let fields = ServerFormFields()
fields.showServer(server.id, load: workflow.serverEditForm)
let oldTree = workflow.tree
var reloads = 0
let observation = workflow.subscriptionServerRefreshes.sink { affected in
fields.updatePresentation(workflow.serverFormPresentation(for: server.id))
fields.subscriptionDidRefresh(affectedServers: affected) { id in
reloads += 1
return try workflow.serverEditForm(for: id)
}
}
defer { observation.cancel() }
let original = try #require(
String(data: SubscriptionDocs.flat(serverCount: 1), encoding: .utf8))
let changed = original.replacingOccurrences(
of: "\"password\": \"pw\"", with: "\"password\": \"new password\"")
fetcher.setBehavior(.success(Data(changed.utf8)))
await workflow.refreshSubscription(summary.id)
#expect(workflow.tree.roots.map(\.id) == oldTree.roots.map(\.id))
#expect(workflow.tree.node(withID: server.id)?.createdAt == server.createdAt)
#expect(workflow.tree.node(withID: server.id)?.updatedAt != server.updatedAt)
#expect(fields.password == "new password")
#expect(reloads == 1)
fetcher.setBehavior(.failure(.transport(detail: "fixture failure")))
await workflow.refreshSubscription(summary.id)
#expect(reloads == 1)
#expect(fields.password == "new password")
fetcher.setBehavior(.success(Data(changed.utf8)))
_ = try await workflow.createSubscription(urlString: "https://other.example/sub.json")
#expect(reloads == 1)
}
}
private static func withWorkflow(
fetcher: SubscriptionFetching = FakeSubscriptionFetcher(behavior: .success(Data())),
_ body: (CatalogWorkflow) async throws -> Void
) async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let workflow = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(
fileStore: makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json")),
runtime: FakeCatalogRuntime()), subscriptionFetcher: fetcher)
try await body(workflow)
}
private static func form(isEditable: Bool = true, password: String = "pw") -> ServerEditForm {
ServerEditForm(
address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm",
password: password, remark: "服务器",
plugin: PluginSectionState(
selection: .none, programs: [], mappingsUnreadable: false,
optionsPresent: false, options: ""), isEditable: isEditable)
}
}
extension ServerFormLoadingTests {
@Test
func newFormStartsUnchangedAndReturningToDefaultsClearsChanges() {
let fields = ServerFormFields.newForm()
#expect(!fields.hasChanges)
fields.remark = "未保存"
#expect(fields.hasChanges)
fields.remark = ""
#expect(!fields.hasChanges)
fields.portText = "bad"
#expect(fields.hasChanges)
}
@Test func detailFactsOmitSecretsAndExplicitParameterReadPreservesRawValue() async throws {
try await Self.withWorkflow { workflow in
let program = ManagedPluginCatalog.plugins[0].program
let id = try await workflow.createServer(
ServerEditDraft(
address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm",
password: "pw", remark: "服务器", plugin: .named(program: program),
pluginOptions: "tls;host=example.com;host=other.example;empty="), into: nil)
let facts = try #require(workflow.serverDetailPresentation(for: id))
#expect(facts.name == "服务器")
#expect(facts.plugin.options.isEmpty)
#expect(
try workflow.serverDetailPluginOptions(for: id)
== "tls;host=example.com;host=other.example;empty=")
}
}
@Test
func unknownPluginDetailReadsOptionsWithoutLoadingPassword() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json"))
var catalog = ConfigurationCatalog()
let id = try catalog.addServer(
ServerFields(
address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm",
password: "password", remark: "未知插件服务器",
pluginProgram: "unknown-test-plugin", pluginOptions: "opaque=one;opaque=two"))
try store.save(CatalogDocument(catalog: catalog))
let workflow = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()))
#expect(
workflow.serverDetailPresentation(for: id)?.plugin.selection
== .unknown(program: "unknown-test-plugin"))
#expect(try workflow.serverDetailPluginOptions(for: id) == "opaque=one;opaque=two")
}
@Test
func parameterSessionSwitchesIdentityAndOnlyReloadsAffectedRefreshes() {
let parameters = ServerDetailParameters()
let first = NodeID(rawValue: "first")
let second = NodeID(rawValue: "second")
var reads = 0
parameters.showServer(first) { _ in
reads += 1
return "tls;host=one;host=two;empty="
}
#expect(parameters.options.rows.filter { !$0.isBlank }.count == 4)
parameters.showServer(first) { _ in
reads += 1
return "tls;host=one;host=two;empty="
}
parameters.didRefresh(affectedServers: [second]) { _ in
Issue.record("不相关刷新不应重载")
return ""
}
parameters.didRefresh(affectedServers: [first]) { _ in
reads += 1
return "host=new"
}
#expect(reads == 3)
#expect(parameters.options.composedString == "host=new")
parameters.showServer(second) { _ in throw ServerFormLoadError.credentialsUnavailable }
#expect(parameters.options.composedString == "")
#expect(parameters.failure == .credentialsUnavailable)
parameters.reload { _ in "trailing\\" }
#expect(parameters.failure == nil)
#expect(parameters.options.mode == .rawText)
#expect(parameters.options.rawText == "trailing\\")
parameters.didRefresh(affectedServers: [second]) { _ in
throw ServerFormLoadError.credentialsUnavailable
}
#expect(parameters.options.rawText.isEmpty)
#expect(parameters.failure == .credentialsUnavailable)
}
@Test
func editingPresentationChangesNeverReplaceDraftAndDeletionBlocksSaving() {
let fields = ServerFormFields()
let id = NodeID(rawValue: "manual")
let form = ServerEditForm(
address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm",
password: "pw", remark: "服务器",
plugin: PluginSectionState(
selection: .unknown(program: "plugin"), programs: [], mappingsUnreadable: false,
optionsPresent: true, options: ""), isEditable: true)
fields.showServer(id) { _ in form }
fields.remark = "未保存名称"
let updatedPlugin = PluginSectionState(
selection: .named(program: "plugin"), programs: [], mappingsUnreadable: false,
optionsPresent: true, options: "")
fields.updatePresentation(
ServerFormPresentation(isEditable: true, plugin: updatedPlugin), preservingDraft: true)
#expect(fields.pluginChoice == .unknown(program: "plugin"))
#expect(fields.remark == "未保存名称")
#expect(fields.canSaveServer)
fields.updatePresentation(nil, preservingDraft: true)
#expect(!fields.canSaveServer)
#expect(fields.hasLoadedServer)
#expect(fields.password == "pw")
#expect(fields.remark == "未保存名称")
}
}