diff --git a/Sources/osaurus_resend/Plugin.swift b/Sources/osaurus_resend/Plugin.swift index b02d1b0..6e75bc7 100644 --- a/Sources/osaurus_resend/Plugin.swift +++ b/Sources/osaurus_resend/Plugin.swift @@ -180,7 +180,7 @@ private nonisolated(unsafe) var api: osr_plugin_api = { "required": ["to", "subject", "body"] }, "requirements": [], - "permission_policy": "auto" + "permission_policy": "ask" }, { "id": "resend_reply", @@ -195,7 +195,7 @@ private nonisolated(unsafe) var api: osr_plugin_api = { "required": ["thread_id", "body"] }, "requirements": [], - "permission_policy": "auto" + "permission_policy": "ask" }, { "id": "resend_list_threads", @@ -224,7 +224,7 @@ private nonisolated(unsafe) var api: osr_plugin_api = { "required": ["thread_id"] }, "requirements": [], - "permission_policy": "auto" + "permission_policy": "ask" }, { "id": "resend_label_thread", @@ -247,7 +247,7 @@ private nonisolated(unsafe) var api: osr_plugin_api = { "required": ["thread_id"] }, "requirements": [], - "permission_policy": "auto" + "permission_policy": "ask" } ], "artifact_handler": true, diff --git a/Tests/osaurus_resend_tests/ManifestContractTests.swift b/Tests/osaurus_resend_tests/ManifestContractTests.swift new file mode 100644 index 0000000..0c7727b --- /dev/null +++ b/Tests/osaurus_resend_tests/ManifestContractTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing + +@testable import osaurus_resend + +@Suite("Plugin Manifest Contract", .serialized) +struct ManifestContractTests { + + private enum ManifestError: Error { + case entryPointFailed + case nilManifest + case invalidJSON + } + + private struct PluginAPI { + let freeString: (@convention(c) (UnsafePointer?) -> Void) + let initContext: (@convention(c) () -> UnsafeMutableRawPointer?) + let destroy: (@convention(c) (UnsafeMutableRawPointer?) -> Void) + let getManifest: (@convention(c) (UnsafeMutableRawPointer?) -> UnsafePointer?) + } + + private func loadAPI() throws -> PluginAPI { + guard let apiPtr = osaurus_plugin_entry() else { + throw ManifestError.entryPointFailed + } + let fnPtrSize = MemoryLayout.stride + return PluginAPI( + freeString: apiPtr.load( + fromByteOffset: 0, + as: (@convention(c) (UnsafePointer?) -> Void).self), + initContext: apiPtr.load( + fromByteOffset: fnPtrSize, + as: (@convention(c) () -> UnsafeMutableRawPointer?).self), + destroy: apiPtr.load( + fromByteOffset: fnPtrSize * 2, + as: (@convention(c) (UnsafeMutableRawPointer?) -> Void).self), + getManifest: apiPtr.load( + fromByteOffset: fnPtrSize * 3, + as: (@convention(c) (UnsafeMutableRawPointer?) -> UnsafePointer?).self) + ) + } + + private func loadManifest() throws -> [String: Any] { + MockHost.setUp() + let api = try loadAPI() + let ctx = api.initContext() + defer { api.destroy(ctx) } + + guard let cStr = api.getManifest(ctx) else { + throw ManifestError.nilManifest + } + let jsonString = String(cString: cStr) + api.freeString(cStr) + + guard let data = jsonString.data(using: .utf8), + let manifest = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw ManifestError.invalidJSON + } + return manifest + } + + private func capabilities(from manifest: [String: Any]) -> [String: Any] { + manifest["capabilities"] as? [String: Any] ?? [:] + } + + private func toolMap(from manifest: [String: Any]) -> [String: [String: Any]] { + let tools = capabilities(from: manifest)["tools"] as? [[String: Any]] ?? [] + return Dictionary( + uniqueKeysWithValues: tools.compactMap { tool -> (String, [String: Any])? in + guard let id = tool["id"] as? String else { return nil } + return (id, tool) + }) + } + + @Test("manifest has correct plugin identity and routes") + func pluginIdentityAndRoutes() throws { + let manifest = try loadManifest() + #expect(manifest["plugin_id"] as? String == "osaurus.resend") + #expect(manifest["version"] as? String == "0.1.0") + + let routes = capabilities(from: manifest)["routes"] as? [[String: Any]] ?? [] + let routeIDs = Set(routes.compactMap { $0["id"] as? String }) + #expect(routeIDs == ["webhook", "health", "reset_webhook"]) + + let byID = Dictionary(uniqueKeysWithValues: routes.map { ($0["id"] as! String, $0) }) + #expect(byID["webhook"]?["auth"] as? String == "verify") + #expect(byID["health"]?["auth"] as? String == "owner") + #expect(byID["reset_webhook"]?["auth"] as? String == "owner") + } + + @Test("manifest declares expected Resend tools") + func toolIDs() throws { + let map = try toolMap(from: loadManifest()) + #expect( + Set(map.keys) == [ + "resend_send", "resend_reply", "resend_list_threads", "resend_get_thread", + "resend_label_thread", + ]) + } + + @Test("sensitive send, read, and mutation tools require approval") + func permissionPolicies() throws { + let map = try toolMap(from: loadManifest()) + for id in ["resend_send", "resend_reply", "resend_get_thread", "resend_label_thread"] { + #expect(map[id]?["permission_policy"] as? String == "ask", "Tool '\(id)' should ask") + } + #expect(map["resend_list_threads"]?["permission_policy"] as? String == "auto") + } + + @Test("send and reply tools declare required parameters") + func requiredParameters() throws { + let map = try toolMap(from: loadManifest()) + + let sendParams = map["resend_send"]?["parameters"] as? [String: Any] + let sendRequired = Set(sendParams?["required"] as? [String] ?? []) + #expect(sendRequired == ["to", "subject", "body"]) + + let replyParams = map["resend_reply"]?["parameters"] as? [String: Any] + let replyRequired = Set(replyParams?["required"] as? [String] ?? []) + #expect(replyRequired == ["thread_id", "body"]) + + let getParams = map["resend_get_thread"]?["parameters"] as? [String: Any] + let getRequired = Set(getParams?["required"] as? [String] ?? []) + #expect(getRequired == ["thread_id"]) + } + + @Test("configuration exposes required Resend setup fields") + func configFields() throws { + let manifest = try loadManifest() + let config = capabilities(from: manifest)["config"] as? [String: Any] + let sections = config?["sections"] as? [[String: Any]] ?? [] + let fields = sections.flatMap { section -> [[String: Any]] in + section["fields"] as? [[String: Any]] ?? [] + } + let keys = Set(fields.compactMap { $0["key"] as? String }) + #expect( + keys.isSuperset(of: [ + "api_key", "from_email", "from_name", "webhook_url", "webhook_status", + "sender_policy", "allowed_senders", + ])) + } +}