diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index ed9df0d..d04dd81 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -114,6 +114,9 @@ 5F9D7B0D27AF6F7500A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0E27AF6FCA00A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; }; + 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; }; + 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; 9A1F47FA26E5CF4B000C0E01 /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1F47F926E5CF4B000C0E01 /* OneTimePassword */; }; @@ -195,7 +198,7 @@ DC4914961E434301007FF592 /* LabelTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914941E434301007FF592 /* LabelTableViewCell.swift */; }; DC4914991E434600007FF592 /* PasswordDetailTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */; }; DC5F385B1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */; }; - DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */; }; + DC6474532D20DD0C004B4BBC /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* PersistenceController.swift */; }; DC64745C2D29BE9B004B4BBC /* PasswordEntityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */; }; DC64745D2D29BEA9004B4BBC /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */; }; DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC64745E2D45B23A004B4BBC /* GitRepository.swift */; }; @@ -422,6 +425,9 @@ 30F6C1B327664C7200BE5AB2 /* SVProgressHUD.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SVProgressHUD.xcframework; path = Carthage/Build/SVProgressHUD.xcframework; sourceTree = ""; }; 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = ""; }; 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; + 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = ""; }; + 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = ""; }; + 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; 9A1EF0B524C50EE00074FEAC /* passBetaExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaExtension.entitlements; sourceTree = ""; }; @@ -498,7 +504,7 @@ DC4914941E434301007FF592 /* LabelTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelTableViewCell.swift; sourceTree = ""; }; DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasswordDetailTableViewController.swift; sourceTree = ""; }; DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PGPKeyArmorImportTableViewController.swift; sourceTree = ""; }; - DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = ""; }; + DC6474522D20DD0C004B4BBC /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = ""; }; DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = ""; }; DC64745E2D45B23A004B4BBC /* GitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepository.swift; sourceTree = ""; }; @@ -624,6 +630,7 @@ 301F6464216164670071A4CE /* Helpers */ = { isa = PBXGroup; children = ( + 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */, 3032328922C9FBA2009EBD9C /* KeyFileManagerTest.swift */, ); path = Helpers; @@ -761,6 +768,14 @@ path = Crypto; sourceTree = ""; }; + 8A4716702F5EF7A900C7A64D /* Controllers */ = { + isa = PBXGroup; + children = ( + 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */, + ); + path = Controllers; + sourceTree = ""; + }; 9A58664F25AADB66006719C2 /* Services */ = { isa = PBXGroup; children = ( @@ -879,10 +894,12 @@ A26075861EEC6F34005DB03E /* passKitTests */ = { isa = PBXGroup; children = ( + 8A4716702F5EF7A900C7A64D /* Controllers */, DC64745A2D29BD43004B4BBC /* CoreData */, 30A86F93230F235800F821A4 /* Crypto */, 30BAC8C322E3BA4300438475 /* Testbase */, 30697C5521F63F870064FCAC /* Extensions */, + 8AD8EBF22F5E268D007475AB /* Fixtures */, 301F6464216164670071A4CE /* Helpers */, 30C015A7214ED378005BB6DF /* Models */, 30C015A6214ED32A005BB6DF /* Parser */, @@ -913,7 +930,7 @@ children = ( 30697C3121F63C8B0064FCAC /* PasscodeLockPresenter.swift */, 30697C3221F63C8B0064FCAC /* PasscodeLockViewController.swift */, - DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */, + DC6474522D20DD0C004B4BBC /* PersistenceController.swift */, ); path = Controllers; sourceTree = ""; @@ -1427,6 +1444,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1604,7 +1622,7 @@ 3087574F2343E42A00B971A2 /* Colors.swift in Sources */, 30697C2C21F63C5A0064FCAC /* FileManagerExtension.swift in Sources */, 30697C3321F63C8B0064FCAC /* PasscodeLockPresenter.swift in Sources */, - DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */, + DC6474532D20DD0C004B4BBC /* PersistenceController.swift in Sources */, 30697C3D21F63C990064FCAC /* UIViewExtension.swift in Sources */, 30697C3A21F63C990064FCAC /* UIViewControllerExtension.swift in Sources */, 30697C2E21F63C5A0064FCAC /* Utils.swift in Sources */, @@ -1623,6 +1641,7 @@ 30A86F95230F237000F821A4 /* CryptoFrameworkTest.swift in Sources */, 30A1D2AC21B32C2A00E2D1F7 /* TokenBuilderTest.swift in Sources */, 30DAFD4C240985E3002456E7 /* Array+SlicesTest.swift in Sources */, + 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */, 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, @@ -1630,6 +1649,7 @@ A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */, 30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */, A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */, + 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */, 30695E2524FAEF2600C9D46E /* GitCredentialTest.swift in Sources */, 30BAC8C622E3BAAF00438475 /* TestBase.swift in Sources */, 30B04860209A5141001013CA /* PasswordTest.swift in Sources */, diff --git a/pass/de.lproj/Localizable.strings b/pass/de.lproj/Localizable.strings index 8177a35..355b200 100644 --- a/pass/de.lproj/Localizable.strings +++ b/pass/de.lproj/Localizable.strings @@ -72,6 +72,7 @@ "KeyImportError." = "Schlüssel kann nicht importiert werden."; "FileNotFoundError." = "Die Datei '%@' kann nicht gelesen werden."; "PasswordDuplicatedError." = "Passwort kann nicht hinzugefügt werden; es existiert bereits."; +"CannotDeleteNonEmptyDirectoryError." = "Ordner muss erst leer sein um gelöscht werden zu können."; "GitResetError." = "Der zuletzt synchronisierte Commit kann nicht identifiziert werden."; "GitCreateSignatureError." = "Es konnte keine valide Signatur für den Author/Committer angelegt werden."; "GitPushNotSuccessfulError." = "Die Übertragung der lokalen Änderungen war nicht erfolgreich. Stelle bitte sicher, dass auf dem Remote-Repository alle Änderungen commitet sind."; diff --git a/pass/en.lproj/Localizable.strings b/pass/en.lproj/Localizable.strings index 5f26010..a4f74e3 100644 --- a/pass/en.lproj/Localizable.strings +++ b/pass/en.lproj/Localizable.strings @@ -73,6 +73,7 @@ "KeyImportError." = "Cannot import the key."; "FileNotFoundError." = "File '%@' cannot be read."; "PasswordDuplicatedError." = "Cannot add the password; password is duplicated."; +"CannotDeleteNonEmptyDirectoryError." = "Delete passwords from the directory before deleting the directory itself."; "GitResetError." = "Cannot identify the latest synced commit."; "GitCreateSignatureError." = "Cannot create a valid author/committer signature."; "GitPushNotSuccessfulError." = "Pushing local changes was not successful. Make sure there are no uncommitted changes on the remote repository."; diff --git a/passKit/Controllers/CoreDataStack.swift b/passKit/Controllers/PersistenceController.swift similarity index 90% rename from passKit/Controllers/CoreDataStack.swift rename to passKit/Controllers/PersistenceController.swift index 0452259..5d05001 100644 --- a/passKit/Controllers/CoreDataStack.swift +++ b/passKit/Controllers/PersistenceController.swift @@ -1,5 +1,5 @@ // -// CoreDataStack.swift +// PersistenceController.swift // passKit // // Created by Mingshen Sun on 12/28/24. @@ -18,19 +18,19 @@ public class PersistenceController { let container: NSPersistentContainer - init(isUnitTest: Bool = false) { + init(storeURL: URL? = nil) { self.container = NSPersistentContainer(name: Self.modelName, managedObjectModel: .sharedModel) let description = container.persistentStoreDescriptions.first description?.shouldMigrateStoreAutomatically = false description?.shouldInferMappingModelAutomatically = false - if isUnitTest { - description?.url = URL(fileURLWithPath: "/dev/null") - } else { - description?.url = URL(fileURLWithPath: Globals.dbPath) - } + description?.url = storeURL ?? URL(fileURLWithPath: Globals.dbPath) setup() } + static func forUnitTests() -> PersistenceController { + PersistenceController(storeURL: URL(fileURLWithPath: "/dev/null")) + } + func setup() { container.loadPersistentStores { _, error in if error != nil { diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 66fbbed..f193515 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -34,6 +34,10 @@ public class PGPAgent { pgpInterface = nil } + public func isInitialized() -> Bool { + pgpInterface != nil + } + public func getKeyID() throws -> [String] { try checkAndInit() return pgpInterface?.keyID ?? [] diff --git a/passKit/Helpers/AppError.swift b/passKit/Helpers/AppError.swift index 8e0aa21..28ceb73 100644 --- a/passKit/Helpers/AppError.swift +++ b/passKit/Helpers/AppError.swift @@ -15,6 +15,7 @@ public enum AppError: Error, Equatable { case keyImport case readingFile(fileName: String) case passwordDuplicated + case cannotDeleteNonEmptyDirectory case gitReset case gitCommit case gitCreateSignature diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 495dce2..b918ab4 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -273,9 +273,15 @@ public class PasswordStore { } public func delete(passwordEntity: PasswordEntity) throws { + if !passwordEntity.children.isEmpty { + throw AppError.cannotDeleteNonEmptyDirectory + } + let deletedFileURL = passwordEntity.fileURL(in: storeURL) let deletedFilePath = passwordEntity.path - try gitRm(path: passwordEntity.path) + if !passwordEntity.isDir { + try gitRm(path: passwordEntity.path) + } try deletePasswordEntities(passwordEntity: passwordEntity) try deleteDirectoryTree(at: deletedFileURL) try gitCommit(message: "RemovePassword.".localize(deletedFilePath)) @@ -283,6 +289,11 @@ public class PasswordStore { } public func edit(passwordEntity: PasswordEntity, password: Password, keyID: String? = nil) throws -> PasswordEntity? { + guard !passwordEntity.isDir else { + // caller should ensure this, so this is not a user-facing error + throw AppError.other(message: "Cannot edit a directory") + } + var newPasswordEntity: PasswordEntity? = passwordEntity let url = passwordEntity.fileURL(in: storeURL) @@ -320,11 +331,11 @@ public class PasswordStore { saveUpdatedContext() } - public func saveUpdatedContext() { + private func saveUpdatedContext() { PersistenceController.shared.save() } - public func deleteCoreData() { + private func deleteCoreData() { PasswordEntity.deleteAll(in: context) PersistenceController.shared.save() } diff --git a/passKitTests/Controllers/PersistenceControllerTest.swift b/passKitTests/Controllers/PersistenceControllerTest.swift new file mode 100644 index 0000000..4ae642f --- /dev/null +++ b/passKitTests/Controllers/PersistenceControllerTest.swift @@ -0,0 +1,93 @@ +// +// PersistenceControllerTest.swift +// passKitTests +// +// Created by Lysann Tranvouez on 9/3/26. +// Copyright © 2026 Bob Sun. All rights reserved. +// + +import CoreData +import XCTest + +@testable import passKit + +final class PersistenceControllerTest: XCTestCase { + func testModelLoads() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + + let entityNames = context.persistentStoreCoordinator!.managedObjectModel.entities.map(\.name) + XCTAssertEqual(entityNames, ["PasswordEntity"]) + } + + func testInsertAndFetch() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 0) + + PasswordEntity.insert(name: "test", path: "test.gpg", isDir: false, into: context) + try? context.save() + + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 1) + } + + func testReinitializePersistentStoreClearsData() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + + PasswordEntity.insert(name: "test1", path: "test1.gpg", isDir: false, into: context) + PasswordEntity.insert(name: "test2", path: "test2.gpg", isDir: false, into: context) + try? context.save() + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 2) + + controller.reinitializePersistentStore() + + // After reinitialize, old data should be gone + // (reinitializePersistentStore calls initPasswordEntityCoreData with the default repo URL, + // which won't exist in tests, so the result should be an empty store) + let remaining = PasswordEntity.fetchAll(in: context) + XCTAssertEqual(remaining.count, 0) + } + + func testMultipleControllersAreIndependent() { + let controller1 = PersistenceController.forUnitTests() + let controller2 = PersistenceController.forUnitTests() + + let context1 = controller1.viewContext() + let context2 = controller2.viewContext() + + PasswordEntity.insert(name: "only-in-1", path: "only-in-1.gpg", isDir: false, into: context1) + try? context1.save() + + XCTAssertEqual(PasswordEntity.fetchAll(in: context1).count, 1) + XCTAssertEqual(PasswordEntity.fetchAll(in: context2).count, 0) + } + + func testSaveAndLoadFromFile() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let storeURL = tempDir.appendingPathComponent("test.sqlite") + + // Write + let controller1 = PersistenceController(storeURL: storeURL) + let context1 = controller1.viewContext() + PasswordEntity.insert(name: "saved", path: "saved.gpg", isDir: false, into: context1) + PasswordEntity.insert(name: "dir", path: "dir", isDir: true, into: context1) + controller1.save() + + // Load in a fresh controller from the same file + let controller2 = PersistenceController(storeURL: storeURL) + let context2 = controller2.viewContext() + let allEntities = PasswordEntity.fetchAll(in: context2) + + XCTAssertEqual(allEntities.count, 2) + XCTAssertNotNil(allEntities.first { $0.name == "saved" && !$0.isDir }) + XCTAssertNotNil(allEntities.first { $0.name == "dir" && $0.isDir }) + } + + func testSaveError() throws { + // NOTE: save() calls fatalError on Core Data save failures, so error propagation + // cannot be tested without refactoring save() to throw... + } +} diff --git a/passKitTests/CoreData/CoreDataTestCase.swift b/passKitTests/CoreData/CoreDataTestCase.swift index fa356bf..b11ddd1 100644 --- a/passKitTests/CoreData/CoreDataTestCase.swift +++ b/passKitTests/CoreData/CoreDataTestCase.swift @@ -20,7 +20,7 @@ class CoreDataTestCase: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - controller = PersistenceController(isUnitTest: true) + controller = PersistenceController.forUnitTests() } override func tearDown() { diff --git a/passKitTests/CoreData/PasswordEntityTest.swift b/passKitTests/CoreData/PasswordEntityTest.swift index 6362e2a..ad70f73 100644 --- a/passKitTests/CoreData/PasswordEntityTest.swift +++ b/passKitTests/CoreData/PasswordEntityTest.swift @@ -85,4 +85,99 @@ final class PasswordEntityTest: CoreDataTestCase { XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 0) } + + // MARK: - initPasswordEntityCoreData tests + + func testInitPasswordEntityCoreDataBuildsTree() throws { + let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDir) } + + // Create directory structure: + // email/ + // work.gpg + // personal.gpg + // social/ + // mastodon.gpg + // toplevel.gpg + // notes.txt (non-.gpg file) + let emailDir = rootDir.appendingPathComponent("email") + let socialDir = rootDir.appendingPathComponent("social") + try FileManager.default.createDirectory(at: emailDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: socialDir, withIntermediateDirectories: true) + try Data("test1".utf8).write(to: emailDir.appendingPathComponent("work.gpg")) + try Data("test2".utf8).write(to: emailDir.appendingPathComponent("personal.gpg")) + try Data("test3".utf8).write(to: socialDir.appendingPathComponent("mastodon.gpg")) + try Data("test4".utf8).write(to: rootDir.appendingPathComponent("toplevel.gpg")) + try Data("test5".utf8).write(to: rootDir.appendingPathComponent("notes.txt")) + + let context = controller.viewContext() + PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) + + // Verify total counts + let allEntities = PasswordEntity.fetchAll(in: context) + let files = allEntities.filter { !$0.isDir } + let dirs = allEntities.filter(\.isDir) + XCTAssertEqual(files.count, 5) // 4 .gpg + 1 .txt + XCTAssertEqual(dirs.count, 2) // email, social + + // Verify .gpg extension is stripped + let workEntity = allEntities.first { $0.path == "email/work.gpg" } + XCTAssertNotNil(workEntity) + XCTAssertEqual(workEntity!.name, "work") + + // Verify non-.gpg file keeps its extension + let notesEntity = allEntities.first { $0.path == "notes.txt" } + XCTAssertNotNil(notesEntity) + XCTAssertEqual(notesEntity!.name, "notes.txt") + + // Verify parent-child relationships + let emailEntity = allEntities.first { $0.path == "email" && $0.isDir } + XCTAssertNotNil(emailEntity) + XCTAssertEqual(emailEntity!.children.count, 2) + + // Verify top-level files have no parent (root was deleted) + let toplevelEntity = allEntities.first { $0.path == "toplevel.gpg" } + XCTAssertNotNil(toplevelEntity) + XCTAssertEqual(toplevelEntity!.name, "toplevel") + XCTAssertNil(toplevelEntity!.parent) + } + + func testInitPasswordEntityCoreDataSkipsHiddenFiles() throws { + let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDir) } + + try Data("test".utf8).write(to: rootDir.appendingPathComponent("visible.gpg")) + try Data("test".utf8).write(to: rootDir.appendingPathComponent(".hidden.gpg")) + try Data("test".utf8).write(to: rootDir.appendingPathComponent(".gpg-id")) + try FileManager.default.createDirectory(at: rootDir.appendingPathComponent(".git"), withIntermediateDirectories: true) + try Data("test".utf8).write(to: rootDir.appendingPathComponent(".git/config")) + + let context = controller.viewContext() + PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) + + let allEntities = PasswordEntity.fetchAll(in: context) + XCTAssertEqual(allEntities.count, 1) + XCTAssertEqual(allEntities.first!.name, "visible") + } + + func testInitPasswordEntityCoreDataHandlesEmptyDirectory() throws { + let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDir) } + + try FileManager.default.createDirectory(at: rootDir.appendingPathComponent("emptydir"), withIntermediateDirectories: true) + + let context = controller.viewContext() + PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) + + let allEntities = PasswordEntity.fetchAll(in: context) + let dirs = allEntities.filter(\.isDir) + let files = allEntities.filter { !$0.isDir } + XCTAssertEqual(dirs.count, 1) + XCTAssertEqual(dirs.first!.name, "emptydir") + XCTAssertEqual(dirs.first!.children.count, 0) + XCTAssertEqual(files.count, 0) + } } diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/HEAD b/passKitTests/Fixtures/password-store-empty-dirs.git/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty-dirs.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/config b/passKitTests/Fixtures/password-store-empty-dirs.git/config new file mode 100644 index 0000000..e6da231 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty-dirs.git/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/description b/passKitTests/Fixtures/password-store-empty-dirs.git/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty-dirs.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/info/exclude b/passKitTests/Fixtures/password-store-empty-dirs.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty-dirs.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/44/73d8218dcffe837e18d56d74c240e565461aea b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/44/73d8218dcffe837e18d56d74c240e565461aea new file mode 100644 index 0000000..6fa149a Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/44/73d8218dcffe837e18d56d74c240e565461aea differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/4c/f9f177c4c015836fca6a31f9c3917e89ae29ec b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/4c/f9f177c4c015836fca6a31f9c3917e89ae29ec new file mode 100644 index 0000000..1c17f20 Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/4c/f9f177c4c015836fca6a31f9c3917e89ae29ec differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 new file mode 100644 index 0000000..5f9c0af Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a new file mode 100644 index 0000000..6802d49 Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/d5/64d0bc3dd917926892c55e3706cc116d5b165e b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/d5/64d0bc3dd917926892c55e3706cc116d5b165e new file mode 100644 index 0000000..24279dc Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/d5/64d0bc3dd917926892c55e3706cc116d5b165e differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 new file mode 100644 index 0000000..7112238 Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/fb/cbb5819e1c864ef33cfffa179a71387a5d90d0 b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/fb/cbb5819e1c864ef33cfffa179a71387a5d90d0 new file mode 100644 index 0000000..7052504 Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/fb/cbb5819e1c864ef33cfffa179a71387a5d90d0 differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/packed-refs b/passKitTests/Fixtures/password-store-empty-dirs.git/packed-refs new file mode 100644 index 0000000..025abc4 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty-dirs.git/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +fbcbb5819e1c864ef33cfffa179a71387a5d90d0 refs/heads/main diff --git a/passKitTests/Fixtures/password-store-empty.git/HEAD b/passKitTests/Fixtures/password-store-empty.git/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/passKitTests/Fixtures/password-store-empty.git/config b/passKitTests/Fixtures/password-store-empty.git/config new file mode 100644 index 0000000..e6da231 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true diff --git a/passKitTests/Fixtures/password-store-empty.git/description b/passKitTests/Fixtures/password-store-empty.git/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/passKitTests/Fixtures/password-store-empty.git/info/exclude b/passKitTests/Fixtures/password-store-empty.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 new file mode 100644 index 0000000..adf6411 Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 differ diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 b/passKitTests/Fixtures/password-store-empty.git/objects/4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 new file mode 100644 index 0000000..c1ae0b0 Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty.git/objects/4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 differ diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 b/passKitTests/Fixtures/password-store-empty.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 new file mode 100644 index 0000000..5f9c0af Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 differ diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/ae/a863facd4acba3b4862e3f42847da1000e486a b/passKitTests/Fixtures/password-store-empty.git/objects/ae/a863facd4acba3b4862e3f42847da1000e486a new file mode 100644 index 0000000..7dbbc42 Binary files /dev/null and b/passKitTests/Fixtures/password-store-empty.git/objects/ae/a863facd4acba3b4862e3f42847da1000e486a differ diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd b/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd new file mode 100644 index 0000000..3e561dd --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd @@ -0,0 +1,3 @@ +xM +0@a9 \zi:@BzzK}eN*3Sv48J=Eo؎D҈k"EMnKNRy,_pr_9p;qETھ +&rɒi@ \ No newline at end of file diff --git a/passKitTests/Fixtures/password-store-empty.git/packed-refs b/passKitTests/Fixtures/password-store-empty.git/packed-refs new file mode 100644 index 0000000..432aeb4 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +f095bb4897e4cd58faadfe4d4f678fb697be3ffd refs/heads/main diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD b/passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD new file mode 100644 index 0000000..ef06926 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD @@ -0,0 +1 @@ +925eb0f6b19282b5f10dfe008e0062b4be6dd41a not-for-merge branch 'master' of https://github.com/mssun/passforios-password-store diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/HEAD b/passKitTests/Fixtures/password-store-with-gpgid.git/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/config b/passKitTests/Fixtures/password-store-with-gpgid.git/config new file mode 100644 index 0000000..876f087 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/config @@ -0,0 +1,9 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true +[remote "origin"] + url = https://github.com/mssun/passforios-password-store.git + fetch = +refs/heads/*:refs/remotes/origin/* diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/description b/passKitTests/Fixtures/password-store-with-gpgid.git/description new file mode 100644 index 0000000..5536ff7 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/description @@ -0,0 +1 @@ +Example password store repository for passforios tests with .gpg-id files. diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude b/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx new file mode 100644 index 0000000..7efd056 Binary files /dev/null and b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx differ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack new file mode 100644 index 0000000..038de79 Binary files /dev/null and b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack differ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs b/passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs new file mode 100644 index 0000000..5b72267 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +925eb0f6b19282b5f10dfe008e0062b4be6dd41a refs/heads/master diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master b/passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master new file mode 100644 index 0000000..7d10008 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master @@ -0,0 +1 @@ +925eb0f6b19282b5f10dfe008e0062b4be6dd41a diff --git a/passKitTests/Helpers/AppKeychainTest.swift b/passKitTests/Helpers/AppKeychainTest.swift new file mode 100644 index 0000000..9b420fc --- /dev/null +++ b/passKitTests/Helpers/AppKeychainTest.swift @@ -0,0 +1,101 @@ +// +// AppKeychainTest.swift +// passKitTests +// +// Created by Lysann Tranvouez on 9/3/26. +// Copyright © 2026 Bob Sun. All rights reserved. +// + +import XCTest + +@testable import passKit + +final class AppKeychainTest: XCTestCase { + private let keychain = AppKeychain.shared + private let testPrefix = "test.AppKeychainTest." + + override func tearDown() { + super.tearDown() + keychain.removeAllContent(withPrefix: testPrefix) + } + + private func key(_ name: String) -> String { + "\(testPrefix)\(name)" + } + + // MARK: - Basic round-trip + + func testAddAndGet() { + keychain.add(string: "hello", for: key("addGet")) + + XCTAssertEqual(keychain.get(for: key("addGet")), "hello") + } + + func testGetMissingKeyReturnsNil() { + XCTAssertNil(keychain.get(for: key("nonexistent"))) + } + + func testOverwriteValue() { + keychain.add(string: "first", for: key("overwrite")) + keychain.add(string: "second", for: key("overwrite")) + + XCTAssertEqual(keychain.get(for: key("overwrite")), "second") + } + + func testAddNilRemovesValue() { + keychain.add(string: "value", for: key("addNil")) + keychain.add(string: nil, for: key("addNil")) + + XCTAssertNil(keychain.get(for: key("addNil"))) + XCTAssertFalse(keychain.contains(key: key("addNil"))) + } + + // MARK: - contains + + func testContainsReturnsTrueForExistingKey() { + keychain.add(string: "value", for: key("exists")) + + XCTAssertTrue(keychain.contains(key: key("exists"))) + } + + func testContainsReturnsFalseForMissingKey() { + XCTAssertFalse(keychain.contains(key: key("missing"))) + } + + // MARK: - removeContent + + func testRemoveContent() { + keychain.add(string: "value", for: key("remove")) + keychain.removeContent(for: key("remove")) + + XCTAssertNil(keychain.get(for: key("remove"))) + XCTAssertFalse(keychain.contains(key: key("remove"))) + } + + func testRemoveContentForMissingKeyDoesNotThrow() { + keychain.removeContent(for: key("neverExisted")) + // No assertion needed — just verifying it doesn't crash + } + + // MARK: - removeAllContent(withPrefix:) + + func testRemoveAllContentWithPrefix() { + keychain.add(string: "1", for: key("prefixA.one")) + keychain.add(string: "2", for: key("prefixA.two")) + keychain.add(string: "3", for: key("prefixB.one")) + + keychain.removeAllContent(withPrefix: key("prefixA")) + + XCTAssertNil(keychain.get(for: key("prefixA.one"))) + XCTAssertNil(keychain.get(for: key("prefixA.two"))) + XCTAssertEqual(keychain.get(for: key("prefixB.one")), "3") + } + + func testRemoveAllContentWithPrefixNoMatches() { + keychain.add(string: "value", for: key("survivor")) + + keychain.removeAllContent(withPrefix: key("noMatch")) + + XCTAssertEqual(keychain.get(for: key("survivor")), "value") + } +} diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index b65bf95..4fe2c80 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,47 +13,386 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private let remoteRepoURL = URL(string: "https://github.com/mssun/passforios-password-store.git")! + private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") + + private var passwordStore: PasswordStore! = nil + + override func setUp() { + passwordStore = PasswordStore(url: localRepoURL) + } + + override func tearDown() { + passwordStore.erase() + passwordStore = nil + + Defaults.removeAll() + } + + func testInitPasswordEntityCoreData() throws { + try cloneRepository(.withGPGID) + + XCTAssertEqual(passwordStore.numberOfPasswords, 4) + XCTAssertEqual(passwordStore.numberOfCommits, 16) + XCTAssertEqual(passwordStore.numberOfLocalCommits, 0) + + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") + XCTAssertEqual(entity!.path, "personal/github.com.gpg") + XCTAssertEqual(entity!.name, "github.com") + XCTAssertTrue(entity!.isSynced) + XCTAssertEqual(entity!.parent!.name, "personal") + + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "family/amazon.com.gpg")) + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "work/github.com.gpg")) + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "shared/github.com.gpg")) + + let dirEntity = passwordStore.fetchPasswordEntity(with: "shared") + XCTAssertNotNil(dirEntity) + XCTAssertTrue(dirEntity!.isDir) + XCTAssertEqual(dirEntity!.name, "shared") + XCTAssertEqual(dirEntity!.children.count, 1) + } + + func testEraseStoreData() throws { + try cloneRepository(.withGPGID) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.path)) + XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) + XCTAssertNotNil(passwordStore.gitRepository) + + expectation(forNotification: .passwordStoreUpdated, object: nil) + expectation(forNotification: .passwordStoreErased, object: nil) + passwordStore.eraseStoreData() + + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.path)) + XCTAssertEqual(passwordStore.numberOfPasswords, 0) + XCTAssertNil(passwordStore.gitRepository) + waitForExpectations(timeout: 1, handler: nil) + } + + func testErase() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + Defaults.gitSignatureName = "Test User" + PasscodeLock.shared.save(passcode: "1234") + + XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) + XCTAssertTrue(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertEqual(Defaults.gitSignatureName, "Test User") + XCTAssertTrue(PasscodeLock.shared.hasPasscode) + XCTAssertTrue(PGPAgent.shared.isInitialized()) + + expectation(forNotification: .passwordStoreUpdated, object: nil) + expectation(forNotification: .passwordStoreErased, object: nil) + passwordStore.erase() + + XCTAssertEqual(passwordStore.numberOfPasswords, 0) + XCTAssertFalse(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) + XCTAssertFalse(PasscodeLock.shared.hasPasscode) + XCTAssertFalse(PGPAgent.shared.isInitialized()) + waitForExpectations(timeout: 1, handler: nil) + } + + func testFetchPasswordEntityCoreDataByParent() throws { + try cloneRepository(.withGPGID) + + let rootChildren = passwordStore.fetchPasswordEntityCoreData(parent: nil) + XCTAssertGreaterThan(rootChildren.count, 0) + rootChildren.forEach { entity in + XCTAssertTrue(entity.isDir) + } + + let personalDir = passwordStore.fetchPasswordEntity(with: "personal") + let personalChildren = passwordStore.fetchPasswordEntityCoreData(parent: personalDir) + XCTAssertEqual(personalChildren.count, 1) + XCTAssertEqual(personalChildren.first?.name, "github.com") + } + + func testFetchPasswordEntityCoreDataWithDir() throws { + try cloneRepository(.withGPGID) + + let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false) + XCTAssertEqual(allPasswords.count, 4) + allPasswords.forEach { entity in + XCTAssertFalse(entity.isDir) + } + } + + func testAddPassword() throws { + try cloneRepository(.empty) + try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits + + let password1 = Password(name: "test1", path: "test1.gpg", plainText: "foobar") + let password2 = Password(name: "test2", path: "test2.gpg", plainText: "hello world") + let password3 = Password(name: "test3", path: "folder/test3.gpg", plainText: "lorem ipsum") + let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") + + for password in [password1, password2, password3, password4] { + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let savedEntity = try passwordStore.add(password: password) + + XCTAssertEqual(savedEntity!.name, password.name) + waitForExpectations(timeout: 1, handler: nil) + } + + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test1.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test2.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder/test3.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test4.gpg").path)) + + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 4) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 4) + } + + func testAddAndDecryptRoundTrip() throws { + try cloneRepository(.empty) + try importSinglePGPKey() + + let password = Password(name: "test", path: "test.gpg", plainText: "foobar") + let savedEntity = try passwordStore.add(password: password) + + let decryptedPassword = try passwordStore.decrypt(passwordEntity: savedEntity!, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + XCTAssertEqual(decryptedPassword.plainText, "foobar") + } + + func testDeletePassword() throws { + try cloneRepository(.withGPGID) + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") + try passwordStore.delete(passwordEntity: entity!) + + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal")) + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) + waitForExpectations(timeout: 1, handler: nil) + } + + func testDeletePasswordKeepsFileSystemFolderIfNotEmpty() throws { + try cloneRepository(.withGPGID) + + // /work contains .gpg-id in addition to a password file + let entity = passwordStore.fetchPasswordEntity(with: "work/github.com.gpg") + try passwordStore.delete(passwordEntity: entity!) + + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("work/github.com.gpg").path)) + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "work/github.com.gpg")) + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "work")) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("work/.gpg-id").path)) + } + + func testDeleteEmptyDirectory() throws { + try cloneRepository(.emptyDirs) + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + // Note: the directory isn't truely empty since Git doesn't track empty directories, + // but it should be treated as empty by the app since it contains only hidden files + let entityToDelete = passwordStore.fetchPasswordEntity(with: "empty-dir") + XCTAssertNotNil(entityToDelete) + try passwordStore.delete(passwordEntity: entityToDelete!) + + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "empty-dir")) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("empty-dir/.gitkeep").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) + waitForExpectations(timeout: 1, handler: nil) + } + + func testDeleteNonEmptyDirectoryFails() throws { + try cloneRepository(.withGPGID) + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits + + expectation(forNotification: .passwordStoreUpdated, object: nil).isInverted = true + + let entity = passwordStore.fetchPasswordEntity(with: "personal") + XCTAssertThrowsError(try passwordStore.delete(passwordEntity: entity!)) { error in + XCTAssertTrue(error is AppError, "Unexpected error type: \(type(of: error))") + XCTAssertEqual(error as? AppError, .cannotDeleteNonEmptyDirectory) + } + + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testEditPasswordValue() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let editedPassword = Password(name: entity.name, path: entity.path, plainText: "editedpassword") + editedPassword.changed = PasswordChange.content.rawValue + let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) + + XCTAssertNotNil(editedEntity) + XCTAssertEqual(editedEntity!.name, "github.com") + XCTAssertFalse(editedEntity!.isSynced) + XCTAssertEqual(try decrypt(path: "personal/github.com.gpg").plainText, "editedpassword") + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) + waitForExpectations(timeout: 1, handler: nil) + } + + func testMovePassword() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let editedPassword = Password(name: "new name", path: "new name.gpg", plainText: "passwordforpersonal\n") + editedPassword.changed = PasswordChange.path.rawValue + let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) + + XCTAssertEqual(editedEntity!.name, "new name") + XCTAssertFalse(editedEntity!.isSynced) + XCTAssertEqual(try decrypt(path: "new name.gpg").plainText, "passwordforpersonal\n") + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) + waitForExpectations(timeout: 1, handler: nil) + } + + func testEditDirectoryFails() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + + let directoryEntity = passwordStore.fetchPasswordEntity(with: "personal")! + let editedPassword = Password(name: "new name", path: "new name", plainText: "") + editedPassword.changed = PasswordChange.path.rawValue + XCTAssertThrowsError(try passwordStore.edit(passwordEntity: directoryEntity, password: editedPassword)) { error in + XCTAssertTrue(error is AppError, "Unexpected error type: \(type(of: error))") + XCTAssertEqual(error as? AppError, .other(message: "Cannot edit a directory")) + } + + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal")) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) + } + + func testReset() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits + + _ = try passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar")) + try passwordStore.delete(passwordEntity: passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")!) + + expectation(forNotification: .passwordStoreUpdated, object: nil) + let numDroppedCommits = try passwordStore.reset() + + XCTAssertEqual(numDroppedCommits, 2) + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) + waitForExpectations(timeout: 1, handler: nil) + } + + // MARK: - .gpg-id support func testCloneAndDecryptMultiKeys() throws { - let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") + try cloneRepository(.withGPGID) + try importMultiplePGPKeys() Defaults.isEnableGPGIDOn = true - let passwordStore = PasswordStore(url: url) - try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") - expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: url.path) }, evaluatedWith: nil) - waitForExpectations(timeout: 3, handler: nil) [ ("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), ("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"), ].forEach { path, id in - let keyID = findGPGID(from: url.appendingPathComponent(path)) + let keyID = findGPGID(from: localRepoURL.appendingPathComponent(path)) XCTAssertEqual(keyID, id) } - let keychain = AppKeychain.shared - try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) - try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) - try PGPAgent.shared.initKeys() - - let personal = try decrypt(passwordStore: passwordStore, path: "personal/github.com.gpg", passphrase: "passforios") + let personal = try decrypt(path: "personal/github.com.gpg") XCTAssertEqual(personal.plainText, "passwordforpersonal\n") - let work = try decrypt(passwordStore: passwordStore, path: "work/github.com.gpg", passphrase: "passforios") + let work = try decrypt(path: "work/github.com.gpg") XCTAssertEqual(work.plainText, "passwordforwork\n") let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword") let testPasswordEntity = try passwordStore.add(password: testPassword)! let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) XCTAssertEqual(testPasswordPlain.plainText, "testpassword") - - passwordStore.erase() - Defaults.isEnableGPGIDOn = false } - private func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { + // MARK: - Helpers + + private enum RemoteRepo { + case empty + case emptyDirs + case withGPGID + + var url: URL { + switch self { + case .empty: + Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-empty.git") + case .emptyDirs: + Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-empty-dirs.git") + case .withGPGID: + Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-with-gpgid.git") + } + } + + var branchName: String { + switch self { + case .empty: + "main" + case .emptyDirs: + "main" + case .withGPGID: + "master" + } + } + } + + private func cloneRepository(_ remote: RemoteRepo) throws { + expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) + expectation(forNotification: .passwordStoreUpdated, object: nil) + + try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) + + waitForExpectations(timeout: 3, handler: nil) + } + + private func importSinglePGPKey() throws { + let keychain = AppKeychain.shared + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.publicKey) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.privateKey) + try PGPAgent.shared.initKeys() + } + + private func importMultiplePGPKeys() throws { + let keychain = AppKeychain.shared + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) + try PGPAgent.shared.initKeys() + } + + private func decrypt(path: String, keyID: String? = nil) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! - return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + return try passwordStore.decrypt(passwordEntity: entity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } }