Compare commits

...

17 commits

Author SHA1 Message Date
Lysann Tranvouez
f0c21dd880 Merge branch 'feature/more-tests-pr' into feature/multi-key-support 2026-03-09 23:08:48 +01:00
Lysann Tranvouez
55b682b4b0 improve directory deletion/editing handling 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
b8b7e1f913 PersistenceController tests 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
cde82d956b rename file to match contained class 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
4c21ab99ad add tests for AppKeychain 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
c6a4f80503 add initPasswordEntityCoreData tests 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
e1da1988b4 add save and decrypt round trip 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
e195280efc test resetting local changes 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
c3bfa861f4 check file system and commits upon changes to store 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
98646242e0 fix deleting directory
this used to corrupt the local state (password entities remained in DB
but files/dirs were removed from git and disk)
2026-03-09 22:58:05 +01:00
Lysann Tranvouez
12c8c04203 test add, edit, delete 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
98ad323431 check notification center notifications 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
e5650ec756 add encrypt-save-decrypt roundtrip test 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
60999c7eab more tests: entity fetching + erase 2026-03-09 22:56:27 +01:00
Lysann Tranvouez
ef188fcfba basic core data tests upon clone 2026-03-09 22:56:27 +01:00
Lysann Tranvouez
85972a02c3 include repo as text fixture, no need to clone from actual github 2026-03-09 22:56:27 +01:00
Lysann Tranvouez
17b6bb8bc2 fix test cleanup 2026-03-09 22:20:04 +01:00
43 changed files with 756 additions and 34 deletions

View file

@ -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 = "<group>"; };
30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = "<group>"; };
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 = "<group>"; };
8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = "<group>"; };
8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = "<group>"; };
9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = "<group>"; };
9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = "<group>"; };
9A1EF0B524C50EE00074FEAC /* passBetaExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaExtension.entitlements; sourceTree = "<group>"; };
@ -498,7 +504,7 @@
DC4914941E434301007FF592 /* LabelTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelTableViewCell.swift; sourceTree = "<group>"; };
DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasswordDetailTableViewController.swift; sourceTree = "<group>"; };
DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PGPKeyArmorImportTableViewController.swift; sourceTree = "<group>"; };
DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = "<group>"; };
DC6474522D20DD0C004B4BBC /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = "<group>"; };
DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = "<group>"; };
DC64745E2D45B23A004B4BBC /* GitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepository.swift; sourceTree = "<group>"; };
@ -624,6 +630,7 @@
301F6464216164670071A4CE /* Helpers */ = {
isa = PBXGroup;
children = (
8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */,
3032328922C9FBA2009EBD9C /* KeyFileManagerTest.swift */,
);
path = Helpers;
@ -761,6 +768,14 @@
path = Crypto;
sourceTree = "<group>";
};
8A4716702F5EF7A900C7A64D /* Controllers */ = {
isa = PBXGroup;
children = (
8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */,
);
path = Controllers;
sourceTree = "<group>";
};
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 = "<group>";
@ -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 */,

View file

@ -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.";

View file

@ -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.";

View file

@ -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 {

View file

@ -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 ?? []

View file

@ -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

View file

@ -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()
}

View file

@ -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...
}
}

View file

@ -20,7 +20,7 @@ class CoreDataTestCase: XCTestCase {
override func setUpWithError() throws {
try super.setUpWithError()
controller = PersistenceController(isUnitTest: true)
controller = PersistenceController.forUnitTests()
}
override func tearDown() {

View file

@ -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)
}
}

View file

@ -0,0 +1 @@
ref: refs/heads/main

View file

@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
ignorecase = true
precomposeunicode = true

View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -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]
# *~

View file

@ -0,0 +1,2 @@
# pack-refs with: peeled fully-peeled sorted
fbcbb5819e1c864ef33cfffa179a71387a5d90d0 refs/heads/main

View file

@ -0,0 +1 @@
ref: refs/heads/main

View file

@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
ignorecase = true
precomposeunicode = true

View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -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]
# *~

View file

@ -0,0 +1,3 @@
x•ÍM
Â0@a×9Åì™ü<E284A2>¤ â\z<>i:Å@zzKÕ¸}ï¥ež³€îôN*3Sôv¤48J=ÙÞEoØŽÎDÒˆÈk"EMnK…ËóN¥ÀµRy,<2C>_pœ¶r_9p;<3B>Á¢‹ˆìqETÚ¾Âÿ
&ú¯ i¥ÞÃ

View file

@ -0,0 +1,2 @@
# pack-refs with: peeled fully-peeled sorted
f095bb4897e4cd58faadfe4d4f678fb697be3ffd refs/heads/main

View file

@ -0,0 +1 @@
925eb0f6b19282b5f10dfe008e0062b4be6dd41a not-for-merge branch 'master' of https://github.com/mssun/passforios-password-store

View file

@ -0,0 +1 @@
ref: refs/heads/master

View file

@ -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/*

View file

@ -0,0 +1 @@
Example password store repository for passforios tests with .gpg-id files.

View file

@ -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]
# *~

View file

@ -0,0 +1,2 @@
# pack-refs with: peeled fully-peeled sorted
925eb0f6b19282b5f10dfe008e0062b4be6dd41a refs/heads/master

View file

@ -0,0 +1 @@
925eb0f6b19282b5f10dfe008e0062b4be6dd41a

View file

@ -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")
}
}

View file

@ -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)
}
}