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) + } +} 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") + } + } +} + +}