Skip to content

Commit 594f3a8

Browse files
jkmasselclaude
andcommitted
Replace network-dependent login tests with mocked versions
Convert login/API discovery tests (specs 1, 3-7, 9-14) to use mock request executors instead of hitting real *.wpmt.co servers. This eliminates CI flakiness from network dependencies while maintaining full test coverage of the discovery flow. All three platforms (Rust, Swift, Kotlin) share the same JSON and HTML fixture files in test-data/login-mocks/, ensuring responses stay in sync across platforms. Tests that require real HTTP stack behavior remain remote: rate limiting (spec 15), DNS failure (spec 16), invalid SSL (spec 17), XML-RPC detection (spec 18), and WordFence plugin detection (spec 8). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1748028 commit 594f3a8

19 files changed

+1024
-44
lines changed

Package.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ var package = Package(
7777
.target(name: libwordpressFFI.name)
7878
],
7979
path: "native/swift/Tests/wordpress-api",
80-
resources: [.copy("../../../../test-data/integration-test-responses/")],
80+
resources: [
81+
.copy("../../../../test-data/integration-test-responses/"),
82+
.copy("../../../../test-data/login-mocks/"),
83+
],
8184
swiftSettings: [
8285
.define("PROGRESS_REPORTING_ENABLED", .when(platforms: [.iOS, .macOS, .tvOS, .watchOS]))
8386
]

native/kotlin/api/kotlin/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ tasks.named("processIntegrationTestResources").configure {
130130
dependsOn(rootProject.tasks.named("copyTestCredentials"))
131131
dependsOn(rootProject.tasks.named("copyTestMedia"))
132132
dependsOn(rootProject.tasks.named("copySampleJSON"))
133+
dependsOn(rootProject.tasks.named("copyTestResponses"))
134+
dependsOn(rootProject.tasks.named("copyLoginMocks"))
133135
}
134136
tasks.named("sourcesJar").configure {
135137
dependsOn(generateUniFFIBindingsTask)

native/kotlin/api/kotlin/src/integrationTest/kotlin/ApiUrlDiscoveryTest.kt

Lines changed: 252 additions & 15 deletions
Large diffs are not rendered by default.

native/kotlin/api/kotlin/src/integrationTest/kotlin/MockRequestExecutor.kt

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,35 @@ class Stub(val evaluator: (WpNetworkRequest) -> Boolean, val response: WpNetwork
2727
class NoStubFoundException(message: String) : Exception(message)
2828

2929
// A class used for testing the request executor.
30-
class MockRequestExecutor(private var stubs: List<Stub> = listOf()) : RequestExecutor {
30+
class MockRequestExecutor(
31+
private var stubs: List<Stub> = listOf(),
32+
private val missingStubResponse: WpNetworkResponse? = null
33+
) : RequestExecutor {
3134

3235
override suspend fun execute(request: WpNetworkRequest): WpNetworkResponse {
3336
val stub = stubs.firstOrNull {
3437
it.evaluator(request)
3538
}
3639

3740
if (stub != null) {
38-
return stub.response
41+
// Copy request headers to response for auth error detection
42+
return WpNetworkResponse(
43+
stub.response.body,
44+
stub.response.statusCode,
45+
stub.response.responseHeaderMap,
46+
stub.response.requestUrl,
47+
request.headerMap()
48+
)
49+
}
50+
51+
if (missingStubResponse != null) {
52+
return WpNetworkResponse(
53+
missingStubResponse.body,
54+
missingStubResponse.statusCode,
55+
missingStubResponse.responseHeaderMap,
56+
missingStubResponse.requestUrl,
57+
request.headerMap()
58+
)
3959
}
4060

4161
throw NoStubFoundException("No stub found for ${request.url()}")
@@ -101,3 +121,34 @@ fun WpNetworkResponse.Companion.retryResponse(delay: ULong): WpNetworkResponse {
101121
WpNetworkHeaderMap.empty
102122
)
103123
}
124+
125+
fun WpNetworkResponse.Companion.htmlResponse(name: String): WpNetworkResponse {
126+
val data = {}.javaClass.getResource(name)?.readText()
127+
128+
if (data == null) {
129+
throw FileNotFoundException("No resource found for $name")
130+
}
131+
132+
return WpNetworkResponse(
133+
data.toByteArray(),
134+
200u,
135+
WpNetworkHeaderMap.fromMap(mapOf("Content-Type" to "text/html; charset=UTF-8")),
136+
"",
137+
WpNetworkHeaderMap.empty
138+
)
139+
}
140+
141+
fun WpNetworkResponse.Companion.responseWithStatus(
142+
statusCode: UShort,
143+
headers: Map<String, String> = mapOf()
144+
): WpNetworkResponse {
145+
return WpNetworkResponse(
146+
ByteArray(0),
147+
statusCode,
148+
WpNetworkHeaderMap.fromMap(headers),
149+
"",
150+
WpNetworkHeaderMap.empty
151+
)
152+
}
153+
154+

native/kotlin/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,18 @@ fun setupJniAndBindings() {
108108
from("$cargoProjectRoot/test-data/integration-test-responses/localhost-json-root.json")
109109
into(generatedTestResourcesPath)
110110
}
111+
112+
tasks.register<Copy>("copyTestResponses") {
113+
dependsOn(tasks.named("deleteGeneratedTestResources"))
114+
from("$cargoProjectRoot/test-data/integration-test-responses/")
115+
into(generatedTestResourcesPath)
116+
}
117+
118+
tasks.register<Copy>("copyLoginMocks") {
119+
dependsOn(tasks.named("deleteGeneratedTestResources"))
120+
from("$cargoProjectRoot/test-data/login-mocks/")
121+
into("$generatedTestResourcesPath/login-mocks")
122+
}
111123
}
112124

113125
fun resolveBinary(name: String): String {

native/swift/Tests/wordpress-api/LoginTests.swift

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ class LoginTests {
1515

1616
@Test("Login Spec Example 1: Valid URL")
1717
func testValidURL() async throws {
18+
let stubs = HTTPStubs(stubs: [
19+
try HTTPStubs.stub(url: "https://vanilla.wpmt.co/", with: .withApiRoot("https://vanilla.wpmt.co/wp-json/")),
20+
try HTTPStubs.stub(url: "https://vanilla.wpmt.co/wp-json/", with: .loginMockResponse(named: "vanilla-api-root"))
21+
])
22+
let client = WordPressLoginClient(requestExecutor: stubs)
1823
let parsedUrl = try await client.findLoginUrl(forSite: "https://vanilla.wpmt.co")
1924
#expect("https://vanilla.wpmt.co/wp-admin/authorize-application.php" == parsedUrl.url())
2025
}
@@ -50,20 +55,46 @@ class LoginTests {
5055
("https://vanilla.wpmt.co/wp-admin", "https://vanilla.wpmt.co/wp-admin/authorize-application.php")
5156
])
5257
func testAdminUrlProvided(_ provided: String, _ expected: String) async throws {
58+
// The UserInput attempt uses the admin URL as-is (no stub found -> fails).
59+
// The AutoStrippedHttps attempt strips the admin suffix and succeeds.
60+
let stubs = HTTPStubs(stubs: [
61+
try HTTPStubs.stub(url: "https://vanilla.wpmt.co/", with: .withApiRoot("https://vanilla.wpmt.co/wp-json/")),
62+
try HTTPStubs.stub(url: "https://vanilla.wpmt.co/wp-json/", with: .loginMockResponse(named: "vanilla-api-root"))
63+
])
64+
let client = WordPressLoginClient(requestExecutor: stubs)
5365
let parsedUrl = try await client.findLoginUrl(forSite: provided)
5466
#expect(expected == parsedUrl.url())
5567
}
5668

5769
@Test("Login Spec Example 4: HTTP URL with HTTPS Support")
5870
func testAutoHttpsSupport() async throws {
71+
// UserInput attempt fetches http://vanilla.wpmt.co/ (no stub -> fails).
72+
// AutoStrippedHttps attempt converts to https:// and succeeds.
73+
let stubs = HTTPStubs(stubs: [
74+
try HTTPStubs.stub(url: "https://vanilla.wpmt.co/", with: .withApiRoot("https://vanilla.wpmt.co/wp-json/")),
75+
try HTTPStubs.stub(url: "https://vanilla.wpmt.co/wp-json/", with: .loginMockResponse(named: "vanilla-api-root"))
76+
])
77+
let client = WordPressLoginClient(requestExecutor: stubs)
5978
let parsedUrl = try await client.findLoginUrl(forSite: "http://vanilla.wpmt.co")
6079
#expect("https://vanilla.wpmt.co/wp-admin/authorize-application.php" == parsedUrl.url())
6180
}
6281

6382
@Test("Login Spec Example 5: HTTP-only site")
6483
func testHttpOnlySite() async {
84+
let stubs: HTTPStubs
85+
do {
86+
stubs = HTTPStubs(stubs: [
87+
try HTTPStubs.stub(url: "http://no-https.wpmt.co/", with: .withApiRoot("http://no-https.wpmt.co/wp-json/")),
88+
try HTTPStubs.stub(url: "http://no-https.wpmt.co/wp-json/", with: .loginMockResponse(named: "http-only-api-root"))
89+
])
90+
} catch {
91+
Issue.record("Failed to create stubs: \(error)")
92+
return
93+
}
94+
let client = WordPressLoginClient(requestExecutor: stubs)
95+
6596
await #expect(performing: {
66-
_ = try await self.client.findLoginUrl(forSite: "http://no-https.wpmt.co")
97+
_ = try await client.findLoginUrl(forSite: "http://no-https.wpmt.co")
6798
}, throws: { error in
6899
let reason = try #require(try self.getApplicationPasswordsNotSupportedReason(from: error))
69100

@@ -78,12 +109,23 @@ class LoginTests {
78109

79110
@Test("Login Spec Example 6: HTTP-Only Site with Application Password Override")
80111
func testHttpOnlySiteWithApplicationPasswordsEnabled() async throws {
112+
let stubs = HTTPStubs(stubs: [
113+
try HTTPStubs.stub(url: "http://no-https-with-application-passwords.wpmt.co/", with: .withApiRoot("http://no-https-with-application-passwords.wpmt.co/wp-json/")),
114+
try HTTPStubs.stub(url: "http://no-https-with-application-passwords.wpmt.co/wp-json/", with: .loginMockResponse(named: "http-only-with-app-passwords-api-root"))
115+
])
116+
let client = WordPressLoginClient(requestExecutor: stubs)
81117
let parsedUrl = try await client.findLoginUrl(forSite: "http://no-https-with-application-passwords.wpmt.co")
82118
#expect("http://no-https-with-application-passwords.wpmt.co/wp-admin/authorize-application.php" == parsedUrl.url())
83119
}
84120

85121
@Test("Login Spec Example 7: CDN-Cached Site")
86122
func testAggressivelyCachedSiteWithNoLinkheader() async throws {
123+
// Homepage has no Link header, but HTML contains a <link> tag pointing to the API root
124+
let stubs = HTTPStubs(stubs: [
125+
try HTTPStubs.stub(url: "https://aggressive-caching.wpmt.co/", with: .htmlResponse(named: "homepage-with-link-tag")),
126+
try HTTPStubs.stub(url: "https://aggressive-caching.wpmt.co/wp-json/", with: .loginMockResponse(named: "aggressive-caching-api-root"))
127+
])
128+
let client = WordPressLoginClient(requestExecutor: stubs)
87129
let parsedUrl = try await client.findLoginUrl(forSite: "https://aggressive-caching.wpmt.co")
88130
#expect("https://aggressive-caching.wpmt.co/wp-admin/authorize-application.php" == parsedUrl.url())
89131
}
@@ -111,8 +153,20 @@ class LoginTests {
111153
"https://google.com"
112154
])
113155
func testNotWordPressSite(url: String) async throws {
156+
// Homepage returns non-WordPress HTML, no Link header, and no WP markers
157+
let stubs: HTTPStubs
158+
do {
159+
stubs = HTTPStubs(stubs: [
160+
try HTTPStubs.stub(url: "https://google.com/", with: .htmlResponse(named: "homepage-not-wordpress"))
161+
])
162+
} catch {
163+
Issue.record("Failed to create stubs: \(error)")
164+
return
165+
}
166+
let client = WordPressLoginClient(requestExecutor: stubs)
167+
114168
await #expect(performing: {
115-
_ = try await self.client.findLoginUrl(forSite: url)
169+
_ = try await client.findLoginUrl(forSite: url)
116170
}, throws: { error in
117171
try #require(error is AutoDiscoveryAttemptFailure)
118172

@@ -130,26 +184,55 @@ class LoginTests {
130184

131185
@Test("Login Spec Example 10: WordPress in a subdirectory with a link header")
132186
func testWordPressSubdirectoryWithLinkHeader() async throws {
187+
let stubs = HTTPStubs(stubs: [
188+
try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/index.php?link_header=true", with: .withApiRoot("https://subdirectory.wpmt.co/wordpress/wp-json/")),
189+
try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/wordpress/wp-json/", with: .loginMockResponse(named: "subdirectory-api-root"))
190+
])
191+
let client = WordPressLoginClient(requestExecutor: stubs)
133192
let parsedUrl = try await client.findLoginUrl(forSite: "https://subdirectory.wpmt.co/index.php?link_header=true")
134193
#expect("https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php" == parsedUrl.url())
135194
}
136195

137196
@Test("Login Spec Example 11: WordPress in a subdirectory with a link tag")
138197
func testWordPressSubdirectoryWithLinkTag() async throws {
198+
// Homepage has no Link header but HTML contains a <link> tag pointing to subdirectory wp-json
199+
let stubs = HTTPStubs(stubs: [
200+
try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/index.php?link_tag=true", with: .htmlResponse(named: "homepage-with-subdirectory-link-tag")),
201+
try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/wordpress/wp-json/", with: .loginMockResponse(named: "subdirectory-api-root"))
202+
])
203+
let client = WordPressLoginClient(requestExecutor: stubs)
139204
let parsedUrl = try await client.findLoginUrl(forSite: "https://subdirectory.wpmt.co/index.php?link_tag=true")
140205
#expect("https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php" == parsedUrl.url())
141206
}
142207

143208
@Test("Login Spec Example 12: WordPress in a subdirectory with a redirect")
144209
func testWordPressSubdirectory() async throws {
210+
// In the real scenario, the server redirects to /wordpress/ which has the Link header.
211+
// With mocks, we simulate the final response directly on the requested URL.
212+
let stubs = HTTPStubs(stubs: [
213+
try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/index.php?redirect=true", with: .withApiRoot("https://subdirectory.wpmt.co/wordpress/wp-json/")),
214+
try HTTPStubs.stub(url: "https://subdirectory.wpmt.co/wordpress/wp-json/", with: .loginMockResponse(named: "subdirectory-api-root"))
215+
])
216+
let client = WordPressLoginClient(requestExecutor: stubs)
145217
let parsedUrl = try await client.findLoginUrl(forSite: "https://subdirectory.wpmt.co/index.php?redirect=true")
146218
#expect("https://subdirectory.wpmt.co/wordpress/wp-admin/authorize-application.php" == parsedUrl.url())
147219
}
148220

149221
@Test("Login Spec Example 13: Site uses HTTP basic with no provided credentials")
150222
func testWordPressHttpBasic() async throws {
223+
let stubs: HTTPStubs
224+
do {
225+
stubs = HTTPStubs(stubs: [
226+
try HTTPStubs.stub(host: "basic-auth.wpmt.co", with: .responseWithStatus(401, headers: ["WWW-Authenticate": "Basic realm=\"Restricted\""]))
227+
])
228+
} catch {
229+
Issue.record("Failed to create stubs: \(error)")
230+
return
231+
}
232+
let client = WordPressLoginClient(requestExecutor: stubs)
233+
151234
await #expect(performing: {
152-
_ = try await self.client.findLoginUrl(forSite: "https://basic-auth.wpmt.co")
235+
_ = try await client.findLoginUrl(forSite: "https://basic-auth.wpmt.co")
153236
}, throws: { error in
154237
let reason = try #require(try self.getRequestExecutionErrorReason(from: error))
155238

@@ -168,11 +251,20 @@ class LoginTests {
168251

169252
@Test("Login Spec Example 13: Site uses HTTP basic with invalid credentials provided")
170253
func testWordPressHttpBasicWithInvalidCredentials() async throws {
254+
let stubs: HTTPStubs
255+
do {
256+
stubs = HTTPStubs(stubs: [
257+
try HTTPStubs.stub(host: "basic-auth.wpmt.co", with: .responseWithStatus(401, headers: ["WWW-Authenticate": "Basic realm=\"Restricted\""]))
258+
])
259+
} catch {
260+
Issue.record("Failed to create stubs: \(error)")
261+
return
262+
}
171263
let invalid = ApiDiscoveryAuthenticationMiddleware(username: "invalid", password: "invalid")
172264

173265
await #expect(performing: {
174266
_ = try await WordPressLoginClient(
175-
urlSession: .init(configuration: .ephemeral),
267+
requestExecutor: stubs,
176268
middleware: MiddlewarePipeline(middlewares: invalid)
177269
).findLoginUrl(forSite: "https://basic-auth.wpmt.co")
178270
}, throws: { error in
@@ -193,10 +285,14 @@ class LoginTests {
193285

194286
@Test("Login Spec Example 13: Site uses HTTP basic with correct credentials provided")
195287
func testWordPressHttpBasicWithValidCredentials() async throws {
288+
let stubs = HTTPStubs(stubs: [
289+
try HTTPStubs.stub(url: "https://basic-auth.wpmt.co/", with: .withApiRoot("https://basic-auth.wpmt.co/wp-json/")),
290+
try HTTPStubs.stub(url: "https://basic-auth.wpmt.co/wp-json/", with: .loginMockResponse(named: "basic-auth-api-root"))
291+
])
196292
let valid = ApiDiscoveryAuthenticationMiddleware(username: "test@example.com", password: "str0ngp4ssw0rd!")
197293

198294
let parsedUrl = try await WordPressLoginClient(
199-
urlSession: .init(configuration: .ephemeral),
295+
requestExecutor: stubs,
200296
middleware: MiddlewarePipeline(middlewares: valid)
201297
).findLoginUrl(forSite: "https://basic-auth.wpmt.co")
202298

@@ -205,6 +301,13 @@ class LoginTests {
205301

206302
@Test("Login Spec Example 14: Custom REST API Prefix")
207303
func testWordPressCustomRestApiPrefix() async throws {
304+
// Site uses a custom REST prefix (e.g., /custom-api/ instead of /wp-json/)
305+
// The Link header points to the custom API root
306+
let stubs = HTTPStubs(stubs: [
307+
try HTTPStubs.stub(url: "https://custom-rest-prefix.wpmt.co/", with: .withApiRoot("https://custom-rest-prefix.wpmt.co/custom-api/")),
308+
try HTTPStubs.stub(url: "https://custom-rest-prefix.wpmt.co/custom-api/", with: .loginMockResponse(named: "custom-rest-prefix-api-root"))
309+
])
310+
let client = WordPressLoginClient(requestExecutor: stubs)
208311
let parsedUrl = try await client.findLoginUrl(forSite: "https://custom-rest-prefix.wpmt.co")
209312
#expect("https://custom-rest-prefix.wpmt.co/wp-admin/authorize-application.php" == parsedUrl.url())
210313
}

0 commit comments

Comments
 (0)