A standing gate for the seven paths that must never break #95
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user