Skip to content

Commit 87e4ad9

Browse files
author
ARCP Swift SDK
committed
test: raise Swift SDK coverage
1 parent e055a58 commit 87e4ad9

5 files changed

Lines changed: 496 additions & 0 deletions

File tree

Tests/ARCPTests/AuthTests.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import ARCP
5+
6+
@Suite("Auth validators")
7+
struct AuthTests {
8+
@Test("Bearer validator maps tokens to principals")
9+
func bearerHappyPath() async throws {
10+
let validator = BearerAuthValidator(subjectsByToken: ["token": "alice"], trustLevel: .untrusted)
11+
#expect(validator.supports(.bearer))
12+
let principal = try await validator.validate(
13+
auth: AuthBlock(scheme: .bearer, token: "token"),
14+
challenge: nil
15+
)
16+
#expect(principal == AuthenticatedPrincipal(subject: "alice", trustLevel: .untrusted))
17+
}
18+
19+
@Test("Bearer validator rejects wrong scheme, missing token, and unknown token")
20+
func bearerFailures() async {
21+
let validator = BearerAuthValidator(subjectsByToken: ["token": "alice"])
22+
await #expect(throws: ARCPError.self) {
23+
_ = try await validator.validate(auth: AuthBlock(scheme: .none), challenge: nil)
24+
}
25+
await #expect(throws: ARCPError.self) {
26+
_ = try await validator.validate(auth: AuthBlock(scheme: .bearer), challenge: nil)
27+
}
28+
await #expect(throws: ARCPError.self) {
29+
_ = try await validator.validate(
30+
auth: AuthBlock(scheme: .bearer, token: "wrong"),
31+
challenge: nil
32+
)
33+
}
34+
}
35+
36+
@Test("Composite validator dispatches by scheme and reports unsupported schemes")
37+
func compositeValidator() async throws {
38+
let bearer = BearerAuthValidator([
39+
"token": AuthenticatedPrincipal(subject: "alice", trustLevel: .trusted)
40+
])
41+
let composite = CompositeAuthValidator([bearer])
42+
#expect(composite.supports(.bearer))
43+
#expect(!composite.supports(.signedJwt))
44+
45+
let principal = try await composite.validate(
46+
auth: AuthBlock(scheme: .bearer, token: "token"),
47+
challenge: nil
48+
)
49+
#expect(principal.subject == "alice")
50+
51+
await #expect(throws: ARCPError.self) {
52+
_ = try await composite.validate(auth: AuthBlock(scheme: .signedJwt), challenge: nil)
53+
}
54+
await #expect(throws: ARCPError.self) {
55+
_ = try await composite.validate(auth: AuthBlock(scheme: .mtls), challenge: nil)
56+
}
57+
}
58+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import ARCP
5+
6+
@Suite("CredentialManager rotation")
7+
struct CredentialManagerTests {
8+
@Test("rotate persists every credential returned by provisioner")
9+
func rotateKeepsFullReturnedSet() async throws {
10+
let provisioner = SequenceCredentialProvisioner([
11+
[
12+
try credential("old_access"),
13+
try credential("old_refresh"),
14+
],
15+
[
16+
try credential("new_access"),
17+
try credential("new_refresh"),
18+
],
19+
])
20+
let retention = InMemoryCredentialRetention()
21+
let manager = CredentialManager(
22+
provisioner: provisioner,
23+
retention: retention,
24+
sessionId: SessionId("sess_credentials")
25+
)
26+
let jobId = JobId("job_credentials")
27+
_ = try await manager.issueForJob(jobId, lease: LeaseSnapshot(expiresAt: Date()))
28+
29+
let replacement = try await manager.rotate(jobId: jobId, credentialId: "old_access")
30+
31+
#expect(replacement.id == "new_access")
32+
#expect(await manager.outstandingCredentialIds == [
33+
"new_access",
34+
"new_refresh",
35+
"old_refresh",
36+
])
37+
#expect(try await retention.loadOutstanding().map(\.1).sorted() == [
38+
"new_access",
39+
"new_refresh",
40+
"old_refresh",
41+
])
42+
#expect(await provisioner.revoked == ["old_access"])
43+
}
44+
45+
@Test("rotate can return an in-place replacement from later in returned array")
46+
func rotateReturnsMatchingReplacementWhenPresent() async throws {
47+
let provisioner = SequenceCredentialProvisioner([
48+
[try credential("old_access")],
49+
[
50+
try credential("paired"),
51+
try credential("old_access"),
52+
],
53+
])
54+
let manager = CredentialManager(
55+
provisioner: provisioner,
56+
retention: InMemoryCredentialRetention(),
57+
sessionId: SessionId("sess_credentials")
58+
)
59+
let jobId = JobId("job_credentials")
60+
_ = try await manager.issueForJob(jobId, lease: LeaseSnapshot(expiresAt: Date()))
61+
62+
let replacement = try await manager.rotate(jobId: jobId, credentialId: "old_access")
63+
64+
#expect(replacement.id == "old_access")
65+
#expect(await manager.outstandingCredentialIds == ["old_access", "paired"])
66+
}
67+
68+
private func credential(_ id: String) throws -> ProvisionedCredential {
69+
try ProvisionedCredential(
70+
id: id,
71+
scheme: .bearer,
72+
value: "value-\(id)",
73+
endpoint: "https://credentials.example.test"
74+
)
75+
}
76+
}
77+
78+
private actor SequenceCredentialProvisioner: CredentialProvisioner {
79+
private var batches: [[ProvisionedCredential]]
80+
private(set) var revoked: [String] = []
81+
82+
init(_ batches: [[ProvisionedCredential]]) {
83+
self.batches = batches
84+
}
85+
86+
func issue(
87+
lease: LeaseSnapshot,
88+
jobId: JobId,
89+
sessionId: SessionId
90+
) async throws -> [ProvisionedCredential] {
91+
guard !batches.isEmpty else { return [] }
92+
return batches.removeFirst()
93+
}
94+
95+
func revoke(credentialId: String) async throws {
96+
revoked.append(credentialId)
97+
}
98+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import ARCP
5+
6+
@Suite("StreamManager")
7+
struct StreamManagerTests {
8+
@Test("outbound handle emits open, chunk, close, and error envelopes")
9+
func outboundHandleEmitsLifecycle() async throws {
10+
let sink = EnvelopeSink()
11+
let manager = StreamManager(sessionId: SessionId("sess_stream"), send: { envelope in
12+
await sink.append(envelope)
13+
})
14+
let handle = try await manager.openOutbound(
15+
jobId: JobId("job_stream"),
16+
kind: .text,
17+
contentType: "text/plain",
18+
encoding: "utf-8"
19+
)
20+
try await handle.sendText("hello", sequence: 1)
21+
try await handle.sendChunk(StreamChunkPayload(sequence: 2, content: "world"))
22+
try await handle.close(reason: "done")
23+
let second = try await manager.openOutbound(
24+
jobId: nil,
25+
kind: .event,
26+
contentType: nil,
27+
encoding: nil
28+
)
29+
try await second.error(.internal(detail: "boom", cause: nil))
30+
31+
let payloads = await sink.payloads()
32+
#expect(payloads.map(\.typeName) == [
33+
"stream.open",
34+
"stream.chunk",
35+
"stream.chunk",
36+
"stream.close",
37+
"stream.open",
38+
"stream.error",
39+
])
40+
}
41+
42+
@Test("inbound subscription receives chunks and finishes on close")
43+
func inboundSubscriptionLifecycle() async throws {
44+
let manager = StreamManager(sessionId: SessionId("sess_stream"), send: { _ in })
45+
let streamId = StreamId("stream_in")
46+
let stream = try await manager.subscribeInbound(streamId: streamId)
47+
var iterator = stream.makeAsyncIterator()
48+
49+
await manager.dispatch(
50+
envelope: Envelope(
51+
streamId: streamId,
52+
payload: .streamChunk(StreamChunkPayload(sequence: 1, content: "one"))
53+
)
54+
)
55+
let first = await iterator.next()
56+
#expect(first?.content == "one")
57+
58+
await manager.dispatch(
59+
envelope: Envelope(streamId: streamId, payload: .streamClose(StreamClosePayload()))
60+
)
61+
let ended = await iterator.next()
62+
#expect(ended == nil)
63+
}
64+
65+
@Test("duplicate inbound subscription throws failedPrecondition")
66+
func duplicateInboundSubscription() async throws {
67+
let manager = StreamManager(sessionId: SessionId("sess_stream"), send: { _ in })
68+
let streamId = StreamId("stream_dupe")
69+
_ = try await manager.subscribeInbound(streamId: streamId)
70+
await #expect(throws: ARCPError.self) {
71+
_ = try await manager.subscribeInbound(streamId: streamId)
72+
}
73+
}
74+
75+
@Test("shutdown finishes active inbound streams")
76+
func shutdownFinishesInboundStreams() async throws {
77+
let manager = StreamManager(sessionId: SessionId("sess_stream"), send: { _ in })
78+
let stream = try await manager.subscribeInbound(streamId: StreamId("stream_shutdown"))
79+
var iterator = stream.makeAsyncIterator()
80+
await manager.shutdown()
81+
#expect(await iterator.next() == nil)
82+
}
83+
}
84+
85+
actor EnvelopeSink {
86+
private var envelopes: [Envelope] = []
87+
88+
func append(_ envelope: Envelope) {
89+
envelopes.append(envelope)
90+
}
91+
92+
func payloads() -> [MessageType] {
93+
envelopes.map(\.payload)
94+
}
95+
96+
func all() -> [Envelope] {
97+
envelopes
98+
}
99+
}

0 commit comments

Comments
 (0)