From dadd64bb22f19cfcf6683cdd2835ada2ff5ef0dd Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:20:50 -0600 Subject: [PATCH 1/2] fix(ios): gate the media upload server on the site root too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startUploadServer` checked only `authHeader.isEmpty`, though the comment directly above it said the uploader "needs a site root and an auth header". Android has checked both since it landed. An iOS host that configured an auth header but no `siteApiRoot` therefore started a server whose every request failed at the URLSession layer, instead of falling back to the WebView upload path the way Android does. `siteApiRoot` is a `URL` here where Android types it as a `String`, so `isEmpty()` has no direct equivalent — "addressable" is spelled as scheme and host both being present. Put the check in `MediaServerCredentials` rather than inline. `EditorViewController` is `#if canImport(UIKit)`, so it does not exist on the macOS host and nothing in it is reachable from the test suite — which is how the two platforms diverged here unnoticed. Outside the gate, the predicate gets five tests, including both arms of the site-root check. --- .../Sources/EditorViewController.swift | 8 +++- .../Media/MediaServerCredentials.swift | 24 +++++++++++ .../Media/MediaServerCredentialsTests.swift | 41 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift create mode 100644 ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index da4c1fefe..09ec7766a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -468,7 +468,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // it because the WebView has no auth cookies). Without both there is nothing // to upload through, so leave the server down and let uploads fall to the // default WebView path rather than start a server that could only fail. - guard !configuration.authHeader.isEmpty else { + // + // `MediaServerCredentials` owns the check so it is reachable from the host + // test suite — this file is not. + guard MediaServerCredentials.areUsable( + siteApiRoot: configuration.siteApiRoot, + authHeader: configuration.authHeader + ) else { return } diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift new file mode 100644 index 000000000..a8ce4ee51 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Whether the editor configuration can reach the configured site for media. +/// +/// Deliberately outside `EditorViewController`. That type is `#if canImport(UIKit)`, +/// so on the macOS host it does not exist and nothing in it can be tested — including +/// this check, which already diverged silently between iOS and Android once. Living +/// here, it is reachable from the host test suite. +enum MediaServerCredentials { + /// Whether a ``DefaultMediaUploader`` built from this configuration could actually + /// reach the site. + /// + /// Both fields are required. The uploader delivers GutenbergKit's uploads to the + /// configured site, so it needs somewhere to send them and credentials to be + /// accepted; with either missing, every media request it makes fails. + /// + /// `siteApiRoot` is a `URL` here where Android types it as a `String`, so the + /// equivalent of Android's `isEmpty()` check is "not absolute" — a URL with no + /// scheme or host cannot address the site, and every request built from it fails at + /// the URLSession layer. + static func areUsable(siteApiRoot: URL, authHeader: String) -> Bool { + siteApiRoot.scheme != nil && siteApiRoot.host() != nil && !authHeader.isEmpty + } +} diff --git a/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift new file mode 100644 index 000000000..140a0d0f1 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing + +@testable import GutenbergKit + +@Suite("MediaServerCredentials") +struct MediaServerCredentialsTests { + private static let siteRoot = URL(string: "https://example.com/wp-json/")! + + @Test("accepts an absolute site root with an auth header") + func acceptsUsableCredentials() { + #expect(MediaServerCredentials.areUsable(siteApiRoot: Self.siteRoot, authHeader: "Bearer t")) + } + + @Test("rejects an empty auth header") + func rejectsEmptyAuthHeader() { + #expect(!MediaServerCredentials.areUsable(siteApiRoot: Self.siteRoot, authHeader: "")) + } + + // The two arms below are what a `URL` makes different from Android's `String`: + // `isEmpty()` has no direct equivalent, so "addressable" is spelled as scheme and + // host both being present. A URL missing either cannot reach the site, and every + // request built from it fails at the URLSession layer. + + @Test("rejects a site root with no scheme") + func rejectsSchemelessSiteRoot() { + let relative = URL(string: "example.com/wp-json/")! + #expect(!MediaServerCredentials.areUsable(siteApiRoot: relative, authHeader: "Bearer t")) + } + + @Test("rejects a site root with no host") + func rejectsHostlessSiteRoot() { + let fileURL = URL(fileURLWithPath: "/tmp/wp-json") + #expect(!MediaServerCredentials.areUsable(siteApiRoot: fileURL, authHeader: "Bearer t")) + } + + @Test("rejects an empty site root, the default when a host configures none") + func rejectsEmptySiteRoot() { + #expect(!MediaServerCredentials.areUsable(siteApiRoot: URL(string: "/")!, authHeader: "Bearer t")) + } +} From a070f27505bc182d509a295b903bf628ec169454 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:49:41 -0600 Subject: [PATCH 2/2] test(ios): isolate the scheme arm of the site-root check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `URL(string: "example.com/wp-json/")` parses as a bare path — no scheme and no host — so "rejects a site root with no scheme" passed on the host arm alone. Dropping `scheme != nil` from `areUsable` left all five tests green. Use a network-path reference (`//example.com/wp-json/`), which has a host and no scheme, and assert the host is present so the case cannot lose that isolation again. Also rename "rejects an empty site root, the default when a host configures none". `siteApiRoot` is a required parameter with no default, so a host cannot configure none; the case covers a root with neither a scheme nor a host. --- .../Media/MediaServerCredentialsTests.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift index 140a0d0f1..b5b39e349 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift @@ -24,7 +24,11 @@ struct MediaServerCredentialsTests { @Test("rejects a site root with no scheme") func rejectsSchemelessSiteRoot() { - let relative = URL(string: "example.com/wp-json/")! + // A network-path reference: it has a host, so only the scheme arm rejects it. + // `example.com/wp-json/` would not do — it parses as a bare path with no host + // either, and passes on the host arm alone. + let relative = URL(string: "//example.com/wp-json/")! + #expect(relative.host() != nil) #expect(!MediaServerCredentials.areUsable(siteApiRoot: relative, authHeader: "Bearer t")) } @@ -34,8 +38,8 @@ struct MediaServerCredentialsTests { #expect(!MediaServerCredentials.areUsable(siteApiRoot: fileURL, authHeader: "Bearer t")) } - @Test("rejects an empty site root, the default when a host configures none") - func rejectsEmptySiteRoot() { + @Test("rejects a site root with no scheme or host") + func rejectsRelativeSiteRoot() { #expect(!MediaServerCredentials.areUsable(siteApiRoot: URL(string: "/")!, authHeader: "Bearer t")) } }