passforios/passKitTests/Models/PasswordStoreTest.swift

402 lines
18 KiB
Swift

//
// PasswordStoreTest.swift
// passKitTests
//
// Created by Mingshen Sun on 13/4/2020.
// Copyright © 2020 Bob Sun. All rights reserved.
//
import Foundation
import ObjectiveGit
import XCTest
@testable import passKit
final class PasswordStoreTest: XCTestCase {
private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/")
private var pgpAgent: PGPAgent! = nil
private var passwordStore: PasswordStore! = nil
override func setUp() {
pgpAgent = PGPAgent()
passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent)
}
override func tearDown() {
passwordStore.erase()
passwordStore = nil
pgpAgent = nil
Defaults.removeAll()
}
func testInitPasswordEntityCoreData() throws {
try cloneRepository(.withGPGID)
XCTAssertEqual(passwordStore.numberOfPasswords, 4)
XCTAssertEqual(passwordStore.numberOfCommits, 17)
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.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.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 {
try cloneRepository(.withGPGID)
try importMultiplePGPKeys()
Defaults.isEnableGPGIDOn = true
[
("work/github.com", ["4712286271220DB299883EA7062E678DA1024DAE"]),
("personal/github.com", ["787EAE1A5FA3E749AA34CC6AA0645EBED862027E"]),
("shared/github.com", ["4712286271220DB299883EA7062E678DA1024DAE", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"]),
].forEach { path, keyIDs in
let foundKeyIDs = findGPGIDs(from: localRepoURL.appendingPathComponent(path))
XCTAssertEqual(foundKeyIDs, keyIDs)
}
let personal = try decrypt(path: "personal/github.com.gpg")
XCTAssertEqual(personal.plainText, "passwordforpersonal\n")
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")
}
// 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, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
}
}