A standing gate for the seven paths that must never break

🔴 **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.
This commit is contained in:
Paul Hitt
2026-09-04 08:20:49 -04:00
parent 2658787670
commit b1258cc7e2
@@ -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")
}
}
}
}