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 | import Foundation
import Testing
@testable import IrdP2P
@testable import IrdP2PSignaling
import IrdP2PCrypto
@Suite struct P2PAppTests {
@Test func descriptorIsCachedAndFallsBack() async throws {
let h = Harness()
let app = h.app()
let d = await app.descriptor()
#expect(!d.isFallback && d.mailbox != nil)
#expect(await app.descriptor().endpoints.ws.absoluteString == "wss://app.example.test/ws")
var cfg = P2PAppConfiguration(host: "down.example.test", appSalt: "x")
cfg.http = FailingHTTP()
cfg.clock = h.clock
let down = P2PApp(configuration: cfg)
let f = await down.descriptor()
#expect(f.isFallback && f.endpoints.ws.absoluteString == "wss://down.example.test/ws" && f.mailbox == nil)
}
@Test func mailboxRequiresTheFeatureOrAnInjectedProvider() async throws {
let h = Harness()
await #expect(throws: P2PError.self) { _ = try await h.app(http: DescriptorHTTP(mailbox: false)).rawMailbox() }
let mbx = try await h.app().mailbox(secret: h.secret)
#expect(mbx.roomId == RoomCrypto.roomId(secret: h.secret, appSalt: h.appSalt))
#expect(mbx.mailboxRoomId != mbx.roomId.string)
// open mint off: only an injected provider works
await #expect(throws: P2PError.self) { _ = try await h.app(http: DescriptorHTTP(openMint: false)).rawMailbox() }
let injected = try await h.app(http: DescriptorHTTP(openMint: false)).rawMailbox(tokens: StaticTokenProvider(MailboxToken(token: "t", expiresAt: .distantFuture)))
#expect(await injected.endpoint.absoluteString == "https://app.example.test/mailbox/v1")
}
@Test func sealedMailboxRoundTripsThroughAFakeNode() async throws {
let h = Harness()
let node = FakeMailboxNode()
var cfg = P2PAppConfiguration(host: "app.example.test", appSalt: h.appSalt, origin: "https://o.example")
cfg.http = node
cfg.clock = h.clock
let app = P2PApp(configuration: cfg)
let mbx = try await app.mailbox(secret: h.secret)
let r1 = try await mbx.deposit(.json(.object(["t": .string("chat"), "text": .string("offline hello")])))
let r2 = try await mbx.deposit(.data(Data([9, 8, 7])))
#expect(r2.seq > r1.seq)
// garbage deposited by someone else in the same mailbox room is dropped silently
_ = try await node.rawDeposit(room: mbx.mailboxRoomId, Data([0x02, 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]))
let all = try await mbx.replayAll(after: nil)
#expect(all.items.map(\.message) == [.json(.object(["t": .string("chat"), "text": .string("offline hello")])), .data(Data([9, 8, 7]))])
#expect(!all.gap && all.cursor != nil)
let later = try await mbx.replayAll(after: all.cursor)
#expect(later.items.isEmpty)
await mbx.enqueue(.json(.string("queued")))
await mbx.outbox.flush()
#expect(await mbx.outbox.pending.isEmpty)
let again = try await mbx.replayAll(after: all.cursor)
#expect(again.items.map(\.message) == [.json(.string("queued"))])
#expect(node.mintCount == 1) // one app-scoped token covered everything
}
@Test func theOpenMintProviderIsSharedAcrossMailboxes() async throws {
let h = Harness()
let http = CountingMintHTTP()
let app = h.app(http: http)
// two rooms, two clients: one app-scoped token
let first = try await app.rawMailbox(), second = try await app.rawMailbox()
_ = try await first.replay(room: "room-one")
_ = try await second.replay(room: "room-two")
_ = try await app.mailbox(secret: .random()).replayAll(after: nil)
#expect(http.mints == 1)
#expect(http.replays == 3)
// a refused token is forgotten by the shared provider and minted afresh, once, for everyone
http.rejectTokensBelow = 2
_ = try await first.replay(room: "room-one")
_ = try await second.replay(room: "room-two")
#expect(http.mints == 2)
// injected providers still win, per call and from the configuration, and are not cached
let injected = CountingTokenProvider()
_ = try await app.rawMailbox(tokens: injected).replay(room: "room-one")
#expect(injected.calls == 1 && http.mints == 2)
_ = try await app.rawMailbox().replay(room: "room-one")
#expect(injected.calls == 1 && http.mints == 2) // the next plain call is back on the shared open-mint provider
var cfg = P2PAppConfiguration(host: "app.example.test", appSalt: h.appSalt, origin: "https://o.example")
cfg.http = http
cfg.mailboxTokens = injected
_ = try await P2PApp(configuration: cfg).rawMailbox().replay(room: "room-one")
#expect(injected.calls == 2 && http.mints == 2)
}
}
/// Descriptor + open mint + an empty mailbox, counting the mint requests. Tokens are
/// numbered (`tok1`, `tok2`, …) so a test can have the node refuse the older ones.
final class CountingMintHTTP: HTTPTransport, @unchecked Sendable {
private let lock = NSLock()
private var mintCount = 0, replayCount = 0, minimum = 1
let descriptor = DescriptorHTTP()
var mints: Int { lock.withLock { mintCount } }
var replays: Int { lock.withLock { replayCount } }
var rejectTokensBelow: Int {
get { lock.withLock { minimum } }
set { lock.withLock { minimum = newValue } }
}
func send(_ request: URLRequest) async throws -> HTTPResponse {
let path = request.url!.path
if path.hasSuffix("/.well-known/ap2p") { return try await descriptor.send(request) }
if path.hasSuffix("/mailbox-credentials") {
let n = lock.withLock { mintCount += 1; return mintCount }
return HTTPResponse(status: 200, body: Data(#"{"token":"tok\#(n)","ttl":3600,"expires_at":\#(Int(Date().timeIntervalSince1970) + 3600)}"#.utf8))
}
let bearer = request.value(forHTTPHeaderField: "Authorization") ?? ""
let accepted: Bool = lock.withLock {
guard bearer == "Bearer injected" || (Int(bearer.dropFirst("Bearer tok".count)) ?? 0) >= minimum else { return false }
replayCount += 1
return true
}
guard accepted else { return HTTPResponse(status: 401, body: Data(#"{"error":"token_expired"}"#.utf8)) }
return HTTPResponse(status: 200, body: Data(#"{"messages":[],"has_more":false,"oldest_seq":null,"latest_seq":null}"#.utf8))
}
}
final class CountingTokenProvider: MailboxTokenProvider, @unchecked Sendable {
private let lock = NSLock()
private var count = 0
var calls: Int { lock.withLock { count } }
func token(for room: String) async throws -> MailboxToken {
lock.withLock { count += 1 }
return MailboxToken(token: "injected", expiresAt: .distantFuture)
}
func invalidate(for room: String) async {}
}
struct FailingHTTP: HTTPTransport {
func send(_ request: URLRequest) async throws -> HTTPResponse { throw P2PError.transport("offline") }
}
/// A minimal in-memory mailbox node: descriptor, open mint, deposit, replay.
final class FakeMailboxNode: HTTPTransport, @unchecked Sendable {
private let lock = NSLock()
private var rooms: [String: [(seq: UInt64, id: String, payload: Data)]] = [:]
private var nextSeq: UInt64 = 1_000_000_000_000_000
private(set) var mintCount = 0
let descriptor = DescriptorHTTP()
func rawDeposit(room: String, _ payload: Data) async throws -> UInt64 {
lock.withLock {
nextSeq += 1
rooms[room, default: []].append((nextSeq, Bytes.hex(Bytes.sha256(payload).prefix(16)), payload))
return nextSeq
}
}
func send(_ request: URLRequest) async throws -> HTTPResponse {
let url = request.url!
if url.path.hasSuffix("/.well-known/ap2p") { return try await descriptor.send(request) }
if url.path.hasSuffix("/mailbox-credentials") {
lock.withLock { mintCount += 1 }
guard request.value(forHTTPHeaderField: "Origin") != nil else { return HTTPResponse(status: 403, body: Data(#"{"error":"origin_not_allowed"}"#.utf8)) }
return HTTPResponse(status: 200, body: Data(#"{"token":"tok","ttl":3600,"expires_at":\#(Int(Date().timeIntervalSince1970) + 3600)}"#.utf8))
}
guard request.value(forHTTPHeaderField: "Authorization") == "Bearer tok" else { return HTTPResponse(status: 401, body: Data(#"{"error":"missing_token"}"#.utf8)) }
let parts = url.path.split(separator: "/").map(String.init)
guard let i = parts.firstIndex(of: "rooms"), i + 2 < parts.count, parts[i + 2] == "messages" else { return HTTPResponse(status: 404) }
let room = parts[i + 1].removingPercentEncoding!
if request.httpMethod == "POST" {
let payload = request.httpBody ?? Data()
let id = Bytes.hex(Bytes.sha256(payload).prefix(16))
let (seq, deduped): (UInt64, Bool) = lock.withLock {
if let existing = rooms[room]?.first(where: { $0.id == id }) { return (existing.seq, true) }
nextSeq += 1
rooms[room, default: []].append((nextSeq, id, payload))
return (nextSeq, false)
}
return HTTPResponse(status: deduped ? 200 : 201, body: Data(#"{"seq":"\#(seq)","id":"\#(id)","expires_at":1,"deduped":\#(deduped)}"#.utf8))
}
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)!
let after = comps.queryItems?.first { $0.name == "after" }?.value.flatMap(UInt64.init)
let limit = comps.queryItems?.first { $0.name == "limit" }?.value.flatMap(Int.init) ?? 100
let all = lock.withLock { rooms[room] ?? [] }
let slice = all.filter { after == nil || $0.seq > after! }
let page = Array(slice.prefix(limit))
let items = page.map { #"{"seq":"\#($0.seq)","id":"\#($0.id)","ts":1712345678901,"payload":"\#($0.payload.base64EncodedString())"}"# }.joined(separator: ",")
let oldest = all.first.map { #""\#($0.seq)""# } ?? "null", latest = all.last.map { #""\#($0.seq)""# } ?? "null"
return HTTPResponse(status: 200, body: Data(#"{"messages":[\#(items)],"has_more":\#(slice.count > page.count),"oldest_seq":\#(oldest),"latest_seq":\#(latest)}"#.utf8))
}
}
|