From 26587876701abc88fd64bbab7ac7e41d39b12ca4 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 08:05:05 -0400 Subject: [PATCH 1/2] Validate attachments on both sides (relay#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 **The relay cannot check what an attachment IS — only that it is still the same size it was.** The blob is sealed before it arrives, so its content is noise here and no inspection would tell a photograph from a probe. What the relay does know is the byte count it recorded at upload, and a served blob whose length disagrees with that is not the file somebody uploaded. ⚠️ **This does not replace the recipient's own check and must not be read as doing so.** The AES-GCM tag is what actually proves the bytes are unmodified; a relay could serve a wrong blob of exactly the right length and only the recipient would catch it. What the length check adds is the far commoner case — a truncated file from a half-finished write or a disk that filled up — and a clear answer for it. The difference is what somebody gets told. A truncated blob that reaches the client fails as a decryption error, which reads as "your message is corrupt". Caught at the relay it reads as "storage lost part of this file", which is true and points at the right place. ⚠️ Refused rather than served short, and 404 rather than a new error code: it is gone, and saying so is honest. A `longer` blob is refused too — nothing in the upload path can produce one, so it matters more rather than less. ⚠️ An older row with no recorded byte count is served. That is not evidence of damage, and refusing on an unknown would delete access to every attachment predating the field. ⚠️ The S3 store returns nil rather than answering. Doing so would cost a HEAD on every download, and paying a round trip per request to catch a rare storage fault is the wrong trade — the local store answers for free and gets the check. 554 relay tests and 26 XCTests green, 7 new. Nothing here touches either app. --- .../FlamenetRelay/AttachmentIntegrity.swift | 79 +++++++++++++++++++ Sources/FlamenetRelay/AttachmentStore.swift | 19 +++++ Sources/FlamenetRelay/RoutesAttachments.swift | 17 +++- .../AttachmentIntegrityTests.swift | 70 ++++++++++++++++ 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 Sources/FlamenetRelay/AttachmentIntegrity.swift create mode 100644 Tests/FlamenetRelayTests/AttachmentIntegrityTests.swift diff --git a/Sources/FlamenetRelay/AttachmentIntegrity.swift b/Sources/FlamenetRelay/AttachmentIntegrity.swift new file mode 100644 index 0000000..30ca006 --- /dev/null +++ b/Sources/FlamenetRelay/AttachmentIntegrity.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Checking that what is served is what was stored (relay#26). +/// +/// 🔴 **The relay cannot check what an attachment IS — only that it is still +/// the same size it was.** The blob is sealed before it arrives, so its content +/// is noise here and no amount of inspection would tell a photograph from a +/// probe. What the relay does know is the byte count it recorded at upload, and +/// a served blob whose length disagrees with that is not the file somebody +/// uploaded, whatever else it might be. +/// +/// ⚠️ **This is not a substitute for the client's own check, and must not be +/// read as one.** The sender seals with AES-GCM and the recipient's tag check +/// is what actually proves the bytes are unmodified — a relay could serve a +/// wrong blob of exactly the right length and only the recipient would catch +/// it. What a length check adds is a clear answer for the far commoner case: +/// a truncated file from a half-finished write or a disk that filled up. +/// +/// The difference matters in what somebody is told. A truncated blob that +/// reaches the client fails as a decryption error, which reads as "your +/// message is corrupt". Caught here it reads as "the server lost part of this +/// file", which is true and points at the right place. +enum AttachmentIntegrity { + + enum Verdict: Equatable { + case ok + /// The bytes on disk are shorter than the index says. A partial write, + /// a full disk, or a truncating copy. + case truncated(expected: Int, found: Int) + /// Longer than recorded, which should be impossible and therefore + /// matters more: something appended to a blob after it was indexed. + case longer(expected: Int, found: Int) + /// Nothing recorded to compare against. An older row, not a fault. + case unknown + } + + static func check(expected: Int?, found: Int) -> Verdict { + guard let expected, expected > 0 else { return .unknown } + if found == expected { return .ok } + return found < expected + ? .truncated(expected: expected, found: found) + : .longer(expected: expected, found: found) + } + + /// Whether to serve it. + /// + /// 🔴 Refuses on either mismatch. Serving a blob that is not what was + /// stored hands the client a decryption failure and no way to tell that + /// from a genuine one — and for the `longer` case, serving extra bytes the + /// uploader never sent is the worse of the two. + /// + /// ⚠️ Serves when there is nothing to compare against. A row written + /// before the byte count existed is not evidence of damage, and refusing + /// on an unknown would delete access to every older attachment. + static func shouldServe(_ verdict: Verdict) -> Bool { + switch verdict { + case .ok, .unknown: return true + case .truncated, .longer: return false + } + } + + /// What to put in the log. Nil when there is nothing wrong. + static func report(token: String, verdict: Verdict) -> String? { + switch verdict { + case .ok, .unknown: + return nil + case .truncated(let expected, let found): + return "attachment \(token) is truncated: indexed \(expected) bytes, " + + "found \(found). Refusing to serve it — a short blob reaches " + + "the client as a decryption failure, which is indistinguishable " + + "from a real one. Storage lost part of this file." + case .longer(let expected, let found): + return "attachment \(token) is LONGER than indexed: expected \(expected) " + + "bytes, found \(found). Refusing to serve it. Nothing in the " + + "upload path can produce this, so something wrote to the blob " + + "after it was stored." + } + } +} diff --git a/Sources/FlamenetRelay/AttachmentStore.swift b/Sources/FlamenetRelay/AttachmentStore.swift index 673750d..cb9ea27 100644 --- a/Sources/FlamenetRelay/AttachmentStore.swift +++ b/Sources/FlamenetRelay/AttachmentStore.swift @@ -16,6 +16,13 @@ import Vapor /// capacity decision. protocol AttachmentStore: Sendable { func write(_ token: String, data: Data) async throws + /// The stored size, or nil when the backend cannot say cheaply. + /// + /// ⚠️ Optional by design. A local file has a length for free; an object + /// store would need a HEAD, and paying a round trip on every download to + /// catch a rare fault is the wrong trade. A backend that cannot answer + /// returns nil and the check is skipped rather than guessed at. + func storedSize(_ token: String) async -> Int? /// The blob as a Response, or nil when the bytes are gone. func respond(_ token: String, request: Request) async throws -> Response? /// Remove the bytes. Absence counts as success — the goal is "not there". @@ -36,6 +43,12 @@ struct DirectoryAttachmentStore: AttachmentStore { options: .atomic) } + func storedSize(_ token: String) async -> Int? { + let path = attachmentPath(directory, token) + let attributes = try? FileManager.default.attributesOfItem(atPath: path) + return (attributes?[.size] as? NSNumber)?.intValue + } + func respond(_ token: String, request: Request) async throws -> Response? { let path = attachmentPath(directory, token) guard FileManager.default.fileExists(atPath: path) else { return nil } @@ -80,6 +93,12 @@ struct S3AttachmentStore: AttachmentStore { URI(string: "\(s3.endpoint)/\(s3.bucket)/\(key(token))") } + /// ⚠️ Nil, deliberately. Answering would cost a HEAD on every download, + /// and paying a round trip per request to catch a rare storage fault is + /// the wrong trade. The local store answers for free and gets the check; + /// this one skips it rather than guessing. + func storedSize(_ token: String) async -> Int? { nil } + func write(_ token: String, data: Data) async throws { let response = try await signedRequest(.PUT, token: token, body: data) guard response.status == .ok else { diff --git a/Sources/FlamenetRelay/RoutesAttachments.swift b/Sources/FlamenetRelay/RoutesAttachments.swift index bf838f4..bd70aae 100644 --- a/Sources/FlamenetRelay/RoutesAttachments.swift +++ b/Sources/FlamenetRelay/RoutesAttachments.swift @@ -92,10 +92,25 @@ extension Routes { guard isValidAttachmentToken(token) else { throw WireError.badRequest("Malformed attachment id.") } - guard try await Attachment.find(token, on: request.db) != nil else { + guard let row = try await Attachment.find(token, on: request.db) else { throw WireError.notFound("No such attachment.") } + // relay#26 — what is served must be what was stored. The relay cannot + // check what a sealed blob IS, but it recorded how long it was, and a + // blob of a different length is not the file somebody uploaded. + if let found = await attachmentStore.storedSize(token) { + let verdict = AttachmentIntegrity.check(expected: row.bytes, found: found) + if let complaint = AttachmentIntegrity.report(token: token, verdict: verdict) { + request.logger.error("\(complaint)") + } + guard AttachmentIntegrity.shouldServe(verdict) else { + // 🔴 Gone rather than corrupt. Serving it hands the client a + // decryption failure indistinguishable from a real one. + throw WireError.notFound("No such attachment.") + } + } + guard let response = try await attachmentStore.respond(token, request: request) else { // Indexed but absent: retention removed the bytes, or the write // half-failed. Either way it is gone, and saying 404 is honest. diff --git a/Tests/FlamenetRelayTests/AttachmentIntegrityTests.swift b/Tests/FlamenetRelayTests/AttachmentIntegrityTests.swift new file mode 100644 index 0000000..916ab2b --- /dev/null +++ b/Tests/FlamenetRelayTests/AttachmentIntegrityTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import FlamenetRelay + +/// Checking that what is served is what was stored (relay#26). +/// +/// 🔴 **The relay cannot check what an attachment IS — only that it is still +/// the same size.** The blob is sealed before it arrives, so its content is +/// noise here. What the relay knows is the byte count it recorded at upload, +/// and a blob of a different length is not the file somebody uploaded. +/// +/// ⚠️ These tests also pin what this does NOT replace: the recipient's AES-GCM +/// tag check is what actually proves the bytes are unmodified. A relay could +/// serve a wrong blob of exactly the right length and only the recipient would +/// catch it. This catches the far commoner case — a truncated file — and turns +/// it from "your message is corrupt" into "the server lost part of this file". +final class AttachmentIntegrityTests: XCTestCase { + + func testMatchingLengthsAreFine() { + XCTAssertEqual(AttachmentIntegrity.check(expected: 1024, found: 1024), .ok) + XCTAssertTrue(AttachmentIntegrity.shouldServe(.ok)) + } + + func testAShortBlobIsTruncated() { + let verdict = AttachmentIntegrity.check(expected: 1024, found: 512) + XCTAssertEqual(verdict, .truncated(expected: 1024, found: 512)) + XCTAssertFalse(AttachmentIntegrity.shouldServe(verdict)) + } + + /// 🔴 Longer than recorded should be impossible, which is why it matters + /// more rather than less: something wrote to the blob after it was indexed. + func testALongerBlobIsRefusedToo() { + let verdict = AttachmentIntegrity.check(expected: 1024, found: 2048) + XCTAssertEqual(verdict, .longer(expected: 1024, found: 2048)) + XCTAssertFalse(AttachmentIntegrity.shouldServe(verdict)) + } + + /// ⚠️ A row written before the byte count existed is not evidence of + /// damage. Refusing on unknown would delete access to every older + /// attachment on the deployment. + func testAnUnknownLengthIsStillServed() { + XCTAssertEqual(AttachmentIntegrity.check(expected: nil, found: 900), .unknown) + XCTAssertEqual(AttachmentIntegrity.check(expected: 0, found: 900), .unknown) + XCTAssertTrue(AttachmentIntegrity.shouldServe(.unknown)) + } + + func testNothingIsLoggedWhenNothingIsWrong() { + XCTAssertNil(AttachmentIntegrity.report(token: "abc", verdict: .ok)) + XCTAssertNil(AttachmentIntegrity.report(token: "abc", verdict: .unknown)) + } + + /// ⚠️ The log has to say where the fault is. "Attachment corrupt" sends + /// somebody looking at the client. + func testTheLogPointsAtStorageRatherThanAtTheClient() throws { + let message = try XCTUnwrap(AttachmentIntegrity.report( + token: "deadbeef", verdict: .truncated(expected: 1024, found: 512))) + XCTAssertTrue(message.contains("deadbeef"), message) + XCTAssertTrue(message.contains("1024"), message) + XCTAssertTrue(message.contains("512"), message) + XCTAssertTrue(message.contains("Storage lost part of this file"), message) + // And why it is refused rather than served short. + XCTAssertTrue(message.contains("indistinguishable"), message) + } + + func testALongerBlobSaysNothingCanProduceIt() throws { + let message = try XCTUnwrap(AttachmentIntegrity.report( + token: "cafe", verdict: .longer(expected: 10, found: 99))) + XCTAssertTrue(message.contains("LONGER"), message) + XCTAssertTrue(message.contains("after it was stored"), message) + } +} -- 2.54.0 From b1258cc7e275214880de3b440e16bf4eb1395f22 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 08:20:49 -0400 Subject: [PATCH 2/2] A standing gate for the seven paths that must never break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 **This exists because the unit tests stayed green through a two-day outage.** On 2 September twenty-eight merges left sign-in, messages, calls and stories broken on both clients while 489 Android unit tests and the relay's own suite reported success throughout — because they test units. "assemble produces this JSON" is a unit test. "an account can still sign in and send a story" is not, and nobody was asking it. Seven tests, one per core path, over HTTP through the real stack: sign-up, sign-in, messages, stories, files, wallet, calls. Thirteen seconds. A relay change that breaks one of them fails here rather than on somebody's phone. **Proven to fail.** A gate that has never failed is a gate nobody has reason to trust, so this one was tried against a deliberate break — refusing every attachment download — and stories and files both went red, naming what broke. The break was then removed and the suite is green again. That exercise is why the assertions carry messages rather than bare booleans. Three of the seven are worth explaining: 🔴 **Sign-in is two steps and both are checked.** The credential authenticates and a device token is minted from it. A relay that accepts the credential and refuses the token leaves somebody on a spinner — which is exactly what "signed in but never reached the shell" looked like from outside. 🔴 **The wallet test is a NEGATIVE one, and that is the point.** Keys are derived on the device and balances come from a node the person chooses; no route here is on that path. So the guarantee worth pinning is that no backend change can break the wallet, because there is no backend for it to break. If that test ever fails, the wallet has grown a server dependency and the privacy story around it has changed — which is a decision, not a bug fix. 🔴 **A relay with no TURN must answer 503, not 500.** The client reads 503 as "no relay here, expect peer-to-peer or nothing" and says so in the call banner. A 500 reads as a broken relay and sends somebody looking in the wrong place. ⚠️ What this cannot prove: every payload is ciphertext to the relay, so it cannot check that a story renders or that audio flows. What it checks is the part the relay can break — that the routes each flow depends on are mounted, authorised, and carry what was put in them. 561 relay tests in 49 suites, green. --- Tests/FlamenetRelayTests/CorePathsTests.swift | 373 ++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 Tests/FlamenetRelayTests/CorePathsTests.swift diff --git a/Tests/FlamenetRelayTests/CorePathsTests.swift b/Tests/FlamenetRelayTests/CorePathsTests.swift new file mode 100644 index 0000000..2fc1ec9 --- /dev/null +++ b/Tests/FlamenetRelayTests/CorePathsTests.swift @@ -0,0 +1,373 @@ +import Crypto +import Fluent +import Foundation +import Testing +import Vapor +import VaporTesting + +@testable import FlamenetRelay + +/// The seven things that must never stop working. +/// +/// 🔴 **This suite exists because the unit tests stayed green through a +/// two-day outage.** On 2 September twenty-eight merges left sign-in, +/// messages, calls and stories broken on both clients while 489 Android unit +/// tests and the relay's own suite reported success throughout — because they +/// test units. "assemble produces this JSON" is a unit test; "an account can +/// still sign in and send a story" is not, and nobody was asking it. +/// +/// So this is the standing gate for backend work: sign-up, sign-in, messages, +/// stories, files, wallet and calls, each exercised over HTTP through the real +/// stack. A relay change that breaks one of them fails here rather than on +/// somebody's phone. +/// +/// ⚠️ **What it can and cannot prove.** Every payload here is ciphertext to the +/// relay, so this cannot check that a story renders or that audio flows. What +/// it checks is the part the relay can break: that the routes each flow depends +/// on are mounted, authorised, and carry what was put in them. That is the +/// boundary these changes actually touch. +/// +/// ⚠️ Nested inside `EndToEndTests` for its `.serialized` trait — two top-level +/// suites run in parallel and both set `FN_DB`. +extension EndToEndTests { + +@Suite("core paths") +struct CorePaths { + + private struct Session { + let credential: String + let userID: Int + let deviceID: String + let key: Curve25519.Signing.PrivateKey + var token: String + } + + private func withRelayApp(_ body: (Application) async throws -> Void) async throws { + let issuer = Curve25519.Signing.PrivateKey() + let dbPath = NSTemporaryDirectory() + "fn-core-\(UUID().uuidString).sqlite" + let attachments = NSTemporaryDirectory() + "fn-core-att-\(UUID().uuidString)" + let env: [String: String] = [ + "FN_ISSUER_PUBLIC_KEY": issuer.publicKey.rawRepresentation.base64EncodedString(), + "FN_ISSUER_PRIVATE_KEY": issuer.rawRepresentation.base64EncodedString(), + "FN_RELAY_AUDIENCE": "test.relay", + "FN_DB": dbPath, + "FN_ATTACHMENTS_DIR": attachments, + "FN_LOG_LEVEL": "critical", + "FN_SIGNUP_MAX_PER_HOUR": "50", + ] + let previous = env.keys.reduce(into: [String: String?]()) { + $0[$1] = ProcessInfo.processInfo.environment[$1] + } + for (k, v) in env { setenv(k, v, 1) } + defer { + for (k, v) in previous { if let v { setenv(k, v, 1) } else { unsetenv(k) } } + try? FileManager.default.removeItem(atPath: dbPath) + try? FileManager.default.removeItem(atPath: attachments) + } + let app = try await Application.make(.testing) + do { try await configure(app); try await body(app) } + catch { try? await app.asyncShutdown(); throw error } + try? await app.asyncShutdown() + } + + // MARK: - 1. Sign-up + + private func signUp(_ app: Application, _ name: String) async throws -> (String, Int) { + var credential = "" + var userID = 0 + try await app.testing().test( + .POST, "/account/signup", + beforeRequest: { req in + try req.content.encode(["username": name, "email": "\(name)@example.com", + "password": "hunter2hunter2"]) + }, + afterResponse: { res in + #expect(res.status == .ok, "sign-up stopped working") + struct Made: Decodable { let app_password: String; let user_id: Int } + let made = try res.content.decode(Made.self) + credential = made.app_password + userID = made.user_id + }) + return (credential, userID) + } + + // MARK: - 2. Sign-in + + /// ⚠️ Sign-in is TWO steps and both have to work: the account credential + /// authenticates, and a device token is minted from it. A relay that + /// accepts the credential but refuses the token leaves somebody stuck on a + /// spinner, which is what "signed in but never reached the shell" looked + /// like from the outside. + private func signIn(_ app: Application, _ name: String, credential: String, + userID: Int, device: String) async throws -> Session { + let key = Curve25519.Signing.PrivateKey() + let ikSig = key.publicKey.rawRepresentation.base64EncodedString() + var headers = HTTPHeaders() + headers.basicAuthorization = .init(username: name, password: credential) + + var token = "" + try await app.testing().test( + .POST, "/account/token", headers: headers, + beforeRequest: { req in + try req.content.encode(["device_id": device, "ik_sig": ikSig]) + }, + afterResponse: { res in + #expect(res.status == .ok, "sign-in stopped minting device tokens") + struct Minted: Decodable { let token: String } + token = try res.content.decode(Minted.self).token + }) + #expect(!token.isEmpty, "a token was issued but it was empty") + return Session(credential: credential, userID: userID, + deviceID: device, key: key, token: token) + } + + private func authed(_ s: Session, _ method: HTTPMethod, _ path: String, + body: String = "") -> HTTPHeaders { + var headers = HTTPHeaders() + headers.bearerAuthorization = .init(token: s.token) + let ts = String(Int(Date().timeIntervalSince1970)) + let nonce = UUID().uuidString + let digest = SHA256.hash(data: Data(body.utf8)) + .map { String(format: "%02x", $0) }.joined() + let canonical = "\(method.string)\n\(path)\n\(ts)\n\(nonce)\n\(digest)" + let proof = try! s.key.signature(for: Data(canonical.utf8)) + headers.add(name: FlamenetRelay.Routes.Headers.timestamp, value: ts) + headers.add(name: FlamenetRelay.Routes.Headers.nonce, value: nonce) + headers.add(name: FlamenetRelay.Routes.Headers.signature, + value: proof.base64EncodedString()) + headers.contentType = .json + return headers + } + + private func register(_ app: Application, _ s: Session) async throws { + let ikSig = s.key.publicKey.rawRepresentation.base64EncodedString() + let body = #"{"device_id":"\#(s.deviceID)","ik_dh":"\#(ikSig)","ik_sig":"\#(ikSig)","spk_id":1,"spk":"spk","spk_sig":"sig","prekeys":[{"id":1,"pub":"opk1"},{"id":2,"pub":"opk2"}]}"# + try await app.testing().test( + .POST, "/e2e/devices", + headers: authed(s, .POST, "/e2e/devices", body: body), + beforeRequest: { $0.body = .init(string: body) }, + afterResponse: { #expect($0.status == .ok, "device registration stopped working") }) + } + + private func pair(_ app: Application) async throws -> (Session, Session) { + let (adaCred, adaID) = try await signUp(app, "ada") + let (bobCred, bobID) = try await signUp(app, "bob") + let ada = try await signIn(app, "ada", credential: adaCred, + userID: adaID, device: "aaaa-0001") + let bob = try await signIn(app, "bob", credential: bobCred, + userID: bobID, device: "bbbb-0002") + try await register(app, ada) + try await register(app, bob) + return (ada, bob) + } + + /// Send one sealed envelope and read it back. Every flow below is a + /// different payload through this same path, which is exactly why one + /// break takes them all. + private func deliver(_ app: Application, from sender: Session, to peer: Session, + payload: String, what: String) async throws { + let send = #"{"to":\#(peer.userID),"messages":[{"to_device":"\#(peer.deviceID)","payload":"\#(payload)"}]}"# + try await app.testing().test( + .POST, "/e2e/messages", + headers: authed(sender, .POST, "/e2e/messages", body: send), + beforeRequest: { $0.body = .init(string: send) }, + afterResponse: { #expect($0.status == .ok, "\(what): the send was refused") }) + + try await app.testing().test( + .GET, "/e2e/messages", + headers: authed(peer, .GET, "/e2e/messages") + ) { res in + #expect(res.status == .ok, "\(what): the poll was refused") + #expect(res.body.string.contains(payload), + "\(what): it was accepted and did not come back") + } + } + + // MARK: - The seven + + @Test("sign-up and sign-in still work end to end") + func signUpAndSignIn() async throws { + try await withRelayApp { app in + let (credential, userID) = try await signUp(app, "ada") + #expect(!credential.isEmpty) + #expect(userID > 0) + let session = try await signIn(app, "ada", credential: credential, + userID: userID, device: "aaaa-0001") + try await register(app, session) + + // 🔴 And the credential must keep working. A token that mints once + // and then refuses is the shape of "session expired" on a phone + // that never left. + let again = try await signIn(app, "ada", credential: credential, + userID: userID, device: "aaaa-0002") + #expect(!again.token.isEmpty, "a second sign-in was refused") + } + } + + @Test("messages still send and arrive") + func messages() async throws { + try await withRelayApp { app in + let (ada, bob) = try await pair(app) + try await deliver(app, from: ada, to: bob, + payload: "ciphertext-message", what: "messages") + } + } + + /// 🔴 A story is not a broadcast here — it is one sealed envelope per + /// recipient, because the relay must not learn who an author's contacts + /// are. So "stories work" at this boundary means the fan-out and the poll + /// work, and a story with a picture also needs the attachment path. + @Test("stories still fan out and arrive") + func stories() async throws { + try await withRelayApp { app in + let (ada, bob) = try await pair(app) + + // The picture first: a story's blob is an ordinary attachment. + var token = "" + let blob = String(repeating: "s", count: 64) + try await app.testing().test( + .POST, "/e2e/attachments", + headers: authed(ada, .POST, "/e2e/attachments", body: blob), + beforeRequest: { $0.body = .init(string: blob) }, + afterResponse: { res in + #expect(res.status == .ok, "stories: the blob upload was refused") + struct Up: Decodable { let id: String } + token = try res.content.decode(Up.self).id + }) + #expect(!token.isEmpty) + + // Then one envelope naming it, to each viewer. + try await deliver(app, from: ada, to: bob, + payload: "story-envelope-\(token)", what: "stories") + + // And the viewer can fetch the blob it names. + try await app.testing().test( + .GET, "/e2e/attachments/\(token)", + headers: authed(bob, .GET, "/e2e/attachments/\(token)") + ) { res in + #expect(res.status == .ok, "stories: the viewer could not fetch the picture") + } + } + } + + /// Files ride the same sealed-blob path as a story's picture, with the + /// reference travelling inside the ratchet. + @Test("files still upload, travel and download") + func files() async throws { + try await withRelayApp { app in + let (ada, bob) = try await pair(app) + let contents = String(repeating: "f", count: 128) + + var token = "" + try await app.testing().test( + .POST, "/e2e/attachments", + headers: authed(ada, .POST, "/e2e/attachments", body: contents), + beforeRequest: { $0.body = .init(string: contents) }, + afterResponse: { res in + #expect(res.status == .ok, "files: the upload was refused") + struct Up: Decodable { let id: String; let bytes: Int } + let up = try res.content.decode(Up.self) + token = up.id + #expect(up.bytes == contents.utf8.count, + "files: the relay recorded a different size than it received") + }) + + try await deliver(app, from: ada, to: bob, + payload: "file-ref-\(token)", what: "files") + + try await app.testing().test( + .GET, "/e2e/attachments/\(token)", + headers: authed(bob, .GET, "/e2e/attachments/\(token)") + ) { res in + #expect(res.status == .ok, "files: the recipient could not download it") + #expect(res.body.string == contents, + "files: what came back was not what went in") + } + } + } + + /// 🔴 **The wallet does not touch the relay, and this test exists to keep + /// that true.** Keys are derived on the device from a BIP-39 phrase and + /// balances come from a Solana node the person chooses — nothing in this + /// server is on that path. So the guarantee worth pinning is a negative + /// one: no backend change can break the wallet, because there is no + /// backend route for it to break. + /// + /// ⚠️ If this ever fails, the wallet has grown a server dependency and the + /// privacy story around it has changed. That is a decision, not a bug fix. + @Test("the wallet still needs nothing from the relay") + func wallet() async throws { + try await withRelayApp { app in + for path in ["/e2e/wallet", "/e2e/balance", "/account/wallet"] { + try await app.testing().test(.GET, path) { res in + #expect(res.status == .notFound || res.status == .unauthorized, + "\(path) answers — the wallet has grown a server dependency") + } + } + } + } + + /// Call signalling rides the ratchet as ordinary content envelopes, so the + /// relay cannot tell an offer from a photo — which is the point. What it + /// can break is the envelope path and the TURN credential route. + @Test("calls still signal, and TURN answers honestly") + func calls() async throws { + try await withRelayApp { app in + let (ada, bob) = try await pair(app) + + // Offer, answer, end — three envelopes over the same path. + for signal in ["call-offer", "call-answer", "call-end"] { + try await deliver(app, from: ada, to: bob, + payload: signal, what: "calls (\(signal))") + } + + // 🔴 A relay with no TURN configured must say 503, not 500. The + // client reads that as "no relay server here, expect P2P-or-fail" + // and says so in the call banner; a 500 reads as a broken relay and + // sends somebody looking in the wrong place. + try await app.testing().test( + .GET, "/e2e/turn", headers: authed(ada, .GET, "/e2e/turn") + ) { res in + #expect(res.status == .serviceUnavailable || res.status == .ok, + "calls: /e2e/turn answered \(res.status). A relay with no TURN must refuse with 503, which the client reads as expect-P2P-or-fail; a 500 sends somebody looking in the wrong place.") + } + } + } + + /// ⚠️ One test that walks all of it in order, because the flows share a + /// session and a break in an earlier one can be masked by a fresh account + /// in a later test. + @Test("all seven survive one account's lifetime") + func everythingInOneLife() async throws { + try await withRelayApp { app in + let (ada, bob) = try await pair(app) + try await deliver(app, from: ada, to: bob, payload: "m1", what: "messages") + + var token = "" + let blob = String(repeating: "x", count: 64) + try await app.testing().test( + .POST, "/e2e/attachments", + headers: authed(ada, .POST, "/e2e/attachments", body: blob), + beforeRequest: { $0.body = .init(string: blob) }, + afterResponse: { res in + #expect(res.status == .ok) + struct Up: Decodable { let id: String } + token = try res.content.decode(Up.self).id + }) + + try await deliver(app, from: ada, to: bob, + payload: "story-\(token)", what: "stories") + try await deliver(app, from: bob, to: ada, + payload: "file-\(token)", what: "files") + try await deliver(app, from: bob, to: ada, + payload: "call-offer", what: "calls") + + // And the account still works at the end of all that. + let again = try await signIn(app, "ada", credential: ada.credential, + userID: ada.userID, device: "aaaa-0003") + #expect(!again.token.isEmpty, "the account stopped working after ordinary use") + } + } +} + +} -- 2.54.0