diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index b85f09b..f4f8eb6 100644 --- a/PayForMe.xcodeproj/project.pbxproj +++ b/PayForMe.xcodeproj/project.pbxproj @@ -68,6 +68,7 @@ BF4D7AF265FC4A90B1AC5AEB /* JSONDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */; }; D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EED3194A2342518B0E74079 /* BillTests.swift */; }; DBBD86EF399BCF232C021935 /* NetworkRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */; }; + 65AF00022F1A000100000002 /* StorageMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF00012F1A000100000001 /* StorageMigrationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -106,6 +107,7 @@ 6514934B26CFC71000F00851 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/LaunchScreen.strings; sourceTree = ""; }; 6514934C26CFC71000F00851 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 6523FC4B25580EEF00BCD843 /* UrlExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlExtensionsTests.swift; sourceTree = ""; }; + 65AF00012F1A000100000001 /* StorageMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageMigrationTests.swift; sourceTree = ""; }; 6523FC502558153600BCD843 /* AddPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPasswordView.swift; sourceTree = ""; }; 6533A02A23D888B100FF822B /* BillCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillCell.swift; sourceTree = ""; }; 654754B72528736F00A82EB6 /* Views+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views+Extensions.swift"; sourceTree = ""; }; @@ -236,6 +238,7 @@ 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */, 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */, 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */, + 65AF00012F1A000100000001 /* StorageMigrationTests.swift */, ); path = PayForMeTests; sourceTree = ""; @@ -551,6 +554,7 @@ 56E7DB46FDCDC5B46C475020 /* BalanceCalculationTests.swift in Sources */, 62C73597878BA5AD4572FE90 /* BillSortingTests.swift in Sources */, DBBD86EF399BCF232C021935 /* NetworkRequestTests.swift in Sources */, + 65AF00022F1A000100000002 /* StorageMigrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PayForMe/Model/Project.swift b/PayForMe/Model/Project.swift index 0935ecd..d75a86d 100644 --- a/PayForMe/Model/Project.swift +++ b/PayForMe/Model/Project.swift @@ -19,11 +19,13 @@ class Project: Codable, Identifiable { var bills: [Bill] var me: Int? - convenience init(name: String, password: String, token: String, backend: ProjectBackend, url: URL) { - self.init(name: name, password: password, token: token, backend: backend, url: url, id: nil) + let projectId: String + + convenience init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, projectId: String) { + self.init(name: name, password: password, token: token, backend: backend, url: url, id: nil, projectId: projectId) } - fileprivate init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, id: Int?, me: Int? = nil) { + fileprivate init(name: String, password: String, token: String, backend: ProjectBackend, url: URL, id: Int?, me: Int? = nil, projectId: String) { self.name = name self.password = password self.token = token @@ -33,6 +35,7 @@ class Project: Codable, Identifiable { members = [:] bills = [] self.me = me + self.projectId = projectId } } @@ -49,8 +52,9 @@ struct StoredProject: Codable { let backend: ProjectBackend var id: Int? let me: Int? + let projectId: String - init(name: String, password: String, token: String, url: URL, backend: ProjectBackend) { + init(name: String, password: String, token: String, url: URL, backend: ProjectBackend, projectId: String) { self.name = name self.password = password self.token = token @@ -58,6 +62,7 @@ struct StoredProject: Codable { self.backend = backend id = nil me = nil + self.projectId = projectId } init(project: Project) { @@ -68,10 +73,11 @@ struct StoredProject: Codable { backend = project.backend id = project.id me = project.me + projectId = project.projectId } func toProject() -> Project { - Project(name: name, password: password, token: token, backend: backend, url: url, id: id!, me: me) + Project(name: name, password: password, token: token, backend: backend, url: url, id: id!, me: me, projectId: projectId) } } @@ -101,10 +107,10 @@ enum ProjectBackend: Int, Codable { } } -let previewProject = Project(name: "TestProject", password: "TestPassword", token: "asdasdas", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 0) +let previewProject = Project(name: "TestProject", password: "TestPassword", token: "asdasdas", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 0, projectId: "TestProject") let previewProjects = [ previewProject, - Project(name: "test1", password: "test23", token: "dasdasa", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 1), - Project(name: "test2", password: "test45", token: "123123122", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 2), + Project(name: "test1", password: "test23", token: "dasdasa", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 1, projectId: "test1"), + Project(name: "test2", password: "test45", token: "123123122", backend: .cospend, url: URL(string: "https://testserver.de")!, id: 2, projectId: "test2"), ] -let demoProject = Project(name: "study-group", password: "no-pass", token: "9da50e410157dc1ca63e594af022f3a2", backend: .cospend, url: URL(string: "https://intranet.mayflower.de")!, id: 1) +let demoProject = Project(name: "study-group", password: "no-pass", token: "9da50e410157dc1ca63e594af022f3a2", backend: .cospend, url: URL(string: "https://intranet.mayflower.de")!, id: 1, projectId: "study-group") diff --git a/PayForMe/SceneDelegate.swift b/PayForMe/SceneDelegate.swift index 720c4b3..f897003 100644 --- a/PayForMe/SceneDelegate.swift +++ b/PayForMe/SceneDelegate.swift @@ -67,7 +67,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_: UIScene, openURLContexts URLContexts: Set) { guard let context = URLContexts.first, let scheme = context.url.scheme, - scheme.localizedCaseInsensitiveContains("cospend") + scheme.localizedCaseInsensitiveContains("cospend") || scheme.localizedCaseInsensitiveContains("ihatemoney") else { return } diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index b67638e..c29c37b 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -91,7 +91,32 @@ class NetworkService { } let apiProject = try JSONDecoder().decode(APIProject.self, from: data) - return Project(name: apiProject.name, password: project.password, token: project.token, backend: project.backend, url: project.url) + return Project(name: apiProject.name, password: project.password, token: project.token, backend: project.backend, url: project.url, projectId: apiProject.id) + } + + func getProjectName(invite: InviteData) async throws -> Project { + guard var components = URLComponents(string: invite.baseUrl), + let baseURL = components.url else { + throw URLError(.badURL) + } + components.path += iHateMoneyStaticPath + "/" + invite.project + guard let url = components.url else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue( + "Bearer \(invite.token)", + forHTTPHeaderField: "Authorization" + ) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode / 100 == 2 else { + throw HTTPError.statuscode + } + let apiProject = try JSONDecoder().decode(APIProject.self, from: data) + + return Project(name: apiProject.name, password: "", token: invite.token, backend: ProjectBackend.iHateMoney, url: baseURL, projectId: apiProject.id) } func postBillPublisher(bill: Bill) -> AnyPublisher { @@ -147,13 +172,21 @@ class NetworkService { .replaceError(with: false) .eraseToAnyPublisher() } + private func baseURLFor(_ project: Project, suffix: String) -> URL { var url = project.url .appendingPathComponent(project.backend.staticPath) - .appendingPathComponent(project.token) + if project.backend == .cospend { - url = url.appendingPathComponent(project.password) + url = url + .appendingPathComponent(project.token) + .appendingPathComponent(project.password) + } + + if project.backend == .iHateMoney { + url = url + .appendingPathComponent(project.projectId) } if suffix.isEmpty { return url @@ -181,8 +214,14 @@ class NetworkService { request = URLRequest(url: requestURL) if project.backend == .iHateMoney { - guard let authString = "\(project.token):\(project.password)".data(using: .utf8)?.base64EncodedString() else { fatalError("error generating authString. THIS SHOULD NOT HAPPEN") } - request.setValue("Basic \(authString)", forHTTPHeaderField: "Authorization") + if project.password.isEmpty { + request.setValue("Bearer \(project.token)", forHTTPHeaderField: "Authorization") + } else { + guard let authString = "\(project.token):\(project.password)".data(using: .utf8)?.base64EncodedString() else { + fatalError("error generating authString. THIS SHOULD NOT HAPPEN") + } + request.setValue("Basic \(authString)", forHTTPHeaderField: "Authorization") + } if !params.isEmpty { request.httpBody = try? JSONSerialization.data(withJSONObject: params) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 00a327d..9f0f839 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -43,12 +43,7 @@ class ProjectManager: ObservableObject { } func openedByURL(url: URL) { - let data = url.decodeCospendString() - guard let _ = data.server, - let _ = data.project - else { - return - } + guard url.decodeCospendString() != nil || url.decodeIHateMoneyString() != nil else { return } openedByURL = url } diff --git a/PayForMe/Services/StorageService.swift b/PayForMe/Services/StorageService.swift index a766417..3a8ce51 100644 --- a/PayForMe/Services/StorageService.swift +++ b/PayForMe/Services/StorageService.swift @@ -19,37 +19,47 @@ class StorageService { private let dbQueue: DatabaseQueue - init() { - do { - var migrator = DatabaseMigrator() - - migrator.registerMigration("v1", migrate: { db in - try db.create(table: "storedProject", ifNotExists: true) { table in - table.autoIncrementedPrimaryKey("id") - table.column("name") - table.column("password") - table.column("url") - table.column("backend") - } + static func makeMigrator() -> DatabaseMigrator { + var migrator = DatabaseMigrator() + + migrator.registerMigration("v1", migrate: { db in + try db.create(table: "storedProject", ifNotExists: true) { table in + table.autoIncrementedPrimaryKey("id") + table.column("name") + table.column("password") + table.column("url") + table.column("backend") + } + }) + migrator.registerMigration("v2", migrate: { db in + try db.alter(table: "storedProject", body: { table in + table.add(column: "token") }) - migrator.registerMigration("v2", migrate: { db in - try db.alter(table: "storedProject", body: { table in - table.add(column: "token") - }) - try db.execute(sql: "UPDATE storedProject SET token = name;") + try db.execute(sql: "UPDATE storedProject SET token = name;") + }) + migrator.registerMigration("v3") { db in + try db.alter(table: "storedProject", body: { table in + table.add(column: "me") }) - migrator.registerMigration("v3") { db in - try db.alter(table: "storedProject", body: { table in - table.add(column: "me") - }) - } - // #if DEBUG - //// Speed up development by nuking the database when migrations change - // migrator.eraseDatabaseOnSchemaChange = true - // #endif + } + migrator.registerMigration("v4") { db in + try db.alter(table: "storedProject", body: { table in + table.add(column: "projectId") + }) + try db.execute(sql: "UPDATE storedProject SET projectId = token;") + } + // #if DEBUG + //// Speed up development by nuking the database when migrations change + // migrator.eraseDatabaseOnSchemaChange = true + // #endif + return migrator + } + + init() { + do { dbQueue = try DatabaseQueue(path: databasePath.appendingPathComponent("payforme.sqlite").path) - try migrator.migrate(dbQueue) + try Self.makeMigrator().migrate(dbQueue) } catch { print("Storage couldn't be initialized \(error.localizedDescription)") fatalError() @@ -153,6 +163,6 @@ private class OldProject: Codable, Identifiable { var bills: [Bill] func toProject() -> StoredProject { - return StoredProject(name: name, password: password, token: name, url: url, backend: backend) + return StoredProject(name: name, password: password, token: name, url: url, backend: backend, projectId: name) } } diff --git a/PayForMe/Util/Util.swift b/PayForMe/Util/Util.swift index a191874..2ebe363 100644 --- a/PayForMe/Util/Util.swift +++ b/PayForMe/Util/Util.swift @@ -176,48 +176,101 @@ extension StringProtocol { } } -typealias ProjectData = (server: URL?, project: String?, passwd: String?) +protocol ProjectData { + var server: URL { get } + var project: String { get } +} + +struct ProjectDataWithToken: ProjectData { + var server: URL + var project: String + var token: String +} + +struct ProjectDataWithPassword: ProjectData { + var server: URL + var project: String + var password: String? +} extension URL { - func decodeMoneyBusterString() -> ProjectData { + func decodeMoneyBusterString() -> ProjectDataWithPassword? { guard absoluteString.hasPrefix("https://net.eneiluj.moneybuster.cospend/"), - pathComponents.count >= 3, pathComponents.count <= 4 else { return (nil, nil, nil) } - return (URL(string: "https://" + pathComponents[1]), pathComponents[2], pathComponents[safe: 3]) + pathComponents.count >= 3, pathComponents.count <= 4 else { + return nil + } + + guard let hostUrl = URL(string: "https://" + pathComponents[1]) else { + return nil + } + + let password = pathComponents[safe: 3] + + return ProjectDataWithPassword( + server: hostUrl, + project: pathComponents[2], + password: password + ) } -} -extension URL { - func decodeCospendString() -> ProjectData { + func decodeCospendString() -> ProjectDataWithPassword? { guard let host = host, let scheme = scheme, scheme.localizedCaseInsensitiveContains("cospend") else { - return (nil, nil, nil) + return nil } - - var hostString = "https://\(host)" - + + var hostString = host + if let port = port {hostString += ":\(port)"} - + if pathComponents.count > 3 { hostString += "/" + pathComponents[1..<(pathComponents.count - 2)].joined(separator: "/") } - - return (URL(string: hostString), - pathComponents[safe: pathComponents.count - 2], - pathComponents.last) + + guard + let hostUrl = URL(string: "https://" + hostString), + let project = pathComponents[safe: pathComponents.count - 2], + let password = pathComponents.last + else { return nil } + + return ProjectDataWithPassword( + server: hostUrl, + project: project, + password: password) } -} -extension URL { - func decodeQRCode() -> ProjectData { - guard let scheme = scheme else { return (nil, nil, nil) } - return scheme.contains("cospend") ? decodeCospendString() : decodeMoneyBusterString() + func decodeIHateMoneyString() -> ProjectDataWithToken? { + guard let host = host, let scheme = scheme, scheme.localizedCaseInsensitiveContains("ihatemoney") else { + return nil + } + + let hostUrl = "https://" + host + + guard + let url = URL(string: hostUrl), + let project = pathComponents[safe: pathComponents.count - 3], + let token = pathComponents.last + else { return nil} + + return ProjectDataWithToken(server: url, project: project, token: token) + } + + func decodeQRCode() -> ProjectData? { + guard let scheme = scheme else { return nil } + if scheme.contains("cospend") { + return decodeCospendString() + } else if scheme.contains("ihatemoney") { + return decodeIHateMoneyString() + } else { + return decodeMoneyBusterString() + } } } -// Why is URL an identifier but not identifiable? extension URL: Identifiable { + // Why is URL an identifier but not identifiable? public var id: URL { self } diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift index edca69c..9aaf3ae 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift @@ -25,18 +25,16 @@ struct AddProjectManualView: View { } .pickerStyle(SegmentedPickerStyle()) .padding(EdgeInsets(top: 8, leading: 8, bottom: 0, trailing: 8)) - if viewmodel.projectType == .cospend { - if #available(iOS 16.0, *) { - PasteButton(payloadType: String.self) { strings in - pasteLink(pasteString: strings[0]) - } - .padding(.top, 10) - } else { - Button("Paste Link") { - pasteLink() - } - .padding(.top, 10) + if #available(iOS 16.0, *) { + PasteButton(payloadType: String.self) { strings in + pasteLink(pasteString: strings[0]) + } + .padding(.top, 10) + } else { + Button("Paste Link") { + pasteLink() } + .padding(.top, 10) } Form { Section( @@ -44,7 +42,7 @@ struct AddProjectManualView: View { ) { TextFieldContainer( viewmodel.projectType == .cospend - ? "https://mynextcloud.org" : "https://ihatemoney.org", + ? "https://mynextcloud.org" : "https://ihatemoney.org", text: self.$viewmodel.serverAddress ) } @@ -52,26 +50,32 @@ struct AddProjectManualView: View { Section(header: Text("Project ID & Password")) { TextField("Enter project id", text: self.$viewmodel.projectName) - .autocapitalization(.none) + .autocapitalization(.none) SecureField("Enter project password", text: self.$viewmodel.projectPassword) } + + if viewmodel.projectType == .iHateMoney { + Section(header: Text("Invite Token")) { + TextField("Enter invite url", text: self.$viewmodel.inviteUrl) + .autocapitalization(.none) + } + } + + + SlickLoadingSpinner(connectionState: viewmodel.validationProgress) + .frame(width: 50, height: 50) + FancyButton( + add: false, + action: addButton, + text: "Add Project" + ) + .disabled(viewmodel.validationProgress != .success) + if !viewmodel.errorText.isEmpty { + Text(viewmodel.errorText) + } } - .id(viewmodel.projectType == .cospend ? "cospend" : "iHateMoney") - .frame(width: UIScreen.main.bounds.width, height: 220, alignment: .center) - SlickLoadingSpinner(connectionState: viewmodel.validationProgress) - .frame(width: 50, height: 50) - FancyButton( - add: false, - action: addButton, - text: "Add Project" - ) - .disabled(viewmodel.validationProgress != .success) - if !viewmodel.errorText.isEmpty { - Text(viewmodel.errorText) - } - Spacer() - } + .id(viewmodel.projectType == .cospend ? "cospend" : "iHateMoney")} .padding(.horizontal, 20) .padding(.vertical, 50) .background(Color.PFMBackground) diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift index defa90e..c84f3ff 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift @@ -10,6 +10,12 @@ import Foundation import SlickLoadingSpinner import UIKit +struct InviteData { + let baseUrl: String + let token: String + let project: String +} + class AddProjectManualViewModel: ObservableObject { @Published var projectType = ProjectBackend.cospend @@ -23,19 +29,31 @@ class AddProjectManualViewModel: ObservableObject { @Published var projectPassword = "" + @Published var inviteUrl = "" + @Published var validationProgress = LoadingState.notStarted @Published var errorText = "" private var lastProjectTestedSuccessfully: Project? + private var cancellables = Set() + init() { validatedInput.map { _ in LoadingState.connecting }.assign(to: &$validationProgress) validatedServer.map { $0 == 200 ? LoadingState.success : LoadingState.failure }.assign(to: &$validationProgress) errorTextPublisher.assign(to: &$errorText) serverCheckUnsupportedProtocoll.assign(to: &$errorText) + + $inviteUrl + .filter { _ in self.projectType == .iHateMoney } + .debounce(for: .seconds(1), scheduler: DispatchQueue.main) + .sink { [weak self] token in self?.validateInviteToken(token) } + .store(in: &cancellables) } + + func reset() { serverAddress = "" projectName = "" @@ -51,34 +69,46 @@ class AddProjectManualViewModel: ObservableObject { } } - func pasteAddress(address: String) { - let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) - guard let url = URL(string: trimmedAddress) else { - return - } - - // If it is a moneybuster URL - let (mUrl, mName, mPassword) = url.decodeQRCode() - if let url = mUrl, let name = mName { - serverAddress = url.absoluteString - projectName = name - if let password = mPassword { - projectPassword = password + private func validateInviteToken(_ token: String) { + guard !token.isEmpty, !serverAddress.isEmpty, !projectName.isEmpty else { return } + let baseUrl = serverAddress.hasPrefix("https://") ? serverAddress : "https://\(serverAddress)" + let inviteData = InviteData(baseUrl: baseUrl, token: token, project: projectName) + validationProgress = .connecting + Task { @MainActor in + do { + let testedProject = try await NetworkService.shared.getProjectName(invite: inviteData) + self.lastProjectTestedSuccessfully = testedProject + self.validationProgress = .success + } catch { + print("Invite URL failed: \(error)") + self.validationProgress = .failure } - return } - // If it is another url - - let pathComponents = url.pathComponents - let pureUrl = url.deletingPathExtension().absoluteString - let trimmIndices = url.absoluteString.indices(of: "/") - if let cutIndex = trimmIndices[safe: 2] { - let trimmedUrl = pureUrl[...cutIndex] - serverAddress = String(trimmedUrl) - } else { - serverAddress = pureUrl + } + + func pasteAddress(address: String) { + let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmedAddress) else { return } + + switch url.decodeQRCode() { + case let project as ProjectDataWithPassword: + serverAddress = project.server.absoluteString + projectName = project.project + projectPassword = project.password ?? "" + case let project as ProjectDataWithToken: + projectType = .iHateMoney + serverAddress = project.server.absoluteString + projectName = project.project + inviteUrl = project.token + default: + guard url.pathComponents.contains("join"), + let scheme = url.scheme, let host = url.host, + url.pathComponents.count >= 4 else { return } + projectType = .iHateMoney + serverAddress = "\(scheme)://\(host)" + projectName = url.pathComponents[1] + inviteUrl = url.pathComponents[3] } - fillFieldsFromComponents(components: pathComponents) } var serverAddressFormatted: AnyPublisher { @@ -134,7 +164,7 @@ class AddProjectManualViewModel: ObservableObject { .compactMap { server, token, password -> Project? in if let address = server.address, address.isValidURL, !token.isEmpty, !password.isEmpty { guard let url = URL(string: address) else { return nil } - return Project(name: token, password: password, token: token, backend: server.0, url: url) + return Project(name: token, password: password, token: token, backend: server.0, url: url, projectId: self.projectName) } else { return nil } diff --git a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift index 1456a04..3ebfdf3 100644 --- a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift +++ b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift @@ -50,7 +50,8 @@ class AddProjectQRViewModel: ObservableObject { ) .map { url, token, password in self.isTestingSubject.send(.connecting) - return Project(name: "", password: password, token: token, backend: .cospend, url: url) + + return Project(name: "", password: password, token: token, backend: .cospend, url: url, projectId: token) } .flatMap { project in NetworkService.shared.testProject(project) @@ -92,42 +93,46 @@ class AddProjectQRViewModel: ObservableObject { foundCode .sink { codedUrl in let projectData = codedUrl.decodeQRCode() - guard let url = projectData.server, let token = projectData.project else { return } - if let password = projectData.passwd { + + if let projectData = projectData as? ProjectDataWithPassword { + if let password = projectData.password { + self.isTestingSubject.send(.connecting) + let project = Project(name: projectData.project, password: password, token: projectData.project, backend: .cospend, url: projectData.server, projectId: projectData.project) + Task(priority: .userInitiated) { + do { + let apiProject = try await NetworkService.shared.getProjectName(project) + try ProjectManager.shared.addProject(apiProject) + self.isTestingSubject.send(.success) + } catch { + print(codedUrl) + print() + print(error) + self.isTestingSubject.send(.failure) + } + } + } else { + withAnimation { + self.url = projectData.server + self.name = projectData.project + self.askForPassword.toggle() + } + } + return + } + + if let projectData = projectData as? ProjectDataWithToken { self.isTestingSubject.send(.connecting) - let project = Project(name: token, password: password, token: token, backend: .cospend, url: url) Task(priority: .userInitiated) { do { - let apiProject = try await NetworkService.shared.getProjectName(project) + let apiProject = try await NetworkService.shared.getProjectName(invite: InviteData(baseUrl: projectData.server.absoluteString, token: projectData.token, project: projectData.project)) try ProjectManager.shared.addProject(apiProject) self.isTestingSubject.send(.success) } catch { - print(codedUrl) - print() - print(error) self.isTestingSubject.send(.failure) } } -// NetworkService.shared.testProject(project) -// .asUIPublisher -// .sink(receiveValue: { -// project, code in -// if code == 200 { -// DispatchQueue.main.asyncAfter(deadline: DispatchTime.now().advanced(by: .seconds(1))) { -// ProjectManager.shared.addProject(project) -// } -// self.isTestingSubject.send(.success) -// } else { -// self.isTestingSubject.send(.failure) -// } -// }).store(in: &self.subscriptions) - } else { - withAnimation { - self.url = url - self.name = token - self.askForPassword.toggle() - } } } + } } diff --git a/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift b/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift index 28818ce..e11eeeb 100644 --- a/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift +++ b/PayForMe/Views/Projects/ShareProjectQRCodeViewModel.swift @@ -25,7 +25,7 @@ final class ShareProjectQRCodeViewModel: ObservableObject { private func generate() { let server = project.url.relativeString .replacingOccurrences(of: "https://", with: "") - self.dataString = "cospend://\(server)/\(project.token)/\(project.password)" + self.dataString = "cospend://\(server)/\(project.projectId)/\(project.password)" self.qrCodeImage = generateQRCode(from: dataString) } diff --git a/PayForMeTests/AddProjectManuallyTests.swift b/PayForMeTests/AddProjectManuallyTests.swift index 61708c1..0e63aee 100644 --- a/PayForMeTests/AddProjectManuallyTests.swift +++ b/PayForMeTests/AddProjectManuallyTests.swift @@ -32,7 +32,22 @@ class AddProjectManuallyTests: XCTestCase { var viewmodel = AddProjectManualViewModel() var subscriptions = Set() + override func setUp() { + super.setUp() + // The view model validates the server over the network once a full + // triple is entered. Intercept it so tests stay offline and deterministic. + URLProtocol.registerClass(MockURLProtocol.self) + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, + httpVersion: nil, headerFields: nil)! + let body = #"{"name": "nameXY", "id": "nameXY"}"#.data(using: .utf8)! + return (response, body) + } + } + override func tearDownWithError() throws { + URLProtocol.unregisterClass(MockURLProtocol.self) + MockURLProtocol.reset() subscriptions.removeAll() } diff --git a/PayForMeTests/BalanceCalculationTests.swift b/PayForMeTests/BalanceCalculationTests.swift index 034c261..a7cc990 100644 --- a/PayForMeTests/BalanceCalculationTests.swift +++ b/PayForMeTests/BalanceCalculationTests.swift @@ -35,7 +35,8 @@ class BalanceCalculationTests: XCTestCase { private func makeProject(members: [Person], bills: [Bill]) -> Project { let project = Project( name: "test", password: "pw", token: "tok", - backend: .cospend, url: URL(string: "https://test.de")! + backend: .cospend, url: URL(string: "https://test.de")!, + projectId: "" ) project.members = Dictionary(uniqueKeysWithValues: members.map { ($0.id, $0) }) project.bills = bills diff --git a/PayForMeTests/NetworkRequestTests.swift b/PayForMeTests/NetworkRequestTests.swift index a7b7037..875ab5e 100644 --- a/PayForMeTests/NetworkRequestTests.swift +++ b/PayForMeTests/NetworkRequestTests.swift @@ -72,7 +72,7 @@ class NetworkRequestTests: XCTestCase { func testCospend_loadBills_urlEmbedsTokenAndPassword() { let project = Project.makeCospend(token: "mytoken", password: "mypass", - url: "https://cloud.example.com") + url: "https://cloud.example.com", projectId: "myproject") MockURLProtocol.requestHandler = jsonHandler() let exp = expectation(description: "request intercepted") @@ -93,7 +93,7 @@ class NetworkRequestTests: XCTestCase { } func testCospend_loadMembers_urlContainsMembersEndpoint() { - let project = Project.makeCospend(token: "tok", password: "pass") + let project = Project.makeCospend(token: "tok", password: "pass", projectId: "proj") MockURLProtocol.requestHandler = jsonHandler() let exp = expectation(description: "request intercepted") @@ -133,7 +133,7 @@ class NetworkRequestTests: XCTestCase { func testIHateMoney_loadBills_urlDoesNotContainPassword() { // iHateMoney uses HTTP Basic Auth — the password must NEVER appear in the URL. let project = Project.makeIHateMoney(token: "mytoken", password: "secret", - url: "https://ihatemoney.org") + url: "https://ihatemoney.org", projectId: "myproject") MockURLProtocol.requestHandler = jsonHandler() let exp = expectation(description: "request intercepted") @@ -145,7 +145,7 @@ class NetworkRequestTests: XCTestCase { let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" XCTAssertFalse(url.contains("secret"), "iHateMoney password must NOT appear in the URL. Got: \(url)") - XCTAssertTrue(url.contains("/api/projects/mytoken/bills"), + XCTAssertTrue(url.contains("/api/projects/myproject/bills"), "iHateMoney URL must use /api/projects/{token}/bills. Got: \(url)") } diff --git a/PayForMeTests/StorageMigrationTests.swift b/PayForMeTests/StorageMigrationTests.swift new file mode 100644 index 0000000..b418884 --- /dev/null +++ b/PayForMeTests/StorageMigrationTests.swift @@ -0,0 +1,36 @@ +// +// StorageMigrationTests.swift +// PayForMeTests +// + +import GRDB +@testable import PayForMe +import XCTest + +class StorageMigrationTests: XCTestCase { + /* + Test Notes: Projects added before V3 used the column "token" to store the project id. + From V4: Projects store project id in column "project id" and use the column token to store a token used for authorization + */ + func test_IHM_legacy_projects_migrate_token_to_project_id() throws { + let dbQueue = try DatabaseQueue() + let migrator = StorageService.makeMigrator() + try migrator.migrate(dbQueue, upTo: "v3") + + try dbQueue.write { db in + try db.execute( + sql: "INSERT INTO storedProject (name, password, url, backend, token) VALUES (?, ?, ?, ?, ?)", + arguments: ["Haushalt", "totally-secret", "https://ihatemoney.org", ProjectBackend.iHateMoney.rawValue, "haushalt-token"] + ) + } + + try migrator.migrate(dbQueue) + + let projects = try dbQueue.read { db in + try StoredProject.fetchAll(db) + } + XCTAssertEqual(projects.count, 1) + XCTAssertEqual(projects[0].projectId, "haushalt-token") + XCTAssertEqual(projects[0].name, "Haushalt") + } +} diff --git a/PayForMeTests/TestHelpers.swift b/PayForMeTests/TestHelpers.swift index c7487b4..76bdddf 100644 --- a/PayForMeTests/TestHelpers.swift +++ b/PayForMeTests/TestHelpers.swift @@ -76,28 +76,32 @@ extension Project { static func makeCospend( token: String = "mytoken", password: String = "mypass", - url: String = "https://nextcloud.example.com" + url: String = "https://nextcloud.example.com", + projectId: String = "my-project" ) -> Project { Project( name: "test-project", password: password, token: token, backend: .cospend, - url: URL(string: url)! + url: URL(string: url)!, + projectId: projectId, ) } static func makeIHateMoney( token: String = "mytoken", password: String = "mypass", - url: String = "https://ihatemoney.org" + url: String = "https://ihatemoney.org", + projectId: String = "my-project" ) -> Project { Project( name: "test-project", password: password, token: token, backend: .iHateMoney, - url: URL(string: url)! + url: URL(string: url)!, + projectId: projectId ) } } diff --git a/PayForMeTests/UrlExtensionsTests.swift b/PayForMeTests/UrlExtensionsTests.swift index 8ae38bd..a177f07 100644 --- a/PayForMeTests/UrlExtensionsTests.swift +++ b/PayForMeTests/UrlExtensionsTests.swift @@ -32,10 +32,11 @@ class UrlExtensionsTests: XCTestCase { // cospend://host/project/password → server="https://host" let url = URL(string: "cospend://myserver.de/myproject/no-pass")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "no-pass") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "no-pass") } func testCospendStringDecodingForSubfolders() throws { @@ -45,10 +46,12 @@ class UrlExtensionsTests: XCTestCase { // → server="https://host/folder1/folder2" let url = URL(string: "cospend://myserver.de/folder1/folder2/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de/folder1/folder2") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de/folder1/folder2") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testCospendStringDecodingForSubdomains() throws { @@ -56,10 +59,12 @@ class UrlExtensionsTests: XCTestCase { // server root, not just the top-level domain. let url = URL(string: "cospend://subdomain.myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://subdomain.myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + XCTAssertEqual(project?.server.absoluteString, "https://subdomain.myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testCospendStringDecodingForNonStandardPort() throws { @@ -68,10 +73,12 @@ class UrlExtensionsTests: XCTestCase { // reach the right endpoint. let url = URL(string: "cospend://myserver.de:1234/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de:1234") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeCospendString() + XCTAssertNotNil(project) + + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de:1234") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testCospendError_wrongScheme() throws { @@ -80,10 +87,9 @@ class UrlExtensionsTests: XCTestCase { // its path looks like it might contain project data. let url = URL(string: "https://myserver/myproject/mypassword")! - let (server, project, password) = url.decodeCospendString() - XCTAssertNil(server) + let project = url.decodeCospendString() + XCTAssertNil(project) - XCTAssertNil(password) } // MARK: - MoneyBuster web link decoding @@ -94,22 +100,11 @@ class UrlExtensionsTests: XCTestCase { // https://net.eneiluj.moneybuster.cospend/{server}/{project}/{password} let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") - } - - func testMoneyBusterNoPassword() throws { - // MoneyBuster omits the password component for passwordless projects. - // The decoder must return nil for password without crashing — the default - // "no-pass" value is set by AddProjectManualViewModel, not here. - let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject")! - - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertNil(password) + let project = url.decodeMoneyBusterString() + XCTAssertNotNil(project) + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } func testMoneyBusterError_wrongHost() throws { @@ -117,23 +112,18 @@ class UrlExtensionsTests: XCTestCase { // nil. Without this guard, any https URL would decode as a project link. let url = URL(string: "https://myserver/myproject/mypassword")! - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertNil(server) + let project = url.decodeMoneyBusterString() XCTAssertNil(project) - XCTAssertNil(password) } func testMoneyBusterDecoding_tooManyPathComponents_returnsNil() throws { // The decoder guards: pathComponents.count must be 3 or 4. // (["", server, project] = 3, or + password = 4) // URLs with more components are malformed — the server/project boundary - // is ambiguous, so all three fields must be nil. + // is ambiguous, so nil must be returned. let url = URL(string: "https://net.eneiluj.moneybuster.cospend/server/project/password/extra")! - let (server, project, password) = url.decodeMoneyBusterString() - XCTAssertNil(server) - XCTAssertNil(project) - XCTAssertNil(password) + XCTAssertNil(url.decodeMoneyBusterString()) } // MARK: - QR code dispatcher (decodeQRCode) @@ -144,20 +134,33 @@ class UrlExtensionsTests: XCTestCase { // a direct call to decodeCospendString() for the same URL. let url = URL(string: "cospend://myserver.de/myproject/mypassword")! - let (server, project, password) = url.decodeQRCode() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeQRCode() as? ProjectDataWithPassword + XCTAssertNotNil(project) + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertEqual(project?.password, "mypassword") } - func testDecodeQRCode_httpsSchemeRoutesToMoneyBusterDecoder() throws { - // For https:// QR codes, decodeQRCode() must call decodeMoneyBusterString(). - // A MoneyBuster QR code uses https:// with the fixed MoneyBuster host prefix. - let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject/mypassword")! + func testMoneyBusterNoPassword() throws { + let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject")! + + let project = url.decodeMoneyBusterString() + XCTAssertNotNil(project) + + XCTAssertEqual(project?.server.absoluteString, "https://myserver.de") + XCTAssertEqual(project?.project, "myproject") + XCTAssertNil(project?.password) + } + + func testIHateMoneyQRDecoding() throws { + let url: URL = URL(string: "ihatemoney://my-server.de/demo-project/join/WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q")! + + let project = url.decodeIHateMoneyString() + XCTAssertNotNil(project) + + XCTAssertEqual(project?.server.absoluteString, "https://my-server.de") + XCTAssertEqual(project?.project, "demo-project") + XCTAssertEqual(project?.token, "WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q") - let (server, project, password) = url.decodeQRCode() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") } }