diff --git a/.buildkite/swift-test.sh b/.buildkite/swift-test.sh index 1b12f4af1..9af5809d6 100755 --- a/.buildkite/swift-test.sh +++ b/.buildkite/swift-test.sh @@ -6,8 +6,25 @@ set -euo pipefail export SKIP_PACKAGE_WP_API=true +# Create and unlock a temporary keychain for SecPKCS12Import (used by MockWebServer TLS tests). +# On CI, the default keychain may be locked, causing errSecInteractionNotAllowed (-25308). +if [ "${BUILDKITE:-}" = "true" ]; then + echo "--- :key: Setting up keychain for TLS tests" + security delete-keychain ci-test.keychain-db 2>/dev/null || true + security create-keychain -p "" ci-test.keychain-db + security default-keychain -s ci-test.keychain-db + security unlock-keychain -p "" ci-test.keychain-db + security set-keychain-settings ci-test.keychain-db +fi + function run_tests() { local platform; platform=$1 + + if [ "$platform" = "iOS" ]; then + echo "--- :lock: Trusting test CA certificate" + make trust-test-ca || echo "⚠️ Could not trust test CA — spec 19 (custom CA cert) will be skipped" + fi + echo "--- :swift: Testing on $platform simulator" make "test-swift-$platform" } diff --git a/Makefile b/Makefile index d1c80b02e..f5690b74e 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,17 @@ clean: @# Help: Remove untracked files from the project via Git. git clean -ffXd +trust-test-ca: + @# Help: Trust the test CA certificate (macOS only). + @# Uses admin domain (-d) for system-wide trust. Requires user interaction + @# on headless CI — if this fails, HTTPS tests using the test CA (spec 19) + @# will be skipped. + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain test-data/ssl-certs/ca-cert.pem + +trust-test-ca-jvm: + @# Help: Trust the test CA certificate in the JVM keystore (requires write access to cacerts). + keytool -importcert -file test-data/ssl-certs/ca-cert.pem -keystore $$JAVA_HOME/lib/security/cacerts -storepass changeit -noprompt -alias wordpress-rs-test-ca + .PHONY: docs # Rebuild docs each time we run this command docs: @# Help: Generate project documentation. diff --git a/Package.resolved b/Package.resolved index 6a09abfcf..61b8ab314 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "283d6745467d75f5921b090c61c117a36db0062bf3ddc329c97354553842b877", + "originHash" : "21ed6276c2428a1fd304b95ed12ef4cccd7561363061b47e8c34d9b30cfb16b3", "pins" : [ { "identity" : "collectionconcurrencykit", @@ -19,6 +19,15 @@ "version" : "1.8.5" } }, + { + "identity" : "mocktail-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jkmassel/mocktail-swift.git", + "state" : { + "branch" : "main", + "revision" : "2c582a18d1a0c49f920386ff0138fc697e17ad20" + } + }, { "identity" : "sourcekitten", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 6b0d21802..e54dd5c6d 100644 --- a/Package.swift +++ b/Package.swift @@ -34,6 +34,7 @@ var package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + .package(url: "https://github.com/jkmassel/mocktail-swift.git", branch: "main"), ], targets: [ .target( @@ -74,10 +75,15 @@ var package = Package( dependencies: [ .target(name: "WordPressAPI"), .target(name: "WordPressApiCache"), - .target(name: libwordpressFFI.name) + .target(name: libwordpressFFI.name), + .product(name: "MockWebServer", package: "mocktail-swift", condition: .when(platforms: [.iOS, .macOS, .tvOS, .watchOS])), ], path: "native/swift/Tests/wordpress-api", - resources: [.copy("../../../../test-data/integration-test-responses/")], + resources: [ + .copy("../../../../test-data/integration-test-responses/"), + .copy("../../../../test-data/login-mocks/"), + .copy("../../../../test-data/ssl-certs/"), + ], swiftSettings: [ .define("PROGRESS_REPORTING_ENABLED", .when(platforms: [.iOS, .macOS, .tvOS, .watchOS])) ] diff --git a/native/kotlin/api/kotlin/build.gradle.kts b/native/kotlin/api/kotlin/build.gradle.kts index 1e836b194..35bf74102 100644 --- a/native/kotlin/api/kotlin/build.gradle.kts +++ b/native/kotlin/api/kotlin/build.gradle.kts @@ -130,6 +130,9 @@ tasks.named("processIntegrationTestResources").configure { dependsOn(rootProject.tasks.named("copyTestCredentials")) dependsOn(rootProject.tasks.named("copyTestMedia")) dependsOn(rootProject.tasks.named("copySampleJSON")) + dependsOn(rootProject.tasks.named("copyTestResponses")) + dependsOn(rootProject.tasks.named("copyLoginMocks")) + dependsOn(rootProject.tasks.named("copySslCerts")) } tasks.named("sourcesJar").configure { dependsOn(generateUniFFIBindingsTask) diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/ApiUrlDiscoveryTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/ApiUrlDiscoveryTest.kt index 57b31abfb..c1d78d28d 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/ApiUrlDiscoveryTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/ApiUrlDiscoveryTest.kt @@ -1,6 +1,10 @@ package rs.wordpress.api.kotlin import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest import okhttp3.OkHttpClient import org.junit.jupiter.api.Test import org.junit.jupiter.api.parallel.Execution @@ -21,6 +25,9 @@ import uniffi.wp_api.InvalidSslErrorReason import uniffi.wp_api.ParseUrlException import uniffi.wp_api.RequestExecutionErrorReason import uniffi.wp_api.RequestExecutionException +import java.security.KeyStore +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext import kotlin.test.assertContains @Execution(ExecutionMode.CONCURRENT) @@ -38,9 +45,23 @@ class ApiUrlDiscoveryTest { @Test // Spec Example 1 fun testValidSiteWorksCorrectly() = runTest { + val executor = MockRequestExecutor( + listOf( + Stub.forUrl( + "https://vanilla.wpmt.co/", + WpNetworkResponse.withApiRoot("https://vanilla.wpmt.co/wp-json/") + ), + Stub.forUrl( + "https://vanilla.wpmt.co/wp-json/", + WpNetworkResponse.jsonResponse("/login-mocks/vanilla-api-root.json") + ), + ) + ) + + val client = WpLoginClient(executor) assertEquals( "https://vanilla.wpmt.co/wp-admin/authorize-application.php", - loginClient.apiDiscovery("https://vanilla.wpmt.co") + client.apiDiscovery("https://vanilla.wpmt.co") .assertSuccess().applicationPasswordsAuthenticationUrl.url() ) } @@ -76,49 +97,135 @@ class ApiUrlDiscoveryTest { @Test // Spec Example 3 fun testAdminUrlProvided() = runTest { + // AutoStrippedHttps strips admin paths and creates an attempt for https://vanilla.wpmt.co + // The UserInput attempts will fail (no stubs for wp-login.php / wp-admin URLs) + // and the AutoStrippedHttps attempt will succeed. + val executor = MockRequestExecutor( + stubs = listOf( + Stub.forUrl( + "https://vanilla.wpmt.co/", + WpNetworkResponse.withApiRoot("https://vanilla.wpmt.co/wp-json/") + ), + Stub.forUrl( + "https://vanilla.wpmt.co/wp-json/", + WpNetworkResponse.jsonResponse("/login-mocks/vanilla-api-root.json") + ), + ), + missingStubResponse = WpNetworkResponse.empty + ) + + val client = WpLoginClient(executor) + assertEquals( "https://vanilla.wpmt.co/wp-admin/authorize-application.php", - loginClient.apiDiscovery("https://vanilla.wpmt.co/wp-login.php") + client.apiDiscovery("https://vanilla.wpmt.co/wp-login.php") .assertSuccess().applicationPasswordsAuthenticationUrl.url() ) assertEquals( "https://vanilla.wpmt.co/wp-admin/authorize-application.php", - loginClient.apiDiscovery("https://vanilla.wpmt.co/wp-admin") + client.apiDiscovery("https://vanilla.wpmt.co/wp-admin") .assertSuccess().applicationPasswordsAuthenticationUrl.url() ) } @Test // Spec Example 4 fun testAutoHttpsSupport() = runTest { + // Input is http://, AutoStrippedHttps creates https:// attempt which succeeds. + // The http:// UserInput attempt will fail (no stubs for http://). + val executor = MockRequestExecutor( + stubs = listOf( + Stub.forUrl( + "https://vanilla.wpmt.co/", + WpNetworkResponse.withApiRoot("https://vanilla.wpmt.co/wp-json/") + ), + Stub.forUrl( + "https://vanilla.wpmt.co/wp-json/", + WpNetworkResponse.jsonResponse("/login-mocks/vanilla-api-root.json") + ), + ), + missingStubResponse = WpNetworkResponse.empty + ) + + val client = WpLoginClient(executor) assertEquals( "https://vanilla.wpmt.co/wp-admin/authorize-application.php", - loginClient.apiDiscovery("http://vanilla.wpmt.co") + client.apiDiscovery("http://vanilla.wpmt.co") .assertSuccess().applicationPasswordsAuthenticationUrl.url() ) } @Test // Spec Example 5 fun testHttpOnlySite() = runTest { - val reason = loginClient.apiDiscovery("http://no-https.wpmt.co").assertFailureFetchAndParseApiRoot() + // HTTP site with no application passwords auth URL. + // The https:// AutoStrippedHttps attempt fails (no stubs). + // The http:// UserInput attempt succeeds in finding the API root, + // but the site has no auth URL and uses HTTP -> ApplicationPasswordsDisabledForHttpSite. + val executor = MockRequestExecutor( + stubs = listOf( + Stub.forUrl( + "http://no-https.wpmt.co/", + WpNetworkResponse.withApiRoot("http://no-https.wpmt.co/wp-json/") + ), + Stub.forUrl( + "http://no-https.wpmt.co/wp-json/", + WpNetworkResponse.jsonResponse("/login-mocks/http-only-api-root.json") + ), + ), + missingStubResponse = WpNetworkResponse.empty + ) + + val client = WpLoginClient(executor) + val reason = client.apiDiscovery("http://no-https.wpmt.co").assertFailureFetchAndParseApiRoot() .getApplicationPasswordsNotSupportedReason() assertInstanceOf(ApplicationPasswordsDisabledForHttpSite::class.java, reason) } @Test // Spec Example 6 fun testHttpOnlySiteWithApplicationPasswordsEnabled() = runTest { + // HTTP site that has application passwords enabled despite being HTTP. + val executor = MockRequestExecutor( + stubs = listOf( + Stub.forUrl( + "http://no-https-with-application-passwords.wpmt.co/", + WpNetworkResponse.withApiRoot("http://no-https-with-application-passwords.wpmt.co/wp-json/") + ), + Stub.forUrl( + "http://no-https-with-application-passwords.wpmt.co/wp-json/", + WpNetworkResponse.jsonResponse("/login-mocks/http-only-with-app-passwords-api-root.json") + ), + ), + missingStubResponse = WpNetworkResponse.empty + ) + + val client = WpLoginClient(executor) assertEquals( "http://no-https-with-application-passwords.wpmt.co/wp-admin/authorize-application.php", - loginClient.apiDiscovery("http://no-https-with-application-passwords.wpmt.co") + client.apiDiscovery("http://no-https-with-application-passwords.wpmt.co") .assertSuccess().applicationPasswordsAuthenticationUrl.url() ) } @Test // Spec Example 7 fun testAggressivelyCachedSiteWithNoLinkHeader() = runTest { + // Homepage has no Link header but HTML body contains a tag with the API root. + val executor = MockRequestExecutor( + listOf( + Stub.forUrl( + "https://aggressive-caching.wpmt.co/", + WpNetworkResponse.htmlResponse("/login-mocks/homepage-with-link-tag.html") + ), + Stub.forUrl( + "https://aggressive-caching.wpmt.co/wp-json/", + WpNetworkResponse.jsonResponse("/login-mocks/aggressive-caching-api-root.json") + ), + ) + ) + + val client = WpLoginClient(executor) assertEquals( "https://aggressive-caching.wpmt.co/wp-admin/authorize-application.php", - loginClient.apiDiscovery("https://aggressive-caching.wpmt.co") + client.apiDiscovery("https://aggressive-caching.wpmt.co") .assertSuccess().applicationPasswordsAuthenticationUrl.url() ) } @@ -139,41 +246,117 @@ class ApiUrlDiscoveryTest { @Test // Spec Example 9 fun testNotWordPressSite() = runTest { - val reason = loginClient.apiDiscovery("https://google.com").assertFailureFindApiRoot() + // Homepage returns non-WordPress HTML. No Link header, no tag. + // Fallback to /wp-json/ gets empty response -> ProbablyNotAWordPressSite. + val executor = MockRequestExecutor( + stubs = listOf( + Stub.forUrl( + "https://google.com/", + WpNetworkResponse.htmlResponse("/login-mocks/homepage-not-wordpress.html") + ), + ), + missingStubResponse = WpNetworkResponse.empty + ) + + val client = WpLoginClient(executor) + val reason = client.apiDiscovery("https://google.com").assertFailureFindApiRoot() assertInstanceOf(FindApiRootFailure.ProbablyNotAWordPressSite::class.java, reason) } @Test // Spec Example 10 fun testWordPressSubdirectoryWithLinkHeader() = runTest { + // Homepage URL includes query params; the Link header points to the subdirectory wp-json. + val executor = MockRequestExecutor( + listOf( + Stub.forUrl( + "https://subdirectory.wpmt.co/index.php?link_header=true", + WpNetworkResponse.withApiRoot("https://subdirectory.wpmt.co/wordpress/wp-json/") + ), + Stub.forUrl( + "https://subdirectory.wpmt.co/wordpress/wp-json/", + WpNetworkResponse.jsonResponse("/login-mocks/subdirectory-api-root.json") + ), + ) + ) + + val client = WpLoginClient(executor) assertEquals( "https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php", - loginClient.apiDiscovery("https://subdirectory.wpmt.co/index.php?link_header=true") + client.apiDiscovery("https://subdirectory.wpmt.co/index.php?link_header=true") .assertSuccess().applicationPasswordsAuthenticationUrl.url() ) } @Test // Spec Example 11 fun testWordPressSubdirectoryWithLinkTag() = runTest { + // Homepage has no Link header but HTML body has a tag pointing to subdirectory wp-json. + // Note: Url::parse adds a trailing slash, so "https://subdirectory.wpmt.co?link_tag=true" + // becomes "https://subdirectory.wpmt.co/?link_tag=true". + val executor = MockRequestExecutor( + listOf( + Stub.forUrl( + "https://subdirectory.wpmt.co/?link_tag=true", + WpNetworkResponse.htmlResponse("/login-mocks/homepage-with-subdirectory-link-tag.html") + ), + Stub.forUrl( + "https://subdirectory.wpmt.co/wordpress/wp-json/", + WpNetworkResponse.jsonResponse("/login-mocks/subdirectory-api-root.json") + ), + ) + ) + + val client = WpLoginClient(executor) assertEquals( "https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php", - loginClient.apiDiscovery("https://subdirectory.wpmt.co?link_tag=true") + client.apiDiscovery("https://subdirectory.wpmt.co?link_tag=true") .assertSuccess().applicationPasswordsAuthenticationUrl.url() ) } @Test // Spec Example 12 fun testWordPressSubdirectoryWithRedirect() = runTest { + // In real life, this URL redirects to the WordPress subdirectory homepage. + // The mock simulates the final response after redirect: homepage with Link header. + val executor = MockRequestExecutor( + listOf( + Stub.forUrl( + "https://subdirectory.wpmt.co/index.php?redirect=true", + WpNetworkResponse.withApiRoot("https://subdirectory.wpmt.co/wordpress/wp-json/") + ), + Stub.forUrl( + "https://subdirectory.wpmt.co/wordpress/wp-json/", + WpNetworkResponse.jsonResponse("/login-mocks/subdirectory-api-root.json") + ), + ) + ) + + val client = WpLoginClient(executor) assertEquals( "https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php", - loginClient.apiDiscovery("https://subdirectory.wpmt.co/index.php?redirect=true") + client.apiDiscovery("https://subdirectory.wpmt.co/index.php?redirect=true") .assertSuccess().applicationPasswordsAuthenticationUrl.url() ) } @Test // Spec Example 13 (with no credentials) fun testWordPressHttpBasicWithMissingCredentials() = runTest { + // Homepage returns 401 with WWW-Authenticate header. + // No auth credentials provided -> HttpAuthenticationRequiredError. + val executor = MockRequestExecutor( + stubs = listOf( + Stub.forHost( + "basic-auth.wpmt.co", + WpNetworkResponse.responseWithStatus( + 401u, + mapOf("WWW-Authenticate" to "Basic realm=\"Restricted\"") + ) + ), + ), + ) + + val client = WpLoginClient(executor) val reason = - loginClient.apiDiscovery("https://basic-auth.wpmt.co").assertFailureFindApiRoot() + client.apiDiscovery("https://basic-auth.wpmt.co").assertFailureFindApiRoot() .getRequestExecutionErrorReason() assertInstanceOf( RequestExecutionErrorReason.HttpAuthenticationRequiredError::class.java, @@ -183,10 +366,25 @@ class ApiUrlDiscoveryTest { @Test // Spec Example 13 (with invalid credentials) fun testWordPressHttpBasicWithInvalidCredentials() = runTest { + // Homepage returns 401 with WWW-Authenticate header. + // The ApiDiscoveryAuthenticationMiddleware adds auth and retries, but still gets 401. + // With auth in request headers -> HttpAuthenticationRejectedError. + val executor = MockRequestExecutor( + listOf( + Stub.forHost( + "basic-auth.wpmt.co", + WpNetworkResponse.responseWithStatus( + 401u, + mapOf("WWW-Authenticate" to "Basic realm=\"Restricted\"") + ) + ), + ) + ) + val invalid = ApiDiscoveryAuthenticationMiddleware(username = "invalid", password = "invalid") val client = WpLoginClient( - WpRequestExecutor(emptyList()), WpApiMiddlewarePipeline(middlewares = listOf(invalid)) + executor, WpApiMiddlewarePipeline(middlewares = listOf(invalid)) ) val reason = client.apiDiscovery("https://basic-auth.wpmt.co") .assertFailureFindApiRoot().getRequestExecutionErrorReason() @@ -198,13 +396,43 @@ class ApiUrlDiscoveryTest { @Test // Spec Example 13 (with valid credentials) fun testWordPressHttpBasicWithValidCredentials() = runTest { + // Homepage returns 401 without auth, but succeeds with valid auth. + // The middleware retries with credentials; the authenticated request succeeds. + val executor = MockRequestExecutor( + listOf( + // Authenticated requests succeed (more specific stub first) + Stub( + evaluator = { request -> + request.url() == "https://basic-auth.wpmt.co/" && + request.headerMap().toMap().containsKey("authorization") + }, + response = WpNetworkResponse.withApiRoot("https://basic-auth.wpmt.co/wp-json/") + ), + Stub( + evaluator = { request -> + request.url() == "https://basic-auth.wpmt.co/wp-json/" && + request.headerMap().toMap().containsKey("authorization") + }, + response = WpNetworkResponse.jsonResponse("/login-mocks/basic-auth-api-root.json") + ), + // Unauthenticated requests return 401 + Stub.forHost( + "basic-auth.wpmt.co", + WpNetworkResponse.responseWithStatus( + 401u, + mapOf("WWW-Authenticate" to "Basic realm=\"Restricted\"") + ) + ), + ) + ) + val valid = ApiDiscoveryAuthenticationMiddleware( username = "test@example.com", password = "str0ngp4ssw0rd!" ) val client = WpLoginClient( - WpRequestExecutor(emptyList()), WpApiMiddlewarePipeline(middlewares = listOf(valid)) + executor, WpApiMiddlewarePipeline(middlewares = listOf(valid)) ) assertEquals( @@ -216,9 +444,25 @@ class ApiUrlDiscoveryTest { @Test // Spec Example 14 fun testWordPressCustomRestApiPrefix() = runTest { + // Site uses a custom REST API prefix (not /wp-json/). + // The Link header points to the custom API root URL. + val executor = MockRequestExecutor( + listOf( + Stub.forUrl( + "https://custom-rest-prefix.wpmt.co/", + WpNetworkResponse.withApiRoot("https://custom-rest-prefix.wpmt.co/custom-api/") + ), + Stub.forUrl( + "https://custom-rest-prefix.wpmt.co/custom-api/", + WpNetworkResponse.jsonResponse("/login-mocks/custom-rest-prefix-api-root.json") + ), + ) + ) + + val client = WpLoginClient(executor) assertEquals( "https://custom-rest-prefix.wpmt.co/wp-admin/authorize-application.php", - loginClient.apiDiscovery("https://custom-rest-prefix.wpmt.co") + client.apiDiscovery("https://custom-rest-prefix.wpmt.co") .assertSuccess().applicationPasswordsAuthenticationUrl.url() ) } @@ -264,73 +508,128 @@ class ApiUrlDiscoveryTest { @Test // Spec Example 17 fun testInvalidHTTPsFails() = runTest { - val reason = loginClient.apiDiscovery("https://wordpress-1315525-4803651.cloudwaysapps.com") - .assertFailureFindApiRoot().getRequestExecutionErrorReason() - assertInstanceOf(RequestExecutionErrorReason.InvalidSslError::class.java, reason) - - val sslError = (reason as RequestExecutionErrorReason.InvalidSslError).reason - assertInstanceOf( - InvalidSslErrorReason.CertificateNotValidForName::class.java, - sslError - ) + val server = MockWebServer() + server.useHttps(sslSocketFactoryFromP12("/ssl-certs/wrong-host.p12", "test"), false) + server.enqueue(MockResponse()) + server.start(java.net.InetAddress.getByName("127.0.0.1"), 0) + try { + val baseUrl = "https://127.0.0.1:${server.port}" + val reason = loginClient.apiDiscovery(baseUrl) + .assertFailureFindApiRoot().getRequestExecutionErrorReason() + assertInstanceOf(RequestExecutionErrorReason.InvalidSslError::class.java, reason) + + val sslError = (reason as RequestExecutionErrorReason.InvalidSslError).reason + assertInstanceOf( + InvalidSslErrorReason.CertificateNotValidForName::class.java, + sslError + ) - val hostname = (sslError as InvalidSslErrorReason.CertificateNotValidForName).hostname - val presentedHostnames = sslError.presentedHostnames + val hostname = (sslError as InvalidSslErrorReason.CertificateNotValidForName).hostname + val presentedHostnames = sslError.presentedHostnames - assertEquals(hostname, "wordpress-1315525-4803651.cloudwaysapps.com") - assertContains(presentedHostnames, "vanilla.wpmt.co") + assertEquals("127.0.0.1", hostname) + assertContains(presentedHostnames, "wrong.example.com") + } finally { + server.shutdown() + } } - @Test // Spec Example 17 (with exception) + @Test // Spec Example 18 fun testInvalidHttpsWithExceptionWorks() = runTest { - val httpClient = WpHttpClient.DefaultHttpClient(emptyList()) - val executor = WpRequestExecutor(httpClient) - httpClient.addAllowedAlternativeNamesForHostname( - "vanilla.wpmt.co", - listOf("wordpress-1315525-4803651.cloudwaysapps.com") - ) + val server = MockWebServer() + server.useHttps(sslSocketFactoryFromP12("/ssl-certs/wrong-host.p12", "test"), false) + server.start(java.net.InetAddress.getByName("127.0.0.1"), 0) + try { + val baseUrl = "https://127.0.0.1:${server.port}" + server.dispatcher = apiDiscoveryDispatcher(baseUrl) + + val httpClient = WpHttpClient.DefaultHttpClient(emptyList()) + val executor = WpRequestExecutor(httpClient) + httpClient.addAllowedAlternativeNamesForHostname( + "wrong.example.com", + listOf("127.0.0.1") + ) - assertEquals( - "https://vanilla.wpmt.co/wp-admin/authorize-application.php", - WpLoginClient(requestExecutor = executor).apiDiscovery("https://wordpress-1315525-4803651.cloudwaysapps.com") - .assertSuccess().applicationPasswordsAuthenticationUrl.url() - ) + assertEquals( + "$baseUrl/wp-admin/authorize-application.php", + WpLoginClient(requestExecutor = executor).apiDiscovery(baseUrl) + .assertSuccess().applicationPasswordsAuthenticationUrl.url() + ) + } finally { + server.shutdown() + } } @Test fun testAllowedHostnamesDoesNotBreakValidSites() = runTest { - val httpClient = WpHttpClient.DefaultHttpClient(emptyList()) - val executor = WpRequestExecutor(httpClient) - val loginClient = WpLoginClient(requestExecutor = executor) - - // First, configure an allowed hostname override for a specific cert/hostname pair - httpClient.addAllowedAlternativeNamesForHostname( - "vanilla.wpmt.co", - listOf("wordpress-1315525-4803651.cloudwaysapps.com") - ) + val wrongHostServer = MockWebServer() + wrongHostServer.useHttps(sslSocketFactoryFromP12("/ssl-certs/wrong-host.p12", "test"), false) + wrongHostServer.start(java.net.InetAddress.getByName("127.0.0.1"), 0) + + val validServer = MockWebServer() + validServer.useHttps(sslSocketFactoryFromP12("/ssl-certs/san-test.p12", "test"), false) + validServer.start(java.net.InetAddress.getByName("127.0.0.1"), 0) + + try { + val wrongHostUrl = "https://127.0.0.1:${wrongHostServer.port}" + wrongHostServer.dispatcher = apiDiscoveryDispatcher(wrongHostUrl) + + // Valid server returns non-WordPress responses for all paths + validServer.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + return MockResponse() + } + } + + val httpClient = WpHttpClient.DefaultHttpClient(emptyList()) + val executor = WpRequestExecutor(httpClient) + val loginClient = WpLoginClient(requestExecutor = executor) + + // Configure an allowed hostname override for the wrong-host cert + httpClient.addAllowedAlternativeNamesForHostname( + "wrong.example.com", + listOf("127.0.0.1") + ) - // The override should work - assertEquals( - "https://vanilla.wpmt.co/wp-admin/authorize-application.php", - loginClient.apiDiscovery("https://wordpress-1315525-4803651.cloudwaysapps.com") - .assertSuccess().applicationPasswordsAuthenticationUrl.url() - ) + // The override should work + assertEquals( + "$wrongHostUrl/wp-admin/authorize-application.php", + loginClient.apiDiscovery(wrongHostUrl) + .assertSuccess().applicationPasswordsAuthenticationUrl.url() + ) - // Other valid SSL sites should still work via fallback to default hostname verification. - // google.com uses wildcard/SAN certificates which require proper OkHttp verification. - val reason = loginClient.apiDiscovery("https://google.com").assertFailureFindApiRoot() - assertInstanceOf(FindApiRootFailure.ProbablyNotAWordPressSite::class.java, reason) + // Other valid SSL sites should still work via fallback to default hostname verification. + // The SAN cert has SAN=IP:127.0.0.1, so connecting to 127.0.0.1 matches via OkHttp's + // default hostname verifier. + val validUrl = "https://127.0.0.1:${validServer.port}" + val reason = loginClient.apiDiscovery(validUrl).assertFailureFindApiRoot() + assertInstanceOf(FindApiRootFailure.ProbablyNotAWordPressSite::class.java, reason) + } finally { + wrongHostServer.shutdown() + validServer.shutdown() + } } @Test fun testCustomOkHttpClient() = runTest { - val executor = - WpRequestExecutor(httpClient = WpHttpClient.CustomOkHttpClient(client = OkHttpClient())) - assertEquals( - "https://vanilla.wpmt.co/wp-admin/authorize-application.php", - WpLoginClient(requestExecutor = executor).apiDiscovery("https://vanilla.wpmt.co") - .assertSuccess().applicationPasswordsAuthenticationUrl.url() - ) + val server = MockWebServer() + server.useHttps(sslSocketFactoryFromP12("/ssl-certs/san-test.p12", "test"), false) + server.start(java.net.InetAddress.getByName("127.0.0.1"), 0) + try { + val baseUrl = "https://127.0.0.1:${server.port}" + server.dispatcher = apiDiscoveryDispatcher(baseUrl) + + val executor = WpRequestExecutor( + httpClient = WpHttpClient.CustomOkHttpClient(client = OkHttpClient()) + ) + assertEquals( + "$baseUrl/wp-admin/authorize-application.php", + WpLoginClient(requestExecutor = executor).apiDiscovery(baseUrl) + .assertSuccess().applicationPasswordsAuthenticationUrl.url() + ) + } finally { + server.shutdown() + } } } @@ -379,3 +678,28 @@ private fun RequestExecutionException.reason(): RequestExecutionErrorReason? { is RequestExecutionException.MediaFileNotFound -> null } } + +private fun sslSocketFactoryFromP12(resourcePath: String, password: String): javax.net.ssl.SSLSocketFactory { + val keyStore = KeyStore.getInstance("PKCS12") + val stream = {}.javaClass.getResourceAsStream(resourcePath) + ?: throw IllegalArgumentException("Resource not found: $resourcePath") + keyStore.load(stream, password.toCharArray()) + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + kmf.init(keyStore, password.toCharArray()) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(kmf.keyManagers, null, null) + return sslContext.socketFactory +} + +private fun apiDiscoveryDispatcher(baseUrl: String) = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + return when (request.path) { + "/" -> MockResponse() + .addHeader("Link", "<$baseUrl/wp-json/>; rel=\"https://api.w.org/\"") + "/wp-json/" -> MockResponse() + .addHeader("Content-Type", "application/json") + .setBody("""{"name":"Test Site","description":"","url":"$baseUrl","home":"$baseUrl","gmt_offset":0,"timezone_string":"UTC","namespaces":["wp/v2"],"authentication":{"application-passwords":{"endpoints":{"authorization":"$baseUrl/wp-admin/authorize-application.php"}}},"routes":{}}""") + else -> MockResponse().setResponseCode(404) + } + } +} diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/MockRequestExecutor.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/MockRequestExecutor.kt index 51446f795..f68c5eca8 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/MockRequestExecutor.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/MockRequestExecutor.kt @@ -27,7 +27,10 @@ class Stub(val evaluator: (WpNetworkRequest) -> Boolean, val response: WpNetwork class NoStubFoundException(message: String) : Exception(message) // A class used for testing the request executor. -class MockRequestExecutor(private var stubs: List = listOf()) : RequestExecutor { +class MockRequestExecutor( + private var stubs: List = listOf(), + private val missingStubResponse: WpNetworkResponse? = null +) : RequestExecutor { override suspend fun execute(request: WpNetworkRequest): WpNetworkResponse { val stub = stubs.firstOrNull { @@ -35,7 +38,24 @@ class MockRequestExecutor(private var stubs: List = listOf()) : RequestExe } if (stub != null) { - return stub.response + // Copy request headers to response for auth error detection + return WpNetworkResponse( + stub.response.body, + stub.response.statusCode, + stub.response.responseHeaderMap, + stub.response.requestUrl, + request.headerMap() + ) + } + + if (missingStubResponse != null) { + return WpNetworkResponse( + missingStubResponse.body, + missingStubResponse.statusCode, + missingStubResponse.responseHeaderMap, + missingStubResponse.requestUrl, + request.headerMap() + ) } throw NoStubFoundException("No stub found for ${request.url()}") @@ -101,3 +121,34 @@ fun WpNetworkResponse.Companion.retryResponse(delay: ULong): WpNetworkResponse { WpNetworkHeaderMap.empty ) } + +fun WpNetworkResponse.Companion.htmlResponse(name: String): WpNetworkResponse { + val data = {}.javaClass.getResource(name)?.readText() + + if (data == null) { + throw FileNotFoundException("No resource found for $name") + } + + return WpNetworkResponse( + data.toByteArray(), + 200u, + WpNetworkHeaderMap.fromMap(mapOf("Content-Type" to "text/html; charset=UTF-8")), + "", + WpNetworkHeaderMap.empty + ) +} + +fun WpNetworkResponse.Companion.responseWithStatus( + statusCode: UShort, + headers: Map = mapOf() +): WpNetworkResponse { + return WpNetworkResponse( + ByteArray(0), + statusCode, + WpNetworkHeaderMap.fromMap(headers), + "", + WpNetworkHeaderMap.empty + ) +} + + diff --git a/native/kotlin/build.gradle.kts b/native/kotlin/build.gradle.kts index eba105968..8b31d28c8 100644 --- a/native/kotlin/build.gradle.kts +++ b/native/kotlin/build.gradle.kts @@ -108,6 +108,24 @@ fun setupJniAndBindings() { from("$cargoProjectRoot/test-data/integration-test-responses/localhost-json-root.json") into(generatedTestResourcesPath) } + + tasks.register("copyTestResponses") { + dependsOn(tasks.named("deleteGeneratedTestResources")) + from("$cargoProjectRoot/test-data/integration-test-responses/") + into(generatedTestResourcesPath) + } + + tasks.register("copyLoginMocks") { + dependsOn(tasks.named("deleteGeneratedTestResources")) + from("$cargoProjectRoot/test-data/login-mocks/") + into("$generatedTestResourcesPath/login-mocks") + } + + tasks.register("copySslCerts") { + dependsOn(tasks.named("deleteGeneratedTestResources")) + from("$cargoProjectRoot/test-data/ssl-certs/") + into("$generatedTestResourcesPath/ssl-certs") + } } fun resolveBinary(name: String): String { diff --git a/native/swift/Tests/wordpress-api/LoginTests.swift b/native/swift/Tests/wordpress-api/LoginTests.swift index f9f12ab0e..df29d952d 100644 --- a/native/swift/Tests/wordpress-api/LoginTests.swift +++ b/native/swift/Tests/wordpress-api/LoginTests.swift @@ -1,6 +1,14 @@ import Foundation import Testing +#if canImport(MockWebServer) +import MockWebServer +#endif + +#if canImport(Security) +import Security +#endif + @testable import WordPressAPI #if os(Linux) @@ -15,6 +23,11 @@ class LoginTests { @Test("Login Spec Example 1: Valid URL") func testValidURL() async throws { + let stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "https://vanilla.wpmt.co/", with: .withApiRoot("https://vanilla.wpmt.co/wp-json/")), + try HTTPStubs.stub(url: "https://vanilla.wpmt.co/wp-json/", with: .loginMockResponse(named: "vanilla-api-root")) + ]) + let client = WordPressLoginClient(requestExecutor: stubs) let parsedUrl = try await client.findLoginUrl(forSite: "https://vanilla.wpmt.co") #expect("https://vanilla.wpmt.co/wp-admin/authorize-application.php" == parsedUrl.url()) } @@ -50,20 +63,46 @@ class LoginTests { ("https://vanilla.wpmt.co/wp-admin", "https://vanilla.wpmt.co/wp-admin/authorize-application.php") ]) func testAdminUrlProvided(_ provided: String, _ expected: String) async throws { + // The UserInput attempt uses the admin URL as-is (no stub found -> fails). + // The AutoStrippedHttps attempt strips the admin suffix and succeeds. + let stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "https://vanilla.wpmt.co/", with: .withApiRoot("https://vanilla.wpmt.co/wp-json/")), + try HTTPStubs.stub(url: "https://vanilla.wpmt.co/wp-json/", with: .loginMockResponse(named: "vanilla-api-root")) + ]) + let client = WordPressLoginClient(requestExecutor: stubs) let parsedUrl = try await client.findLoginUrl(forSite: provided) #expect(expected == parsedUrl.url()) } @Test("Login Spec Example 4: HTTP URL with HTTPS Support") func testAutoHttpsSupport() async throws { + // UserInput attempt fetches http://vanilla.wpmt.co/ (no stub -> fails). + // AutoStrippedHttps attempt converts to https:// and succeeds. + let stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "https://vanilla.wpmt.co/", with: .withApiRoot("https://vanilla.wpmt.co/wp-json/")), + try HTTPStubs.stub(url: "https://vanilla.wpmt.co/wp-json/", with: .loginMockResponse(named: "vanilla-api-root")) + ]) + let client = WordPressLoginClient(requestExecutor: stubs) let parsedUrl = try await client.findLoginUrl(forSite: "http://vanilla.wpmt.co") #expect("https://vanilla.wpmt.co/wp-admin/authorize-application.php" == parsedUrl.url()) } @Test("Login Spec Example 5: HTTP-only site") func testHttpOnlySite() async { + let stubs: HTTPStubs + do { + stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "http://no-https.wpmt.co/", with: .withApiRoot("http://no-https.wpmt.co/wp-json/")), + try HTTPStubs.stub(url: "http://no-https.wpmt.co/wp-json/", with: .loginMockResponse(named: "http-only-api-root")) + ]) + } catch { + Issue.record("Failed to create stubs: \(error)") + return + } + let client = WordPressLoginClient(requestExecutor: stubs) + await #expect(performing: { - _ = try await self.client.findLoginUrl(forSite: "http://no-https.wpmt.co") + _ = try await client.findLoginUrl(forSite: "http://no-https.wpmt.co") }, throws: { error in let reason = try #require(try self.getApplicationPasswordsNotSupportedReason(from: error)) @@ -78,12 +117,23 @@ class LoginTests { @Test("Login Spec Example 6: HTTP-Only Site with Application Password Override") func testHttpOnlySiteWithApplicationPasswordsEnabled() async throws { + let stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "http://no-https-with-application-passwords.wpmt.co/", with: .withApiRoot("http://no-https-with-application-passwords.wpmt.co/wp-json/")), + try HTTPStubs.stub(url: "http://no-https-with-application-passwords.wpmt.co/wp-json/", with: .loginMockResponse(named: "http-only-with-app-passwords-api-root")) + ]) + let client = WordPressLoginClient(requestExecutor: stubs) let parsedUrl = try await client.findLoginUrl(forSite: "http://no-https-with-application-passwords.wpmt.co") #expect("http://no-https-with-application-passwords.wpmt.co/wp-admin/authorize-application.php" == parsedUrl.url()) } @Test("Login Spec Example 7: CDN-Cached Site") func testAggressivelyCachedSiteWithNoLinkheader() async throws { + // Homepage has no Link header, but HTML contains a tag pointing to the API root + let stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "https://aggressive-caching.wpmt.co/", with: .htmlResponse(named: "homepage-with-link-tag")), + try HTTPStubs.stub(url: "https://aggressive-caching.wpmt.co/wp-json/", with: .loginMockResponse(named: "aggressive-caching-api-root")) + ]) + let client = WordPressLoginClient(requestExecutor: stubs) let parsedUrl = try await client.findLoginUrl(forSite: "https://aggressive-caching.wpmt.co") #expect("https://aggressive-caching.wpmt.co/wp-admin/authorize-application.php" == parsedUrl.url()) } @@ -111,8 +161,20 @@ class LoginTests { "https://google.com" ]) func testNotWordPressSite(url: String) async throws { + // Homepage returns non-WordPress HTML, no Link header, and no WP markers + let stubs: HTTPStubs + do { + stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "https://google.com/", with: .htmlResponse(named: "homepage-not-wordpress")) + ]) + } catch { + Issue.record("Failed to create stubs: \(error)") + return + } + let client = WordPressLoginClient(requestExecutor: stubs) + await #expect(performing: { - _ = try await self.client.findLoginUrl(forSite: url) + _ = try await client.findLoginUrl(forSite: url) }, throws: { error in try #require(error is AutoDiscoveryAttemptFailure) @@ -130,26 +192,55 @@ class LoginTests { @Test("Login Spec Example 10: WordPress in a subdirectory with a link header") func testWordPressSubdirectoryWithLinkHeader() async throws { + let stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/index.php?link_header=true", with: .withApiRoot("https://subdirectory.wpmt.co/wordpress/wp-json/")), + try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/wordpress/wp-json/", with: .loginMockResponse(named: "subdirectory-api-root")) + ]) + let client = WordPressLoginClient(requestExecutor: stubs) let parsedUrl = try await client.findLoginUrl(forSite: "https://subdirectory.wpmt.co/index.php?link_header=true") #expect("https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php" == parsedUrl.url()) } @Test("Login Spec Example 11: WordPress in a subdirectory with a link tag") func testWordPressSubdirectoryWithLinkTag() async throws { + // Homepage has no Link header but HTML contains a tag pointing to subdirectory wp-json + let stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/index.php?link_tag=true", with: .htmlResponse(named: "homepage-with-subdirectory-link-tag")), + try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/wordpress/wp-json/", with: .loginMockResponse(named: "subdirectory-api-root")) + ]) + let client = WordPressLoginClient(requestExecutor: stubs) let parsedUrl = try await client.findLoginUrl(forSite: "https://subdirectory.wpmt.co/index.php?link_tag=true") #expect("https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php" == parsedUrl.url()) } @Test("Login Spec Example 12: WordPress in a subdirectory with a redirect") func testWordPressSubdirectory() async throws { + // In the real scenario, the server redirects to /wordpress/ which has the Link header. + // With mocks, we simulate the final response directly on the requested URL. + let stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/index.php?redirect=true", with: .withApiRoot("https://subdirectory.wpmt.co/wordpress/wp-json/")), + try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/wordpress/wp-json/", with: .loginMockResponse(named: "subdirectory-api-root")) + ]) + let client = WordPressLoginClient(requestExecutor: stubs) let parsedUrl = try await client.findLoginUrl(forSite: "https://subdirectory.wpmt.co/index.php?redirect=true") #expect("https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php" == parsedUrl.url()) } @Test("Login Spec Example 13: Site uses HTTP basic with no provided credentials") func testWordPressHttpBasic() async throws { + let stubs: HTTPStubs + do { + stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(host: "basic-auth.wpmt.co", with: .responseWithStatus(401, headers: ["WWW-Authenticate": "Basic realm=\"Restricted\""])) + ]) + } catch { + Issue.record("Failed to create stubs: \(error)") + return + } + let client = WordPressLoginClient(requestExecutor: stubs) + await #expect(performing: { - _ = try await self.client.findLoginUrl(forSite: "https://basic-auth.wpmt.co") + _ = try await client.findLoginUrl(forSite: "https://basic-auth.wpmt.co") }, throws: { error in let reason = try #require(try self.getRequestExecutionErrorReason(from: error)) @@ -168,11 +259,20 @@ class LoginTests { @Test("Login Spec Example 13: Site uses HTTP basic with invalid credentials provided") func testWordPressHttpBasicWithInvalidCredentials() async throws { + let stubs: HTTPStubs + do { + stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(host: "basic-auth.wpmt.co", with: .responseWithStatus(401, headers: ["WWW-Authenticate": "Basic realm=\"Restricted\""])) + ]) + } catch { + Issue.record("Failed to create stubs: \(error)") + return + } let invalid = ApiDiscoveryAuthenticationMiddleware(username: "invalid", password: "invalid") await #expect(performing: { _ = try await WordPressLoginClient( - urlSession: .init(configuration: .ephemeral), + requestExecutor: stubs, middleware: MiddlewarePipeline(middlewares: invalid) ).findLoginUrl(forSite: "https://basic-auth.wpmt.co") }, throws: { error in @@ -193,10 +293,14 @@ class LoginTests { @Test("Login Spec Example 13: Site uses HTTP basic with correct credentials provided") func testWordPressHttpBasicWithValidCredentials() async throws { + let stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "https://basic-auth.wpmt.co/", with: .withApiRoot("https://basic-auth.wpmt.co/wp-json/")), + try HTTPStubs.stub(url: "https://basic-auth.wpmt.co/wp-json/", with: .loginMockResponse(named: "basic-auth-api-root")) + ]) let valid = ApiDiscoveryAuthenticationMiddleware(username: "test@example.com", password: "str0ngp4ssw0rd!") let parsedUrl = try await WordPressLoginClient( - urlSession: .init(configuration: .ephemeral), + requestExecutor: stubs, middleware: MiddlewarePipeline(middlewares: valid) ).findLoginUrl(forSite: "https://basic-auth.wpmt.co") @@ -205,6 +309,13 @@ class LoginTests { @Test("Login Spec Example 14: Custom REST API Prefix") func testWordPressCustomRestApiPrefix() async throws { + // Site uses a custom REST prefix (e.g., /custom-api/ instead of /wp-json/) + // The Link header points to the custom API root + let stubs = HTTPStubs(stubs: [ + try HTTPStubs.stub(url: "https://custom-rest-prefix.wpmt.co/", with: .withApiRoot("https://custom-rest-prefix.wpmt.co/custom-api/")), + try HTTPStubs.stub(url: "https://custom-rest-prefix.wpmt.co/custom-api/", with: .loginMockResponse(named: "custom-rest-prefix-api-root")) + ]) + let client = WordPressLoginClient(requestExecutor: stubs) let parsedUrl = try await client.findLoginUrl(forSite: "https://custom-rest-prefix.wpmt.co") #expect("https://custom-rest-prefix.wpmt.co/wp-admin/authorize-application.php" == parsedUrl.url()) } @@ -253,10 +364,20 @@ class LoginTests { }) } + #if canImport(MockWebServer) @Test("Login Spec Example 17: Invalid SSL Certificate") func testInvalidHTTPsFails() async throws { + let server = MockWebServer() + try server.start(tls: .wrongHostname()) + defer { server.shutdown() } + + server.enqueue(MockResponse(statusCode: 200)) + + let siteUrl = server.url(forPath: "/").absoluteString + let client = WordPressLoginClient(urlSession: .init(configuration: .ephemeral)) + await #expect(performing: { - _ = try await self.client.findLoginUrl(forSite: "https://wordpress-1315525-4803651.cloudwaysapps.com") + _ = try await client.findLoginUrl(forSite: siteUrl) }, throws: { error in let reason = try #require(try self.getRequestExecutionErrorReason(from: error)) @@ -276,8 +397,8 @@ class LoginTests { return false } - #expect(hostname == "wordpress-1315525-4803651.cloudwaysapps.com") - #expect(presentedHostnames == ["vanilla.wpmt.co"]) + #expect(hostname == "127.0.0.1") + #expect(presentedHostnames == ["wrong.example.com"]) #endif return true @@ -287,18 +408,54 @@ class LoginTests { /// This test is unavailable in Linux until https://github.com/swiftlang/swift-corelibs-foundation/pull/4937 lands @Test("Login Spec Example 18: Invalid SSL Certificate with explicit exception", .enabled(if: !isLinux())) func testInvalidHttpsWithExceptionWorks() async throws { + let server = MockWebServer() + try server.start(tls: .wrongHostname()) + defer { server.shutdown() } + + let port = server.port + let baseUrl = "https://127.0.0.1:\(port)" + + server.route("/", MockResponse(statusCode: 200) + .withHeader("Link", "<\(baseUrl)/wp-json/>; rel=\"https://api.w.org/\"")) + + server.route("/wp-json/", .json(""" + {"name":"Test Site","description":"","url":"\(baseUrl)","home":"\(baseUrl)","gmt_offset":0,"timezone_string":"UTC","namespaces":["oembed/1.0","wp/v2","wp-site-health/v1"],"authentication":{"application-passwords":{"endpoints":{"authorization":"\(baseUrl)/wp-admin/authorize-application.php"}}},"routes":{},"site_logo":0,"site_icon":0,"site_icon_url":""} + """)) + let executor = WpRequestExecutor(urlSession: .init(configuration: .ephemeral)) - executor.allowSSL(altNames: ["wordpress-1315525-4803651.cloudwaysapps.com"], forCommonName: "vanilla.wpmt.co") + executor.allowSSL(altNames: ["127.0.0.1"], forCommonName: "wrong.example.com") let client = WordPressLoginClient(requestExecutor: executor) - _ = try await client.findLoginUrl(forSite: "https://wordpress-1315525-4803651.cloudwaysapps.com") + _ = try await client.findLoginUrl(forSite: baseUrl) } - /// This test is unavailable in Linux until https://github.com/swiftlang/swift-corelibs-foundation/pull/4937 lands - @Test("Login Spec Example 19: Alternative name in SSL Certificate", .enabled(if: !isLinux())) - func testAlternameWorks() async throws { - // "vanilla1.wpmt.co" is one of the alternative names in vanilla.wpmt.co certificate. - _ = try await self.client.findLoginUrl(forSite: "https://vanilla1.wpmt.co") + /// This test requires the test CA to be trusted: `make trust-test-ca` + @Test("Login Spec Example 19: Alternative name in SSL Certificate", .enabled(if: !isLinux() && isTestCATrusted())) + func testAlternativeNameWorks() async throws { + guard let p12Url = Bundle.module.url(forResource: "san-test", withExtension: "p12", subdirectory: "ssl-certs") else { + preconditionFailure("Could not find san-test.p12 in ssl-certs") + } + let p12Data = try Data(contentsOf: p12Url) + + let server = MockWebServer() + try server.start(tls: TLSConfiguration(p12Data: p12Data, password: "test")) + defer { server.shutdown() } + + let port = server.port + let baseUrl = "https://127.0.0.1:\(port)" + + server.route("/", MockResponse(statusCode: 200) + .withHeader("Link", "<\(baseUrl)/wp-json/>; rel=\"https://api.w.org/\"")) + + server.route("/wp-json/", .json(""" + {"name":"Test Site","description":"","url":"\(baseUrl)","home":"\(baseUrl)","gmt_offset":0,"timezone_string":"UTC","namespaces":["oembed/1.0","wp/v2","wp-site-health/v1"],"authentication":{"application-passwords":{"endpoints":{"authorization":"\(baseUrl)/wp-admin/authorize-application.php"}}},"routes":{},"site_logo":0,"site_icon":0,"site_icon_url":""} + """)) + + // Connect using default URLSession (no allowSSL bypass) — succeeds because + // 127.0.0.1 is a SAN on the cert and the CA is trusted in the system keychain + let client = WordPressLoginClient(urlSession: .init(configuration: .ephemeral)) + _ = try await client.findLoginUrl(forSite: baseUrl) } + #endif // canImport(MockWebServer) @Test("Cancel API discovery process") func testCancellation() async throws { diff --git a/native/swift/Tests/wordpress-api/Support/Extensions.swift b/native/swift/Tests/wordpress-api/Support/Extensions.swift index 3b56ca337..01f27be22 100644 --- a/native/swift/Tests/wordpress-api/Support/Extensions.swift +++ b/native/swift/Tests/wordpress-api/Support/Extensions.swift @@ -1,6 +1,10 @@ import Foundation import WordPressAPI +#if canImport(Security) +import Security +#endif + extension WpNetworkHeaderMap { static var empty: WpNetworkHeaderMap { // swiftlint:disable:next force_try @@ -34,4 +38,37 @@ func isLinux() -> Bool { #endif } +/// Returns true if the test CA certificate is trusted in the system keychain. +/// Run `make trust-test-ca` to trust it. On CI VMs where trust modification +/// isn't possible, this returns false and dependent tests will be skipped. +func isTestCATrusted() -> Bool { + #if canImport(Security) + guard let pemUrl = Bundle.module.url(forResource: "ca-cert", withExtension: "pem", subdirectory: "ssl-certs"), + let pemData = try? Data(contentsOf: pemUrl), + let pemString = String(data: pemData, encoding: .utf8) else { + return false + } + + let base64 = pemString + .components(separatedBy: "\n") + .filter { !$0.hasPrefix("-----") && !$0.isEmpty } + .joined() + guard let derData = Data(base64Encoded: base64), + let cert = SecCertificateCreateWithData(nil, derData as CFData) else { + return false + } + + var trust: SecTrust? + let policy = SecPolicyCreateBasicX509() + guard SecTrustCreateWithCertificates(cert as CFTypeRef, policy, &trust) == errSecSuccess, + let trust else { + return false + } + + return SecTrustEvaluateWithError(trust, nil) + #else + return false + #endif +} + let isXCTest: Bool = Bundle.main.infoDictionary?["CFBundleName"] as? String == "xctest" diff --git a/native/swift/Tests/wordpress-api/Support/HTTPStubs.swift b/native/swift/Tests/wordpress-api/Support/HTTPStubs.swift index 21778f3b4..b6c569e93 100644 --- a/native/swift/Tests/wordpress-api/Support/HTTPStubs.swift +++ b/native/swift/Tests/wordpress-api/Support/HTTPStubs.swift @@ -29,7 +29,15 @@ final class HTTPStubs: SafeRequestExecutor { _ request: WpNetworkRequest ) async -> Result { if let response = stub(for: request) { - return .success(response) + // Propagate request headers to the response so auth detection works correctly + let responseWithRequestHeaders = WpNetworkResponse( + body: response.body, + statusCode: response.statusCode, + responseHeaderMap: response.responseHeaderMap, + requestUrl: request.url(), + requestHeaderMap: request.headerMap() + ) + return .success(responseWithRequestHeaders) } switch missingStub { @@ -124,6 +132,24 @@ extension WpNetworkResponse { ) } + static func loginMockResponse(named name: String) throws -> WpNetworkResponse { + + guard let resourceUrl = Bundle + .module + .url(forResource: name, withExtension: "json", subdirectory: "login-mocks") + else { + preconditionFailure("Could not find \(name).json in login-mocks") + } + + return WpNetworkResponse( + body: try Data(contentsOf: resourceUrl), + statusCode: 200, + responseHeaderMap: try WpNetworkHeaderMap.fromMap(hashMap: ["Content-Type": "application/json"]), + requestUrl: "https://example.com", + requestHeaderMap: .empty + ) + } + static func retryResponse(after: TimeInterval) throws -> WpNetworkResponse { return WpNetworkResponse( body: Data(), @@ -145,4 +171,32 @@ extension WpNetworkResponse { requestHeaderMap: .empty ) } + + static func htmlResponse(named name: String) throws -> WpNetworkResponse { + guard let resourceUrl = Bundle + .module + .url(forResource: name, withExtension: "html", subdirectory: "login-mocks") + else { + preconditionFailure("Could not find \(name).html") + } + + return WpNetworkResponse( + body: try Data(contentsOf: resourceUrl), + statusCode: 200, + responseHeaderMap: try WpNetworkHeaderMap.fromMap(hashMap: ["Content-Type": "text/html; charset=UTF-8"]), + requestUrl: "https://example.com", + requestHeaderMap: .empty + ) + } + + static func responseWithStatus(_ statusCode: UInt16, headers: [String: String] = [:]) throws -> WpNetworkResponse { + return WpNetworkResponse( + body: Data(), + statusCode: statusCode, + responseHeaderMap: try WpNetworkHeaderMap.fromMap(hashMap: headers), + requestUrl: "https://example.com", + requestHeaderMap: .empty + ) + } + } diff --git a/scripts/run-kotlin-integration-tests.sh b/scripts/run-kotlin-integration-tests.sh index e9ea48395..0b02e8a52 100755 --- a/scripts/run-kotlin-integration-tests.sh +++ b/scripts/run-kotlin-integration-tests.sh @@ -1,6 +1,18 @@ #!/bin/bash -eu # The project should be mounted to this location -cd /app/native/kotlin +cd /app + +# Detect JAVA_HOME if not set +if [ -z "${JAVA_HOME:-}" ]; then + export JAVA_HOME=$(dirname $(dirname $(readlink -f $(which java)))) +fi + +# Trust the test CA certificate in the JVM keystore for SSL mock tests +keytool -importcert -file test-data/ssl-certs/ca-cert.pem \ + -keystore $JAVA_HOME/lib/security/cacerts \ + -storepass changeit -noprompt -alias wordpress-rs-test-ca + +cd native/kotlin ./gradlew :api:kotlin:integrationTest diff --git a/test-data/login-mocks/aggressive-caching-api-root.json b/test-data/login-mocks/aggressive-caching-api-root.json new file mode 100644 index 000000000..dc1a0e9fc --- /dev/null +++ b/test-data/login-mocks/aggressive-caching-api-root.json @@ -0,0 +1,24 @@ +{ + "name": "Aggressive Caching Site", + "description": "", + "url": "https://aggressive-caching.wpmt.co", + "home": "https://aggressive-caching.wpmt.co", + "gmt_offset": 0, + "timezone_string": "UTC", + "namespaces": [ + "oembed/1.0", + "wp/v2", + "wp-site-health/v1" + ], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "https://aggressive-caching.wpmt.co/wp-admin/authorize-application.php" + } + } + }, + "routes": {}, + "site_logo": 0, + "site_icon": 0, + "site_icon_url": "" +} diff --git a/test-data/login-mocks/basic-auth-api-root.json b/test-data/login-mocks/basic-auth-api-root.json new file mode 100644 index 000000000..7f4f2d52f --- /dev/null +++ b/test-data/login-mocks/basic-auth-api-root.json @@ -0,0 +1,24 @@ +{ + "name": "Basic Auth Site", + "description": "", + "url": "https://basic-auth.wpmt.co", + "home": "https://basic-auth.wpmt.co", + "gmt_offset": 0, + "timezone_string": "UTC", + "namespaces": [ + "oembed/1.0", + "wp/v2", + "wp-site-health/v1" + ], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "https://basic-auth.wpmt.co/wp-admin/authorize-application.php" + } + } + }, + "routes": {}, + "site_logo": 0, + "site_icon": 0, + "site_icon_url": "" +} diff --git a/test-data/login-mocks/custom-rest-prefix-api-root.json b/test-data/login-mocks/custom-rest-prefix-api-root.json new file mode 100644 index 000000000..9e1f876b5 --- /dev/null +++ b/test-data/login-mocks/custom-rest-prefix-api-root.json @@ -0,0 +1,24 @@ +{ + "name": "Custom REST Prefix Site", + "description": "", + "url": "https://custom-rest-prefix.wpmt.co", + "home": "https://custom-rest-prefix.wpmt.co", + "gmt_offset": 0, + "timezone_string": "UTC", + "namespaces": [ + "oembed/1.0", + "wp/v2", + "wp-site-health/v1" + ], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "https://custom-rest-prefix.wpmt.co/wp-admin/authorize-application.php" + } + } + }, + "routes": {}, + "site_logo": 0, + "site_icon": 0, + "site_icon_url": "" +} diff --git a/test-data/login-mocks/homepage-not-wordpress.html b/test-data/login-mocks/homepage-not-wordpress.html new file mode 100644 index 000000000..7e36ba34a --- /dev/null +++ b/test-data/login-mocks/homepage-not-wordpress.html @@ -0,0 +1,10 @@ + + + + +Example Website + + +

This is not a WordPress site.

+ + diff --git a/test-data/login-mocks/homepage-with-link-tag.html b/test-data/login-mocks/homepage-with-link-tag.html new file mode 100644 index 000000000..1d1aabd9c --- /dev/null +++ b/test-data/login-mocks/homepage-with-link-tag.html @@ -0,0 +1,12 @@ + + + + + + + + + +

WordPress site with CDN stripping Link headers

+ + diff --git a/test-data/login-mocks/homepage-with-subdirectory-link-tag.html b/test-data/login-mocks/homepage-with-subdirectory-link-tag.html new file mode 100644 index 000000000..80ec3096f --- /dev/null +++ b/test-data/login-mocks/homepage-with-subdirectory-link-tag.html @@ -0,0 +1,12 @@ + + + + + + + + + +

WordPress installed in a subdirectory

+ + diff --git a/test-data/login-mocks/http-only-api-root.json b/test-data/login-mocks/http-only-api-root.json new file mode 100644 index 000000000..8c0e886c1 --- /dev/null +++ b/test-data/login-mocks/http-only-api-root.json @@ -0,0 +1,18 @@ +{ + "name": "HTTP Only Site", + "description": "", + "url": "http://no-https.wpmt.co", + "home": "http://no-https.wpmt.co", + "gmt_offset": 0, + "timezone_string": "UTC", + "namespaces": [ + "oembed/1.0", + "wp/v2", + "wp-site-health/v1" + ], + "authentication": {}, + "routes": {}, + "site_logo": 0, + "site_icon": 0, + "site_icon_url": "" +} diff --git a/test-data/login-mocks/http-only-with-app-passwords-api-root.json b/test-data/login-mocks/http-only-with-app-passwords-api-root.json new file mode 100644 index 000000000..90ece1c88 --- /dev/null +++ b/test-data/login-mocks/http-only-with-app-passwords-api-root.json @@ -0,0 +1,24 @@ +{ + "name": "HTTP Site with App Passwords", + "description": "", + "url": "http://no-https-with-application-passwords.wpmt.co", + "home": "http://no-https-with-application-passwords.wpmt.co", + "gmt_offset": 0, + "timezone_string": "UTC", + "namespaces": [ + "oembed/1.0", + "wp/v2", + "wp-site-health/v1" + ], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "http://no-https-with-application-passwords.wpmt.co/wp-admin/authorize-application.php" + } + } + }, + "routes": {}, + "site_logo": 0, + "site_icon": 0, + "site_icon_url": "" +} diff --git a/test-data/login-mocks/subdirectory-api-root.json b/test-data/login-mocks/subdirectory-api-root.json new file mode 100644 index 000000000..c346872c3 --- /dev/null +++ b/test-data/login-mocks/subdirectory-api-root.json @@ -0,0 +1,24 @@ +{ + "name": "Subdirectory Site", + "description": "", + "url": "https://subdirectory.wpmt.co/wordpress", + "home": "https://subdirectory.wpmt.co", + "gmt_offset": 0, + "timezone_string": "UTC", + "namespaces": [ + "oembed/1.0", + "wp/v2", + "wp-site-health/v1" + ], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php" + } + } + }, + "routes": {}, + "site_logo": 0, + "site_icon": 0, + "site_icon_url": "" +} diff --git a/test-data/login-mocks/vanilla-api-root.json b/test-data/login-mocks/vanilla-api-root.json new file mode 100644 index 000000000..e8f2d0bc1 --- /dev/null +++ b/test-data/login-mocks/vanilla-api-root.json @@ -0,0 +1,24 @@ +{ + "name": "Test Site", + "description": "", + "url": "https://vanilla.wpmt.co", + "home": "https://vanilla.wpmt.co", + "gmt_offset": 0, + "timezone_string": "UTC", + "namespaces": [ + "oembed/1.0", + "wp/v2", + "wp-site-health/v1" + ], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "https://vanilla.wpmt.co/wp-admin/authorize-application.php" + } + } + }, + "routes": {}, + "site_logo": 0, + "site_icon": 0, + "site_icon_url": "" +} diff --git a/test-data/ssl-certs/ca-cert.pem b/test-data/ssl-certs/ca-cert.pem new file mode 100644 index 000000000..26121524b --- /dev/null +++ b/test-data/ssl-certs/ca-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDHzCCAgegAwIBAgIUVq8ghMmnTdmRz/vPM6x88G1t6jIwDQYJKoZIhvcNAQEL +BQAwHzEdMBsGA1UEAwwUV29yZFByZXNzIFJTIFRlc3QgQ0EwHhcNMjYwMjI4MDAx +ODQyWhcNMzYwMjI2MDAxODQyWjAfMR0wGwYDVQQDDBRXb3JkUHJlc3MgUlMgVGVz +dCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANlsYT0kksBzrqwj +89hljWGeAgrmlycF3UuzIOFYECWRaTzSrzeaj2ChejSHF6/yTYZW+pFjgLBZrmSd +HGPZ0+F8BWifoiHBDE+L0BrFudF8ry8pwFSxVdfwXVeUGeFolMVYW+s2l1XOto+V +/VXLnCk4uk5P+Cd5Q9wH4jqgRK8gsVXaUsYAovErUwGGdOiaVFRKFa3JnMkCrD9U +I6aa9txBAvbqP4gPoLzLX+v3lSeGke1aLmBMJLWN7OXUez63VT29bUBEsn7BM1sh +BiVTAMYWpCN5b+uak7QRd0YkZ7f2Do3eMSN+Eh3YEFtmfCT/sCnaIZax78mphuRa +zSd5WFECAwEAAaNTMFEwHQYDVR0OBBYEFOi+nEnqdvJK5DHVYl3ZgzTf+wfeMB8G +A1UdIwQYMBaAFOi+nEnqdvJK5DHVYl3ZgzTf+wfeMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQELBQADggEBADcTxKtnHFmbhOg92/cEu77SmxuVrfxGnLwtegJt +ctzzOL2mRBd6FD8Vko4lH7y8PG92tRDbrvexlJ4NUA79GKVrMBqenz/w69WNlAXf +ySonOnUp4rUIzGevMYHrhD6HR9txlY2f89mKu0GLMKFTZ3dBbnqX60jTIUDlQa2x +oLrAekY5AAEb9M60qrh2f28KIiXE0uI+mNA/T1pGc/8oOwaIzsi0FZjylIg7pbYi +PQqtKvLepTnt7lTyJdpZC+va14srB3cnm69PXPQe6Y2geVMNzISv6BSzj9dWGI0f +iY2GIB14hxtPMMEztlY2D1x2/JhEdLejXXDc19B3rhI1fZM= +-----END CERTIFICATE----- diff --git a/test-data/ssl-certs/san-test.p12 b/test-data/ssl-certs/san-test.p12 new file mode 100644 index 000000000..14432764d Binary files /dev/null and b/test-data/ssl-certs/san-test.p12 differ diff --git a/test-data/ssl-certs/wrong-host.p12 b/test-data/ssl-certs/wrong-host.p12 new file mode 100644 index 000000000..f4bebafe7 Binary files /dev/null and b/test-data/ssl-certs/wrong-host.p12 differ diff --git a/wp_api_integration_tests/src/mock.rs b/wp_api_integration_tests/src/mock.rs index d87bf4d76..e3d1f9e86 100644 --- a/wp_api_integration_tests/src/mock.rs +++ b/wp_api_integration_tests/src/mock.rs @@ -68,6 +68,14 @@ pub mod response_helpers { json_response_from_path(&json_file_path) } + pub fn json_response_from_login_mocks(file_name: &str) -> WpNetworkResponse { + let mut json_file_path = std::path::PathBuf::from(env!("CARGO_WORKSPACE_DIR")); + json_file_path.push("test-data"); + json_file_path.push("login-mocks"); + json_file_path.push(file_name); + json_response_from_path(&json_file_path) + } + pub fn json_response_from_path(json_file_path: &PathBuf) -> WpNetworkResponse { let json = fs::read_to_string(json_file_path).unwrap_or_else(|_| { panic!("Should have been able to read the json file at: '{json_file_path:#?}'") @@ -111,4 +119,39 @@ pub mod response_helpers { request_header_map: WpNetworkHeaderMap::default().into(), } } + + pub fn html_response_from_login_mocks(file_name: &str) -> WpNetworkResponse { + let mut file_path = std::path::PathBuf::from(env!("CARGO_WORKSPACE_DIR")); + file_path.push("test-data"); + file_path.push("login-mocks"); + file_path.push(file_name); + let html = fs::read_to_string(&file_path).unwrap_or_else(|_| { + panic!("Should have been able to read the file at: '{file_path:#?}'") + }); + let mut map = HeaderMap::new(); + map.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=UTF-8"), + ); + WpNetworkResponse { + body: html.as_bytes().to_vec(), + status_code: 200, + response_header_map: Arc::new(map.into()), + request_url: WpEndpointUrl("".to_string()), + request_header_map: WpNetworkHeaderMap::default().into(), + } + } + + pub fn response_with_status_and_headers( + status_code: u16, + headers: HeaderMap, + ) -> WpNetworkResponse { + WpNetworkResponse { + body: vec![], + status_code, + response_header_map: Arc::new(headers.into()), + request_url: WpEndpointUrl("".to_string()), + request_header_map: WpNetworkHeaderMap::default().into(), + } + } } diff --git a/wp_api_integration_tests/tests/test_login_remote.rs b/wp_api_integration_tests/tests/test_login_remote.rs index b67b9dcba..7b928224a 100644 --- a/wp_api_integration_tests/tests/test_login_remote.rs +++ b/wp_api_integration_tests/tests/test_login_remote.rs @@ -11,7 +11,9 @@ use wp_api::{ ApiDiscoveryAuthenticationMiddleware, RetryAfterMiddleware, WpApiMiddleware, WpApiMiddlewarePipeline, }, - request::{NetworkRequestAccessor, RequestExecutor}, + request::{ + NetworkRequestAccessor, RequestExecutor, WpNetworkResponse, endpoint::WpEndpointUrl, + }, reqwest_request_executor::ReqwestRequestExecutor, }; use wp_api_integration_tests::prelude::*; @@ -20,8 +22,20 @@ use wp_api_integration_tests::prelude::*; #[parallel] async fn login_spec_1_valid_site_works_correctly() { // Spec Example 1 + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + "https://vanilla.wpmt.co/" => Ok(response_helpers::with_api_root( + "https://vanilla.wpmt.co/wp-json/", + )), + "https://vanilla.wpmt.co/wp-json/" => Ok(response_helpers::json_response_from_login_mocks( + "vanilla-api-root.json", + )), + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let login_url = discovery_helper(Arc::new(executor), vec![], "https://vanilla.wpmt.co") + .await + .expect("Expected api discovery to be successful"); assert_eq!( - login_url("https://vanilla.wpmt.co").await, + login_url, "https://vanilla.wpmt.co/wp-admin/authorize-application.php" ); } @@ -61,12 +75,44 @@ async fn login_spec_2_local_development_environment() { #[parallel] async fn login_spec_3_admin_url_provided() { // Spec Example 3 + // Mock handles URLs for both wp-login.php and wp-admin variants + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + // UserInput attempts for admin URLs — return non-WP page + "https://vanilla.wpmt.co/wp-login.php" | "https://vanilla.wpmt.co/wp-admin" => Ok( + response_helpers::html_response_from_login_mocks("homepage-not-wordpress.html"), + ), + // Fallback wp-json for UserInput attempts — return error + "https://vanilla.wpmt.co/wp-login.php/wp-json" + | "https://vanilla.wpmt.co/wp-admin/wp-json" => Ok(response_helpers::empty_response(404)), + // AutoStrippedHttps attempt homepage — success with Link header + "https://vanilla.wpmt.co/" => Ok(response_helpers::with_api_root( + "https://vanilla.wpmt.co/wp-json/", + )), + // API root + "https://vanilla.wpmt.co/wp-json/" => Ok(response_helpers::json_response_from_login_mocks( + "vanilla-api-root.json", + )), + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let executor: Arc = Arc::new(executor); assert_eq!( - login_url("https://vanilla.wpmt.co/wp-login.php").await, + discovery_helper( + Arc::clone(&executor), + vec![], + "https://vanilla.wpmt.co/wp-login.php" + ) + .await + .expect("Expected api discovery to be successful"), "https://vanilla.wpmt.co/wp-admin/authorize-application.php" ); assert_eq!( - login_url("https://vanilla.wpmt.co/wp-admin").await, + discovery_helper( + Arc::clone(&executor), + vec![], + "https://vanilla.wpmt.co/wp-admin" + ) + .await + .expect("Expected api discovery to be successful"), "https://vanilla.wpmt.co/wp-admin/authorize-application.php" ); } @@ -75,8 +121,28 @@ async fn login_spec_3_admin_url_provided() { #[parallel] async fn login_spec_4_auth_https_support() { // Spec Example 4 + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + // HTTP UserInput homepage — non-WP page (HTTPS attempt succeeds instead) + "http://vanilla.wpmt.co/" => Ok(response_helpers::html_response_from_login_mocks( + "homepage-not-wordpress.html", + )), + // HTTP fallback wp-json — error + "http://vanilla.wpmt.co/wp-json" => Ok(response_helpers::empty_response(404)), + // HTTPS AutoStrippedHttps homepage — success with Link header + "https://vanilla.wpmt.co/" => Ok(response_helpers::with_api_root( + "https://vanilla.wpmt.co/wp-json/", + )), + // API root + "https://vanilla.wpmt.co/wp-json/" => Ok(response_helpers::json_response_from_login_mocks( + "vanilla-api-root.json", + )), + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let login_url = discovery_helper(Arc::new(executor), vec![], "http://vanilla.wpmt.co") + .await + .expect("Expected api discovery to be successful"); assert_eq!( - login_url("http://vanilla.wpmt.co").await, + login_url, "https://vanilla.wpmt.co/wp-admin/authorize-application.php" ); } @@ -85,8 +151,26 @@ async fn login_spec_4_auth_https_support() { #[parallel] async fn login_spec_5_http_only_site() { // Spec Example 5 - let error = login_err("http://no-https.wpmt.co") + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + // HTTP UserInput homepage — Link header pointing to HTTP API root + "http://no-https.wpmt.co/" => Ok(response_helpers::with_api_root( + "http://no-https.wpmt.co/wp-json/", + )), + // HTTP API root — returns JSON with no auth URL + "http://no-https.wpmt.co/wp-json/" => Ok(response_helpers::json_response_from_login_mocks( + "http-only-api-root.json", + )), + // HTTPS AutoStrippedHttps homepage — non-WP page (HTTPS not available) + "https://no-https.wpmt.co/" => Ok(response_helpers::html_response_from_login_mocks( + "homepage-not-wordpress.html", + )), + // HTTPS fallback wp-json — error + "https://no-https.wpmt.co/wp-json" => Ok(response_helpers::empty_response(404)), + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let error = discovery_helper(Arc::new(executor), vec![], "http://no-https.wpmt.co") .await + .expect_err("Expected api discovery to fail") .to_fetch_and_parse_api_root_failure(); if let FetchAndParseApiRootFailure::ApplicationPasswordsNotSupported { reason, .. } = error { assert_eq!( @@ -104,8 +188,38 @@ async fn login_spec_5_http_only_site() { #[parallel] async fn login_spec_6_http_only_site_with_application_passwords_enabled() { // Spec Example 6 + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + // HTTP UserInput homepage — Link header pointing to HTTP API root + "http://no-https-with-application-passwords.wpmt.co/" => { + Ok(response_helpers::with_api_root( + "http://no-https-with-application-passwords.wpmt.co/wp-json/", + )) + } + // HTTP API root — returns JSON with auth URL + "http://no-https-with-application-passwords.wpmt.co/wp-json/" => { + Ok(response_helpers::json_response_from_login_mocks( + "http-only-with-app-passwords-api-root.json", + )) + } + // HTTPS AutoStrippedHttps homepage — non-WP page (HTTPS not available) + "https://no-https-with-application-passwords.wpmt.co/" => Ok( + response_helpers::html_response_from_login_mocks("homepage-not-wordpress.html"), + ), + // HTTPS fallback wp-json — error + "https://no-https-with-application-passwords.wpmt.co/wp-json" => { + Ok(response_helpers::empty_response(404)) + } + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let login_url = discovery_helper( + Arc::new(executor), + vec![], + "http://no-https-with-application-passwords.wpmt.co", + ) + .await + .expect("Expected api discovery to be successful"); assert_eq!( - login_url("http://no-https-with-application-passwords.wpmt.co").await, + login_url, "http://no-https-with-application-passwords.wpmt.co/wp-admin/authorize-application.php" ); } @@ -114,8 +228,25 @@ async fn login_spec_6_http_only_site_with_application_passwords_enabled() { #[parallel] async fn login_spec_7_aggressively_cached_site_with_no_link_header() { // Spec Example 7 + // Homepage has no Link header but HTML body contains link tag + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + "https://aggressive-caching.wpmt.co/" => Ok( + response_helpers::html_response_from_login_mocks("homepage-with-link-tag.html"), + ), + "https://aggressive-caching.wpmt.co/wp-json/" => Ok( + response_helpers::json_response_from_login_mocks("aggressive-caching-api-root.json"), + ), + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let login_url = discovery_helper( + Arc::new(executor), + vec![], + "https://aggressive-caching.wpmt.co", + ) + .await + .expect("Expected api discovery to be successful"); assert_eq!( - login_url("https://aggressive-caching.wpmt.co").await, + login_url, "https://aggressive-caching.wpmt.co/wp-admin/authorize-application.php" ); } @@ -147,18 +278,45 @@ async fn login_spec_8_site_with_application_passwords_disabled_by_wordfence() { #[parallel] async fn login_spec_9_not_a_wordpress_site() { // Spec Example 9 - assert_eq!( - login_err("google.com").await.to_find_api_root_failure(), - FindApiRootFailure::ProbablyNotAWordPressSite - ); + // "google.com" → UserInput "google.com" (fails to parse) + AutoStrippedHttps "https://google.com" + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + // AutoStrippedHttps homepage — non-WP page + "https://google.com/" => Ok(response_helpers::html_response_from_login_mocks( + "homepage-not-wordpress.html", + )), + // Fallback wp-json — empty response + "https://google.com/wp-json" => Ok(response_helpers::empty_response(404)), + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let error = discovery_helper(Arc::new(executor), vec![], "google.com") + .await + .expect_err("Expected api discovery to fail") + .to_find_api_root_failure(); + assert_eq!(error, FindApiRootFailure::ProbablyNotAWordPressSite); } #[tokio::test] #[parallel] async fn login_spec_10_wordpress_subdirectory_with_link_header() { // Spec Example 10 + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + "https://subdirectory.wpmt.co/index.php?link_header=true" => Ok( + response_helpers::with_api_root("https://subdirectory.wpmt.co/wordpress/wp-json/"), + ), + "https://subdirectory.wpmt.co/wordpress/wp-json/" => Ok( + response_helpers::json_response_from_login_mocks("subdirectory-api-root.json"), + ), + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let login_url = discovery_helper( + Arc::new(executor), + vec![], + "https://subdirectory.wpmt.co/index.php?link_header=true", + ) + .await + .expect("Expected api discovery to be successful"); assert_eq!( - login_url("https://subdirectory.wpmt.co/index.php?link_header=true").await, + login_url, "https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php" ); } @@ -167,8 +325,27 @@ async fn login_spec_10_wordpress_subdirectory_with_link_header() { #[parallel] async fn login_spec_11_wordpress_subdirectory_with_link_tag() { // Spec Example 11 + // Homepage HTML body contains link tag pointing to subdirectory wp-json + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + "https://subdirectory.wpmt.co/index.php?link_tag=true" => { + Ok(response_helpers::html_response_from_login_mocks( + "homepage-with-subdirectory-link-tag.html", + )) + } + "https://subdirectory.wpmt.co/wordpress/wp-json/" => Ok( + response_helpers::json_response_from_login_mocks("subdirectory-api-root.json"), + ), + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let login_url = discovery_helper( + Arc::new(executor), + vec![], + "https://subdirectory.wpmt.co/index.php?link_tag=true", + ) + .await + .expect("Expected api discovery to be successful"); assert_eq!( - login_url("https://subdirectory.wpmt.co/index.php?link_tag=true").await, + login_url, "https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php" ); } @@ -177,8 +354,25 @@ async fn login_spec_11_wordpress_subdirectory_with_link_tag() { #[parallel] async fn login_spec_12_wordpress_subdirectory_with_redirect() { // Spec Example 12 + // Since mock doesn't follow redirects, simulate with Link header pointing to subdirectory + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + "https://subdirectory.wpmt.co/index.php?redirect=true" => Ok( + response_helpers::with_api_root("https://subdirectory.wpmt.co/wordpress/wp-json/"), + ), + "https://subdirectory.wpmt.co/wordpress/wp-json/" => Ok( + response_helpers::json_response_from_login_mocks("subdirectory-api-root.json"), + ), + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let login_url = discovery_helper( + Arc::new(executor), + vec![], + "https://subdirectory.wpmt.co/index.php?redirect=true", + ) + .await + .expect("Expected api discovery to be successful"); assert_eq!( - login_url("https://subdirectory.wpmt.co/index.php?redirect=true").await, + login_url, "https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php" ); } @@ -187,9 +381,25 @@ async fn login_spec_12_wordpress_subdirectory_with_redirect() { #[parallel] async fn login_spec_13_wordpress_http_basic_with_missing_credentials() { // Spec Example 13 (with missing credentials) + // No middleware — homepage returns 401 with WWW-Authenticate, no auth in request let expected_hostname = "https://basic-auth.wpmt.co/"; - let reason = login_err(expected_hostname) + let executor = MockExecutor::with_execute_fn(|request| { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::WWW_AUTHENTICATE, + HeaderValue::from_static("Basic realm=\"Restricted\""), + ); + Ok(WpNetworkResponse { + body: vec![], + status_code: 401, + response_header_map: Arc::new(headers.into()), + request_url: WpEndpointUrl(request.url().0.clone()), + request_header_map: request.header_map(), + }) + }); + let reason = discovery_helper(Arc::new(executor), vec![], expected_hostname) .await + .expect_err("Expected api discovery to fail") .to_fetch_home_page_reason(); if let RequestExecutionErrorReason::HttpAuthenticationRequiredError { hostname, .. } = reason { assert_eq!(hostname, expected_hostname); @@ -204,9 +414,25 @@ async fn login_spec_13_wordpress_http_basic_with_missing_credentials() { #[parallel] async fn login_spec_13_wordpress_http_basic_with_invalid_credentials() { // Spec Example 13 (with invalid credentials) + // Middleware adds auth but server still returns 401 let expected_hostname = "https://basic-auth.wpmt.co/"; + let executor = MockExecutor::with_execute_fn(|request| { + // Always return 401 with WWW-Authenticate, copying request headers to response + let mut headers = HeaderMap::new(); + headers.insert( + http::header::WWW_AUTHENTICATE, + HeaderValue::from_static("Basic realm=\"Restricted\""), + ); + Ok(WpNetworkResponse { + body: vec![], + status_code: 401, + response_header_map: Arc::new(headers.into()), + request_url: WpEndpointUrl(request.url().0.clone()), + request_header_map: request.header_map(), + }) + }); let reason = discovery_helper( - Arc::new(ReqwestRequestExecutor::default()), + Arc::new(executor), vec![Arc::new(ApiDiscoveryAuthenticationMiddleware::new( "invalid".to_string(), "invalid".to_string(), @@ -229,8 +455,44 @@ async fn login_spec_13_wordpress_http_basic_with_invalid_credentials() { #[parallel] async fn login_spec_13_wordpress_http_basic_with_valid_credentials() { // Spec Example 13 (with valid credentials) + // Middleware adds auth, server returns success on authenticated requests + let executor = MockExecutor::with_execute_fn(|request| { + let url = request.url(); + let url_str = url.0.as_str(); + let has_auth = request.has_http_authentication(); + + match (url_str, has_auth) { + // Unauthenticated requests — return 401 (triggers middleware retry with auth) + ("https://basic-auth.wpmt.co/" | "https://basic-auth.wpmt.co/wp-json/", false) => { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::WWW_AUTHENTICATE, + HeaderValue::from_static("Basic realm=\"Restricted\""), + ); + Ok(WpNetworkResponse { + body: vec![], + status_code: 401, + response_header_map: Arc::new(headers.into()), + request_url: WpEndpointUrl(url.0.clone()), + request_header_map: request.header_map(), + }) + } + // Authenticated homepage — return Link header + ("https://basic-auth.wpmt.co/", true) => Ok(response_helpers::with_api_root( + "https://basic-auth.wpmt.co/wp-json/", + )), + // Authenticated API root — return JSON + ("https://basic-auth.wpmt.co/wp-json/", true) => Ok( + response_helpers::json_response_from_login_mocks("basic-auth-api-root.json"), + ), + _ => panic!( + "Unexpected request URL: {:#?} (has_auth: {})", + url, has_auth + ), + } + }); let login_url = discovery_helper( - Arc::new(ReqwestRequestExecutor::default()), + Arc::new(executor), vec![Arc::new(ApiDiscoveryAuthenticationMiddleware::new( "test@example.com".to_string(), "str0ngp4ssw0rd!".to_string(), @@ -238,7 +500,7 @@ async fn login_spec_13_wordpress_http_basic_with_valid_credentials() { "https://basic-auth.wpmt.co/", ) .await - .expect("Expected api discovery to fail"); + .expect("Expected api discovery to succeed"); assert_eq!( login_url, "https://basic-auth.wpmt.co/wp-admin/authorize-application.php" @@ -249,8 +511,25 @@ async fn login_spec_13_wordpress_http_basic_with_valid_credentials() { #[parallel] async fn login_spec_14_wordpress_custom_rest_api_prefix() { // Spec Example 14 + // Link header points to a custom REST API prefix + let executor = MockExecutor::with_execute_fn(|request| match request.url().0.as_str() { + "https://custom-rest-prefix.wpmt.co/" => Ok(response_helpers::with_api_root( + "https://custom-rest-prefix.wpmt.co/api/", + )), + "https://custom-rest-prefix.wpmt.co/api/" => Ok( + response_helpers::json_response_from_login_mocks("custom-rest-prefix-api-root.json"), + ), + _ => panic!("Unexpected request URL: {:#?}", request.url()), + }); + let login_url = discovery_helper( + Arc::new(executor), + vec![], + "https://custom-rest-prefix.wpmt.co", + ) + .await + .expect("Expected api discovery to be successful"); assert_eq!( - login_url("https://custom-rest-prefix.wpmt.co").await, + login_url, "https://custom-rest-prefix.wpmt.co/wp-admin/authorize-application.php" ); }