From 7c12263458463785595447bd428acaf45c8044ef Mon Sep 17 00:00:00 2001 From: Danny Moesch Date: Sun, 11 Nov 2018 18:09:52 +0100 Subject: [PATCH] Separate parser and helpers from Password class for better testability --- pass.xcodeproj/project.pbxproj | 98 ++++- .../EditPasswordTableViewController.swift | 2 +- .../GeneralSettingsTableViewController.swift | 2 +- .../PasswordEditorTableViewController.swift | 3 +- passKit/Helpers/PasswordHelpers.swift | 30 ++ passKit/Helpers/StringExtension.swift | 6 + passKit/Models/Password.swift | 258 ++++--------- passKit/Parser/AdditionField.swift | 47 +++ passKit/Parser/Constants.swift | 47 +++ passKit/Parser/Parser.swift | 93 +++++ .../Helpers/PasswordHelpersTest.swift | 29 ++ .../Helpers/StringExtensionTest.swift | 35 ++ passKitTests/Models/PasswordTest.swift | 276 ++++++++++++++ passKitTests/Parser/AdditionFieldTest.swift | 52 +++ passKitTests/Parser/ConstantsTest.swift | 31 ++ passKitTests/Parser/ParserTest.swift | 102 ++++++ passKitTests/PasswordTests.swift | 339 ------------------ 17 files changed, 913 insertions(+), 537 deletions(-) create mode 100644 passKit/Helpers/PasswordHelpers.swift create mode 100644 passKit/Parser/AdditionField.swift create mode 100644 passKit/Parser/Constants.swift create mode 100644 passKit/Parser/Parser.swift create mode 100644 passKitTests/Helpers/PasswordHelpersTest.swift create mode 100644 passKitTests/Helpers/StringExtensionTest.swift create mode 100644 passKitTests/Models/PasswordTest.swift create mode 100644 passKitTests/Parser/AdditionFieldTest.swift create mode 100644 passKitTests/Parser/ConstantsTest.swift create mode 100644 passKitTests/Parser/ParserTest.swift delete mode 100644 passKitTests/PasswordTests.swift diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 4ccbaee..b0c95af 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -9,7 +9,16 @@ /* Begin PBXBuildFile section */ 18F19A67B0C07F13C17169E0 /* Pods_pass.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A5620D17DF5E86B61761D0E /* Pods_pass.framework */; }; 23B82F0228254275DBA609E7 /* Pods_passExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B975797E0F0B7476CADD6A7D /* Pods_passExtension.framework */; }; - 30B04860209A5141001013CA /* PasswordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B0485F209A5141001013CA /* PasswordTests.swift */; }; + 301F6463216162550071A4CE /* AdditionField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301F6462216162550071A4CE /* AdditionField.swift */; }; + 301F6466216164830071A4CE /* PasswordHelpersTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301F6465216164830071A4CE /* PasswordHelpersTest.swift */; }; + 301F6468216165290071A4CE /* ConstantsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301F6467216165290071A4CE /* ConstantsTest.swift */; }; + 301F646A216166000071A4CE /* StringExtensionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301F6469216166000071A4CE /* StringExtensionTest.swift */; }; + 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301F646C216166AA0071A4CE /* AdditionFieldTest.swift */; }; + 302E85612125ECC70031BA64 /* Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302E85602125ECC70031BA64 /* Parser.swift */; }; + 302E85632125EE550031BA64 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302E85622125EE550031BA64 /* Constants.swift */; }; + 30AAC05321989DCE00F656CE /* PasswordHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AAC05221989DCE00F656CE /* PasswordHelpers.swift */; }; + 30B04860209A5141001013CA /* PasswordTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B0485F209A5141001013CA /* PasswordTest.swift */; }; + 30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */; }; 61326CDA7A73757FB68DCB04 /* Pods_passKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DAB3F5541E51ADC8C6B56642 /* Pods_passKit.framework */; }; A20691F41F2A3D0E0096483D /* SecurePasteboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20691F31F2A3D0E0096483D /* SecurePasteboard.swift */; }; A2168A7F1EFD40D5005EA873 /* OnePasswordExtensionConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2168A7E1EFD40D5005EA873 /* OnePasswordExtensionConstants.swift */; }; @@ -175,7 +184,16 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 30B0485F209A5141001013CA /* PasswordTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordTests.swift; sourceTree = ""; }; + 301F6462216162550071A4CE /* AdditionField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionField.swift; sourceTree = ""; }; + 301F6465216164830071A4CE /* PasswordHelpersTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordHelpersTest.swift; sourceTree = ""; }; + 301F6467216165290071A4CE /* ConstantsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstantsTest.swift; sourceTree = ""; }; + 301F6469216166000071A4CE /* StringExtensionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensionTest.swift; sourceTree = ""; }; + 301F646C216166AA0071A4CE /* AdditionFieldTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionFieldTest.swift; sourceTree = ""; }; + 302E85602125ECC70031BA64 /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; + 302E85622125EE550031BA64 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 30AAC05221989DCE00F656CE /* PasswordHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PasswordHelpers.swift; path = Helpers/PasswordHelpers.swift; sourceTree = ""; }; + 30B0485F209A5141001013CA /* PasswordTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordTest.swift; sourceTree = ""; }; + 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = ""; }; 31C3033E8868D05B2C55C8B1 /* Pods-passExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-passExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-passExtension/Pods-passExtension.debug.xcconfig"; sourceTree = ""; }; 3A5620D17DF5E86B61761D0E /* Pods_pass.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_pass.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 666769E0B255666D02945C15 /* Pods-passKitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-passKitTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-passKitTests/Pods-passKitTests.release.xcconfig"; sourceTree = ""; }; @@ -361,6 +379,43 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 301F6464216164670071A4CE /* Helpers */ = { + isa = PBXGroup; + children = ( + 301F6465216164830071A4CE /* PasswordHelpersTest.swift */, + 301F6469216166000071A4CE /* StringExtensionTest.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 30C015A3214ECF2B005BB6DF /* Parser */ = { + isa = PBXGroup; + children = ( + 301F6462216162550071A4CE /* AdditionField.swift */, + 302E85622125EE550031BA64 /* Constants.swift */, + 302E85602125ECC70031BA64 /* Parser.swift */, + ); + path = Parser; + sourceTree = ""; + }; + 30C015A6214ED32A005BB6DF /* Parser */ = { + isa = PBXGroup; + children = ( + 301F646C216166AA0071A4CE /* AdditionFieldTest.swift */, + 301F6467216165290071A4CE /* ConstantsTest.swift */, + 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */, + ); + path = Parser; + sourceTree = ""; + }; + 30C015A7214ED378005BB6DF /* Models */ = { + isa = PBXGroup; + children = ( + 30B0485F209A5141001013CA /* PasswordTest.swift */, + ); + path = Models; + sourceTree = ""; + }; A2168A801EFD431A005EA873 /* Controllers */ = { isa = PBXGroup; children = ( @@ -413,6 +468,7 @@ A2F4E20F1EED7F0A0011986E /* Helpers */, A260757B1EEC6F34005DB03E /* Info.plist */, A2F4E20E1EED7F040011986E /* Models */, + 30C015A3214ECF2B005BB6DF /* Parser */, A26075A51EEC7125005DB03E /* pass.xcdatamodeld */, A260757A1EEC6F34005DB03E /* passKit.h */, ); @@ -422,9 +478,11 @@ A26075861EEC6F34005DB03E /* passKitTests */ = { isa = PBXGroup; children = ( + 301F6464216164670071A4CE /* Helpers */, + 30C015A7214ED378005BB6DF /* Models */, + 30C015A6214ED32A005BB6DF /* Parser */, A26075871EEC6F34005DB03E /* passKitTests.swift */, A26075891EEC6F34005DB03E /* Info.plist */, - 30B0485F209A5141001013CA /* PasswordTests.swift */, ); path = passKitTests; sourceTree = ""; @@ -455,8 +513,8 @@ A2F4E20E1EED7F040011986E /* Models */ = { isa = PBXGroup; children = ( - A2C532BA201E5A9600DB9F53 /* PasscodeLock.swift */, A2F4E2101EED800F0011986E /* GitCredential.swift */, + A2C532BA201E5A9600DB9F53 /* PasscodeLock.swift */, A2F4E2111EED800F0011986E /* Password.swift */, A2F4E2121EED800F0011986E /* PasswordEntity.swift */, A2F4E2131EED800F0011986E /* PasswordStore.swift */, @@ -469,13 +527,14 @@ children = ( A2F4E2181EED80160011986E /* AppError.swift */, A2F4E2191EED80160011986E /* DefaultsKeys.swift */, + A239F5202157B75E00576CBF /* FileManagerExtension.swift */, A2F4E21A1EED80160011986E /* Globals.swift */, A2F4E21B1EED80160011986E /* NotificationNames.swift */, - A2F4E21D1EED80160011986E /* Utils.swift */, + 30AAC05221989DCE00F656CE /* PasswordHelpers.swift */, + A239F51E2157B72700576CBF /* StringExtension.swift */, A2F4E21C1EED80160011986E /* UITextFieldExtension.swift */, A2BEC1BA207D2EFE00F3051C /* UIViewExtension.swift */, - A239F51E2157B72700576CBF /* StringExtension.swift */, - A239F5202157B75E00576CBF /* FileManagerExtension.swift */, + A2F4E21D1EED80160011986E /* Utils.swift */, ); name = Helpers; sourceTree = ""; @@ -577,15 +636,15 @@ DC917BCA1E2E8231000FDF54 = { isa = PBXGroup; children = ( - DC917BD51E2E8231000FDF54 /* pass */, - A26075791EEC6F34005DB03E /* passKit */, - A26700251EEC466A00176B8A /* passExtension */, - A239F5972158C08C00576CBF /* passAutoFillExtension */, - DC13B14F1E8640810097803F /* passTests */, - A26075861EEC6F34005DB03E /* passKitTests */, - DC917BD41E2E8231000FDF54 /* Products */, DC917BED1E2F38C4000FDF54 /* Frameworks */, + DC917BD51E2E8231000FDF54 /* pass */, + A239F5972158C08C00576CBF /* passAutoFillExtension */, + A26700251EEC466A00176B8A /* passExtension */, + A26075791EEC6F34005DB03E /* passKit */, + A26075861EEC6F34005DB03E /* passKitTests */, + DC13B14F1E8640810097803F /* passTests */, A51B01737D08DB47BB58F85A /* Pods */, + DC917BD41E2E8231000FDF54 /* Products */, ); sourceTree = ""; }; @@ -1088,6 +1147,8 @@ buildActionMask = 2147483647; files = ( A2BEC1BB207D2EFE00F3051C /* UIViewExtension.swift in Sources */, + 302E85632125EE550031BA64 /* Constants.swift in Sources */, + 301F6463216162550071A4CE /* AdditionField.swift in Sources */, A2C532BB201E5A9600DB9F53 /* PasscodeLock.swift in Sources */, A2F4E2151EED800F0011986E /* Password.swift in Sources */, A26075AD1EEC7125005DB03E /* pass.xcdatamodeld in Sources */, @@ -1095,10 +1156,12 @@ A239F5212157B75E00576CBF /* FileManagerExtension.swift in Sources */, A2F4E21E1EED80160011986E /* AppError.swift in Sources */, A2F4E2171EED800F0011986E /* PasswordStore.swift in Sources */, + 302E85612125ECC70031BA64 /* Parser.swift in Sources */, A2F4E2211EED80160011986E /* NotificationNames.swift in Sources */, A2F4E2221EED80160011986E /* UITextFieldExtension.swift in Sources */, A2C532BF201E5AA100DB9F53 /* PasscodeLockPresenter.swift in Sources */, A2C532BE201E5AA100DB9F53 /* PasscodeLockViewController.swift in Sources */, + 30AAC05321989DCE00F656CE /* PasswordHelpers.swift in Sources */, A2F4E2201EED80160011986E /* Globals.swift in Sources */, A2F4E2231EED80160011986E /* Utils.swift in Sources */, A2F4E21F1EED80160011986E /* DefaultsKeys.swift in Sources */, @@ -1111,7 +1174,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 30B04860209A5141001013CA /* PasswordTests.swift in Sources */, + 301F646A216166000071A4CE /* StringExtensionTest.swift in Sources */, + 301F6466216164830071A4CE /* PasswordHelpersTest.swift in Sources */, + 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, + 30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */, + 30B04860209A5141001013CA /* PasswordTest.swift in Sources */, + 301F6468216165290071A4CE /* ConstantsTest.swift in Sources */, A26075881EEC6F34005DB03E /* passKitTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/pass/Controllers/EditPasswordTableViewController.swift b/pass/Controllers/EditPasswordTableViewController.swift index 8701c8c..0c756ad 100644 --- a/pass/Controllers/EditPasswordTableViewController.swift +++ b/pass/Controllers/EditPasswordTableViewController.swift @@ -14,7 +14,7 @@ class EditPasswordTableViewController: PasswordEditorTableViewController { tableData = [ [[.type: PasswordEditorCellType.nameCell, .title: "name", .content: password!.namePath]], [[.type: PasswordEditorCellType.fillPasswordCell, .title: "password", .content: password!.password]], - [[.type: PasswordEditorCellType.additionsCell, .title: "additions", .content: password!.getAdditionsPlainText()]], + [[.type: PasswordEditorCellType.additionsCell, .title: "additions", .content: password!.additionsPlainText]], [[.type: PasswordEditorCellType.scanQRCodeCell], [.type: PasswordEditorCellType.deletePasswordCell]] ] diff --git a/pass/Controllers/GeneralSettingsTableViewController.swift b/pass/Controllers/GeneralSettingsTableViewController.swift index 2688647..c978a1c 100644 --- a/pass/Controllers/GeneralSettingsTableViewController.swift +++ b/pass/Controllers/GeneralSettingsTableViewController.swift @@ -174,7 +174,7 @@ class GeneralSettingsTableViewController: BasicStaticTableViewController { } @objc func tapHideOTPSwitchDetailButton(_ sender: Any?) { - let keywordsString = Password.OTP_KEYWORDS.joined(separator: ",") + let keywordsString = Constants.OTP_KEYWORDS.joined(separator: ",") let alertMessage = "Turn on this switch to hide the fields related to one time passwords (i.e., \(keywordsString))." let alertTitle = "Hide One Time Password Fields" Utils.alert(title: alertTitle, message: alertMessage, controller: self, completion: nil) diff --git a/pass/Controllers/PasswordEditorTableViewController.swift b/pass/Controllers/PasswordEditorTableViewController.swift index f268d36..51067d4 100644 --- a/pass/Controllers/PasswordEditorTableViewController.swift +++ b/pass/Controllers/PasswordEditorTableViewController.swift @@ -185,8 +185,7 @@ class PasswordEditorTableViewController: UITableViewController, FillPasswordTabl // generate password, copy to pasteboard, and set the cell // check whether the current password looks like an OTP field func generateAndCopyPassword() { - if let currentPassword = fillPasswordCell?.getContent(), - Password.LooksLikeOTP(line: currentPassword) { + if let currentPassword = fillPasswordCell?.getContent(), Constants.isOtpRelated(line: currentPassword) { let alert = UIAlertController(title: "Overwrite?", message: "Overwrite the one-time password configuration?", preferredStyle: UIAlertControllerStyle.alert) alert.addAction(UIAlertAction(title: "Yes", style: UIAlertActionStyle.destructive, handler: {_ in self.generateAndCopyPasswordNoOtpCheck() diff --git a/passKit/Helpers/PasswordHelpers.swift b/passKit/Helpers/PasswordHelpers.swift new file mode 100644 index 0000000..3cd1236 --- /dev/null +++ b/passKit/Helpers/PasswordHelpers.swift @@ -0,0 +1,30 @@ +// +// PasswordHelpers.swift +// passKit +// +// Created by Danny Moesch on 17.08.18. +// Copyright © 2018 Bob Sun. All rights reserved. +// + +import OneTimePassword + +public enum OtpType { + case totp, hotp, none + + init(token: Token?) { + switch token?.generator.factor { + case .some(.counter): + self = .hotp + case .some(.timer): + self = .totp + default: + self = .none + } + } +} + +enum PasswordChange: Int { + case path = 0x01 + case content = 0x02 + case none = 0x00 +} diff --git a/passKit/Helpers/StringExtension.swift b/passKit/Helpers/StringExtension.swift index 35ed3d2..bd068b3 100644 --- a/passKit/Helpers/StringExtension.swift +++ b/passKit/Helpers/StringExtension.swift @@ -16,3 +16,9 @@ public extension String { return addingPercentEncoding(withAllowedCharacters: allowed) } } + +extension String { + static func | (left: String, right: String) -> String { + return right.isEmpty ? left : left + "\n" + right + } +} diff --git a/passKit/Models/Password.swift b/passKit/Models/Password.swift index 67ca850..ebef4ea 100644 --- a/passKit/Models/Password.swift +++ b/passKit/Models/Password.swift @@ -6,163 +6,99 @@ // Copyright © 2017 Bob Sun. All rights reserved. // -import Foundation import SwiftyUserDefaults import OneTimePassword import Base32 import KeychainAccess -public struct AdditionField: Equatable { - public var title: String = "" - public var content: String = "" - - var asString: String { return title.isEmpty ? content : title + ": " + content } - var asTuple: (String, String) { return (title, content) } - - public static func == (first: AdditionField, second: AdditionField) -> Bool { - return first.asTuple == second.asTuple - } -} - -public enum OtpType { - case totp, hotp, none - - static func from(token: Token?) -> OtpType { - switch token?.generator.factor { - case .some(.counter): - return .hotp - case .some(.timer): - return .totp - default: - return .none - } - } -} - -enum PasswordChange: Int { - case path = 0x01 - case content = 0x02 - case none = 0x00 -} - public class Password { - public static let OTP_KEYWORDS = ["otp_secret", "otp_type", "otp_algorithm", "otp_period", "otp_digits", "otp_counter", "otpauth"] - - public static let BLANK = " " - public static let MULTILINE_WITH_LINE_BREAK_INDICATOR = "|" - public static let MULTILINE_WITH_LINE_BREAK_SEPARATOR = "\n" - public static let MULTILINE_WITHOUT_LINE_BREAK_INDICATOR = ">" - public static let MULTILINE_WITHOUT_LINE_BREAK_SEPARATOR = BLANK - - private static let OTPAUTH = "otpauth" - private static let OTPAUTH_URL_START = "\(OTPAUTH)://" - private static let PASSWORD_KEYWORD = "password" - private static let USERNAME_KEYWORD = "username" - private static let LOGIN_KEYWORD = "login" - private static let URL_KEYWORD = "url" - private static let UNKNOWN = "unknown" - + public var name: String public var url: URL - public var namePath: String { return url.deletingPathExtension().path } + public var plainText: String - public var password = "" public var changed: Int = 0 - public var plainText = "" - public var plainData: Data { return plainText.data(using: .utf8)! } - public var username: String? { return getAdditionValue(withKey: Password.USERNAME_KEYWORD, caseSensitive: false) } - public var login: String? { return getAdditionValue(withKey: Password.LOGIN_KEYWORD, caseSensitive: false) } - public var urlString: String? { return getAdditionValue(withKey: Password.URL_KEYWORD, caseSensitive: false) } + public var otpType: OtpType = .none + private var parser = Parser(plainText: "") private var additions = [AdditionField]() private var firstLineIsOTPField = false - private var otpToken: Token? - public var otpType: OtpType { return OtpType.from(token: self.otpToken) } + private var otpToken: Token? { + didSet { + otpType = OtpType(token: otpToken) + } + } + + public var namePath: String { + return url.deletingPathExtension().path + } + + public var password: String { + return parser.firstLine + } + + public var plainData: Data { + return plainText.data(using: .utf8)! + } + + public var additionsPlainText: String { + return parser.additionsSection + } + + public var username: String? { + return getAdditionValue(withKey: Constants.USERNAME_KEYWORD) + } + + public var login: String? { + return getAdditionValue(withKey: Constants.LOGIN_KEYWORD) + } + + public var urlString: String? { + return getAdditionValue(withKey: Constants.URL_KEYWORD) + } public init(name: String, url: URL, plainText: String) { - self.name = name - self.url = url - self.initEverything(name: name, url: url, plainText: plainText) - } - - public func updatePassword(name: String, url: URL, plainText: String) { - if self.plainText != plainText || self.url != url { - if self.plainText != plainText { - changed = changed|PasswordChange.content.rawValue - } - if self.url != url { - changed = changed|PasswordChange.path.rawValue - } - self.initEverything(name: name, url: url, plainText: plainText) - } - } - - private func initEverything(name: String, url: URL, plainText: String) { self.name = name self.url = url self.plainText = plainText - additions.removeAll() - - // split the plain text - let plainTextSplit = self.plainText - .split(omittingEmptySubsequences: false) { $0 == "\n" || $0 == "\r\n" } - .map(String.init) + initEverything() + } + - // get password - password = plainTextSplit.first ?? "" + public func updatePassword(name: String, url: URL, plainText: String) { + guard self.plainText != plainText || self.url != url else { + return + } - // get remaining lines (filter out empty lines) - let additionalLines = plainTextSplit[1...].filter { !$0.isEmpty } + if self.plainText != plainText { + self.plainText = plainText + changed = changed|PasswordChange.content.rawValue + } + if self.url != url { + self.url = url + changed = changed|PasswordChange.path.rawValue + } - // parse lines to get key-value pairs - parseDataFrom(lines: additionalLines) + self.name = name + initEverything() + } - // check whether the first line looks like an otp entry + private func initEverything() { + parser = Parser(plainText: self.plainText) + additions = parser.additionFields + + // Check whether the first line looks like an otp entry. checkPasswordForOtpToken() - // construct the otp token + // Construct the otp token. updateOtpToken() } - private func parseDataFrom(lines: [String]) { - var unknownIndex = 0 - var i = lines.startIndex - while i < lines.count { - let line = lines[i] - i += 1 - var (key, value) = Password.getKeyValuePair(from: line) - if key == nil { - unknownIndex += 1 - key = "\(Password.UNKNOWN) \(unknownIndex)" - } else if value == Password.MULTILINE_WITH_LINE_BREAK_INDICATOR { - value = gatherMultilineValue(from: lines, startingAt: &i, removingLineBreaks: false) - } else if value == Password.MULTILINE_WITHOUT_LINE_BREAK_INDICATOR { - value = gatherMultilineValue(from: lines, startingAt: &i, removingLineBreaks: true) - } - additions.append(AdditionField(title: key!, content: value)) - } - } - - private func gatherMultilineValue(from content: [String], startingAt i: inout Int, removingLineBreaks: Bool) -> String { - var result = "" - guard i < content.count else { return result } - let numberInitialBlanks = content[i].enumerated().first(where: { $1 != Character(Password.BLANK) })?.0 ?? content[i].count - guard numberInitialBlanks != 0 else { return result } - let initialBlanks = String(repeating: Password.BLANK, count: numberInitialBlanks) - - while i < content.count && content[i].starts(with: initialBlanks) { - result.append(String(content[i].dropFirst(numberInitialBlanks))) - result.append(removingLineBreaks ? Password.MULTILINE_WITHOUT_LINE_BREAK_SEPARATOR : Password.MULTILINE_WITH_LINE_BREAK_SEPARATOR) - i += 1 - } - return result.trimmingCharacters(in: .whitespacesAndNewlines) - } - private func checkPasswordForOtpToken() { - let (key, value) = Password.getKeyValuePair(from: self.password) - if Password.OTP_KEYWORDS.contains(key ?? "") { + let (key, value) = Parser.getKeyValuePair(from: password) + if Constants.OTP_KEYWORDS.contains(key ?? "") { firstLineIsOTPField = true - self.additions.append(AdditionField(title: key!, content: value)) + additions.append(key! => value) } else { firstLineIsOTPField = false } @@ -170,48 +106,17 @@ public class Password { public func getFilteredAdditions() -> [AdditionField] { return additions.filter { field in - field.title.lowercased() != Password.USERNAME_KEYWORD - && field.title.lowercased() != Password.LOGIN_KEYWORD - && field.title.lowercased() != Password.PASSWORD_KEYWORD - && (!field.title.hasPrefix(Password.UNKNOWN) || !SharedDefaults[.isHideUnknownOn]) - && (!Password.OTP_KEYWORDS.contains(field.title) || !SharedDefaults[.isHideOTPOn]) + field.title.lowercased() != Constants.USERNAME_KEYWORD + && field.title.lowercased() != Constants.LOGIN_KEYWORD + && field.title.lowercased() != Constants.PASSWORD_KEYWORD + && (!field.title.hasPrefix(Constants.UNKNOWN) || !SharedDefaults[.isHideUnknownOn]) + && (!Constants.OTP_KEYWORDS.contains(field.title) || !SharedDefaults[.isHideOTPOn]) } } - - // return a key-value pair from the line - // key might be nil, if there is no ":" in the line - private static func getKeyValuePair(from line: String) -> (key: String?, value: String) { - let items = line.components(separatedBy: ": ").map{String($0).trimmingCharacters(in: .whitespaces)} - var key : String? = nil - var value = "" - if items.count == 1 || (items[0].isEmpty && items[1].isEmpty) { - // no ": " found, or empty on both sides of ": " - value = line - // otpauth special case - if value.hasPrefix(Password.OTPAUTH_URL_START) { - key = Password.OTPAUTH - } - } else { - if !items[0].isEmpty { - key = items[0] - } - value = items[1] - } - return (key, value) - } - - public func getAdditionsPlainText() -> String { - // lines starting from the second - let plainTextSplit = plainText.split(maxSplits: 1, omittingEmptySubsequences: false) { - $0 == "\n" || $0 == "\r\n" - }.map(String.init) - return plainTextSplit.count == 1 ? "" : plainTextSplit[1] - } - - private func getAdditionValue(withKey key: String, caseSensitive: Bool = true) -> String? { - let searchKey = caseSensitive ? key : key.lowercased() - let matchingField = additions.first { (caseSensitive ? $0.title : $0.title.lowercased()) == searchKey } - return matchingField?.content + + private func getAdditionValue(withKey key: String, caseSensitive: Bool = false) -> String? { + let toLowercase = { (string: String) -> String in return caseSensitive ? string : string.lowercased() } + return additions.first(where: { toLowercase($0.title) == toLowercase(key) })?.content } /* @@ -239,9 +144,9 @@ public class Password { self.otpToken = nil // get otpauth, if we are able to generate a token, return - if var otpauthString = getAdditionValue(withKey: Password.OTPAUTH) { - if !otpauthString.hasPrefix("\(Password.OTPAUTH):") { - otpauthString = "\(Password.OTPAUTH):\(otpauthString)" + if var otpauthString = getAdditionValue(withKey: Constants.OTPAUTH, caseSensitive: true) { + if !otpauthString.hasPrefix("\(Constants.OTPAUTH):") { + otpauthString = "\(Constants.OTPAUTH):\(otpauthString)" } if let otpauthUrl = URL(string: otpauthString), let token = Token(url: otpauthUrl) { @@ -358,10 +263,10 @@ public class Password { var lines : [String] = [] self.plainText.enumerateLines() { line, _ in - let (key, _) = Password.getKeyValuePair(from: line) - if !Password.OTP_KEYWORDS.contains(key ?? "") { + let (key, _) = Parser.getKeyValuePair(from: line) + if !Constants.OTP_KEYWORDS.contains(key ?? "") { lines.append(line) - } else if key == Password.OTPAUTH && newOtpauth != nil { + } else if key == Constants.OTPAUTH && newOtpauth != nil { lines.append(newOtpauth!) // set to nil to prevent duplication newOtpauth = nil @@ -376,11 +281,6 @@ public class Password { return self.otpToken?.currentPassword } - public static func LooksLikeOTP(line: String) -> Bool { - let (key, _) = getKeyValuePair(from: line) - return Password.OTP_KEYWORDS.contains(key ?? "") - } - public static func generatePassword(length: Int) -> String{ switch SharedDefaults[.passwordGeneratorFlavor] { case "Random": diff --git a/passKit/Parser/AdditionField.swift b/passKit/Parser/AdditionField.swift new file mode 100644 index 0000000..bffb2e0 --- /dev/null +++ b/passKit/Parser/AdditionField.swift @@ -0,0 +1,47 @@ +// +// AdditionField.swift +// passKit +// +// Created by Danny Moesch on 30.09.18. +// Copyright © 2018 Bob Sun. All rights reserved. +// + +public struct AdditionField: Hashable { + + public let title: String, content: String + + var asString: String { + return title.isEmpty ? content : title + ": " + content + } + + var asTuple: (String, String) { + return (title, content) + } +} + +extension AdditionField { + + static func | (left: String, right: AdditionField) -> String { + return left | right.asString + } + + static func | (left: AdditionField, right: String) -> String { + return left.asString | right + } + + static func | (left: AdditionField, right: AdditionField) -> String { + return left.asString | right + } +} + +extension AdditionField: Equatable { + + public static func == (first: AdditionField, second: AdditionField) -> Bool { + return first.asTuple == second.asTuple + } +} + +infix operator =>: MultiplicationPrecedence +func => (key: String, value: String) -> AdditionField { + return AdditionField(title: key, content: value) +} diff --git a/passKit/Parser/Constants.swift b/passKit/Parser/Constants.swift new file mode 100644 index 0000000..11786ea --- /dev/null +++ b/passKit/Parser/Constants.swift @@ -0,0 +1,47 @@ +// +// Constants.swift +// passKit +// +// Created by Danny Moesch on 16.08.18. +// Copyright © 2018 Bob Sun. All rights reserved. +// + +public struct Constants { + + public static let OTP_KEYWORDS = [ + "otp_secret", + "otp_type", + "otp_algorithm", + "otp_period", + "otp_digits", + "otp_counter", + "otpauth", + ] + + static let BLANK = " " + static let MULTILINE_WITH_LINE_BREAK_INDICATOR = "|" + static let MULTILINE_WITH_LINE_BREAK_SEPARATOR = "\n" + static let MULTILINE_WITHOUT_LINE_BREAK_INDICATOR = ">" + static let MULTILINE_WITHOUT_LINE_BREAK_SEPARATOR = BLANK + + static let OTPAUTH = "otpauth" + static let OTPAUTH_URL_START = "\(OTPAUTH)://" + static let PASSWORD_KEYWORD = "password" + static let USERNAME_KEYWORD = "username" + static let LOGIN_KEYWORD = "login" + static let URL_KEYWORD = "url" + static let UNKNOWN = "unknown" + + public static func isOtpRelated(line: String) -> Bool { + let (key, _) = Parser.getKeyValuePair(from: line) + return OTP_KEYWORDS.contains(key ?? "") + } + + static func unknown(_ number: UInt) -> String { + return "\(UNKNOWN) \(number)" + } + + static func getSeparator(breakingLines: Bool) -> String { + return breakingLines ? MULTILINE_WITH_LINE_BREAK_SEPARATOR : MULTILINE_WITHOUT_LINE_BREAK_SEPARATOR + } +} diff --git a/passKit/Parser/Parser.swift b/passKit/Parser/Parser.swift new file mode 100644 index 0000000..aba3f3b --- /dev/null +++ b/passKit/Parser/Parser.swift @@ -0,0 +1,93 @@ +// +// Parser.swift +// passKit +// +// Created by Danny Moesch on 16.08.18. +// Copyright © 2018 Bob Sun. All rights reserved. +// + +class Parser { + + let firstLine: String + let additionsSection: String + let purgedAdditionalLines: [String] + + // The parsing process is expensive. This field makes sure it is only done once if actually needed. + private(set) lazy var additionFields = getAdditionFields() + + init(plainText: String) { + let splittedPlainText = plainText + .split(omittingEmptySubsequences: false) { $0 == "\n" || $0 == "\r\n" } + .map(String.init) + + firstLine = splittedPlainText.first! + additionsSection = splittedPlainText[1...].joined(separator: "\n") + purgedAdditionalLines = splittedPlainText[1...].filter { !$0.isEmpty } + } + + private func getAdditionFields() -> [AdditionField] { + var additions: [AdditionField] = [] + var unknownIndex: UInt = 0 + var i = purgedAdditionalLines.startIndex + while i < purgedAdditionalLines.count { + let line = purgedAdditionalLines[i] + i += 1 + var (key, value) = Parser.getKeyValuePair(from: line) + if key == nil { + unknownIndex += 1 + key = Constants.unknown(unknownIndex) + } else if value == Constants.MULTILINE_WITH_LINE_BREAK_INDICATOR { + value = gatherMultilineValue(startingAt: &i, removingLineBreaks: false) + } else if value == Constants.MULTILINE_WITHOUT_LINE_BREAK_INDICATOR { + value = gatherMultilineValue(startingAt: &i, removingLineBreaks: true) + } + additions.append(key! => value) + } + return additions + } + + private func gatherMultilineValue(startingAt i: inout Int, removingLineBreaks: Bool) -> String { + var result = "" + guard i < purgedAdditionalLines.count else { + return result + } + let numberInitialBlanks = purgedAdditionalLines[i].enumerated().first { + $1 != Character(Constants.BLANK) + }?.0 ?? purgedAdditionalLines[i].count + guard numberInitialBlanks != 0 else { + return result + } + let initialBlanks = String(repeating: Constants.BLANK, count: numberInitialBlanks) + + while i < purgedAdditionalLines.count && purgedAdditionalLines[i].starts(with: initialBlanks) { + result.append(String(purgedAdditionalLines[i].dropFirst(numberInitialBlanks))) + result.append(Constants.getSeparator(breakingLines: !removingLineBreaks)) + i += 1 + } + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Split line from password file in to a key-value pair separted by `: `. + /// + /// - Parameter line: Line from a password file + /// - Returns: Pair of two `String`s of which the first one can be 'nil' + static func getKeyValuePair(from line: String) -> (key: String?, value: String) { + let items = line.components(separatedBy: ": ").map { String($0).trimmingCharacters(in: .whitespaces) } + var key: String? + var value = "" + if items.count == 1 || (items[0].isEmpty && items[1].isEmpty) { + // No ': ' found, or empty on both sides of ': '. + value = line + // "otpauth" special case + if value.hasPrefix(Constants.OTPAUTH_URL_START) { + key = Constants.OTPAUTH + } + } else { + if !items[0].isEmpty { + key = items[0] + } + value = items[1] + } + return (key, value) + } +} diff --git a/passKitTests/Helpers/PasswordHelpersTest.swift b/passKitTests/Helpers/PasswordHelpersTest.swift new file mode 100644 index 0000000..5d9ca87 --- /dev/null +++ b/passKitTests/Helpers/PasswordHelpersTest.swift @@ -0,0 +1,29 @@ +// +// PasswordHelpersTest.swift +// passKitTests +// +// Created by Danny Moesch on 30.09.18. +// Copyright © 2018 Bob Sun. All rights reserved. +// + +import OneTimePassword +import XCTest + +@testable import passKit + +class PasswordHelpersTest: XCTestCase { + + func testOtpType() { + let secret = "secret".data(using: .utf8)! + + let totpGenerator = Generator(factor: .timer(period: 30.0), secret: secret, algorithm: .sha1, digits: 6)! + let totpToken = Token(name: "", issuer: "", generator: totpGenerator) + XCTAssertEqual(OtpType(token: totpToken), .totp) + + let hotpGenerator = Generator(factor: .counter(4), secret: secret, algorithm: .sha1, digits: 6)! + let hotpToken = Token(name: "", issuer: "", generator: hotpGenerator) + XCTAssertEqual(OtpType(token: hotpToken), .hotp) + + XCTAssertEqual(OtpType(token: nil), .none) + } +} diff --git a/passKitTests/Helpers/StringExtensionTest.swift b/passKitTests/Helpers/StringExtensionTest.swift new file mode 100644 index 0000000..c7204bb --- /dev/null +++ b/passKitTests/Helpers/StringExtensionTest.swift @@ -0,0 +1,35 @@ +// +// StringExtensionTest.swift +// passKitTests +// +// Created by Danny Moesch on 30.09.18. +// Copyright © 2018 Bob Sun. All rights reserved. +// + +import XCTest + +@testable import passKit + +class StringExtensionTest: XCTestCase { + + func testStringByAddingPercentEncodingForRFC3986() { + [ + ("!#$&'()*+,/:;=?@[]^", "%21%23%24%26%27%28%29%2A%2B%2C/%3A%3B%3D?%40%5B%5D%5E"), + ("-._~/?", "-._~/?"), + ("A*b!c", "A%2Ab%21c"), + ].forEach { unencoded, encoded in + XCTAssertEqual(unencoded.stringByAddingPercentEncodingForRFC3986(), encoded) + } + } + + func testConcatenateAsLines() { + [ + ("a" | "b", "a\nb"), + ("" | "b", "\nb"), + ("a" | "", "a"), + ("" | "", ""), + ].forEach { concatenated, result in + XCTAssertEqual(concatenated, result) + } + } +} diff --git a/passKitTests/Models/PasswordTest.swift b/passKitTests/Models/PasswordTest.swift new file mode 100644 index 0000000..b84a42c --- /dev/null +++ b/passKitTests/Models/PasswordTest.swift @@ -0,0 +1,276 @@ +// +// PasswordTests.swift +// passKitTests +// +// Created by Danny Moesch on 02.05.18. +// Copyright © 2018 Bob Sun. All rights reserved. +// + +import XCTest + +@testable import passKit + +class PasswordTest: XCTestCase { + + private let PASSWORD_PATH = "/path/to/password" + private let PASSWORD_URL = URL(fileURLWithPath: "/path/to/password") + private let PASSWORD_STRING = "abcd1234" + private let OTP_TOKEN = "otpauth://totp/email@email.com?secret=abcd1234" + + private let SECURE_URL_FIELD = "url" => "https://secure.com" + private let INSECURE_URL_FIELD = "url" => "http://insecure.com" + private let LOGIN_FIELD = "login" => "login name" + private let USERNAME_FIELD = "username" => "some username" + private let NOTE_FIELD = "note" => "A NOTE" + private let HINT_FIELD = "some hints" => "äöüß // €³ %% −° && @²` | [{\\}],.<>" + + func testUrl() { + let password = getPasswordObjectWith(content: "") + XCTAssertEqual(password.url, PASSWORD_URL) + XCTAssertEqual(password.namePath, PASSWORD_PATH) + } + + func testEmptyFile() { + [ + "", + "\n", + ].forEach { fileContent in + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, "") + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + + XCTAssertEqual(password.additionsPlainText, "") + XCTAssertTrue(password.getFilteredAdditions().isEmpty) + + XCTAssertNil(password.username) + XCTAssertNil(password.urlString) + XCTAssertNil(password.login) + } + } + + func testEmptyPassword() { + let fileContent = "\n\(LOGIN_FIELD.asString)" + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, "") + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, LOGIN_FIELD.asString) + + XCTAssertFalse(does(password, contain: LOGIN_FIELD)) + + XCTAssertNil(password.username) + XCTAssertNil(password.urlString) + XCTAssertEqual(password.login, LOGIN_FIELD.content) + } + + func testSimplePasswordFile() { + let additions = SECURE_URL_FIELD | LOGIN_FIELD | USERNAME_FIELD | NOTE_FIELD + let fileContent = PASSWORD_STRING | additions + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, PASSWORD_STRING) + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, additions) + + XCTAssertTrue(does(password, contain: SECURE_URL_FIELD)) + XCTAssertFalse(does(password, contain: LOGIN_FIELD)) + XCTAssertFalse(does(password, contain: USERNAME_FIELD)) + XCTAssertTrue(does(password, contain: NOTE_FIELD)) + + XCTAssertEqual(password.urlString, SECURE_URL_FIELD.content) + XCTAssertEqual(password.login, LOGIN_FIELD.content) + XCTAssertEqual(password.username, USERNAME_FIELD.content) + } + + func testTwoPasswords() { + let additions = "efgh5678" | INSECURE_URL_FIELD + let fileContent = PASSWORD_STRING | additions + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, PASSWORD_STRING) + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, additions) + + XCTAssertTrue(does(password, contain: INSECURE_URL_FIELD)) + XCTAssertTrue(does(password, contain: Constants.unknown(1) => "efgh5678")) + + XCTAssertNil(password.username) + XCTAssertEqual(password.urlString, INSECURE_URL_FIELD.content) + XCTAssertNil(password.login) + } + + func testNoPassword() { + let fileContent = SECURE_URL_FIELD | NOTE_FIELD + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, SECURE_URL_FIELD.asString) + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, NOTE_FIELD.asString) + + XCTAssertTrue(does(password, contain: NOTE_FIELD)) + + XCTAssertNil(password.username) + XCTAssertNil(password.urlString) + XCTAssertNil(password.login) + } + + func testDuplicateKeys() { + let additions = SECURE_URL_FIELD | INSECURE_URL_FIELD + let fileContent = PASSWORD_STRING | additions + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, PASSWORD_STRING) + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, additions) + + XCTAssertTrue(does(password, contain: SECURE_URL_FIELD)) + XCTAssertTrue(does(password, contain: INSECURE_URL_FIELD)) + + XCTAssertNil(password.username) + XCTAssertEqual(password.urlString, SECURE_URL_FIELD.content) + XCTAssertNil(password.login) + } + + func testUnknownKeys() { + let value1 = "value 1" + let value2 = "value 2" + let value3 = "value 3" + let value4 = "value 4" + let additions = value1 | NOTE_FIELD | value2 | value3 | SECURE_URL_FIELD | value4 + let fileContent = PASSWORD_STRING | additions + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, PASSWORD_STRING) + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, additions) + + XCTAssertTrue(does(password, contain: Constants.unknown(1) => value1)) + XCTAssertTrue(does(password, contain: NOTE_FIELD)) + XCTAssertTrue(does(password, contain: Constants.unknown(2) => value2)) + XCTAssertTrue(does(password, contain: Constants.unknown(3) => value3)) + XCTAssertTrue(does(password, contain: SECURE_URL_FIELD)) + XCTAssertTrue(does(password, contain: Constants.unknown(4) => value4)) + + XCTAssertNil(password.username) + XCTAssertEqual(password.urlString, SECURE_URL_FIELD.content) + XCTAssertNil(password.login) + } + + func testPasswordFileWithOtpToken() { + let additions = NOTE_FIELD | OTP_TOKEN + let fileContent = PASSWORD_STRING | additions + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, PASSWORD_STRING) + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, additions) + + XCTAssertEqual(password.otpType, OtpType.totp) + XCTAssertNotNil(password.getOtp()) + } + + func testFirstLineIsOtpToken() { + let password = getPasswordObjectWith(content: OTP_TOKEN) + + XCTAssertEqual(password.password, OTP_TOKEN) + XCTAssertEqual(password.plainData, OTP_TOKEN.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, "") + + XCTAssertNil(password.username) + XCTAssertNil(password.urlString) + XCTAssertNil(password.login) + + XCTAssertEqual(password.otpType, OtpType.totp) + XCTAssertNotNil(password.getOtp()) + } + + func testWrongOtpToken() { + let fileContent = "otpauth://htop/blabla" + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, fileContent) + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertTrue(password.additionsPlainText.isEmpty) + + XCTAssertEqual(password.otpType, OtpType.none) + XCTAssertNil(password.getOtp()) + } + + func testEmptyMultilineValues() { + let lineBreakField1 = "with line breaks" => "| \n" + let lineBreakField2 = "with line breaks" => "| \n " + let noLineBreakField = "without line breaks" => " > " + let additions = lineBreakField1 | lineBreakField2 | NOTE_FIELD | noLineBreakField + let fileContent = PASSWORD_STRING | additions + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, PASSWORD_STRING) + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, additions) + + XCTAssertTrue(does(password, contain: lineBreakField1.title => "")) + XCTAssertTrue(does(password, contain: lineBreakField2.title => "")) + XCTAssertTrue(does(password, contain: NOTE_FIELD)) + XCTAssertTrue(does(password, contain: noLineBreakField.title => "")) + } + + func testMultilineValues() { + let lineBreakField = "with line breaks" => "|\n This is \n text spread over \n multiple lines! " + let noLineBreakField = "without line breaks" => " > \n This is \n text spread over\n multiple lines!" + let additions = lineBreakField | NOTE_FIELD | noLineBreakField + let fileContent = PASSWORD_STRING | additions + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, PASSWORD_STRING) + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, additions) + + XCTAssertTrue(does(password, contain: lineBreakField.title => "This is \n text spread over \nmultiple lines!")) + XCTAssertTrue(does(password, contain: NOTE_FIELD)) + XCTAssertTrue(does(password, contain: noLineBreakField.title => "This is text spread over multiple lines!")) + } + + func testMultilineValuesMixed() { + let lineBreakField = "with line breaks" => "|\n This is \n \(HINT_FIELD.asString) spread over\n multiple lines!" + let noLineBreakField = "without line breaks" => " > \n This is \n | \n text spread over\nmultiple lines!" + let additions = lineBreakField | noLineBreakField | NOTE_FIELD + let fileContent = PASSWORD_STRING | additions + let password = getPasswordObjectWith(content: fileContent) + + XCTAssertEqual(password.password, PASSWORD_STRING) + XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) + XCTAssertEqual(password.additionsPlainText, additions) + + XCTAssertTrue(does(password, contain: lineBreakField.title => "This is \n\(HINT_FIELD.asString) spread over")) + XCTAssertTrue(does(password, contain: Constants.unknown(1) => " multiple lines!")) + XCTAssertTrue(does(password, contain: noLineBreakField.title => "This is | text spread over")) + XCTAssertTrue(does(password, contain: Constants.unknown(2) => "multiple lines!")) + XCTAssertTrue(does(password, contain: NOTE_FIELD)) + } + + func testUpdatePassword() { + let password = getPasswordObjectWith(content: "") + XCTAssertEqual(password.changed, 0) + + password.updatePassword(name: "password", url: PASSWORD_URL, plainText: "") + XCTAssertEqual(password.changed, 0) + + password.updatePassword(name: "", url: PASSWORD_URL, plainText: "a") + XCTAssertEqual(password.changed, 2) + + password.updatePassword(name: "", url: URL(fileURLWithPath: "/some/path/"), plainText: "a") + XCTAssertEqual(password.changed, 3) + + password.updatePassword(name: "", url: PASSWORD_URL, plainText: "") + XCTAssertEqual(password.changed, 3) + } + + private func getPasswordObjectWith(content: String, url: URL? = nil) -> Password { + return Password(name: "password", url: url ?? PASSWORD_URL, plainText: content) + } + + private func does(_ password: Password, contain field: AdditionField) -> Bool { + return password.getFilteredAdditions().contains(field) + } +} diff --git a/passKitTests/Parser/AdditionFieldTest.swift b/passKitTests/Parser/AdditionFieldTest.swift new file mode 100644 index 0000000..706937e --- /dev/null +++ b/passKitTests/Parser/AdditionFieldTest.swift @@ -0,0 +1,52 @@ +// +// AdditionFieldTest.swift +// passKitTests +// +// Created by Danny Moesch on 30.09.18. +// Copyright © 2018 Bob Sun. All rights reserved. +// + +import XCTest + +@testable import passKit + +class AdditionFieldTest: XCTestCase { + + func testAdditionField() { + let field1 = "key" => "value" + let field2 = "some other key" => "some other value" + let field3 = "" => "no title" + + XCTAssertEqual(field1.asString, "key: value") + XCTAssertEqual(field2.asString, "some other key: some other value") + XCTAssertEqual(field3.asString, "no title") + + XCTAssertTrue(field1.asTuple == ("key", "value")) + XCTAssertTrue(field2.asTuple == ("some other key", "some other value")) + XCTAssertTrue(field3.asTuple == ("", "no title")) + } + + func testAdditionFieldEquals() { + XCTAssertEqual("key" => "value", "key" => "value") + XCTAssertNotEqual("key" => "value", "key" => "some other value") + } + + func testInfixAdditionFieldInitialization() { + XCTAssertEqual("key" => "value", AdditionField(title: "key", content: "value")) + } + + func testAdditionFieldOperators() { + let field1 = "key" => "value" + let field2 = "some other key" => "some other value" + let field3 = "" => "no title" + + XCTAssertEqual("start" | field1, "start\nkey: value") + XCTAssertEqual("" | field1, "\nkey: value") + XCTAssertEqual(field1 | "end", "key: value\nend") + XCTAssertEqual(field1 | "", "key: value") + XCTAssertEqual("start" | field1 | field2, "start\nkey: value\nsome other key: some other value") + XCTAssertEqual(field1 | field2 | "end", "key: value\nsome other key: some other value\nend") + XCTAssertEqual(field1 | field2 | field3, "key: value\nsome other key: some other value\nno title") + XCTAssertEqual("check" => "for right" | "operator" => "precedence", "check: for right\noperator: precedence") + } +} diff --git a/passKitTests/Parser/ConstantsTest.swift b/passKitTests/Parser/ConstantsTest.swift new file mode 100644 index 0000000..809e347 --- /dev/null +++ b/passKitTests/Parser/ConstantsTest.swift @@ -0,0 +1,31 @@ +// +// ConstantsTest.swift +// passKitTests +// +// Created by Danny Moesch on 30.09.18. +// Copyright © 2018 Bob Sun. All rights reserved. +// + +import XCTest + +@testable import passKit + +class ConstantsTest: XCTestCase { + + func testIsOtpRelated() { + XCTAssertTrue(Constants.isOtpRelated(line: "otpauth://something")) + XCTAssertTrue(Constants.isOtpRelated(line: "otp_algorithm: algorithm")) + XCTAssertFalse(Constants.isOtpRelated(line: "otp: something")) + XCTAssertFalse(Constants.isOtpRelated(line: "otp")) + } + + func testUnknown() { + XCTAssertEqual(Constants.unknown(0), "unknown 0") + XCTAssertEqual(Constants.unknown(10), "unknown 10") + } + + func testGetSeparator() { + XCTAssertEqual(Constants.getSeparator(breakingLines: true), "\n") + XCTAssertEqual(Constants.getSeparator(breakingLines: false), " ") + } +} diff --git a/passKitTests/Parser/ParserTest.swift b/passKitTests/Parser/ParserTest.swift new file mode 100644 index 0000000..3feeba8 --- /dev/null +++ b/passKitTests/Parser/ParserTest.swift @@ -0,0 +1,102 @@ +// +// ParserTest.swift +// passKitTests +// +// Created by Danny Moesch on 18.08.18. +// Copyright © 2018 Bob Sun. All rights reserved. +// + +@testable import passKit +import XCTest + +class ParserTest: XCTestCase { + + private let FIELD = "key" => "value" + private let LOGIN_FIELD = "login" => "login name" + private let SECURE_URL_FIELD = "url" => "https://secure.com" + private let INSECURE_URL_FIELD = "url" => "http://insecure.com" + private let USERNAME_FIELD = "username" => "微 分 方 程" + private let NOTE_FIELD = "note" => "A NOTE" + private let MULTILINE_BLOCK_START = "multiline block" => "|" + private let MULTILINE_LINE_START = "multiline line" => ">" + + func testInit() { + [ + ("", "", "", []), + ("a", "a", "", []), + ("a\nb", "a", "b", ["b"]), + ("a\n\nb", "a", "\nb", ["b"]), + ("a\r\nb", "a", "b", ["b"]), + ("a\nb\nc\n\nd", "a", "b\nc\n\nd", ["b", "c", "d"]), + ].forEach { plainText, firstLine, additionsSection, purgedAdditionalLines in + let parser = Parser(plainText: plainText) + XCTAssertEqual(parser.firstLine, firstLine) + XCTAssertEqual(parser.additionsSection, additionsSection) + XCTAssertEqual(parser.purgedAdditionalLines, purgedAdditionalLines) + } + } + + func testGetKeyValuePair() { + XCTAssertTrue(Parser.getKeyValuePair(from: "key: value") == ("key", "value")) + XCTAssertTrue(Parser.getKeyValuePair(from: "a key: a value") == ("a key", "a value")) + XCTAssertTrue(Parser.getKeyValuePair(from: "key:value") == (nil, "key:value")) + XCTAssertTrue(Parser.getKeyValuePair(from: ": value") == (nil, "value")) + XCTAssertTrue(Parser.getKeyValuePair(from: "key: ") == ("key", "")) + XCTAssertTrue(Parser.getKeyValuePair(from: "otpauth://value") == ("otpauth", "otpauth://value")) + } + + func testEmptyFiles() { + XCTAssertEqual(Parser(plainText: "").additionFields, []) + XCTAssertEqual(Parser(plainText: "\n").additionFields, []) + } + + func testSimpleKeyValueLines() { + let fields0 = Parser(plainText: "" | FIELD | LOGIN_FIELD | SECURE_URL_FIELD).additionFields + let fields1 = Parser(plainText: "" | FIELD | "" | SECURE_URL_FIELD).additionFields + + XCTAssertEqual(fields0, [FIELD, LOGIN_FIELD, SECURE_URL_FIELD]) + XCTAssertEqual(fields1, [FIELD, SECURE_URL_FIELD]) + } + + func testLinesWithoutKey() { + let fields0 = Parser(plainText: "" | "value").additionFields + let fields1 = Parser(plainText: "" | LOGIN_FIELD | "value only" | INSECURE_URL_FIELD).additionFields + let fields2 = Parser(plainText: "" | LOGIN_FIELD | USERNAME_FIELD | "value:only").additionFields + let fields3 = Parser(plainText: "" | LOGIN_FIELD | "value 1" | "value 2").additionFields + + XCTAssertEqual(fields0, [Constants.unknown(1) => "value"]) + XCTAssertEqual(fields1, [LOGIN_FIELD, Constants.unknown(1) => "value only", INSECURE_URL_FIELD]) + XCTAssertEqual(fields2, [LOGIN_FIELD, USERNAME_FIELD, Constants.unknown(1) => "value:only"]) + XCTAssertEqual(fields3, [LOGIN_FIELD, Constants.unknown(1) => "value 1", Constants.unknown(2) => "value 2"]) + } + + func testMultilineValues() { + [ + // Normal with one leading space + (" a b" | " cd", "a b\ncd", []), + // Normal with two leading spaces + (" a b" | " cd", "a b\ncd", []), + // Changing leading space lenght + (" a b" | " cd", "a b\n cd", []), + // First leading space longer than others + (" a b" | " cd", "a b", [Constants.unknown(1) => " cd"]), + // Empty first line + (" " | " cd", "cd", []), + // No leading space + ("a b" | "cd", "", [Constants.unknown(1) => "a b", Constants.unknown(2) => "cd"]), + // Characters with special meaning in value + (" a: b" | " c: |" | " d", "a: b\nc: |\nd", []), + // Empty value at end + ("", "", []), + // Empty value in between + ("" | NOTE_FIELD, "", [NOTE_FIELD]), + ].forEach { wrappedMultilineValue, content, additionalFields in + let blockField = Parser(plainText: "" | MULTILINE_BLOCK_START | wrappedMultilineValue).additionFields + XCTAssertEqual(blockField, [MULTILINE_BLOCK_START.title => content] + additionalFields) + + let lineField = Parser(plainText: "" | MULTILINE_LINE_START | wrappedMultilineValue).additionFields + let contentWithoutLineBreaks = content.replacingOccurrences(of: "\n", with: " ") + XCTAssertEqual(lineField, [MULTILINE_LINE_START.title => contentWithoutLineBreaks] + additionalFields) + } + } +} diff --git a/passKitTests/PasswordTests.swift b/passKitTests/PasswordTests.swift deleted file mode 100644 index 7121d38..0000000 --- a/passKitTests/PasswordTests.swift +++ /dev/null @@ -1,339 +0,0 @@ -// -// PasswordTests.swift -// passKitTests -// -// Created by Danny Mösch on 02.05.18. -// Copyright © 2018 Bob Sun. All rights reserved. -// - -import XCTest -@testable import passKit - -class PasswordTest: XCTestCase { - static let EMPTY_STRING = "" - static let PASSWORD_NAME = "password" - static let PASSWORD_PATH = "/path/to/\(PASSWORD_NAME)" - static let PASSWORD_URL = URL(fileURLWithPath: PASSWORD_PATH) - static let PASSWORD_STRING = "abcd1234" - static let OTP_TOKEN = "otpauth://totp/email@email.com?secret=abcd1234" - - static let SECURE_URL_FIELD = AdditionField(title: "url", content: "https://secure.com") - static let INSECURE_URL_FIELD = AdditionField(title: "url", content: "http://insecure.com") - static let LOGIN_FIELD = AdditionField(title: "login", content: "login name") - static let USERNAME_FIELD = AdditionField(title: "username", content: "some username") - static let NOTE_FIELD = AdditionField(title: "note", content: "A NOTE") - static let HINT_FIELD = AdditionField(title: "some hints", content: "äöüß // €³ %% −° && @²` | [{\\}],.<>") - - func testUrl() { - let password1 = getPasswordObjectWith(content: PasswordTest.EMPTY_STRING) - XCTAssertEqual(password1.namePath, PasswordTest.PASSWORD_PATH) - } - - func testLooksLikeOTP() { - XCTAssertTrue(Password.LooksLikeOTP(line: PasswordTest.OTP_TOKEN)) - XCTAssertFalse(Password.LooksLikeOTP(line: "no_auth://totp/blabla")) - } - - func testEmptyFile() { - let fileContent = PasswordTest.EMPTY_STRING - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, PasswordTest.EMPTY_STRING) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), PasswordTest.EMPTY_STRING) - XCTAssertTrue(password.getFilteredAdditions().isEmpty) - - XCTAssertNil(password.username) - XCTAssertNil(password.urlString) - XCTAssertNil(password.login) - } - - func testOneEmptyLine() { - let fileContent = """ - - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, PasswordTest.EMPTY_STRING) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), PasswordTest.EMPTY_STRING) - XCTAssertTrue(password.getFilteredAdditions().isEmpty) - - XCTAssertNil(password.username) - XCTAssertNil(password.urlString) - XCTAssertNil(password.login) - } - - func testSimplePasswordFile() { - let passwordString = PasswordTest.PASSWORD_STRING - let urlField = PasswordTest.SECURE_URL_FIELD - let loginField = PasswordTest.LOGIN_FIELD - let usernameField = PasswordTest.USERNAME_FIELD - let noteField = PasswordTest.NOTE_FIELD - let fileContent = """ - \(passwordString) - \(urlField.asString) - \(loginField.asString) - \(usernameField.asString) - \(noteField.asString) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, passwordString) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), asPlainText(urlField, loginField, usernameField, noteField)) - XCTAssertTrue(does(password, contain: urlField)) - XCTAssertFalse(does(password, contain: loginField)) - XCTAssertFalse(does(password, contain: usernameField)) - XCTAssertTrue(does(password, contain: noteField)) - - XCTAssertEqual(password.urlString, urlField.content) - XCTAssertEqual(password.login, loginField.content) - XCTAssertEqual(password.username, usernameField.content) - } - - func testTwoPasswords() { - let firstPasswordString = PasswordTest.PASSWORD_STRING - let secondPasswordString = "efgh5678" - let urlField = PasswordTest.INSECURE_URL_FIELD - let fileContent = """ - \(firstPasswordString) - \(secondPasswordString) - \(urlField.asString) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, firstPasswordString) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - XCTAssertEqual(password.getAdditionsPlainText(), asPlainText(secondPasswordString, urlField.asString)) - - XCTAssertTrue(does(password, contain: urlField)) - XCTAssertTrue(does(password, contain: AdditionField(title: "unknown 1", content: secondPasswordString))) - - XCTAssertNil(password.username) - XCTAssertEqual(password.urlString, urlField.content) - XCTAssertNil(password.login) - } - - func testNoPassword() { - let urlField = PasswordTest.SECURE_URL_FIELD - let noteField = PasswordTest.NOTE_FIELD - let fileContent = """ - \(urlField.asString) - \(noteField.asString) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, urlField.asString) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), asPlainText(noteField)) - XCTAssertTrue(does(password, contain: noteField)) - - XCTAssertNil(password.username) - XCTAssertNil(password.urlString) - XCTAssertNil(password.login) - } - - func testDuplicateKeys() { - let passwordString = PasswordTest.PASSWORD_STRING - let urlField1 = PasswordTest.SECURE_URL_FIELD - let urlField2 = PasswordTest.INSECURE_URL_FIELD - let fileContent = """ - \(passwordString) - \(urlField1.asString) - \(urlField2.asString) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, passwordString) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), asPlainText(urlField1, urlField2)) - XCTAssertTrue(does(password, contain: urlField1)) - XCTAssertTrue(does(password, contain: urlField2)) - - XCTAssertNil(password.username) - XCTAssertEqual(password.urlString, urlField1.content) - XCTAssertNil(password.login) - } - - func testUnknownKeys() { - let passwordString = PasswordTest.PASSWORD_STRING - let value1 = "value 1" - let value2 = "value 2" - let value3 = "value 3" - let value4 = "value 4" - let noteField = PasswordTest.NOTE_FIELD - let urlField = PasswordTest.SECURE_URL_FIELD - let fileContent = """ - \(passwordString) - \(value1) - \(noteField.asString) - \(value2) - \(value3) - \(urlField.asString) - \(value4) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, passwordString) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), asPlainText(value1, noteField.asString, value2, value3, urlField.asString, value4)) - XCTAssertTrue(does(password, contain: AdditionField(title: "unknown 1", content: value1))) - XCTAssertTrue(does(password, contain: noteField)) - XCTAssertTrue(does(password, contain: AdditionField(title: "unknown 2", content: value2))) - XCTAssertTrue(does(password, contain: AdditionField(title: "unknown 3", content: value3))) - XCTAssertTrue(does(password, contain: urlField)) - XCTAssertTrue(does(password, contain: AdditionField(title: "unknown 4", content: value4))) - - XCTAssertNil(password.username) - XCTAssertEqual(password.urlString, urlField.content) - XCTAssertNil(password.login) - } - - func testPasswordFileWithOtpToken() { - let passwordString = PasswordTest.PASSWORD_STRING - let noteField = PasswordTest.NOTE_FIELD - let otpToken = PasswordTest.OTP_TOKEN - let fileContent = """ - \(passwordString) - \(noteField.asString) - \(otpToken) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, passwordString) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), asPlainText(noteField.asString, otpToken)) - - XCTAssertEqual(password.otpType, OtpType.totp) - XCTAssertNotNil(password.getOtp()) - } - - func testFirstLineIsOtpToken() { - let otpToken = PasswordTest.OTP_TOKEN - let fileContent = """ - \(otpToken) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, otpToken) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), PasswordTest.EMPTY_STRING) - - XCTAssertNil(password.username) - XCTAssertNil(password.urlString) - XCTAssertNil(password.login) - - XCTAssertEqual(password.otpType, OtpType.totp) - XCTAssertNotNil(password.getOtp()) - } - - func testWrongOtpToken() { - let otpToken = "otpauth://htop/blabla" - let fileContent = """ - \(otpToken) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, otpToken) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.otpType, OtpType.none) - XCTAssertNil(password.getOtp()) - } - - func testEmptyMultilineValues() { - let passwordString = PasswordTest.PASSWORD_STRING - let lineBreakField1 = AdditionField(title: "with line breaks", content: "| \n") - let lineBreakField2 = AdditionField(title: "with line breaks", content: "| \n ") - let noteField = PasswordTest.NOTE_FIELD - let noLineBreakField = AdditionField(title: "without line breaks", content: " > ") - let fileContent = """ - \(passwordString) - \(lineBreakField1.asString) - \(lineBreakField2.asString) - \(noteField.asString) - \(noLineBreakField.asString) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, passwordString) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), asPlainText(lineBreakField1, lineBreakField2, noteField, noLineBreakField)) - XCTAssertTrue(does(password, contain: AdditionField(title: lineBreakField1.title, content: ""))) - XCTAssertTrue(does(password, contain: AdditionField(title: lineBreakField2.title, content: ""))) - XCTAssertTrue(does(password, contain: noteField)) - XCTAssertTrue(does(password, contain: AdditionField(title: noLineBreakField.title, content: ""))) - } - - func testMultilineValues() { - let passwordString = PasswordTest.PASSWORD_STRING - let noteField = PasswordTest.NOTE_FIELD - let lineBreakField = AdditionField(title: "with line breaks", content: "|\n This is \n text spread over \n multiple lines! ") - let noLineBreakField = AdditionField(title: "without line breaks", content: " > \n This is \n text spread over\n multiple lines!") - let fileContent = """ - \(passwordString) - \(lineBreakField.asString) - \(noteField.asString) - \(noLineBreakField.asString) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, passwordString) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), asPlainText(lineBreakField, noteField, noLineBreakField)) - XCTAssertTrue(does(password, contain: AdditionField(title: lineBreakField.title, content: "This is \n text spread over \nmultiple lines!"))) - XCTAssertTrue(does(password, contain: noteField)) - XCTAssertTrue(does(password, contain: AdditionField(title: noLineBreakField.title, content: "This is text spread over multiple lines!"))) - } - - func testMultilineValuesMixed() { - let passwordString = PasswordTest.PASSWORD_STRING - let hintField = PasswordTest.HINT_FIELD - let noteField = PasswordTest.NOTE_FIELD - let lineBreakField = AdditionField(title: "with line breaks", content: "|\n This is \n \(hintField.asString) spread over\n multiple lines!") - let noLineBreakField = AdditionField(title: "without line breaks", content: " > \n This is \n | \n text spread over\nmultiple lines!") - let fileContent = """ - \(passwordString) - \(lineBreakField.asString) - \(noLineBreakField.asString) - \(noteField.asString) - """ - let password = getPasswordObjectWith(content: fileContent) - - XCTAssertEqual(password.password, passwordString) - XCTAssertEqual(password.plainData, fileContent.data(using: .utf8)) - - XCTAssertEqual(password.getAdditionsPlainText(), asPlainText(lineBreakField, noLineBreakField, noteField)) - XCTAssertTrue(does(password, contain: AdditionField(title: lineBreakField.title, content: "This is \n\(hintField.asString) spread over"))) - XCTAssertTrue(does(password, contain: AdditionField(title: "unknown 1", content: " multiple lines!"))) - XCTAssertTrue(does(password, contain: AdditionField(title: noLineBreakField.title, content: "This is | text spread over"))) - XCTAssertTrue(does(password, contain: AdditionField(title: "unknown 2", content: "multiple lines!"))) - XCTAssertTrue(does(password, contain: noteField)) - } - - private func getPasswordObjectWith(content: String, url: URL = PasswordTest.PASSWORD_URL) -> Password { - return Password(name: PasswordTest.PASSWORD_NAME, url: url, plainText: content) - } - - private func does(_ password: Password, contain field: AdditionField) -> Bool { - return password.getFilteredAdditions().contains(field) - } - - private func asPlainText(_ strings: String...) -> String { - return strings.joined(separator: "\n") - } - private func asPlainText(_ fields: AdditionField...) -> String { - return fields.map { $0.asString }.joined(separator: "\n") - } -}