Skip to content

Commit 8370073

Browse files
committed
Merge branch 'joao/new-connections-should-fail'
2 parents 7c4936e + 0c5da07 commit 8370073

File tree

5 files changed

+165
-169
lines changed

5 files changed

+165
-169
lines changed

FullStackTests/Tests/ConnectionFullStackTests.swift

Lines changed: 132 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -13,174 +13,166 @@
1313
// limitations under the License.
1414

1515
import CryptoTokenKit
16-
import XCTest
16+
import Testing
1717

1818
@testable import FullStackTests
1919
@testable import YubiKit
2020

21-
class ConnectionFullStackTests: XCTestCase {
21+
@Suite("Connection Full Stack Tests", .serialized)
22+
struct ConnectionFullStackTests {
2223

2324
typealias Connection = USBSmartCardConnection
2425

25-
func testSingleConnection() throws {
26-
runAsyncTest {
27-
do {
28-
let connection = try await Connection.connection()
29-
print("✅ Got connection \(connection)")
30-
XCTAssertNotNil(connection)
31-
} catch {
32-
XCTFail("🚨 Failed with: \(error)")
33-
}
34-
}
26+
@Test("Single Connection")
27+
func singleConnection() async throws {
28+
let connection = try await Connection.connection()
29+
#expect(true, "✅ Got connection \(connection)")
30+
await connection.close(error: nil)
3531
}
3632

37-
func testSerialConnections() throws {
38-
runAsyncTest {
39-
do {
40-
let firstConnection = try await Connection.connection()
41-
print("✅ Got first connection \(firstConnection)")
42-
let task = Task {
43-
let result = await firstConnection.connectionDidClose()
44-
print("✅ First connection did close")
45-
return result
46-
}
47-
try? await Task.sleep(for: .seconds(1))
48-
let secondConnection = try await Connection.connection()
49-
print("✅ Got second connection \(secondConnection)")
50-
XCTAssertNotNil(secondConnection)
51-
let closingError = await task.value
52-
XCTAssertNil(closingError)
53-
print("✅ connectionDidClose() returned: \(String(describing: closingError))")
54-
} catch {
55-
XCTFail("🚨 Failed with: \(error)")
56-
}
33+
@Test("Serial Connections")
34+
func serialConnections() async throws {
35+
let firstConnection = try await Connection.connection()
36+
#expect(true, "✅ Got first connection \(firstConnection)")
37+
let task = Task {
38+
let result = await firstConnection.connectionDidClose()
39+
#expect(true, "✅ First connection did close")
40+
return result
5741
}
42+
43+
// attempt to create a second connection (should fail!)
44+
try? await Task.sleep(for: .seconds(1))
45+
let new = try? await Connection.connection()
46+
#expect(new == nil, "✅ Second connection failed as expected")
47+
48+
// close the first connection
49+
_ = await firstConnection.close(error: nil)
50+
let closingError = await task.value
51+
#expect(closingError == nil, "✅ connectionDidClose() returned: \(String(describing: closingError))")
52+
53+
// attempt to create a second connection (now it should succed!)
54+
try? await Task.sleep(for: .seconds(1))
55+
let secondConnection = try await Connection.connection()
56+
#expect(true, "✅ Got second connection \(secondConnection)")
57+
58+
// close the second connection
59+
await secondConnection.close(error: nil)
5860
}
5961

60-
func testConnectionCancellation() {
61-
runAsyncTest {
62-
let task1 = Task {
63-
try await Connection.connection()
64-
}
65-
let task2 = Task {
66-
try await Connection.connection()
67-
}
68-
let task3 = Task {
69-
try await Connection.connection()
70-
}
71-
let task4 = Task {
72-
try await Connection.connection()
73-
}
74-
75-
let result1 = try? await task1.value
76-
print("✅ Result 1: \(String(describing: result1))")
77-
let result2 = try? await task2.value
78-
print("✅ Result 2: \(String(describing: result2))")
79-
let result3 = try? await task3.value
80-
print("✅ Result 3: \(String(describing: result3))")
81-
let result4 = try? await task4.value
82-
print("✅ Result 4: \(String(describing: result4))")
83-
84-
XCTAssert([result1, result2, result3, result4].compactMap { $0 }.count == 1)
62+
@Test("Connection Cancellation")
63+
func connectionCancellation() async {
64+
let task1 = Task {
65+
try await Connection.connection()
66+
}
67+
let task2 = Task {
68+
try await Connection.connection()
69+
}
70+
let task3 = Task {
71+
try await Connection.connection()
72+
}
73+
let task4 = Task {
74+
try await Connection.connection()
8575
}
76+
77+
let result1 = try? await task1.value
78+
print("✅ Result 1: \(String(describing: result1))")
79+
let result2 = try? await task2.value
80+
print("✅ Result 2: \(String(describing: result2))")
81+
let result3 = try? await task3.value
82+
print("✅ Result 3: \(String(describing: result3))")
83+
let result4 = try? await task4.value
84+
print("✅ Result 4: \(String(describing: result4))")
85+
86+
let connections = [result1, result2, result3, result4].compactMap { $0 }
87+
#expect(connections.count == 1)
88+
89+
// close the only established connection
90+
await connections.first?.close(error: nil)
8691
}
8792

88-
func testSendManually() {
89-
runAsyncTest {
90-
let connection = try await Connection.connection()
91-
// Select Management application
92-
let apdu = APDU(
93-
cla: 0x00,
94-
ins: 0xa4,
95-
p1: 0x04,
96-
p2: 0x00,
97-
command: Data([0xA0, 0x00, 0x00, 0x05, 0x27, 0x47, 0x11, 0x17])
98-
)
99-
let resultData = try await connection.send(data: apdu.data)
100-
let result = Response(rawData: resultData)
101-
XCTAssertEqual(result.responseStatus.status, .ok)
102-
/// Get version number
103-
let deviceInfoApdu = APDU(cla: 0, ins: 0x1d, p1: 0, p2: 0)
104-
let deviceInfoResultData = try await connection.send(data: deviceInfoApdu.data)
105-
let deviceInfoResult = Response(rawData: deviceInfoResultData)
106-
XCTAssertEqual(deviceInfoResult.responseStatus.status, .ok)
107-
let records = TKBERTLVRecord.sequenceOfRecords(
108-
from: deviceInfoResult.data.subdata(in: 1..<deviceInfoResult.data.count)
109-
)
110-
guard let versionData = records?.filter({ $0.tag == 0x05 }).first?.value else {
111-
XCTFail("No YubiKey version record in result.")
112-
return
113-
}
114-
guard versionData.count == 3 else {
115-
XCTFail("Wrong sized return data. Got \(versionData.hexEncodedString)")
116-
return
117-
}
118-
let bytes = [UInt8](versionData)
119-
let major = bytes[0]
120-
let minor = bytes[1]
121-
let micro = bytes[2]
122-
print("✅ Got version: \(major).\(minor).\(micro)")
123-
XCTAssertEqual(major, 5)
124-
// Try to select non existing application
125-
let notFoundApdu = APDU(cla: 0x00, ins: 0xa4, p1: 0x04, p2: 0x00, command: Data([0x01, 0x02, 0x03]))
126-
let notFoundResultData = try await connection.send(data: notFoundApdu.data)
127-
let notFoundResult = Response(rawData: notFoundResultData)
128-
if !(notFoundResult.responseStatus.status == .fileNotFound
93+
@Test("Send Manually")
94+
func sendManually() async throws {
95+
let connection = try await Connection.connection()
96+
// Select Management application
97+
let apdu = APDU(
98+
cla: 0x00,
99+
ins: 0xa4,
100+
p1: 0x04,
101+
p2: 0x00,
102+
command: Data([0xA0, 0x00, 0x00, 0x05, 0x27, 0x47, 0x11, 0x17])
103+
)
104+
let resultData = try await connection.send(data: apdu.data)
105+
let result = Response(rawData: resultData)
106+
#expect(result.responseStatus.status == .ok)
107+
/// Get version number
108+
let deviceInfoApdu = APDU(cla: 0, ins: 0x1d, p1: 0, p2: 0)
109+
let deviceInfoResultData = try await connection.send(data: deviceInfoApdu.data)
110+
let deviceInfoResult = Response(rawData: deviceInfoResultData)
111+
#expect(deviceInfoResult.responseStatus.status == .ok)
112+
let records = TKBERTLVRecord.sequenceOfRecords(
113+
from: deviceInfoResult.data.subdata(in: 1..<deviceInfoResult.data.count)
114+
)
115+
let versionData = try #require(
116+
records?.filter({ $0.tag == 0x05 }).first?.value,
117+
"No YubiKey version record in result."
118+
)
119+
#expect(versionData.count == 3, "Wrong sized return data. Got \(versionData.hexEncodedString)")
120+
let bytes = [UInt8](versionData)
121+
let major = bytes[0]
122+
let minor = bytes[1]
123+
let micro = bytes[2]
124+
print("✅ Got version: \(major).\(minor).\(micro)")
125+
#expect(major == 5)
126+
// Try to select non existing application
127+
let notFoundApdu = APDU(cla: 0x00, ins: 0xa4, p1: 0x04, p2: 0x00, command: Data([0x01, 0x02, 0x03]))
128+
let notFoundResultData = try await connection.send(data: notFoundApdu.data)
129+
let notFoundResult = Response(rawData: notFoundResultData)
130+
#expect(
131+
notFoundResult.responseStatus.status == .fileNotFound
129132
|| notFoundResult.responseStatus.status == .incorrectParameters
130-
|| notFoundResult.responseStatus.status == .invalidInstruction)
131-
{
132-
XCTFail("Unexpected result: \(notFoundResult.responseStatus)")
133-
}
134-
}
133+
|| notFoundResult.responseStatus.status == .invalidInstruction,
134+
"Unexpected result: \(notFoundResult.responseStatus)"
135+
)
136+
137+
await connection.close(error: nil)
135138
}
136139
}
137140

138141
#if os(iOS)
139-
class NFCFullStackTests: XCTestCase {
140-
141-
func testNFCAlertMessage() throws {
142-
runAsyncTest {
143-
do {
144-
let connection = try await TestableConnections.create(with: .nfc(alertMessage: "Test Alert Message"))
145-
await connection.nfcConnection?.setAlertMessage("Updated Alert Message")
146-
try? await Task.sleep(for: .seconds(1))
147-
await connection.nfcConnection?.close(message: "Closing Alert Message")
148-
} catch {
149-
XCTFail("🚨 Failed with: \(error)")
150-
}
151-
}
142+
@Suite("NFC Full Stack Tests", .serialized)
143+
struct NFCFullStackTests {
144+
145+
@Test("NFC Alert Message")
146+
func nfcAlertMessage() async throws {
147+
let connection = try await TestableConnections.create(with: .nfc(alertMessage: "Test Alert Message"))
148+
await connection.nfcConnection?.setAlertMessage("Updated Alert Message")
149+
try? await Task.sleep(for: .seconds(1))
150+
await connection.nfcConnection?.close(message: "Closing Alert Message")
152151
}
153152

154-
func testNFCClosingErrorMessage() throws {
155-
runAsyncTest {
156-
do {
157-
let connection = try await TestableConnections.create(with: .nfc(alertMessage: "Test Alert Message"))
158-
await connection.close(error: nil)
159-
} catch {
160-
XCTFail("🚨 Failed with: \(error)")
161-
}
162-
}
153+
@Test("NFC Closing Error Message")
154+
func nfcClosingErrorMessage() async throws {
155+
let connection = try await TestableConnections.create(with: .nfc(alertMessage: "Test Alert Message"))
156+
await connection.close(error: nil)
163157
}
164158

165159
}
166160
#endif
167161

168-
class SmartCardConnectionFullStackTests: XCTestCase {
169-
170-
func testSmartCardConnectionWithSlot() throws {
171-
runAsyncTest {
172-
let allSlots = try await USBSmartCardConnection.availableSlots
173-
allSlots.enumerated().forEach { index, slot in
174-
print("\(index): \(slot.name)")
175-
}
176-
let random = allSlots.randomElement()
177-
// we need at least one YubiKey connected
178-
XCTAssertNotNil(random)
179-
guard let random else { return }
180-
let connection = try await USBSmartCardConnection.connection(slot: random)
181-
print("✅ Got connection \(connection)")
182-
XCTAssertNotNil(connection)
162+
@Suite("SmartCard Connection Full Stack Tests", .serialized)
163+
struct SmartCardConnectionFullStackTests {
164+
165+
@Test("SmartCard Connection With Slot")
166+
func smartCardConnectionWithSlot() async throws {
167+
let allSlots = try await USBSmartCardConnection.availableSlots
168+
allSlots.enumerated().forEach { index, slot in
169+
print("\(index): \(slot.name)")
183170
}
171+
let random = allSlots.randomElement()
172+
// we need at least one YubiKey connected
173+
let slot = try #require(random, "No YubiKey slots available")
174+
let connection = try await USBSmartCardConnection.connection(slot: slot)
175+
#expect(true, "✅ Got connection \(connection)")
184176
}
185177

186178
}

YubiKit/YubiKit/Connection.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ public protocol SmartCardConnection: Sendable {
6868

6969
/// SmartCardConnection Errors.
7070
public enum ConnectionError: Error, Sendable {
71+
/// There is an active connection.
72+
case busy
7173
/// No current connection.
7274
case noConnection
7375
/// Unexpected result returned from YubiKey.

YubiKit/YubiKit/LightningSmartCardConnection.swift

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,27 +72,19 @@ private actor LightningConnectionManager {
7272

7373
static let shared = LightningConnectionManager()
7474

75-
private var connectionTask: Task<LightningSmartCardConnection, Error>?
7675
private var pendingConnectionPromise: Promise<LightningSmartCardConnection>?
7776
private var connectionState: (connectionID: ConnectionID, didCloseConnection: (Promise<Error?>))?
7877

7978
private init() {}
8079

8180
func connect() async throws -> LightningSmartCardConnection {
82-
// If a connection task is already running, await its result
83-
if let connectionTask {
84-
trace(message: "awaiting existing connection task")
85-
_ = try await connectionTask.value
86-
// we cancel this task because only one of multiple
87-
// concurrent connections can succed
88-
throw ConnectionError.cancelled
81+
// If there is already a connection the caller must close the connection first.
82+
if connectionState != nil || pendingConnectionPromise != nil {
83+
throw ConnectionError.busy
8984
}
9085

9186
// Otherwise, create and store a new connection task.
9287
let task = Task { () -> LightningSmartCardConnection in
93-
// When the task finishes (on any path), clear it to allow a new connection.
94-
defer { self.connectionTask = nil }
95-
9688
trace(message: "begin new connection task")
9789

9890
do {
@@ -121,12 +113,12 @@ private actor LightningConnectionManager {
121113
trace(message: "connection failed: \(error.localizedDescription)")
122114
// Cleanup on failure
123115
self.pendingConnectionPromise = nil
116+
self.connectionState = nil
124117
await EAAccessoryWrapper.shared.stopMonitoring()
125118
throw error
126119
}
127120
}
128121

129-
self.connectionTask = task
130122
return try await task.value
131123
}
132124

YubiKit/YubiKit/NFCSmartCardConnection.swift

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -226,21 +226,22 @@ private final actor NFCConnectionManager: NSObject {
226226
throw NFCConnectionError.unsupported
227227
}
228228

229-
// To proceed with a new connection we need to acquire a lock
230-
guard !isEstablishing else { throw ConnectionError.cancelled }
231-
defer { isEstablishing = false }
232-
isEstablishing = true
233-
234-
// Close the previous connection before establishing a new one
229+
// if there is already a connection for this slot we throw `ConnectionError.busy`.
230+
// The caller must close the connection first.
235231
switch currentState {
236232
case .inactive:
237233
// lets continue
238234
break
239235
case .scanning, .connected:
240-
// invalidate and continue
241-
await invalidate()
236+
// throw
237+
throw ConnectionError.busy
242238
}
243239

240+
// To proceed with a new connection we need to acquire a lock
241+
guard !isEstablishing else { throw ConnectionError.cancelled }
242+
defer { isEstablishing = false }
243+
isEstablishing = true
244+
244245
// Start polling
245246
guard let session = NFCTagReaderSession(pollingOption: [.iso14443], delegate: self, queue: nil) else {
246247
throw NFCConnectionError.failedToPoll

0 commit comments

Comments
 (0)