Skip to content

Commit a0e5293

Browse files
hyochanclaude
andcommitted
fix(apple): validate appAccountToken UUID format before purchase
Add validation that throws a clear error when appAccountToken is not a valid UUID format. Apple silently returns null for non-UUID values, which makes debugging difficult. This change fails fast with a descriptive error message to help developers identify the issue. Closes hyochan/expo-iap#128 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 15d6ef2 commit a0e5293

File tree

2 files changed

+261
-1
lines changed

2 files changed

+261
-1
lines changed

packages/apple/Sources/Helpers/StoreKitTypesBridge.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,19 @@ enum StoreKitTypesBridge {
364364
if let quantity = props.quantity, quantity > 1 {
365365
options.insert(.quantity(quantity))
366366
}
367-
if let token = props.appAccountToken, let uuid = UUID(uuidString: token) {
367+
if let token = props.appAccountToken {
368+
guard let uuid = UUID(uuidString: token) else {
369+
// Apple requires appAccountToken to be a valid UUID format.
370+
// If a non-UUID value is provided, Apple silently returns null for this field.
371+
// Fail fast with a clear error message so developers can identify the issue.
372+
// Reference: https://openiap.dev/docs/types/request
373+
OpenIapLog.error("❌ Invalid appAccountToken format: '\(token)'. Must be a valid UUID (e.g., '550e8400-e29b-41d4-a716-446655440000')")
374+
throw PurchaseError.make(
375+
code: .developerError,
376+
productId: props.sku,
377+
message: "appAccountToken must be a valid UUID format (e.g., '550e8400-e29b-41d4-a716-446655440000'). Received: '\(token)'. Apple silently returns null for non-UUID values."
378+
)
379+
}
368380
options.insert(.appAccountToken(uuid))
369381
}
370382
if let offerInput = props.withOffer {
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import XCTest
2+
@testable import OpenIAP
3+
4+
@available(iOS 15.0, macOS 14.0, *)
5+
final class AppAccountTokenTests: XCTestCase {
6+
7+
// MARK: - Valid UUID Format Tests
8+
9+
func testAppAccountToken_ValidUUID_DoesNotThrow() throws {
10+
let props = RequestPurchaseIosProps(
11+
advancedCommerceData: nil,
12+
andDangerouslyFinishTransactionAutomatically: nil,
13+
appAccountToken: "550e8400-e29b-41d4-a716-446655440000",
14+
quantity: nil,
15+
sku: "dev.hyo.premium",
16+
withOffer: nil
17+
)
18+
19+
// Should not throw for valid UUID
20+
XCTAssertNoThrow(try StoreKitTypesBridge.purchaseOptions(from: props))
21+
}
22+
23+
func testAppAccountToken_ValidUUID_Uppercase_DoesNotThrow() throws {
24+
let props = RequestPurchaseIosProps(
25+
advancedCommerceData: nil,
26+
andDangerouslyFinishTransactionAutomatically: nil,
27+
appAccountToken: "550E8400-E29B-41D4-A716-446655440000",
28+
quantity: nil,
29+
sku: "dev.hyo.premium",
30+
withOffer: nil
31+
)
32+
33+
// Should not throw for valid uppercase UUID
34+
XCTAssertNoThrow(try StoreKitTypesBridge.purchaseOptions(from: props))
35+
}
36+
37+
func testAppAccountToken_GeneratedUUID_DoesNotThrow() throws {
38+
let props = RequestPurchaseIosProps(
39+
advancedCommerceData: nil,
40+
andDangerouslyFinishTransactionAutomatically: nil,
41+
appAccountToken: UUID().uuidString,
42+
quantity: nil,
43+
sku: "dev.hyo.premium",
44+
withOffer: nil
45+
)
46+
47+
// Should not throw for UUID generated from Foundation
48+
XCTAssertNoThrow(try StoreKitTypesBridge.purchaseOptions(from: props))
49+
}
50+
51+
func testAppAccountToken_Nil_DoesNotThrow() throws {
52+
let props = RequestPurchaseIosProps(
53+
advancedCommerceData: nil,
54+
andDangerouslyFinishTransactionAutomatically: nil,
55+
appAccountToken: nil,
56+
quantity: nil,
57+
sku: "dev.hyo.premium",
58+
withOffer: nil
59+
)
60+
61+
// Should not throw when appAccountToken is nil
62+
XCTAssertNoThrow(try StoreKitTypesBridge.purchaseOptions(from: props))
63+
}
64+
65+
// MARK: - Invalid UUID Format Tests
66+
67+
func testAppAccountToken_InvalidFormat_UserID_ThrowsDeveloperError() throws {
68+
let props = RequestPurchaseIosProps(
69+
advancedCommerceData: nil,
70+
andDangerouslyFinishTransactionAutomatically: nil,
71+
appAccountToken: "user-123",
72+
quantity: nil,
73+
sku: "dev.hyo.premium",
74+
withOffer: nil
75+
)
76+
77+
// Should throw developerError for non-UUID format
78+
XCTAssertThrowsError(try StoreKitTypesBridge.purchaseOptions(from: props)) { error in
79+
guard let purchaseError = error as? PurchaseError else {
80+
XCTFail("Expected PurchaseError, got \(type(of: error))")
81+
return
82+
}
83+
XCTAssertEqual(purchaseError.code, .developerError)
84+
XCTAssertEqual(purchaseError.productId, "dev.hyo.premium")
85+
XCTAssertTrue(purchaseError.message.contains("UUID"))
86+
XCTAssertTrue(purchaseError.message.contains("user-123"))
87+
}
88+
}
89+
90+
func testAppAccountToken_InvalidFormat_PlainString_ThrowsDeveloperError() throws {
91+
let props = RequestPurchaseIosProps(
92+
advancedCommerceData: nil,
93+
andDangerouslyFinishTransactionAutomatically: nil,
94+
appAccountToken: "my-account-token",
95+
quantity: nil,
96+
sku: "dev.hyo.consumable",
97+
withOffer: nil
98+
)
99+
100+
XCTAssertThrowsError(try StoreKitTypesBridge.purchaseOptions(from: props)) { error in
101+
guard let purchaseError = error as? PurchaseError else {
102+
XCTFail("Expected PurchaseError, got \(type(of: error))")
103+
return
104+
}
105+
XCTAssertEqual(purchaseError.code, .developerError)
106+
XCTAssertEqual(purchaseError.productId, "dev.hyo.consumable")
107+
}
108+
}
109+
110+
func testAppAccountToken_InvalidFormat_NumericString_ThrowsDeveloperError() throws {
111+
let props = RequestPurchaseIosProps(
112+
advancedCommerceData: nil,
113+
andDangerouslyFinishTransactionAutomatically: nil,
114+
appAccountToken: "12345678",
115+
quantity: nil,
116+
sku: "dev.hyo.premium",
117+
withOffer: nil
118+
)
119+
120+
XCTAssertThrowsError(try StoreKitTypesBridge.purchaseOptions(from: props)) { error in
121+
guard let purchaseError = error as? PurchaseError else {
122+
XCTFail("Expected PurchaseError, got \(type(of: error))")
123+
return
124+
}
125+
XCTAssertEqual(purchaseError.code, .developerError)
126+
}
127+
}
128+
129+
func testAppAccountToken_InvalidFormat_EmptyString_ThrowsDeveloperError() throws {
130+
let props = RequestPurchaseIosProps(
131+
advancedCommerceData: nil,
132+
andDangerouslyFinishTransactionAutomatically: nil,
133+
appAccountToken: "",
134+
quantity: nil,
135+
sku: "dev.hyo.premium",
136+
withOffer: nil
137+
)
138+
139+
XCTAssertThrowsError(try StoreKitTypesBridge.purchaseOptions(from: props)) { error in
140+
guard let purchaseError = error as? PurchaseError else {
141+
XCTFail("Expected PurchaseError, got \(type(of: error))")
142+
return
143+
}
144+
XCTAssertEqual(purchaseError.code, .developerError)
145+
}
146+
}
147+
148+
func testAppAccountToken_InvalidFormat_MalformedUUID_ThrowsDeveloperError() throws {
149+
// UUID with wrong number of characters in a group
150+
let props = RequestPurchaseIosProps(
151+
advancedCommerceData: nil,
152+
andDangerouslyFinishTransactionAutomatically: nil,
153+
appAccountToken: "550e8400-e29b-41d4-a716-44665544000", // Missing one character
154+
quantity: nil,
155+
sku: "dev.hyo.premium",
156+
withOffer: nil
157+
)
158+
159+
XCTAssertThrowsError(try StoreKitTypesBridge.purchaseOptions(from: props)) { error in
160+
guard let purchaseError = error as? PurchaseError else {
161+
XCTFail("Expected PurchaseError, got \(type(of: error))")
162+
return
163+
}
164+
XCTAssertEqual(purchaseError.code, .developerError)
165+
}
166+
}
167+
168+
func testAppAccountToken_InvalidFormat_UUIDWithoutDashes_ThrowsDeveloperError() throws {
169+
// UUID without dashes is not valid for Foundation's UUID(uuidString:)
170+
let props = RequestPurchaseIosProps(
171+
advancedCommerceData: nil,
172+
andDangerouslyFinishTransactionAutomatically: nil,
173+
appAccountToken: "550e8400e29b41d4a716446655440000",
174+
quantity: nil,
175+
sku: "dev.hyo.premium",
176+
withOffer: nil
177+
)
178+
179+
XCTAssertThrowsError(try StoreKitTypesBridge.purchaseOptions(from: props)) { error in
180+
guard let purchaseError = error as? PurchaseError else {
181+
XCTFail("Expected PurchaseError, got \(type(of: error))")
182+
return
183+
}
184+
XCTAssertEqual(purchaseError.code, .developerError)
185+
}
186+
}
187+
188+
// MARK: - Subscription Props Tests
189+
190+
func testAppAccountToken_SubscriptionProps_ValidUUID_DoesNotThrow() throws {
191+
let props = RequestSubscriptionIosProps(
192+
advancedCommerceData: nil,
193+
andDangerouslyFinishTransactionAutomatically: nil,
194+
appAccountToken: "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
195+
quantity: nil,
196+
sku: "dev.hyo.subscription.monthly",
197+
withOffer: nil
198+
)
199+
200+
XCTAssertNoThrow(try StoreKitTypesBridge.purchaseOptions(from: props))
201+
}
202+
203+
func testAppAccountToken_SubscriptionProps_InvalidUUID_ThrowsDeveloperError() throws {
204+
let props = RequestSubscriptionIosProps(
205+
advancedCommerceData: nil,
206+
andDangerouslyFinishTransactionAutomatically: nil,
207+
appAccountToken: "user_account_123",
208+
quantity: nil,
209+
sku: "dev.hyo.subscription.monthly",
210+
withOffer: nil
211+
)
212+
213+
XCTAssertThrowsError(try StoreKitTypesBridge.purchaseOptions(from: props)) { error in
214+
guard let purchaseError = error as? PurchaseError else {
215+
XCTFail("Expected PurchaseError, got \(type(of: error))")
216+
return
217+
}
218+
XCTAssertEqual(purchaseError.code, .developerError)
219+
XCTAssertEqual(purchaseError.productId, "dev.hyo.subscription.monthly")
220+
XCTAssertTrue(purchaseError.message.contains("UUID"))
221+
}
222+
}
223+
224+
// MARK: - Error Message Content Tests
225+
226+
func testAppAccountToken_ErrorMessage_ContainsGuidance() throws {
227+
let invalidToken = "my-custom-user-id"
228+
let props = RequestPurchaseIosProps(
229+
advancedCommerceData: nil,
230+
andDangerouslyFinishTransactionAutomatically: nil,
231+
appAccountToken: invalidToken,
232+
quantity: nil,
233+
sku: "dev.hyo.premium",
234+
withOffer: nil
235+
)
236+
237+
XCTAssertThrowsError(try StoreKitTypesBridge.purchaseOptions(from: props)) { error in
238+
guard let purchaseError = error as? PurchaseError else {
239+
XCTFail("Expected PurchaseError")
240+
return
241+
}
242+
// Error message should contain helpful information
243+
XCTAssertTrue(purchaseError.message.contains("UUID"), "Error should mention UUID requirement")
244+
XCTAssertTrue(purchaseError.message.contains(invalidToken), "Error should show the invalid value")
245+
XCTAssertTrue(purchaseError.message.lowercased().contains("apple"), "Error should mention Apple behavior")
246+
}
247+
}
248+
}

0 commit comments

Comments
 (0)