Compare commits
17 commits
c8cce5c322
...
f0c21dd880
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c21dd880 | ||
|
|
55b682b4b0 | ||
|
|
b8b7e1f913 | ||
|
|
cde82d956b | ||
|
|
4c21ab99ad | ||
|
|
c6a4f80503 | ||
|
|
e1da1988b4 | ||
|
|
e195280efc | ||
|
|
c3bfa861f4 | ||
|
|
98646242e0 | ||
|
|
12c8c04203 | ||
|
|
98ad323431 | ||
|
|
e5650ec756 | ||
|
|
60999c7eab | ||
|
|
ef188fcfba | ||
|
|
85972a02c3 | ||
|
|
17b6bb8bc2 |
43 changed files with 756 additions and 34 deletions
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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 ?? []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
93
passKitTests/Controllers/PersistenceControllerTest.swift
Normal file
93
passKitTests/Controllers/PersistenceControllerTest.swift
Normal 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...
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ class CoreDataTestCase: XCTestCase {
|
|||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
controller = PersistenceController(isUnitTest: true)
|
||||
controller = PersistenceController.forUnitTests()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
passKitTests/Fixtures/password-store-empty-dirs.git/HEAD
Normal file
1
passKitTests/Fixtures/password-store-empty-dirs.git/HEAD
Normal file
|
|
@ -0,0 +1 @@
|
|||
ref: refs/heads/main
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
|
|
@ -0,0 +1 @@
|
|||
Unnamed repository; edit this file 'description' to name the repository.
|
||||
|
|
@ -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]
|
||||
# *~
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,2 @@
|
|||
# pack-refs with: peeled fully-peeled sorted
|
||||
fbcbb5819e1c864ef33cfffa179a71387a5d90d0 refs/heads/main
|
||||
1
passKitTests/Fixtures/password-store-empty.git/HEAD
Normal file
1
passKitTests/Fixtures/password-store-empty.git/HEAD
Normal file
|
|
@ -0,0 +1 @@
|
|||
ref: refs/heads/main
|
||||
6
passKitTests/Fixtures/password-store-empty.git/config
Normal file
6
passKitTests/Fixtures/password-store-empty.git/config
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
|
|
@ -0,0 +1 @@
|
|||
Unnamed repository; edit this file 'description' to name the repository.
|
||||
|
|
@ -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]
|
||||
# *~
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,3 @@
|
|||
x•ÍM
|
||||
Â0@a×9Åì™ü<E284A2>¤ â\z<>i:Å@›BœzzKÕ¸}‹ï¥ež³€îôN*3Sôv¤48J=ÙÞEoØŽÎDÒˆÈk"EMnK…ËóN¥ÀµRy,<2C>_pœ¶r–_9p;<3B>Á¢‹ˆìqETÚ¾Âÿ
|
||||
&ú¯ rÉ’i‚¥ÞÃ@í
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# pack-refs with: peeled fully-peeled sorted
|
||||
f095bb4897e4cd58faadfe4d4f678fb697be3ffd refs/heads/main
|
||||
|
|
@ -0,0 +1 @@
|
|||
925eb0f6b19282b5f10dfe008e0062b4be6dd41a not-for-merge branch 'master' of https://github.com/mssun/passforios-password-store
|
||||
1
passKitTests/Fixtures/password-store-with-gpgid.git/HEAD
Normal file
1
passKitTests/Fixtures/password-store-with-gpgid.git/HEAD
Normal file
|
|
@ -0,0 +1 @@
|
|||
ref: refs/heads/master
|
||||
|
|
@ -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/*
|
||||
|
|
@ -0,0 +1 @@
|
|||
Example password store repository for passforios tests with .gpg-id files.
|
||||
|
|
@ -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]
|
||||
# *~
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,2 @@
|
|||
# pack-refs with: peeled fully-peeled sorted
|
||||
925eb0f6b19282b5f10dfe008e0062b4be6dd41a refs/heads/master
|
||||
|
|
@ -0,0 +1 @@
|
|||
925eb0f6b19282b5f10dfe008e0062b4be6dd41a
|
||||
101
passKitTests/Helpers/AppKeychainTest.swift
Normal file
101
passKitTests/Helpers/AppKeychainTest.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue