Skip to content
Closed
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
19 changes: 16 additions & 3 deletions projects/ios-readplace/Shared/TokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,33 @@ struct TokenStore {
guard
let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"),
let data = try? Data(contentsOf: url),
let group = parseAppGroupId(fromProvisioningProfile: data)
else { return AppConfig.appGroupId }
return group
}()

/// Extracts the first application-group entitlement from an embedded
/// `.mobileprovision` blob, or nil when it can't be read. The profile is a
/// CMS-signed container with a plist inside; the signature isn't verified here
/// (the OS already did at install), so this just scans out the `<?xml…</plist>`
/// slice and reads the entitlement. Pulled out of `resolvedAppGroupId` so the
/// parsing is unit-testable without a real `Bundle.main`.
static func parseAppGroupId(fromProvisioningProfile data: Data) -> String? {
guard
let raw = String(data: data, encoding: .isoLatin1),
let start = raw.range(of: "<?xml"),
let end = raw.range(of: "</plist>")
else { return AppConfig.appGroupId }
else { return nil }
let plistString = String(raw[start.lowerBound..<end.upperBound])
guard
let plistData = plistString.data(using: .isoLatin1),
let plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any],
let entitlements = plist["Entitlements"] as? [String: Any],
let groups = entitlements["com.apple.security.application-groups"] as? [String],
let group = groups.first
else { return AppConfig.appGroupId }
else { return nil }
return group
}()
}

var tokens: OAuthTokens? {
guard
Expand Down
45 changes: 45 additions & 0 deletions projects/ios-readplace/Tests/KeychainTokenStorageTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import XCTest
@testable import Readplace

/// Exercises the real Keychain-backed storage against the simulator keychain. The
/// test host app carries the `group.com.readplace` app-group entitlement (which
/// doubles as the keychain access group), so generic-password items in that group
/// round-trip here — the path production uses, not the `UserDefaults` test double.
final class KeychainTokenStorageTests: XCTestCase {
private let keychain = KeychainTokenStorage(accessGroup: AppConfig.appGroupId)

override func setUp() {
super.setUp()
for key in TokenKey.allCases { keychain.removeValue(for: key) }
}

override func tearDown() {
for key in TokenKey.allCases { keychain.removeValue(for: key) }
super.tearDown()
}

func testAddsReadsUpdatesAndRemovesAToken() {
XCTAssertNil(keychain.value(for: .accessToken), "starts empty")

keychain.setValue("first", for: .accessToken) // insert path (SecItemAdd)
XCTAssertEqual(keychain.value(for: .accessToken), "first")

keychain.setValue("second", for: .accessToken) // update path (SecItemUpdate)
XCTAssertEqual(keychain.value(for: .accessToken), "second")

keychain.removeValue(for: .accessToken)
XCTAssertNil(keychain.value(for: .accessToken))
}

func testKeysAreStoredIndependently() {
keychain.setValue("acc", for: .accessToken)
keychain.setValue("ref", for: .refreshToken)

XCTAssertEqual(keychain.value(for: .accessToken), "acc")
XCTAssertEqual(keychain.value(for: .refreshToken), "ref")

keychain.removeValue(for: .accessToken)
XCTAssertNil(keychain.value(for: .accessToken))
XCTAssertEqual(keychain.value(for: .refreshToken), "ref", "removing one key leaves the other")
}
}
47 changes: 47 additions & 0 deletions projects/ios-readplace/Tests/LoginFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,51 @@ final class LoginFlowTests: XCTestCase {
XCTAssertEqual(queueRequest.value(forHTTPHeaderField: "Authorization"), "Bearer access-1")
XCTAssertEqual(queueRequest.value(forHTTPHeaderField: "Accept"), "application/vnd.siren+json")
}

func testCallbackCarryingAnErrorParamIsDeniedWithoutExchanging() async {
let store = TokenStore(defaults: TestSupport.ephemeralDefaults())
let session = AppSession(store: store, sessionConfiguration: TestSupport.stubbedConfiguration())

let result = await session.completeSignIn(
callbackURL: URL(string: "\(AppConfig.nativeCallbackURL)?error=access_denied&state=S")!,
verifier: "v", expectedState: "S", redirectURI: AppConfig.nativeCallbackURL
)

guard case .failure(let error) = result else { return XCTFail("expected .failure, got \(result)") }
XCTAssertEqual((error as? AuthFlowError)?.errorDescription, AuthFlowError.denied("access_denied").errorDescription)
XCTAssertFalse(session.isLoggedIn)
XCTAssertTrue(StubURLProtocol.records(path: "/oauth/token").isEmpty, "a denied authorization exchanges no code")
}

func testCallbackWithoutACodeIsMissingCodeWithoutExchanging() async {
let store = TokenStore(defaults: TestSupport.ephemeralDefaults())
let session = AppSession(store: store, sessionConfiguration: TestSupport.stubbedConfiguration())

let result = await session.completeSignIn(
callbackURL: URL(string: "\(AppConfig.nativeCallbackURL)?state=S")!,
verifier: "v", expectedState: "S", redirectURI: AppConfig.nativeCallbackURL
)

guard case .failure(let error) = result else { return XCTFail("expected .failure, got \(result)") }
XCTAssertEqual((error as? AuthFlowError)?.errorDescription, AuthFlowError.missingCode.errorDescription)
XCTAssertTrue(StubURLProtocol.records(path: "/oauth/token").isEmpty)
}

func testExchangeFailureDuringSignInSurfacesAsFailureAndStaysLoggedOut() async {
let store = TokenStore(defaults: TestSupport.ephemeralDefaults())
let session = AppSession(store: store, sessionConfiguration: TestSupport.stubbedConfiguration())
StubURLProtocol.setHandler { _, _ in .json(400, "{\"error\":\"invalid_grant\"}") }

let result = await session.completeSignIn(
callbackURL: URL(string: "\(AppConfig.nativeCallbackURL)?code=abc&state=S")!,
verifier: "v", expectedState: "S", redirectURI: AppConfig.nativeCallbackURL
)

guard case .failure(let error) = result else { return XCTFail("expected .failure, got \(result)") }
XCTAssertEqual(
(error as? OAuthError)?.errorDescription,
OAuthError.tokenExchangeFailed(status: 400).errorDescription
)
XCTAssertFalse(session.isLoggedIn)
}
}
42 changes: 42 additions & 0 deletions projects/ios-readplace/Tests/OAuthServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,46 @@ final class OAuthServiceTests: XCTestCase {
let json = TestSupport.jsonObject(record.body)
XCTAssertEqual(json["token"] as? String, "r1")
}

func testExchangeCodeWithAMalformedBodyThrowsMalformedResponse() async {
let store = TestSupport.loggedInStore()
StubURLProtocol.setHandler { _, _ in .json(200, "not json at all") }
do {
try await makeService(store: store).exchangeCode("c", verifier: "v", redirectURI: AppConfig.nativeCallbackURL)
XCTFail("expected .malformedResponse")
} catch {
XCTAssertEqual((error as? OAuthError)?.errorDescription, OAuthError.malformedResponse.errorDescription)
}
}

func testExchangeCodeWithoutARefreshTokenThrowsMalformedResponse() async {
let store = TestSupport.loggedInStore()
// A 200 that carries an access token but no refresh_token, and no fallback
// (exchange has none), is malformed — the session can't be refreshed later.
StubURLProtocol.setHandler { _, _ in .json(200, Fixtures.tokenResponse(access: "a", refresh: nil)) }
do {
try await makeService(store: store).exchangeCode("c", verifier: "v", redirectURI: AppConfig.nativeCallbackURL)
XCTFail("expected .malformedResponse")
} catch {
XCTAssertEqual((error as? OAuthError)?.errorDescription, OAuthError.malformedResponse.errorDescription)
}
}

func testRefreshWithoutAStoredRefreshTokenThrowsAndMakesNoRequest() async {
let store = TokenStore(defaults: TestSupport.ephemeralDefaults())
do {
_ = try await makeService(store: store).refresh()
XCTFail("expected .noRefreshToken")
} catch {
XCTAssertEqual((error as? OAuthError)?.errorDescription, OAuthError.noRefreshToken.errorDescription)
}
XCTAssertTrue(StubURLProtocol.records(path: "/oauth/token").isEmpty, "no token request without a refresh token")
}

func testRevokeWithoutAStoredTokenSkipsTheNetworkButStillClears() async {
let store = TokenStore(defaults: TestSupport.ephemeralDefaults())
await makeService(store: store).revoke()
XCTAssertNil(store.tokens)
XCTAssertTrue(StubURLProtocol.records(path: "/oauth/revoke").isEmpty, "nothing to revoke without a token")
}
}
40 changes: 40 additions & 0 deletions projects/ios-readplace/Tests/ReadingListViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -858,4 +858,44 @@ final class ReadingListViewModelTests: XCTestCase {
XCTAssertNil(cookies, "a failed bootstrap mints no session, so the sheet shows its unavailable view")
XCTAssertNotNil(viewModel.errorText)
}

// MARK: - Session expiry & warnings

func testUnauthorizedLoadLogsOutWithoutAnErrorBanner() async {
let api = ReadplaceAPI(
baseURL: AppConfig.serverBaseURL,
store: TestSupport.loggedInStore(),
sessionConfiguration: TestSupport.stubbedConfiguration()
)
var expired = false
let viewModel = ReadingListViewModel(api: api, onSessionExpired: { expired = true })
// 401 everywhere: the entry-point load 401s, the single refresh 401s, and
// the load surfaces .unauthorized.
StubURLProtocol.setHandler { _, _ in .json(401, "{}") }

await viewModel.refresh()

XCTAssertTrue(expired, "a 401 whose refresh also fails logs the user out")
XCTAssertNil(viewModel.errorText, "a session-expiry logout is not shown as an error banner")
}

func testCollectionWarningPopulatesWarningText() async {
let warnedQueue = """
{
"class": ["collection", "articles"],
"properties": { "total": 1, "page": 1, "pageSize": 20, "warning": { "code": "not-saveable", "message": "Cannot save that link." } },
"entities": [\(Fixtures.article(id: "a1"))],
"links": [{ "rel": ["self"], "href": "/queue" }, { "rel": ["root"], "href": "/queue" }],
"actions": []
}
"""
StubURLProtocol.setHandler { request, _ in
request.url?.path == "/" ? .redirect(to: "/queue") : .json(200, warnedQueue)
}
let viewModel = makeViewModel(store: TestSupport.loggedInStore())

await viewModel.refresh()

XCTAssertEqual(viewModel.warningText, "Cannot save that link.")
}
}
49 changes: 49 additions & 0 deletions projects/ios-readplace/Tests/TokenStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,53 @@ final class TokenStoreTests: XCTestCase {
XCTAssertNil(target.value(for: .accessToken), "a partial legacy token is not a valid session")
XCTAssertNil(legacy.string(forKey: TokenKey.accessToken.rawValue), "the stray partial token is cleared")
}

// MARK: - parseAppGroupId (embedded provisioning profile)

private func profile(_ innerPlist: String) -> Data {
// A .mobileprovision is a CMS blob with a plist inside; the parser only
// scans out the <?xml…</plist> slice, so wrapping it in arbitrary bytes
// models the real container without needing a signed profile.
Data("....signature-bytes....\(innerPlist)....trailer....".utf8)
}

func testParsesTheFirstApplicationGroupFromAProfile() {
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>Entitlements</key><dict>
<key>com.apple.security.application-groups</key>
<array><string>group.com.rewritten.readplace</string><string>group.other</string></array>
</dict></dict></plist>
"""
XCTAssertEqual(
TokenStore.parseAppGroupId(fromProvisioningProfile: profile(plist)),
"group.com.rewritten.readplace"
)
}

func testReturnsNilWhenTheProfileHasNoPlist() {
XCTAssertNil(TokenStore.parseAppGroupId(fromProvisioningProfile: Data("no plist here".utf8)))
}

func testReturnsNilWhenTheProfileHasNoAppGroupEntitlement() {
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>Entitlements</key><dict><key>application-identifier</key><string>ABCDE.com.x</string></dict>
</dict></plist>
"""
XCTAssertNil(TokenStore.parseAppGroupId(fromProvisioningProfile: profile(plist)))
}

func testReturnsNilWhenTheAppGroupArrayIsEmpty() {
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>Entitlements</key><dict>
<key>com.apple.security.application-groups</key><array></array>
</dict></dict></plist>
"""
XCTAssertNil(TokenStore.parseAppGroupId(fromProvisioningProfile: profile(plist)))
}
}
12 changes: 6 additions & 6 deletions projects/ios-readplace/scripts/coverage-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@
"PKCE.swift": 95,
"ReadplaceAPI.swift": 94,
"URLDetection.swift": 94,
"ReadingListViewModel.swift": 93,
"ReadingListViewModel.swift": 94,
"SaveSharedPage.swift": 93,
"LoginView.swift": 91,
"AffordancePresentation.swift": 91,
"OAuthService.swift": 89,
"TokenStore.swift": 85,
"AppSession.swift": 79,
"ReadplaceApp.swift": 64,
"KeychainTokenStorage.swift": 57
"TokenStore.swift": 98,
"OAuthService.swift": 97,
"KeychainTokenStorage.swift": 97,
"AppSession.swift": 82,
"ReadplaceApp.swift": 64
}
}