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/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index b67638e..02e1465 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -91,7 +91,43 @@ 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 { + var request = URLRequest(url: URL(string: invite.url + "/api/projects/" + invite.project)!) + 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: URL(string: invite.url)!, projectId: apiProject.id) + } + + func fetchInvite(_ invite: InviteData?) async throws { + guard let invite = invite, let url = URL(string: invite.url) else { + return + } + + 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) + if let httpResponse = response as? HTTPURLResponse { + print("status:", httpResponse.statusCode) + } + print(String(data: data, encoding: .utf8) ?? "") } func postBillPublisher(bill: Bill) -> AnyPublisher { @@ -147,13 +183,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.projectId) + .appendingPathComponent(project.password) + } + + if project.backend == .iHateMoney { + url = url + .appendingPathComponent(project.projectId) } if suffix.isEmpty { return url @@ -181,8 +225,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..8e16dc0 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -44,11 +44,7 @@ class ProjectManager: ObservableObject { func openedByURL(url: URL) { let data = url.decodeCospendString() - guard let _ = data.server, - let _ = data.project - else { - return - } + guard data != nil else { return } openedByURL = url } diff --git a/PayForMe/Services/StorageService.swift b/PayForMe/Services/StorageService.swift index a766417..2285f5e 100644 --- a/PayForMe/Services/StorageService.swift +++ b/PayForMe/Services/StorageService.swift @@ -44,6 +44,12 @@ class StorageService { table.add(column: "me") }) } + 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 = name;") + } // #if DEBUG //// Speed up development by nuking the database when migrations change // migrator.eraseDatabaseOnSchemaChange = true @@ -153,6 +159,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..6c40aeb 100644 --- a/PayForMe/Util/Util.swift +++ b/PayForMe/Util/Util.swift @@ -176,43 +176,115 @@ 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 + + init(server: URL, project: String, token: String) { + self.server = server + self.project = project + self.token = token + } + +} + +struct ProjectDataWithPassword: ProjectData { + var server: URL + var project: String + var password: String? + + init(server: URL, project: String, password: String?) { + self.server = server + self.project = project + self.password = password + } +} 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 decodeIHateMoenyString() -> ProjectDataWithToken? { + guard var 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) } } extension URL { - func decodeQRCode() -> ProjectData { - guard let scheme = scheme else { return (nil, nil, nil) } - return scheme.contains("cospend") ? decodeCospendString() : decodeMoneyBusterString() + func decodeQRCode() -> ProjectData? { + guard let scheme = scheme else { return nil } + if scheme.contains("cospend") { + return decodeCospendString() + } else if scheme.contains("ihatemoney") { + return decodeIHateMoenyString() + } else { + return decodeMoneyBusterString() + } } } 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..52be310 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift @@ -10,6 +10,13 @@ import Foundation import SlickLoadingSpinner import UIKit +struct InviteData { + let url: String + let baseUrl: String + let token: String + let project: String +} + class AddProjectManualViewModel: ObservableObject { @Published var projectType = ProjectBackend.cospend @@ -23,19 +30,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 +70,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(url: baseUrl, 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 +165,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..620456f 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,59 @@ 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) + } + } + // 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 = 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(url: projectData.server.absoluteString, 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/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..d0e67bc 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") @@ -87,13 +87,13 @@ class NetworkRequestTests: XCTestCase { // The full Cospend path must be: // /index.php/apps/cospend/api/projects/{token}/{password}/bills XCTAssertTrue( - url.contains("/index.php/apps/cospend/api/projects/mytoken/mypass/bills"), + url.contains("/index.php/apps/cospend/api/projects/myproject/mypass/bills"), "Cospend must put token and password in the URL path. Got: \(url)" ) } 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") @@ -105,7 +105,7 @@ class NetworkRequestTests: XCTestCase { let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" XCTAssertTrue(url.hasSuffix("/members") || url.contains("/members"), "Members endpoint must end with /members. Got: \(url)") - XCTAssertTrue(url.contains("/tok/pass/members"), + XCTAssertTrue(url.contains("/proj/pass/members"), "Cospend must embed token and password. Got: \(url)") } @@ -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/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..cebedcb 100644 --- a/PayForMeTests/UrlExtensionsTests.swift +++ b/PayForMeTests/UrlExtensionsTests.swift @@ -32,10 +32,14 @@ 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) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "no-pass") + } } func testCospendStringDecodingForSubfolders() throws { @@ -45,10 +49,14 @@ 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) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de/folder1/folder2") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") + } } func testCospendStringDecodingForSubdomains() throws { @@ -56,10 +64,14 @@ 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) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://subdomain.myserver.de") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") + } } func testCospendStringDecodingForNonStandardPort() throws { @@ -68,10 +80,14 @@ 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) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de:1234") + XCTAssertEqual(project, "myproject") + XCTAssertEqual(password, "mypassword") + } } func testCospendError_wrongScheme() throws { @@ -80,10 +96,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 +109,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 +121,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,10 +143,11 @@ 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 { @@ -155,9 +155,37 @@ class UrlExtensionsTests: XCTestCase { // 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")! - let (server, project, password) = url.decodeQRCode() - XCTAssertEqual(server?.absoluteString, "https://myserver.de") - XCTAssertEqual(project, "myproject") - XCTAssertEqual(password, "mypassword") + let project = url.decodeMoneyBusterString() + XCTAssertNotNil(project) + + if let server = project?.server, let password = project?.password, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de") + XCTAssertEqual(password, "mypassword") + XCTAssertEqual(project, "myproject") + } + } + + func testMoneyBusterNoPassword() throws { + let url = URL(string: "https://net.eneiluj.moneybuster.cospend/myserver.de/myproject")! + + let project = url.decodeMoneyBusterString() + XCTAssertNotNil(project) + + if let server = project?.server, let project = project?.project { + XCTAssertEqual(server.absoluteString, "https://myserver.de") + XCTAssertEqual(project, "myproject") + } + } + + func testIHateMoneyQRDecoding() throws { + let url: URL = URL(string: "ihatemoney://my-server.de/demo-project/join/WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q")! + + let project = url.decodeIHateMoenyString() + XCTAssertNotNil(project) + + XCTAssertEqual(project?.server.absoluteString, "https://my-server.de") + XCTAssertEqual(project?.project, "demo-project") + XCTAssertEqual(project?.token, "WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q") + } }