diff --git a/Jared.xcodeproj/project.pbxproj b/Jared.xcodeproj/project.pbxproj index 62fe67e..367db59 100644 --- a/Jared.xcodeproj/project.pbxproj +++ b/Jared.xcodeproj/project.pbxproj @@ -20,6 +20,9 @@ B319236224DE76A2001904BA /* JaredUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B319236124DE76A2001904BA /* JaredUITests.swift */; }; B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32DE51424D51F9C00AB3A71 /* RouterTests.swift */; }; EC71CM00000000000000A002 /* CoreModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CM00000000000000A001 /* CoreModuleTests.swift */; }; + RT00TEST0000000000002BF0 /* RouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RT00TEST0000000000001FRF /* RouteTests.swift */; }; + WRF0TEST0000000000002BF0 /* WebhookRouteFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = WRF0TEST0000000000001FRF /* WebhookRouteFactoryTests.swift */; }; + PM00TEST0000000000002BF0 /* PluginManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PM00TEST0000000000001FRF /* PluginManagerTests.swift */; }; B32DE51624D522DA00AB3A71 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C1244EBE9A00CC44C5 /* Router.swift */; }; EC70RF00000000000000A004 /* RuntimeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70RF00000000000000A001 /* RuntimeFlags.swift */; }; EC70FP00000000000000A004 /* InboundFilterPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70FP00000000000000A001 /* InboundFilterPolicy.swift */; }; @@ -80,6 +83,7 @@ B38BB6AD24EB99BE000700F7 /* Webhook.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B7E52824EA579600A3FBD1 /* Webhook.swift */; }; B38BB6AF24EBB4D8000700F7 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36C8D7E24EA655900C00A99 /* Configuration.swift */; }; B39A65E01CC1FB1B003E26B0 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39A65DF1CC1FB1B003E26B0 /* PluginManager.swift */; }; + PM00000000000000000001BF /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39A65DF1CC1FB1B003E26B0 /* PluginManager.swift */; }; B39F2B391CB3BCA700C0D35C /* SendText.scpt in Resources */ = {isa = PBXBuildFile; fileRef = B39F2B381CB3BCA700C0D35C /* SendText.scpt */; }; B3A4C61423B867E300B7F009 /* MessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A4C61323B867E300B7F009 /* MessageRequest.swift */; }; B3AECB5F2207CE33003E32F8 /* JaredFramework.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F84B61F1CC506790059A82B /* JaredFramework.framework */; }; @@ -204,6 +208,9 @@ B319236324DE76A2001904BA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B32DE51424D51F9C00AB3A71 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; EC71CM00000000000000A001 /* CoreModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreModuleTests.swift; sourceTree = ""; }; + RT00TEST0000000000001FRF /* RouteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteTests.swift; sourceTree = ""; }; + WRF0TEST0000000000001FRF /* WebhookRouteFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookRouteFactoryTests.swift; sourceTree = ""; }; + PM00TEST0000000000001FRF /* PluginManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManagerTests.swift; sourceTree = ""; }; B32DE51924D5524300AB3A71 /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; B32DE51B24D552D300AB3A71 /* MockPluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPluginManager.swift; sourceTree = ""; }; B33278511CB2C94700BBDD3A /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; @@ -409,6 +416,9 @@ B3B844B924D77A4800DC162A /* SendStyleTest.swift */, B32DE51424D51F9C00AB3A71 /* RouterTests.swift */, EC71CM00000000000000A001 /* CoreModuleTests.swift */, + RT00TEST0000000000001FRF /* RouteTests.swift */, + WRF0TEST0000000000001FRF /* WebhookRouteFactoryTests.swift */, + PM00TEST0000000000001FRF /* PluginManagerTests.swift */, EC70PT00000000000000A001 /* InboundFilterPolicyTests.swift */, B3B844CF24DA6AC800DC162A /* JaredWebServerTest.swift */, TM0000010000000000000005 /* TunnelManagerTests.swift */, @@ -882,6 +892,7 @@ B3B844C724D9298000DC162A /* MockRouter.swift in Sources */, B347A4AD24DD170C00E93657 /* ActionTest.swift in Sources */, B36710E224DFB5EC00DA1C5D /* JaredMock.swift in Sources */, + PM00000000000000000001BF /* PluginManager.swift in Sources */, B3B844C224D8FA7D00DC162A /* DatabaseTestHelper.swift in Sources */, B38BB6AD24EB99BE000700F7 /* Webhook.swift in Sources */, AD456B8A60A74A9E90DC26CA /* KeychainAccessor.swift in Sources */, @@ -899,6 +910,9 @@ TM0000010000000000000002 /* TunnelManager.swift in Sources */, B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */, EC71CM00000000000000A002 /* CoreModuleTests.swift in Sources */, + RT00TEST0000000000002BF0 /* RouteTests.swift in Sources */, + WRF0TEST0000000000002BF0 /* WebhookRouteFactoryTests.swift in Sources */, + PM00TEST0000000000002BF0 /* PluginManagerTests.swift in Sources */, EC71C000000000000000A010 /* CoreModule.swift in Sources */, EC71CS00000000000000A004 /* ContactNameService.swift in Sources */, EC71CK00000000000000A004 /* Clock.swift in Sources */, diff --git a/JaredTests/PluginManagerTests.swift b/JaredTests/PluginManagerTests.swift new file mode 100644 index 0000000..04133ab --- /dev/null +++ b/JaredTests/PluginManagerTests.swift @@ -0,0 +1,76 @@ +import XCTest +import JaredFramework +@testable import Jared + +class PluginManagerTests: XCTestCase { + + private func makeManager(disabledCommands: [String: Bool] = [:]) -> PluginManager { + let config = ConfigurationFile(disabledCommands: disabledCommands) + return PluginManager(sender: JaredMock(), configuration: config) + } + + // MARK: - enabled() + + func testEnabledReturnsTrueWhenCommandNotInMap() { + XCTAssertTrue(makeManager().enabled(routeName: "/ping")) + } + + func testEnabledReturnsFalseWhenCommandDisabled() { + XCTAssertFalse(makeManager(disabledCommands: ["/ping": true]).enabled(routeName: "/ping")) + } + + func testEnabledReturnsTrueWhenCommandExplicitlyEnabled() { + XCTAssertTrue(makeManager(disabledCommands: ["/ping": false]).enabled(routeName: "/ping")) + } + + func testEnabledIsCaseInsensitive() { + XCTAssertFalse(makeManager(disabledCommands: ["/ping": true]).enabled(routeName: "/PING")) + } + + // MARK: - getAllModules() / getAllRoutes() + + func testGetAllModulesHasFourInternalModulesOnInit() { + // CoreModule, ScheduleModule, InternalModule, WebHookManager + XCTAssertEqual(makeManager().getAllModules().count, 4) + } + + func testGetAllRoutesIncludesPingRoute() { + XCTAssertTrue(makeManager().getAllRoutes().contains { $0.name == "/ping" }) + } + + // MARK: - reload() + + func testReloadPreservesModuleCount() { + let manager = makeManager() + manager.reload() + XCTAssertEqual(manager.getAllModules().count, 4) + } + + func testReloadRestoresPingRoute() { + let manager = makeManager() + manager.reload() + XCTAssertTrue(manager.getAllRoutes().contains { $0.name == "/ping" }) + } + + // MARK: - apply() + + func testApplyUpdatesDisabledCommands() { + let manager = makeManager() + XCTAssertTrue(manager.enabled(routeName: "/ping")) + + manager.apply(ConfigurationFile(disabledCommands: ["/ping": true])) + + XCTAssertFalse(manager.enabled(routeName: "/ping")) + } + + func testApplyUpdatesWebhooks() { + let manager = makeManager() + XCTAssertTrue(manager.config.webhooks.isEmpty) + + let hook = RichWebhook(url: "https://example.com") + manager.apply(ConfigurationFile(webhooks: [hook])) + + XCTAssertEqual(manager.config.webhooks.count, 1) + XCTAssertEqual(manager.config.webhooks.first?.url, "https://example.com") + } +} diff --git a/JaredTests/RouteTests.swift b/JaredTests/RouteTests.swift new file mode 100644 index 0000000..014ff54 --- /dev/null +++ b/JaredTests/RouteTests.swift @@ -0,0 +1,87 @@ +import XCTest +import JaredFramework + +class RouteTests: XCTestCase { + + // MARK: - Compare raw values + + func testCompareRawValues() { + XCTAssertEqual(Compare.startsWith.rawValue, "startsWith") + XCTAssertEqual(Compare.contains.rawValue, "contains") + XCTAssertEqual(Compare.is.rawValue, "is") + XCTAssertEqual(Compare.containsURL.rawValue, "containsURL") + XCTAssertEqual(Compare.isReaction.rawValue, "isReaction") + } + + // MARK: - Route init + + func testRouteInitMinimalPreservesProperties() { + var called = false + let route = Route(name: "ping", comparisons: [.startsWith: ["/ping"]], call: { _ in called = true }) + XCTAssertEqual(route.name, "ping") + XCTAssertEqual(route.comparisons[.startsWith], ["/ping"]) + XCTAssertNil(route.description) + XCTAssertNil(route.parameterSyntax) + } + + func testRouteInitWithDescriptionPreservesIt() { + let route = Route(name: "r", comparisons: [.contains: ["foo"]], call: { _ in }, description: "does foo") + XCTAssertEqual(route.description, "does foo") + XCTAssertNil(route.parameterSyntax) + } + + func testRouteInitWithSyntaxPreservesIt() { + let route = Route(name: "r", comparisons: [:], call: { _ in }, description: "d", parameterSyntax: "/r,arg") + XCTAssertEqual(route.parameterSyntax, "/r,arg") + } + + // MARK: - JSON decoding + + func testRouteDecodesFromJSON() throws { + let json = """ + { + "name": "ping", + "description": "Replies pong", + "parameterSyntax": "/ping", + "comparisons": { "startsWith": ["/ping"] } + } + """.data(using: .utf8)! + + let route = try JSONDecoder().decode(Route.self, from: json) + + XCTAssertEqual(route.name, "ping") + XCTAssertEqual(route.description, "Replies pong") + XCTAssertEqual(route.parameterSyntax, "/ping") + XCTAssertEqual(route.comparisons[.startsWith], ["/ping"]) + } + + func testRouteDecodesMultipleComparisons() throws { + let json = """ + { + "name": "multi", + "description": "d", + "parameterSyntax": "/m", + "comparisons": { "startsWith": ["/m"], "contains": ["foo", "bar"] } + } + """.data(using: .utf8)! + + let route = try JSONDecoder().decode(Route.self, from: json) + + XCTAssertEqual(route.comparisons[.startsWith], ["/m"]) + XCTAssertEqual(route.comparisons[.contains], ["foo", "bar"]) + } + + func testCompareDecoderThrowsOnUnknownKey() { + let json = """ + { + "name": "test", + "description": "test", + "parameterSyntax": "/test", + "comparisons": { "unknownCompareType": ["/test"] } + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try JSONDecoder().decode(Route.self, from: json), + "unknown Compare key must throw DecodingError") + } +} diff --git a/JaredTests/TunnelManagerTests.swift b/JaredTests/TunnelManagerTests.swift index e135b33..ba7ae7e 100644 --- a/JaredTests/TunnelManagerTests.swift +++ b/JaredTests/TunnelManagerTests.swift @@ -8,10 +8,13 @@ final class MockTunnelRunner: TunnelRunner { var stopCallCount = 0 var onOutput: ((String) -> Void)? + var onComplete: ((Error?) -> Void)? + func start(command: TunnelLaunchCommand, onOutput: @escaping (String) -> Void, onComplete: @escaping (Error?) -> Void) { startCallCount += 1 lastCommand = command self.onOutput = onOutput + self.onComplete = onComplete isRunning = true } @@ -162,6 +165,94 @@ class TunnelManagerTests: XCTestCase { XCTAssertGreaterThanOrEqual(runner.stopCallCount, 1) } + func testSyncStartsNgrokWithTokenFromKeychain() { + let runner = MockTunnelRunner() + let keychain = MockKeychain() + keychain.save(secret: "mytoken", for: TunnelManager.ngrokKeychainAccount) + let manager = TunnelManager( + configuration: TunnelConfiguration(enabled: true, provider: .ngrok), + runner: runner, + keychain: keychain, + localPortProvider: { 3005 } + ) + + manager.sync(restApiEnabled: true, localPort: 3005) + + XCTAssertEqual(runner.startCallCount, 1) + XCTAssertEqual(runner.lastCommand, .ngrok(localPort: 3005, authToken: "mytoken")) + } + + func testSyncSetsErrorWhenNgrokTokenMissing() { + let runner = MockTunnelRunner() + let manager = TunnelManager( + configuration: TunnelConfiguration(enabled: true, provider: .ngrok), + runner: runner, + keychain: MockKeychain(), + localPortProvider: { 3005 } + ) + + manager.sync(restApiEnabled: true, localPort: 3005) + + XCTAssertEqual(runner.startCallCount, 0) + XCTAssertNotNil(manager.lastError) + } + + func testSyncSetsErrorWhenNgrokTokenIsEmpty() { + let runner = MockTunnelRunner() + let keychain = MockKeychain() + keychain.save(secret: "", for: TunnelManager.ngrokKeychainAccount) + let manager = TunnelManager( + configuration: TunnelConfiguration(enabled: true, provider: .ngrok), + runner: runner, + keychain: keychain, + localPortProvider: { 3005 } + ) + + manager.sync(restApiEnabled: true, localPort: 3005) + + XCTAssertEqual(runner.startCallCount, 0) + XCTAssertNotNil(manager.lastError) + } + + func testProcessErrorSetsLastError() { + let runner = MockTunnelRunner() + let manager = TunnelManager( + configuration: TunnelConfiguration(enabled: true, provider: .cloudflared), + runner: runner, + keychain: MockKeychain(), + localPortProvider: { 3005 } + ) + + manager.sync(restApiEnabled: true, localPort: 3005) + runner.onComplete?(TunnelError.processExited(127)) + + XCTAssertNotNil(manager.lastError) + XCTAssertTrue(manager.lastError!.contains("127"), "error message should include exit code") + } + + func testDuplicateURLLineDoesNotPostSecondNotification() { + let runner = MockTunnelRunner() + let manager = TunnelManager( + configuration: TunnelConfiguration(enabled: true, provider: .cloudflared), + runner: runner, + keychain: MockKeychain(), + localPortProvider: { 3005 } + ) + var notificationCount = 0 + let token = NotificationCenter.default.addObserver( + forName: TunnelManager.publicURLDidChangeNotification, + object: manager, + queue: nil + ) { _ in notificationCount += 1 } + + manager.sync(restApiEnabled: true, localPort: 3005) + runner.onOutput?("| https://abc-def.trycloudflare.com |") + runner.onOutput?("| https://abc-def.trycloudflare.com |") + + NotificationCenter.default.removeObserver(token) + XCTAssertEqual(notificationCount, 1, "duplicate URL line must not post a second notification") + } + func testWebserverConfigurationDecodesTunnelBlock() throws { let json = """ { diff --git a/JaredTests/WebhookRouteFactoryTests.swift b/JaredTests/WebhookRouteFactoryTests.swift new file mode 100644 index 0000000..29ad5e3 --- /dev/null +++ b/JaredTests/WebhookRouteFactoryTests.swift @@ -0,0 +1,55 @@ +import XCTest +import JaredFramework +@testable import Jared + +class WebhookRouteFactoryTests: XCTestCase { + + private func makeFactory() -> WebhookRouteFactory { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("WebhookRouteFactoryTests-\(UUID().uuidString)", isDirectory: true) + let client = WebhookDeliveryClient( + session: .ephemeral, + keychain: MockKeychain(), + sender: nil, + deliveryStore: WebhookDeliveryStore(fileURL: dir.appendingPathComponent("deliveries.json")) + ) + return WebhookRouteFactory(client: client) + } + + func testRoutesFromWebhookWithNilRoutesReturnsEmpty() { + let webhook = RichWebhook(url: "https://example.com", routes: nil) + XCTAssertTrue(makeFactory().routes(from: webhook).isEmpty) + } + + func testRoutesFromWebhookWithEmptyRoutesReturnsEmpty() { + let webhook = RichWebhook(url: "https://example.com", routes: []) + XCTAssertTrue(makeFactory().routes(from: webhook).isEmpty) + } + + func testRoutesPreserveNamesAndComparisons() { + let r1 = Route(name: "greet", comparisons: [.startsWith: ["/hello"]], call: { _ in }) + let r2 = Route(name: "bye", comparisons: [.is: ["/bye"]], call: { _ in }) + let webhook = RichWebhook(url: "https://example.com", routes: [r1, r2]) + + let produced = makeFactory().routes(from: webhook) + + XCTAssertEqual(produced.count, 2) + XCTAssertEqual(produced[0].name, "greet") + XCTAssertEqual(produced[0].comparisons[.startsWith], ["/hello"]) + XCTAssertEqual(produced[1].name, "bye") + XCTAssertEqual(produced[1].comparisons[.is], ["/bye"]) + } + + func testEachRouteGetsFreshDeliveryCallClosure() { + let r1 = Route(name: "a", comparisons: [.startsWith: ["/a"]], call: { _ in }) + let r2 = Route(name: "b", comparisons: [.startsWith: ["/b"]], call: { _ in }) + let webhook = RichWebhook(url: "https://example.com", routes: [r1, r2]) + + let produced = makeFactory().routes(from: webhook) + + // Each route's call is wired by the factory (not the original stub closure) + XCTAssertEqual(produced.count, 2) + XCTAssertNotNil(produced[0].call as Any) + XCTAssertNotNil(produced[1].call as Any) + } +}