2020-04-11 23:23:38 -07:00
|
|
|
//
|
|
|
|
|
// PasswordStoreTest.swift
|
|
|
|
|
// passKitTests
|
|
|
|
|
//
|
2021-09-03 02:50:40 +02:00
|
|
|
// Created by Mingshen Sun on 13/4/2020.
|
2020-04-11 23:23:38 -07:00
|
|
|
// Copyright © 2020 Bob Sun. All rights reserved.
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
import Foundation
|
|
|
|
|
import ObjectiveGit
|
2020-06-28 21:25:40 +02:00
|
|
|
import XCTest
|
2020-04-11 23:23:38 -07:00
|
|
|
|
|
|
|
|
@testable import passKit
|
|
|
|
|
|
2024-11-24 13:14:38 +01:00
|
|
|
final class PasswordStoreTest: XCTestCase {
|
2026-03-09 00:17:31 +01:00
|
|
|
private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/")
|
|
|
|
|
|
2026-03-12 09:30:54 +01:00
|
|
|
private var keyStore: KeyStore! = nil
|
2026-03-11 23:25:46 +01:00
|
|
|
private var pgpAgent: PGPAgent! = nil
|
2026-03-09 00:17:31 +01:00
|
|
|
private var passwordStore: PasswordStore! = nil
|
|
|
|
|
|
|
|
|
|
override func setUp() {
|
2026-03-12 09:30:54 +01:00
|
|
|
super.setUp()
|
|
|
|
|
|
|
|
|
|
keyStore = DictBasedKeychain()
|
|
|
|
|
pgpAgent = PGPAgent(keyStore: keyStore)
|
2026-03-11 23:25:46 +01:00
|
|
|
passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent)
|
2026-03-09 00:17:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-12 09:31:37 +01:00
|
|
|
private func setUpMockedPGPInterface() -> MockPGPInterface {
|
|
|
|
|
let mockPGPInterface = MockPGPInterface()
|
|
|
|
|
keyStore = DictBasedKeychain()
|
|
|
|
|
pgpAgent = PGPAgent(keyStore: keyStore, pgpInterface: mockPGPInterface)
|
|
|
|
|
passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent)
|
|
|
|
|
|
|
|
|
|
// Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock.
|
|
|
|
|
keyStore.add(string: "dummy", for: Globals.pgpKeyPassphrase)
|
|
|
|
|
|
|
|
|
|
return mockPGPInterface
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:17:31 +01:00
|
|
|
override func tearDown() {
|
|
|
|
|
passwordStore.erase()
|
|
|
|
|
passwordStore = nil
|
2026-03-11 23:25:46 +01:00
|
|
|
pgpAgent = nil
|
2026-03-12 09:30:54 +01:00
|
|
|
keyStore = nil
|
2026-03-09 10:57:39 +01:00
|
|
|
|
|
|
|
|
Defaults.removeAll()
|
2026-03-12 09:30:54 +01:00
|
|
|
|
|
|
|
|
super.tearDown()
|
2026-03-09 00:17:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testInitPasswordEntityCoreData() throws {
|
2026-03-09 10:57:39 +01:00
|
|
|
try cloneRepository(.withGPGID)
|
2026-03-09 00:17:31 +01:00
|
|
|
|
|
|
|
|
XCTAssertEqual(passwordStore.numberOfPasswords, 4)
|
2026-03-11 22:57:51 +01:00
|
|
|
XCTAssertEqual(passwordStore.numberOfCommits, 17)
|
2026-03-09 12:49:33 +01:00
|
|
|
XCTAssertEqual(passwordStore.numberOfLocalCommits, 0)
|
2026-03-09 00:17:31 +01:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:34:34 +01:00
|
|
|
func testEraseStoreData() throws {
|
2026-03-09 10:57:39 +01:00
|
|
|
try cloneRepository(.withGPGID)
|
2026-03-09 00:34:34 +01:00
|
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.path))
|
|
|
|
|
XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0)
|
|
|
|
|
XCTAssertNotNil(passwordStore.gitRepository)
|
|
|
|
|
|
2026-03-09 11:45:04 +01:00
|
|
|
expectation(forNotification: .passwordStoreUpdated, object: nil)
|
|
|
|
|
expectation(forNotification: .passwordStoreErased, object: nil)
|
2026-03-09 00:34:34 +01:00
|
|
|
passwordStore.eraseStoreData()
|
|
|
|
|
|
|
|
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.path))
|
|
|
|
|
XCTAssertEqual(passwordStore.numberOfPasswords, 0)
|
|
|
|
|
XCTAssertNil(passwordStore.gitRepository)
|
2026-03-09 11:45:04 +01:00
|
|
|
waitForExpectations(timeout: 1, handler: nil)
|
2026-03-09 00:34:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testErase() throws {
|
2026-03-09 10:57:39 +01:00
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
try importSinglePGPKey()
|
2026-03-09 00:34:34 +01:00
|
|
|
Defaults.gitSignatureName = "Test User"
|
|
|
|
|
PasscodeLock.shared.save(passcode: "1234")
|
|
|
|
|
|
|
|
|
|
XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0)
|
2026-03-12 09:30:54 +01:00
|
|
|
XCTAssertTrue(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey()))
|
2026-03-09 00:34:34 +01:00
|
|
|
XCTAssertEqual(Defaults.gitSignatureName, "Test User")
|
|
|
|
|
XCTAssertTrue(PasscodeLock.shared.hasPasscode)
|
2026-03-11 23:25:46 +01:00
|
|
|
XCTAssertTrue(pgpAgent.isInitialized())
|
2026-03-09 00:34:34 +01:00
|
|
|
|
2026-03-09 11:45:04 +01:00
|
|
|
expectation(forNotification: .passwordStoreUpdated, object: nil)
|
|
|
|
|
expectation(forNotification: .passwordStoreErased, object: nil)
|
2026-03-09 00:34:34 +01:00
|
|
|
passwordStore.erase()
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(passwordStore.numberOfPasswords, 0)
|
2026-03-12 09:30:54 +01:00
|
|
|
XCTAssertFalse(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey()))
|
2026-03-09 00:34:34 +01:00
|
|
|
XCTAssertFalse(Defaults.hasKey(\.gitSignatureName))
|
|
|
|
|
XCTAssertFalse(PasscodeLock.shared.hasPasscode)
|
2026-03-11 23:25:46 +01:00
|
|
|
XCTAssertFalse(pgpAgent.isInitialized())
|
2026-03-09 11:45:04 +01:00
|
|
|
waitForExpectations(timeout: 1, handler: nil)
|
2026-03-09 00:34:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testFetchPasswordEntityCoreDataByParent() throws {
|
2026-03-09 10:57:39 +01:00
|
|
|
try cloneRepository(.withGPGID)
|
2026-03-09 00:34:34 +01:00
|
|
|
|
|
|
|
|
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 {
|
2026-03-09 10:57:39 +01:00
|
|
|
try cloneRepository(.withGPGID)
|
2026-03-09 00:34:34 +01:00
|
|
|
|
|
|
|
|
let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false)
|
|
|
|
|
XCTAssertEqual(allPasswords.count, 4)
|
|
|
|
|
allPasswords.forEach { entity in
|
|
|
|
|
XCTAssertFalse(entity.isDir)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:46:26 +01:00
|
|
|
func testAddPassword() throws {
|
2026-03-09 10:57:39 +01:00
|
|
|
try cloneRepository(.empty)
|
|
|
|
|
try importSinglePGPKey()
|
2026-03-09 12:49:33 +01:00
|
|
|
let numCommitsBefore = passwordStore.numberOfCommits!
|
|
|
|
|
let numLocalCommitsBefore = passwordStore.numberOfLocalCommits
|
2026-03-09 10:57:39 +01:00
|
|
|
|
2026-03-09 11:46:26 +01:00
|
|
|
let password1 = Password(name: "test1", path: "test1.gpg", plainText: "foobar")
|
|
|
|
|
let password2 = Password(name: "test2", path: "test2.gpg", plainText: "hello world")
|
2026-03-09 12:49:33 +01:00
|
|
|
let password3 = Password(name: "test3", path: "folder/test3.gpg", plainText: "lorem ipsum")
|
2026-03-09 11:46:26 +01:00
|
|
|
let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter")
|
|
|
|
|
|
2026-03-09 13:15:03 +01:00
|
|
|
for password in [password1, password2, password3, password4] {
|
2026-03-09 11:46:26 +01:00
|
|
|
expectation(forNotification: .passwordStoreUpdated, object: nil)
|
|
|
|
|
|
2026-03-09 13:15:03 +01:00
|
|
|
let savedEntity = try passwordStore.add(password: password)
|
2026-03-09 11:46:26 +01:00
|
|
|
|
|
|
|
|
XCTAssertEqual(savedEntity!.name, password.name)
|
|
|
|
|
waitForExpectations(timeout: 1, handler: nil)
|
|
|
|
|
}
|
2026-03-09 12:49:33 +01:00
|
|
|
|
|
|
|
|
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)
|
2026-03-09 11:46:26 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 13:15:03 +01:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:46:26 +01:00
|
|
|
func testDeletePassword() throws {
|
|
|
|
|
try cloneRepository(.withGPGID)
|
2026-03-09 12:49:33 +01:00
|
|
|
let numCommitsBefore = passwordStore.numberOfCommits!
|
|
|
|
|
let numLocalCommitsBefore = passwordStore.numberOfLocalCommits
|
2026-03-09 11:46:26 +01:00
|
|
|
|
|
|
|
|
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"))
|
2026-03-09 12:11:37 +01:00
|
|
|
XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal"))
|
|
|
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal").path))
|
2026-03-09 12:49:33 +01:00
|
|
|
XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1)
|
|
|
|
|
XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1)
|
2026-03-09 11:46:26 +01:00
|
|
|
waitForExpectations(timeout: 1, handler: nil)
|
|
|
|
|
}
|
2026-03-09 12:11:37 +01:00
|
|
|
|
2026-03-09 22:16:15 +01:00
|
|
|
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 {
|
2026-03-09 12:11:37 +01:00
|
|
|
try cloneRepository(.withGPGID)
|
2026-03-09 12:49:33 +01:00
|
|
|
let numCommitsBefore = passwordStore.numberOfCommits!
|
|
|
|
|
let numLocalCommitsBefore = passwordStore.numberOfLocalCommits
|
2026-03-09 12:11:37 +01:00
|
|
|
|
|
|
|
|
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))")
|
2026-03-09 22:16:15 +01:00
|
|
|
XCTAssertEqual(error as? AppError, .cannotDeleteNonEmptyDirectory)
|
2026-03-09 12:11:37 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg"))
|
|
|
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path))
|
2026-03-09 12:49:33 +01:00
|
|
|
XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore)
|
|
|
|
|
XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore)
|
2026-03-09 12:11:37 +01:00
|
|
|
waitForExpectations(timeout: 0.1, handler: nil)
|
|
|
|
|
}
|
2026-03-09 11:46:26 +01:00
|
|
|
|
|
|
|
|
func testEditPasswordValue() throws {
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
try importSinglePGPKey()
|
2026-03-09 12:49:33 +01:00
|
|
|
let numCommitsBefore = passwordStore.numberOfCommits!
|
|
|
|
|
let numLocalCommitsBefore = passwordStore.numberOfLocalCommits
|
2026-03-09 11:46:26 +01:00
|
|
|
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")
|
2026-03-09 12:49:33 +01:00
|
|
|
XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1)
|
|
|
|
|
XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1)
|
2026-03-09 11:46:26 +01:00
|
|
|
waitForExpectations(timeout: 1, handler: nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testMovePassword() throws {
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
try importSinglePGPKey()
|
2026-03-09 12:49:33 +01:00
|
|
|
let numCommitsBefore = passwordStore.numberOfCommits!
|
|
|
|
|
let numLocalCommitsBefore = passwordStore.numberOfLocalCommits
|
2026-03-09 11:46:26 +01:00
|
|
|
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"))
|
2026-03-09 12:49:33 +01:00
|
|
|
XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1)
|
|
|
|
|
XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1)
|
2026-03-09 11:46:26 +01:00
|
|
|
waitForExpectations(timeout: 1, handler: nil)
|
2026-03-09 10:57:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 22:16:15 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 12:55:16 +01:00
|
|
|
func testReset() throws {
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
try importSinglePGPKey()
|
|
|
|
|
let numCommitsBefore = passwordStore.numberOfCommits!
|
|
|
|
|
let numLocalCommitsBefore = passwordStore.numberOfLocalCommits
|
|
|
|
|
|
2026-03-09 13:15:03 +01:00
|
|
|
_ = try passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar"))
|
2026-03-09 12:55:16 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 22:44:42 +01:00
|
|
|
// MARK: - Find .gpg-id
|
|
|
|
|
|
|
|
|
|
func testFindGPGIDFile() throws {
|
|
|
|
|
try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true)
|
|
|
|
|
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data("under root".utf8)))
|
|
|
|
|
|
|
|
|
|
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("foo/bar/baz"), withIntermediateDirectories: true)
|
|
|
|
|
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path, contents: Data("under foo".utf8)))
|
|
|
|
|
|
|
|
|
|
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/"), withIntermediateDirectories: true)
|
|
|
|
|
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey"), withIntermediateDirectories: true)
|
|
|
|
|
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path, contents: Data("under hey".utf8)))
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "/")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/..")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
|
|
|
|
|
|
|
|
|
|
// there is a _drectory_ called .gpg-id in here
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id/hey")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path))
|
|
|
|
|
|
|
|
|
|
// "foo/bar/../../baz" resolves to "baz" which has no .gpg-id, so should find root's.
|
|
|
|
|
// Without path resolution, the walk ["foo","bar","..","..","baz"] → remove "baz" → remove ".." →
|
|
|
|
|
// "foo/bar/.." → remove ".." → "foo/bar" → finds foo/.gpg-id (wrong).
|
|
|
|
|
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("baz"), withIntermediateDirectories: true)
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/../../baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testMissingGPGIDFile() throws {
|
|
|
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent(".gpg-id").path))
|
|
|
|
|
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("subdir"), withIntermediateDirectories: true)
|
|
|
|
|
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: ""))
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "subdir"))
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "missing"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testFindGPGIDFileStopsAtRoot() throws {
|
|
|
|
|
// Place a .gpg-id file ABOVE the store root, this should not be found
|
|
|
|
|
let parentDir = localRepoURL.deletingLastPathComponent()
|
|
|
|
|
let escapedGPGIDURL = parentDir.appendingPathComponent(".gpg-id")
|
|
|
|
|
XCTAssertTrue(FileManager.default.createFile(atPath: escapedGPGIDURL.path, contents: Data("ESCAPED_KEY".utf8)))
|
|
|
|
|
defer { try? FileManager.default.removeItem(at: escapedGPGIDURL) }
|
|
|
|
|
|
|
|
|
|
// Store has no .gpg-id at all
|
|
|
|
|
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("sub/deep"), withIntermediateDirectories: true)
|
|
|
|
|
// Direct paths, should not find the escaped .gpg-id since it's outside the store root
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: ""))
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub"))
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep"))
|
|
|
|
|
|
|
|
|
|
// Path traversal attempts via ".."
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: ".."))
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "../.."))
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/../.."))
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../.."))
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../../../../etc"))
|
|
|
|
|
|
|
|
|
|
// Symlink escape: create a symlink inside the store pointing outside
|
|
|
|
|
let evilDir = parentDir.appendingPathComponent("evil")
|
|
|
|
|
try FileManager.default.createDirectory(at: evilDir, withIntermediateDirectories: true)
|
|
|
|
|
XCTAssertTrue(FileManager.default.createFile(atPath: evilDir.appendingPathComponent(".gpg-id").path, contents: Data("EVIL_KEY".utf8)))
|
|
|
|
|
defer { try? FileManager.default.removeItem(at: evilDir) }
|
|
|
|
|
try FileManager.default.createSymbolicLink(at: localRepoURL.appendingPathComponent("sub/escape"), withDestinationURL: evilDir)
|
|
|
|
|
// Following the symlink would find evil/.gpg-id — must not happen
|
|
|
|
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/escape"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Parse .gpg-id
|
2026-03-09 11:46:26 +01:00
|
|
|
|
2026-03-12 09:31:37 +01:00
|
|
|
func testReadGPGIDFile() throws {
|
2026-03-09 10:57:39 +01:00
|
|
|
try cloneRepository(.withGPGID)
|
2020-04-13 01:30:00 -07:00
|
|
|
[
|
2026-03-12 09:31:37 +01:00
|
|
|
("", [RSA4096.longFingerprint]),
|
|
|
|
|
("family", [String(NISTP384.longFingerprint.suffix(16))]),
|
|
|
|
|
("personal", [RSA4096.longFingerprint]),
|
|
|
|
|
("shared", [RSA2048.longFingerprint, RSA4096.longFingerprint]),
|
|
|
|
|
("work", [RSA2048.longFingerprint]),
|
|
|
|
|
].forEach { path, expectedKeyIDs in
|
2026-03-12 22:44:42 +01:00
|
|
|
let foundKeyIDs = passwordStore.findGPGIDs(underPath: path)
|
|
|
|
|
XCTAssertEqual(foundKeyIDs, expectedKeyIDs.map { $0.uppercased() }, "Failed for path: \(path)")
|
2020-04-13 01:30:00 -07:00
|
|
|
}
|
2026-03-12 09:31:37 +01:00
|
|
|
}
|
2020-04-13 01:30:00 -07:00
|
|
|
|
2026-03-12 22:44:42 +01:00
|
|
|
func testReadEmptyGPGIDFile() throws {
|
|
|
|
|
try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true)
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: nil))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), [])
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \n\t".utf8)))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), [])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testReadGPGIDFileWithWhitespace() throws {
|
|
|
|
|
try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true)
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: nil))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), [])
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \n\t".utf8)))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), [])
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \nbar foo\n\tbaz\n \n".utf8)))
|
|
|
|
|
XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), ["bar foo", "baz"])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Handle .gpg-id
|
|
|
|
|
|
2026-03-12 09:31:37 +01:00
|
|
|
func testAddPasswordInRoot_WithSingleEntryInPGPIDFile_EncryptsWithThatKey() throws {
|
|
|
|
|
let mockPGPInterface = setUpMockedPGPInterface()
|
|
|
|
|
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
Defaults.isEnableGPGIDOn = true
|
2020-04-13 01:30:00 -07:00
|
|
|
|
2025-01-25 15:40:12 -08:00
|
|
|
let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword")
|
2026-03-12 09:31:37 +01:00
|
|
|
_ = try passwordStore.add(password: testPassword)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
|
|
|
|
|
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
|
|
|
|
|
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
|
|
|
|
|
XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEncryptWithSingleKeyViaGPGIDFileInSubDirectory() throws {
|
|
|
|
|
let mockPGPInterface = setUpMockedPGPInterface()
|
|
|
|
|
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
Defaults.isEnableGPGIDOn = true
|
|
|
|
|
|
|
|
|
|
let testPassword = Password(name: "test", path: "family/test.gpg", plainText: "testpassword")
|
|
|
|
|
_ = try passwordStore.add(password: testPassword)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
|
|
|
|
|
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
|
|
|
|
|
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
|
|
|
|
|
XCTAssertEqual(encryptCall?.keyIDs, [String(NISTP384.longFingerprint.suffix(16))].map { $0.uppercased() })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEncryptWithSingleKeyViaGPGIDFileInParentDir() throws {
|
|
|
|
|
let mockPGPInterface = setUpMockedPGPInterface()
|
|
|
|
|
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
Defaults.isEnableGPGIDOn = true
|
|
|
|
|
|
|
|
|
|
// /personal doesn't have its own .gpg-id file, but should inherit from the root .gpg-id file
|
|
|
|
|
let testPassword = Password(name: "test", path: "personal/test.gpg", plainText: "testpassword")
|
|
|
|
|
_ = try passwordStore.add(password: testPassword)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
|
|
|
|
|
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
|
|
|
|
|
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
|
|
|
|
|
XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEncryptWithMultipleKeysViaGPGIDFile() throws {
|
|
|
|
|
let mockPGPInterface = setUpMockedPGPInterface()
|
|
|
|
|
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
Defaults.isEnableGPGIDOn = true
|
|
|
|
|
|
|
|
|
|
// /shared uses both RSA2048 and RSA4096
|
|
|
|
|
let testPassword = Password(name: "test", path: "shared/test.gpg", plainText: "testpassword")
|
|
|
|
|
_ = try passwordStore.add(password: testPassword)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
|
|
|
|
|
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
|
|
|
|
|
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
|
|
|
|
|
XCTAssertEqual(encryptCall?.keyIDs, RSA2048_RSA4096.longFingerprints.map { $0.uppercased() })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEncryptWithSingleKeyViaGPGFile_MissingKey() throws {
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
try importSinglePGPKey() // Only import RSA4096, but not RSA2048
|
|
|
|
|
Defaults.isEnableGPGIDOn = true
|
|
|
|
|
|
|
|
|
|
// /work uses RSA2048, but we didn't import that one
|
|
|
|
|
let testPassword = Password(name: "test", path: "work/test.gpg", plainText: "testpassword")
|
|
|
|
|
XCTAssertThrowsError(try passwordStore.add(password: testPassword)) {
|
|
|
|
|
XCTAssertEqual($0 as? AppError, .pgpPublicKeyNotFound(keyID: RSA2048.longFingerprint.uppercased()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEncryptWithMultipleKeysViaGPGFile_MissingKey() throws {
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
try importSinglePGPKey() // Only import RSA4096, but not RSA2048
|
|
|
|
|
Defaults.isEnableGPGIDOn = true
|
|
|
|
|
|
|
|
|
|
// /shared uses both RSA2048 and RSA4096, but we only imported RSA4096, so encryption should fail since one of the keys is missing
|
|
|
|
|
let testPassword = Password(name: "test", path: "shared/test.gpg", plainText: "testpassword")
|
|
|
|
|
XCTAssertThrowsError(try passwordStore.add(password: testPassword)) {
|
|
|
|
|
XCTAssertEqual($0 as? AppError, .pgpPublicKeyNotFound(keyID: RSA2048.longFingerprint.uppercased()))
|
|
|
|
|
}
|
2020-04-12 19:32:58 -07:00
|
|
|
}
|
2020-04-13 01:30:00 -07:00
|
|
|
|
2026-03-12 22:44:42 +01:00
|
|
|
func testGPGIDDisabledIgnoresGPGIDFile() throws {
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
try importSinglePGPKey() // Only import RSA4096, but not RSA2048
|
|
|
|
|
Defaults.isEnableGPGIDOn = false
|
|
|
|
|
|
|
|
|
|
// /work uses RSA2048, but we didn't import that one
|
|
|
|
|
let testPassword = Password(name: "test", path: "work/test.gpg", plainText: "testpassword")
|
|
|
|
|
// this would throw if isEnableGPGIDOn was true, since we are missing the key to encrypt it
|
|
|
|
|
_ = try passwordStore.add(password: testPassword)
|
|
|
|
|
|
|
|
|
|
// check that we can decrypt it with the key we have, which confirms that it was encrypted without using the .gpg-id file
|
|
|
|
|
let decryptedPassword = try decrypt(path: "work/test.gpg", keyID: RSA4096.longFingerprint)
|
|
|
|
|
XCTAssertEqual(decryptedPassword.plainText, "testpassword")
|
|
|
|
|
|
|
|
|
|
// we can't even decrypt it with RSA2048
|
|
|
|
|
try importMultiplePGPKeys()
|
|
|
|
|
XCTAssertThrowsError(try decrypt(path: "work/test.gpg", keyID: RSA2048.longFingerprint)) {
|
|
|
|
|
XCTAssertEqual($0 as? AppError, .keyExpiredOrIncompatible)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testEncryptWithExplicitKeyID_OverridesGPGIDFile() throws {
|
|
|
|
|
continueAfterFailure = false // avoid index out of bounds error below
|
|
|
|
|
|
|
|
|
|
let mockPGPInterface = setUpMockedPGPInterface()
|
|
|
|
|
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
|
|
|
|
|
try cloneRepository(.withGPGID)
|
|
|
|
|
Defaults.isEnableGPGIDOn = true
|
|
|
|
|
|
|
|
|
|
// Even though /personal would normally use RSA4096 from the root .gpg-id file, if we explicitly specify a key ID then that should be used instead
|
|
|
|
|
let testPassword1 = Password(name: "test1", path: "personal/test1.gpg", plainText: "testpassword1")
|
|
|
|
|
_ = try passwordStore.add(password: testPassword1)
|
|
|
|
|
let testPassword2 = Password(name: "test2", path: "personal/test2.gpg", plainText: "testpassword2")
|
|
|
|
|
_ = try passwordStore.add(password: testPassword2, keyID: RSA2048.longFingerprint)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 2)
|
|
|
|
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[0].plainData, testPassword1.plainData)
|
|
|
|
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[0].keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() })
|
|
|
|
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[1].plainData, testPassword2.plainData)
|
|
|
|
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[1].keyIDs, [RSA2048.longFingerprint])
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:34:34 +01:00
|
|
|
// MARK: - Helpers
|
|
|
|
|
|
2026-03-09 10:57:39 +01:00
|
|
|
private enum RemoteRepo {
|
|
|
|
|
case empty
|
2026-03-09 22:16:15 +01:00
|
|
|
case emptyDirs
|
2026-03-09 10:57:39 +01:00
|
|
|
case withGPGID
|
|
|
|
|
|
|
|
|
|
var url: URL {
|
|
|
|
|
switch self {
|
|
|
|
|
case .empty:
|
|
|
|
|
Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-empty.git")
|
2026-03-09 22:16:15 +01:00
|
|
|
case .emptyDirs:
|
|
|
|
|
Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-empty-dirs.git")
|
2026-03-09 10:57:39 +01:00
|
|
|
case .withGPGID:
|
|
|
|
|
Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-with-gpgid.git")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var branchName: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .empty:
|
|
|
|
|
"main"
|
2026-03-09 22:16:15 +01:00
|
|
|
case .emptyDirs:
|
|
|
|
|
"main"
|
2026-03-09 10:57:39 +01:00
|
|
|
case .withGPGID:
|
|
|
|
|
"master"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func cloneRepository(_ remote: RemoteRepo) throws {
|
2026-03-09 00:17:31 +01:00
|
|
|
expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil)
|
2026-03-09 11:45:04 +01:00
|
|
|
expectation(forNotification: .passwordStoreUpdated, object: nil)
|
|
|
|
|
|
|
|
|
|
try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName)
|
|
|
|
|
|
2026-03-09 00:17:31 +01:00
|
|
|
waitForExpectations(timeout: 3, handler: nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 10:57:39 +01:00
|
|
|
private func importSinglePGPKey() throws {
|
2026-03-12 09:30:54 +01:00
|
|
|
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.publicKey)
|
|
|
|
|
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.privateKey)
|
|
|
|
|
try pgpAgent.initKeys()
|
2026-03-09 10:57:39 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func importMultiplePGPKeys() throws {
|
2026-03-12 09:30:54 +01:00
|
|
|
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.publicKeys)
|
|
|
|
|
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.privateKeys)
|
|
|
|
|
try pgpAgent.initKeys()
|
2026-03-09 00:34:34 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 10:57:39 +01:00
|
|
|
private func decrypt(path: String, keyID: String? = nil) throws -> Password {
|
2025-02-08 14:26:45 -08:00
|
|
|
let entity = passwordStore.fetchPasswordEntity(with: path)!
|
2026-03-09 10:57:39 +01:00
|
|
|
return try passwordStore.decrypt(passwordEntity: entity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
2020-04-13 01:30:00 -07:00
|
|
|
}
|
2020-04-11 23:23:38 -07:00
|
|
|
}
|