Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions InterlinedList/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,26 @@ final class APIClient {
return response.identities ?? []
}

func unlinkIdentity(provider: String, providerId: String) async throws {
struct Body: Encodable { let provider: String; let providerId: String }
try await deleteCamel("/api/user/identities", body: Body(provider: provider, providerId: providerId))
}
/// `DELETE /api/user/identities?provider=…` — the route reads `provider` from
/// the **query string** and never parses a body, so a JSON payload here answers
/// `400 "provider is required"`. It also deletes by `{userId, provider}` alone,
/// which removes *every* identity stored under that provider value (an account
/// with two rows for the same provider loses both); the identity's own id is
/// not read at all.
func unlinkIdentity(provider: String) async throws {
let encoded = provider.addingPercentEncoding(withAllowedCharacters: Self.queryValueAllowed) ?? provider
try await delete("/api/user/identities?provider=\(encoded)")
}

/// `.urlQueryAllowed` permits the sub-delimiters `+ & = ? # /` — legal
/// *somewhere* in a query string, but inside a single value they end it or
/// split it into another parameter. `+` matters most: the backend reads params
/// through `URLSearchParams`, which decodes a literal `+` as a space.
private static let queryValueAllowed: CharacterSet = {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "+&=?#/")
return allowed
}()

/// Actively verifies a stored OAuth credential — the only route that reports real
/// token health. The five `/status` routes report whether a *provider* is
Expand Down
5 changes: 3 additions & 2 deletions InterlinedList/Services/APIClientTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,9 @@ extension APIClient {
return try decoder.decode(T.self, from: data)
}

/// DELETE with a camelCase JSON body — a few routes identify their target in
/// the body rather than the path (identity unlink, push unregister).
/// DELETE with a camelCase JSON body — for routes that identify their target in
/// the body rather than the path (push unregister). Check the route first: a
/// body sent to a route that reads the query string is silently ignored.
func deleteCamel<B: Encodable>(_ path: String, body: B) async throws {
var request = try jsonRequest(path, method: "DELETE")
request.httpBody = try camelCaseEncoder.encode(body)
Expand Down
2 changes: 1 addition & 1 deletion InterlinedList/Views/LinkedIdentitiesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ struct LinkedIdentitiesView: View {
errorMessage = nil
pendingUnlink = nil
do {
try await APIClient.shared.unlinkIdentity(provider: identity.provider, providerId: identity.id)
try await APIClient.shared.unlinkIdentity(provider: identity.provider)
await load()
} catch APIError.status(401) {
authState.handleUnauthorized()
Expand Down
66 changes: 57 additions & 9 deletions InterlinedListTests/APIClientTests/APIClientIdentitiesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,33 +65,81 @@ final class APIClientIdentitiesTests: XCTestCase {

// MARK: unlinkIdentity

func test_unlinkIdentity_sendsDeleteWithBody() async throws {
func test_unlinkIdentity_sendsDeleteToIdentitiesPath() async throws {
session.stub(data: Data(), statusCode: 204)
try await sut.unlinkIdentity(provider: "github", providerId: "abc-123")
XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/identities")
try await sut.unlinkIdentity(provider: "github")
XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE")
let body = String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? ""
XCTAssertTrue(body.contains("\"provider\":\"github\""))
XCTAssertTrue(body.contains("\"providerId\":\"abc-123\""),
"Body must use camelCase providerId. Got: \(body)")
XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/identities")
}

func test_unlinkIdentity_sendsProviderAsQueryParameter() async throws {
session.stub(data: Data(), statusCode: 204)
try await sut.unlinkIdentity(provider: "github")
XCTAssertEqual(session.lastRequest?.url?.query, "provider=github")
}

func test_unlinkIdentity_sendsEmptyBody() async throws {
// The route reads the query string and never parses a body; a JSON payload
// here is what produced 400 "provider is required".
session.stub(data: Data(), statusCode: 204)
try await sut.unlinkIdentity(provider: "github")
XCTAssertNil(session.lastRequest?.httpBody)
}

func test_unlinkIdentity_mastodonProvider_keepsInstanceSuffix() async throws {
session.stub(data: Data(), statusCode: 204)
try await sut.unlinkIdentity(provider: "mastodon:techhub.social")
let value = URLComponents(url: try XCTUnwrap(session.lastRequest?.url), resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "provider" })?.value
XCTAssertEqual(value, "mastodon:techhub.social")
}

func test_unlinkIdentity_providerWithQueryDelimiters_percentEncodesThem() async throws {
session.stub(data: Data(), statusCode: 204)
try await sut.unlinkIdentity(provider: "mastodon:a+b&c=d")
let query = try XCTUnwrap(session.lastRequest?.url?.query)
XCTAssertEqual(query, "provider=mastodon:a%2Bb%26c%3Dd")
let value = URLComponents(url: try XCTUnwrap(session.lastRequest?.url), resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "provider" })?.value
XCTAssertEqual(value, "mastodon:a+b&c=d")
}

func test_unlinkIdentity_sendsBearerToken() async throws {
session.stub(data: Data(), statusCode: 204)
try await sut.unlinkIdentity(provider: "github", providerId: "x")
try await sut.unlinkIdentity(provider: "github")
XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok")
}

func test_unlinkIdentity_403_throws() async throws {
session.stub(data: Data(), statusCode: 403)
do {
try await sut.unlinkIdentity(provider: "github", providerId: "x")
try await sut.unlinkIdentity(provider: "github")
XCTFail("Expected throw")
} catch APIError.status(let code) {
XCTAssertEqual(code, 403)
}
}

func test_unlinkIdentity_401_throws() async throws {
session.stub(data: Data(), statusCode: 401)
do {
try await sut.unlinkIdentity(provider: "github")
XCTFail("Expected throw")
} catch APIError.status(let code) {
XCTAssertEqual(code, 401)
}
}

func test_unlinkIdentity_404_surfacesRouteMessage() async throws {
session.stub(json: #"{"error":"Identity not found"}"#, statusCode: 404)
do {
try await sut.unlinkIdentity(provider: "github")
XCTFail("Expected throw")
} catch APIError.server(let message) {
XCTAssertEqual(message, "Identity not found")
}
}

// MARK: verifyIdentity

func test_verifyIdentity_sendsCorrectPath() async throws {
Expand Down
Loading