-
Notifications
You must be signed in to change notification settings - Fork 56
Expand file tree
/
Copy pathCreateIdentityResumableTests.swift
More file actions
432 lines (399 loc) · 19.5 KB
/
Copy pathCreateIdentityResumableTests.swift
File metadata and controls
432 lines (399 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
import XCTest
import SwiftDashSDK
@testable import SwiftExampleApp
/// Tests the pure anti-join that powers the Identities-tab
/// "Resumable Registrations" section. This is the *only* surface
/// the user has post-app-restart to see an in-flight registration:
/// the in-memory `RegistrationCoordinator` map is wiped on relaunch,
/// so without the SwiftData-backed cross-wallet filter the user
/// would have no signal that an orphan asset lock is waiting.
///
/// The filter has five pieces of business logic that can silently
/// regress:
///
/// 1. `fundingTypeRaw` in `0...2` — only generic identity-resume
/// variants (IdentityRegistration / IdentityTopUp /
/// IdentityTopUpNotBound) reach this surface. IdentityInvitation
/// (3) is a bearer voucher restricted to the invitation reclaim
/// flow; address and shielded-address top-ups (4 / 5) have their
/// own surfaces; every other discriminator is rejected fail-closed.
/// 2. `statusRaw` in `1...3` — Built (0) is rejected (tight
/// crash window with no useful UX action), Broadcast (1),
/// InstantSendLocked (2), and ChainLocked (3) are accepted.
/// Since PR #4010 every row now renders a Resume button; the
/// Broadcast row's Resume tap re-enters the finality wait via
/// `resume_asset_lock`. Consumed (4) is rejected — see
/// `testConsumedLocksAreHiddenFromResumableList`.
/// 3. Anti-join on `(walletId, identityIndex)` — a slot taken
/// by a `PersistentIdentity` row removes its lock from the
/// surface even if the lock's own status would otherwise
/// qualify. This is what makes the filter "orphan-only".
/// 4. The anti-join is **per-wallet**: slot 0 used on wallet A
/// must not block slot 0 on wallet B. The `UsedSlot` value
/// key (walletId + slot) carries that scoping.
/// 5. `identityIndexRaw` is stored as `Int32` but compared as
/// `UInt32` via `UInt32(bitPattern:)`. A future cast change
/// (e.g. `UInt32(lockIdentityIndexRaw)` — which would trap on
/// negative inputs) must fail loudly here, not in production.
///
/// We exercise these with a lightweight `FakeAssetLockRow` so the
/// test stays a pure function call — no SwiftData container needed.
final class CreateIdentityResumableTests: XCTestCase {
private struct FakeAssetLockRow: AssetLockResumeRow, Equatable {
let walletId: Data
let statusRaw: Int
let identityIndexRaw: Int32
/// Defaults to `0` (IdentityRegistration) so existing status /
/// anti-join / bridge tests stay focused on their invariant.
/// Funding-type coverage lives in its own MARK block below.
var fundingTypeRaw: Int = 0
}
private let walletA = Data(repeating: 0xA1, count: 8)
private let walletB = Data(repeating: 0xB2, count: 8)
// MARK: - status floor
func testRejectsBuiltLocks() {
// Built (0) is the tight crash window between "build TX" and
// "broadcast TX". There's no useful resume action, so it
// shouldn't appear on the Identities tab at all.
let locks = [
FakeAssetLockRow(walletId: walletA, statusRaw: 0, identityIndexRaw: 0)
]
let result = IdentitiesContentView.crossWalletResumableLocks(
in: locks,
usedSlots: []
)
XCTAssertTrue(result.isEmpty)
}
/// Broadcast (1) must surface. Since PR #4010 the trailing
/// affordance is a Resume button for every row — tapping it
/// on a Broadcast lock re-enters the finality wait via
/// `resume_asset_lock` (re-broadcast + await ChainLock), which
/// is what unstrands broadcast-but-interrupted locks (app
/// killed mid-wait, or an aged lock whose IS quorum has
/// rotated). Hiding (1) would create the UX asymmetry where a
/// just-broadcast lock vanishes from the UI for ~10-30 seconds
/// and then reappears at (2) — confusing rather than reassuring.
func testAcceptsBroadcastForVisibility() {
let lock = FakeAssetLockRow(walletId: walletA, statusRaw: 1, identityIndexRaw: 0)
let result = IdentitiesContentView.crossWalletResumableLocks(
in: [lock],
usedSlots: []
)
XCTAssertEqual(result, [lock])
}
func testAcceptsInstantSendLockedAndChainLocked() {
let locks = [
FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0),
FakeAssetLockRow(walletId: walletA, statusRaw: 3, identityIndexRaw: 1),
]
let result = IdentitiesContentView.crossWalletResumableLocks(
in: locks,
usedSlots: []
)
XCTAssertEqual(result, locks)
}
/// `statusRaw == 4` (Consumed) is the terminal state for a lock
/// that already funded an identity. It must NOT surface on the
/// Resumable Registrations list: in the happy path the anti-join
/// against `PersistentIdentity` hides it, but the local-only
/// delete-identity action removes the identity row WITHOUT the
/// asset-lock row, which frees the slot in the anti-join. Without
/// the upper bound (`<= 3`) on the filter, a Consumed lock would
/// re-surface as a Resume row that can't advance
/// (`resume_asset_lock` rejects Consumed entries with
/// "already Consumed — nothing to resume").
func testConsumedLocksAreHiddenFromResumableList() {
let lock = FakeAssetLockRow(walletId: walletA, statusRaw: 4, identityIndexRaw: 0)
let result = IdentitiesContentView.crossWalletResumableLocks(
in: [lock],
usedSlots: []
)
XCTAssertEqual(result, [])
}
// MARK: - funding-type filter
/// The generic Resume surface is a strict whitelist. Invitation (3)
/// is tested at every otherwise-resumable status because it is a
/// shared bearer voucher: only the invitation reclaim flow may opt in
/// to consuming it. Address variants (4 / 5) and unknown values must
/// also fail closed rather than reaching IdentityCreate.
func testFundingTypeGateAcceptsOnlyGenericIdentityResumeTypes() {
let cases: [(fundingType: Int, statuses: [Int], shouldSurface: Bool)] = [
(0, [2], true),
(1, [2], true),
(2, [2], true),
(3, Array(1...3), false), // IdentityInvitation
(4, [2], false), // AssetLockAddressTopUp
(5, [2], false), // AssetLockShieldedAddressTopUp
(-1, [2], false),
(6, [2], false),
(99, [2], false),
]
for testCase in cases {
for status in testCase.statuses {
let lock = FakeAssetLockRow(
walletId: walletA,
statusRaw: status,
identityIndexRaw: 0,
fundingTypeRaw: testCase.fundingType
)
let result = IdentitiesContentView.crossWalletResumableLocks(
in: [lock],
usedSlots: []
)
XCTAssertEqual(
result,
testCase.shouldSurface ? [lock] : [],
"fundingTypeRaw \(testCase.fundingType) at status \(status)"
)
}
}
}
// MARK: - anti-join
func testFiltersOutLocksWhoseOwnWalletSlotIsAlreadyUsed() {
// A registration that completed normally leaves both a
// `PersistentAssetLock` and a `PersistentIdentity` row. The
// lock is "consumed" even if it hasn't been purged yet, so
// the surface must hide it.
let lock = FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 7)
let usedSlots: Set<IdentitiesContentView.UsedSlot> = [
IdentitiesContentView.UsedSlot(walletId: walletA, slot: 7)
]
let result = IdentitiesContentView.crossWalletResumableLocks(
in: [lock],
usedSlots: usedSlots
)
XCTAssertTrue(result.isEmpty)
}
func testDoesNotBleedUsedSlotsAcrossWallets() {
// Same numerical slot (0) on both wallets, but only
// wallet-A's slot is taken by an existing identity. The
// lock on wallet B at slot 0 must stay surfaced — the
// user's identity on A has no bearing on B's slot pool.
let locks = [
FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0),
FakeAssetLockRow(walletId: walletB, statusRaw: 2, identityIndexRaw: 0),
]
let usedSlots: Set<IdentitiesContentView.UsedSlot> = [
IdentitiesContentView.UsedSlot(walletId: walletA, slot: 0)
]
let result = IdentitiesContentView.crossWalletResumableLocks(
in: locks,
usedSlots: usedSlots
)
XCTAssertEqual(result, [locks[1]])
}
// MARK: - Int32 -> UInt32 bridge
/// `identityIndexRaw` is `Int32` (the storage row type) but the
/// `UsedSlot.slot` field is `UInt32` (the FFI / wallet-side
/// type). The filter bridges via `UInt32(bitPattern:)`. A
/// negative `Int32` maps to a high `UInt32`. This test pins
/// the conversion so a future change to `UInt32(...)` —
/// which would trap on negative inputs — fails loudly here
/// instead of crashing in production.
func testNegativeIdentityIndexBridgesViaBitPattern() {
let negativeIndex: Int32 = -1
let bridged = UInt32(bitPattern: negativeIndex) // 0xFFFF_FFFF
let lock = FakeAssetLockRow(
walletId: walletA,
statusRaw: 2,
identityIndexRaw: negativeIndex
)
// With the bridged slot in `usedSlots`, the lock is filtered.
let blocked = IdentitiesContentView.crossWalletResumableLocks(
in: [lock],
usedSlots: [IdentitiesContentView.UsedSlot(walletId: walletA, slot: bridged)]
)
XCTAssertTrue(blocked.isEmpty)
// Without it, the lock stays.
let kept = IdentitiesContentView.crossWalletResumableLocks(
in: [lock],
usedSlots: []
)
XCTAssertEqual(kept, [lock])
}
// MARK: - empty inputs
func testEmptyLocksReturnsEmpty() {
let result = IdentitiesContentView.crossWalletResumableLocks(
in: [FakeAssetLockRow](),
usedSlots: [
IdentitiesContentView.UsedSlot(walletId: walletA, slot: 0)
]
)
XCTAssertTrue(result.isEmpty)
}
// MARK: - in-flight controller exclusion
/// Regression for the double-counting bug: during a normal
/// in-session registration, the asset lock reaches `Broadcast`
/// (or higher) **before** the persister writes a
/// `PersistentIdentity` row. Without including in-flight
/// controller slots in `usedSlots`, the same lock would
/// appear in BOTH the in-memory Pending Registrations list
/// and the SwiftData-backed Resumable Registrations section
/// — and a second tap on Resume would race a duplicate FFI
/// call against the original. The fix is structural: the
/// `ResumableRegistrationsList` view unions identity-claimed
/// slots with `controller.phase.isActive` slots before
/// calling `crossWalletResumableLocks`. The unioned set is
/// what gets passed here; this test pins that the filter
/// honors active-slot occupancy the same way it honors
/// identity-row occupancy.
func testInFlightSlotIsExcludedFromResumableSurface() {
let lock = FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 3)
// No `PersistentIdentity` exists at (walletA, 3) yet, but a
// controller in `.inFlight` does. The union of identity
// slots (empty) and active slots (one) must hide the lock.
let activeSlots: Set<IdentitiesContentView.UsedSlot> = [
IdentitiesContentView.UsedSlot(walletId: walletA, slot: 3)
]
let result = IdentitiesContentView.crossWalletResumableLocks(
in: [lock],
usedSlots: activeSlots
)
XCTAssertTrue(result.isEmpty)
}
// MARK: - outpoint hex round-trip
/// `PersistentAssetLock.encodeOutPoint` (rawBytes → display
/// hex) and `CreateIdentityView.parseOutPointHex` (display hex
/// → rawBytes) are the two halves of the outpoint round-trip
/// the resume path depends on: the persister writes the
/// display-order hex, the submit path reads it and hands the
/// raw wire-order bytes to the FFI. If either side flips
/// endianness, the resume FFI silently addresses a different
/// outpoint and the Platform proof-verification failure that
/// follows is opaque. Pin the round-trip explicitly.
func testOutPointHexRoundTripPreservesRawBytes() {
// Deliberately asymmetric bytes so any endian flip is
// visible. txid wire-order = little-endian.
let originalTxid = Data([
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20,
])
let originalVout: UInt32 = 7
// The persister writes the 36-byte (txid_le || vout_le)
// blob — assemble it the same way and encode.
var raw = Data(originalTxid)
withUnsafeBytes(of: originalVout.littleEndian) { raw.append(contentsOf: $0) }
XCTAssertEqual(raw.count, 36)
let hex = PersistentAssetLock.encodeOutPoint(rawBytes: raw)
// Decode via the submit-path parser.
guard let (rtTxid, rtVout) = CreateIdentityView.parseOutPointHex(hex) else {
XCTFail("parseOutPointHex returned nil for canonical encoding \(hex)")
return
}
XCTAssertEqual(rtTxid, originalTxid,
"txid bytes round-tripped through display-hex must match")
XCTAssertEqual(rtVout, originalVout)
}
/// Defensive: parseOutPointHex must reject malformed inputs
/// instead of silently producing zeros. Same risk class as the
/// endianness flip — a "valid-looking" but wrong outpoint
/// would fail at the Platform layer with confusing errors.
func testParseOutPointHexRejectsMalformedInputs() {
XCTAssertNil(CreateIdentityView.parseOutPointHex(""))
XCTAssertNil(CreateIdentityView.parseOutPointHex("nope"))
XCTAssertNil(CreateIdentityView.parseOutPointHex("abc:1")) // txid too short
XCTAssertNil(CreateIdentityView.parseOutPointHex(
String(repeating: "a", count: 64) + ":bogus")) // vout NaN
XCTAssertNil(CreateIdentityView.parseOutPointHex(
String(repeating: "g", count: 64) + ":0")) // non-hex chars
}
/// Exhaustive predicate test for which controller phases hold
/// the slot. The Resumable surface uses this to decide which
/// in-memory controllers block their lock from re-appearing
/// in the section. Changing this predicate without thinking
/// about both the Pending and Resumable surfaces is how
/// double-tap-Resume bugs come back.
func testControllerPhaseIsActivePredicate() {
XCTAssertFalse(IdentityRegistrationController.Phase.idle.isActive,
".idle: pre-submit — no slot occupancy yet")
XCTAssertTrue(IdentityRegistrationController.Phase.preparingKeys.isActive,
".preparingKeys: keys derived; FFI imminent — slot held")
XCTAssertTrue(IdentityRegistrationController.Phase.inFlight.isActive,
".inFlight: FFI mid-call — slot held")
XCTAssertFalse(
IdentityRegistrationController.Phase
.completed(identityId: Data(repeating: 0xCC, count: 32))
.isActive,
".completed: PersistentIdentity row covers the slot via the identity anti-join — don't double-block"
)
XCTAssertFalse(IdentityRegistrationController.Phase.failed("nope").isActive,
".failed: user is expected to retry — let the lock resurface")
XCTAssertTrue(
IdentityRegistrationController.Phase
.unconfirmed(
identityId: Data(repeating: 0xDD, count: 32),
message: "pending"
)
.isActive,
".unconfirmed: identity is probably live on chain — keep the slot held so a re-submission can't burn funds against the registered-key-hash check"
)
}
// MARK: - network-switch gate
/// The network picker's `.disabled(_:)` gate reads
/// `RegistrationCoordinator.hasInFlightRegistrations`. That predicate
/// must hold for an `.unconfirmed` slot (the same reason the dismissal
/// gate does): switching networks tears down the
/// `PlatformWalletManager` and with it the coordinator + the Rust-side
/// note reservation, so releasing the gate for an `.unconfirmed`
/// controller lets the same HD slot be re-selected and burn funds
/// against the registered-key-hash check.
///
/// The gate is implemented as `controller.phase.isActive` so it
/// cannot list a different set of phases than the slot-occupancy
/// model. Reaching `.unconfirmed` directly requires throwing the
/// SDK's `ShieldedIdentityCreateUnconfirmedError`, whose initializer
/// is `internal` to `SwiftDashSDK` and not constructible here — so
/// the `.unconfirmed → gate held` leg is pinned transitively: the
/// exhaustive `testControllerPhaseIsActivePredicate` already asserts
/// `.unconfirmed.isActive == true`, and this test pins that the gate
/// is exactly `isActive` over the map (true iff some controller is
/// active, false when all are terminal-non-active).
@MainActor
func testNetworkSwitchGateMatchesPhaseIsActive() async throws {
let coordinator = RegistrationCoordinator()
let walletId = Data(repeating: 0xAB, count: 32)
XCTAssertFalse(
coordinator.hasInFlightRegistrations,
"empty coordinator holds nothing in flight"
)
// A controller that fails terminally is `.failed` (isActive ==
// false): the gate must release once it's the only entry.
let failing = coordinator.startRegistration(
walletId: walletId,
identityIndex: 1,
fundingKind: .shieldedPool
) {
throw PlatformWalletError.shieldedBroadcastFailed("rejected on merits")
}
for _ in 0..<200 {
if case .failed = failing.phase { break }
try await Task.sleep(nanoseconds: 10_000_000)
}
guard case .failed = failing.phase else {
return XCTFail("controller did not reach .failed in time")
}
XCTAssertFalse(failing.phase.isActive)
XCTAssertFalse(
coordinator.hasInFlightRegistrations,
"a lone .failed controller must not hold the network-switch gate (it's not isActive)"
)
// A controller stuck `.inFlight` (isActive == true) — the gate
// must hold. Use a never-returning body so the phase stays
// `.inFlight` for the duration of the assertion.
let inFlight = coordinator.startRegistration(
walletId: walletId,
identityIndex: 2,
fundingKind: .shieldedPool
) {
try await Task.sleep(nanoseconds: 60_000_000_000)
return Data()
}
XCTAssertTrue(inFlight.phase.isActive)
XCTAssertTrue(
coordinator.hasInFlightRegistrations,
"an active (.inFlight) controller must hold the network-switch gate; the gate is exactly phase.isActive, which also covers .unconfirmed"
)
}
}