diff --git a/appleApp/FTW/Access/OfferFlowView.swift b/appleApp/FTW/Access/OfferFlowView.swift index 16915c9b..a155aed9 100644 --- a/appleApp/FTW/Access/OfferFlowView.swift +++ b/appleApp/FTW/Access/OfferFlowView.swift @@ -1,10 +1,10 @@ import FTWKit import SwiftUI -/// An assistant's offer, from its consent page to Face ID. The code the -/// page shows first, so a link someone else sent stops here; then who is -/// asking, the host before the name it gives itself, what it may read, -/// control as its own switch, off, and for how long. +/// An assistant's offer, from its consent page to Face ID. A link or the +/// page's QR code opens it at once; with neither, the owner types the code +/// first. Then who is asking, the host before the name it gives itself, +/// what it may read, control as its own switch, off, and for how long. /// The steps of one offer, pushed over the home for a link or from Access /// for a typed code. A page, never a sheet: FTW Cloud's first screen or /// Settings may be up when a link arrives, and a page waits under them. @@ -124,6 +124,12 @@ struct OfferReview: View { } } .padding(.vertical, 4) + if let code = flow.pageCode { + LabeledContent(AccessWords.text("Code on the page")) { + Text(code).font(Theme.mono(.body, weight: .semibold)).foregroundStyle(Theme.ink) + } + .accessibilityIdentifier("offer.pageCode") + } } footer: { if !offer.verified { Text(AccessWords.text("FTW Cloud doesn't know this client. Check that the address is the one you expect.")) @@ -191,9 +197,9 @@ struct OfferReview: View { } /// The home the grant is for. With several, the owner picks one: that -/// home opens and the link starts again there, from the code, so nothing -/// checked at one home is taken to another. An offer that names its home, -/// or one found by a code, which FTW Cloud gives once, stays where it is. +/// home opens and the link starts again there, so nothing checked at one +/// home is taken to another. An offer that names its home, or one found by +/// a code, which FTW Cloud gives once, stays where it is. struct HomePick: View { let app: AppModel let flow: OfferFlow diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index 3be87dbd..627e59d6 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -49,7 +49,17 @@ struct FTWApp: App { // the Secure Enclave, behind Face ID; the demo has keys of its own. model.approvalKeys = ApprovalKeychain(store: store) Biometry.name() - model.demoAccess = (SimulatedKeyring(), SimulatedAccess.offerKeys, SimulatedDesk(), SimulatedAccess.lookup) + var demoOffers = SimulatedAccess.offerKeys + #if DEBUG + // For screenshots: `-FTWOfferKeys :` pins those keys in the + // demo too, beside its own, so a consent link signed elsewhere opens + // in the simulator as a tapped one would. + if let extra = UserDefaults.standard.string(forKey: "FTWOfferKeys") { + let own = "\(OfferKeys.kid(SimulatedAccess.offerKey.publicKey)):\(Base64url.encode(SimulatedAccess.offerKey.publicKey))" + demoOffers = OfferKeys(pins: [own] + extra.split(separator: ",").map(String.init)) + } + #endif + model.demoAccess = (SimulatedKeyring(), demoOffers, SimulatedDesk(), SimulatedAccess.lookup) // A paired phone paints its cached home before anything else runs. model.launch() #if DEBUG diff --git a/appleApp/FTW/Platform/QRScanner.swift b/appleApp/FTW/Platform/QRScanner.swift index 8074d268..520878d0 100644 --- a/appleApp/FTW/Platform/QRScanner.swift +++ b/appleApp/FTW/Platform/QRScanner.swift @@ -1,8 +1,9 @@ import AVFoundation import SwiftUI -/// Reads a pairing QR through the camera. The first code that looks like an -/// FTW link is handed over once; the camera stops at that moment. +/// Reads a pairing QR, or an assistant's consent QR, through the camera. +/// The first code that looks like an FTW link is handed over once; the +/// camera stops at that moment. struct QRScanner: View { var onCode: (String) -> Void @State private var allowed: Bool? diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 31fc316c..803f168f 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -38,7 +38,7 @@ enum HomeRoute: Hashable { case price /// What the home saved, from the home's node and the title's menu. case savings - /// An assistant's consent link: its offer, from the code to Face ID. + /// An assistant's consent link: its offer, to Face ID. case offer(OfferLink) /// The name in the bar, the same as the screen's own title. @@ -120,7 +120,10 @@ struct HomeView: View { case .savings: SavingsView(home: home) case .offer(let link): + // Its own identity, so a newer link in the same + // place gets a new flow, not the old one's state. OfferFlowView(app: app, flow: OfferFlow(link: link, lookup: app.offerLookup, keys: app.offerKeys)) + .id(link) } } .homeBar(app: app, home: home, title: route.title) @@ -136,10 +139,12 @@ struct HomeView: View { } // A consent page's link opens its offer as a page over the home, // in front of anything open, once this phone can grant from here. + // The newest link wins: an offer page already open goes. .onChange(of: app.accessLink, initial: true) { _, link in guard let link, home.access != nil else { return } app.accessLink = nil sheet = nil + path.removeAll { if case .offer = $0 { return true } else { return false } } path.append(.offer(link)) } // Now and the car read the chargers from one model, so a screen diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index 7b5d7790..d8053b3c 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -521,6 +521,7 @@ public enum AccessWords { "Boxen kontrollerar varje anrop, och du kan ta bort åtkomsten när du vill.", "Done": "Klar", "Checking the offer": "Kontrollerar erbjudandet", + "Code on the page": "Koden på sidan", // One grant "Save with %@": "Spara med %@", "Remove access": "Ta bort åtkomst", diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift index 776fd8ea..c69d74a9 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -4,19 +4,21 @@ import Observation /// One offer from a consent page, from the link or the typed code to Face /// ID (docs/agents.md in srcfl/ftw, The grant offer). /// -/// A link someone else sent never reaches Face ID on its own: the owner -/// types the code the page in front of them shows, and it must be the -/// offer's. Typed without a link, the code fetches the offer from FTW Cloud, -/// once. Either way the offer is checked against the pinned offer keys -/// before anything is shown, and refused when it is unsigned, expired, for -/// a home this phone does not have, or support from anyone but Sourceful. -/// Approving checks it again, against the home open at that moment and the -/// clock then, from the signed bytes rather than what was shown. +/// A link, tapped or opened by the iPhone Camera from the page's QR code, +/// goes straight to review: the owner reads who asks, its host, whether FTW +/// Cloud knows it and what it asks for, and may compare the page's code. +/// Only with neither link nor QR code does the owner type the code, which +/// fetches the offer from FTW Cloud, once. Either way the offer is checked +/// against the pinned offer keys before anything is shown, and refused when +/// it is unsigned, expired, for a home this phone does not have, or support +/// from anyone but Sourceful. Approving checks it again, against the home +/// open at that moment and the clock then, from the signed bytes rather +/// than what was shown. @Observable @MainActor public final class OfferFlow { public enum Step: Equatable { - /// Type the code from the page. + /// No link: type the code from the page. case code case checking /// The offer, verified, for the owner to choose from. @@ -27,8 +29,8 @@ public final class OfferFlow { case refused(OfferError) } - public private(set) var step: Step = .code - /// The code as typed. + public private(set) var step: Step + /// The code as typed, when there is no link. public var typed = "" /// Control is a separate switch, off until ticked. public var control = false @@ -43,9 +45,6 @@ public final class OfferFlow { @ObservationIgnored let lookup: OfferLookup @ObservationIgnored let keys: OfferKeys @ObservationIgnored let wallMs: @MainActor () -> Double - /// Opened before the code: a link's offer is checked at once, so a bad - /// one is refused without asking for anything. - @ObservationIgnored private var opened: GrantOffer? /// The offer as signed, checked again when the owner approves. @ObservationIgnored private var signed: SignedOffer? /// Told of the row once the box granted it. @@ -56,6 +55,7 @@ public final class OfferFlow { self.lookup = lookup self.keys = keys self.wallMs = wallMs + step = link == nil ? .code : .checking } var nowMs: Int64 { Int64(wallMs()) } @@ -68,36 +68,32 @@ public final class OfferFlow { } } - /// For a link: check its offer before asking for the code. + /// For a link: check its offer and show it, with no code to type. public func start(home: String?) async { - guard let link, opened == nil, step == .code else { return } - guard !keys.isEmpty else { return refuse(.noPinnedKey) } + guard let link, step == .checking else { return } do { - let carried = try SignedOffer(base64url: link.offer) - opened = try check(carried, home: home) - signed = carried - } catch let e as OfferError { - refuse(e) + present(try SignedOffer(base64url: link.offer), home: home) } catch { refuse(.malformed) } } - /// The owner typed the code: compare it with the link's offer, or fetch - /// the offer it names. + /// The code on the page, to compare, for an offer that came by link. + /// A typed code needs no second look. + public var pageCode: String? { + guard link != nil, let offer, !offer.code.isEmpty else { return nil } + return OfferCode.show(offer.code) + } + + /// The owner typed the code: fetch the offer it names, once. A second + /// press while the first is out asks nothing: the code works once. public func submit(home: String?) async { + guard link == nil, step == .code else { return } codeHelp = nil guard let code = OfferCode.normalize(typed) else { codeHelp = AccessWords.offer(.wrongCode) return } - if let opened { - guard OfferCode.matches(code, opened) else { - codeHelp = AccessWords.offer(.wrongCode) - return - } - return review(opened) - } guard !keys.isEmpty else { return refuse(.noPinnedKey) } step = .checking do { @@ -117,7 +113,10 @@ public final class OfferFlow { } /// An offer that came another way, such as Get help: checked like any. + /// Only before one is shown, so nothing swaps the signed offer under a + /// review or an approval. public func present(_ given: SignedOffer, home: String?) { + guard step == .code || step == .checking else { return } do { let offer = try check(given, home: home) signed = given diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 947545bf..d8491610 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -308,6 +308,8 @@ public enum NodeWords { "That link is not an FTW pairing code.": "Länken är ingen FTW-kod för parkoppling.", "That didn't work. Try scanning again.": "Det gick inte. Försök skanna igen.", "There is no FTW pairing code in that picture. Try a closer screenshot of the QR.": "Bilden har ingen FTW-kod för parkoppling. Prova en närmare skärmbild av QR-koden.", + "That is an assistant's QR code. Connect your box first, then scan it with the iPhone Camera.": "Det är en assistents QR-kod. Anslut boxen först och skanna den sedan med iPhones kamera.", + "The assistant's request opens once your home does.": "Assistentens begäran öppnas när ditt hem öppnas.", // The status line under Now's title: short words in mono caps. // LIVE and AS OF are the node view's, above. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift index 5fa4d340..77d92cae 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift @@ -39,7 +39,9 @@ public final class PairModel { public var canOpen: Bool { known != nil && app.connectHelp == nil } /// Scanned with the camera or pasted: a deliberate act, so it pairs. + /// An assistant's consent page goes where its link goes. public func pair(_ raw: String) async { + if OfferLink(raw) != nil { return scannedOffer(raw) } stage = .pairing message = nil do { @@ -57,6 +59,23 @@ public final class PairModel { } } + /// An assistant's consent QR in the pairing scanner, taken as its link: + /// the home this phone opens shows the offer, or keeps it until it can + /// open. With no home there is nothing to grant from. + private func scannedOffer(_ raw: String) { + stage = .intro + guard app.vault.isEnrolled, let home = app.sites.current() else { + message = NodeWords.text("That is an assistant's QR code. Connect your box first, then scan it with the iPhone Camera.") + return + } + app.offer(raw) + if app.connectHelp == nil { + app.paired(home.siteId) + } else { + message = NodeWords.text("The assistant's request opens once your home does.") + } + } + /// Pair with the link that arrived, now that someone said so. public func acceptOffer() async { guard let link = app.offeredLink else { return } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift index b4ca5958..0e775fd3 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -258,6 +258,25 @@ import FTWDemo #expect(keyring.unlocks == 1) } + /// A consent page's link, tapped or opened from its QR code, reaches the + /// box with one Face ID and no code typed. Approving checks the offer + /// again before it signs. + @Test func aLinkIsApprovedWithNoCode() async throws { + let w = await world(seed: false) + let link = OfferLink(request: "r1", offer: SimulatedAccess.offer(nowMs: w.scheduler.nowMs).base64url) + let flow = OfferFlow(link: link, lookup: OfferLookup(base: URL(string: "https://cloud.test")!) { _ in + Issue.record("a link asked FTW Cloud") + return (Data(), 500) + }, keys: SimulatedAccess.offerKeys, wallMs: { [s = w.scheduler] in s.nowMs }) + await flow.start(home: nil) + guard case .review = flow.step else { Issue.record("no review: \(flow.step)"); return } + await w.during { await flow.approve(w.access, home: nil) } + guard case .done(let row) = flow.step else { Issue.record("not granted: \(flow.step) \(w.access.help ?? "")"); return } + #expect(row.principal.client == "Claude") + #expect(w.keyring.unlocks == 1) + #expect(w.box.access.rows.contains { $0.id == row.id }) + } + /// A cached list knows no keys: the box's own list decides, and with /// no answer nothing is signed. @Test func aCachedListNeverRegistersAKey() async throws { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index e61bcab9..d033660a 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -184,6 +184,41 @@ import FTWDemo #expect(PairModel(app: app).offeredFingerprint != nil) } + /// The pairing scanner reads an assistant's consent QR as its link: the + /// home this phone opens shows the offer, and nothing pairs. With no + /// home it says to connect the box first. + @Test func aConsentQRInThePairingScannerOpensTheOffer() async throws { + let world = World() + let link = "https://app.ftw.energy/a#r1.b2ZmZXI" + let app = world.app() + app.launch() + let first = PairModel(app: app) + await first.pair(link) + #expect(first.stage == .intro) + #expect(first.message == NodeWords.text("That is an assistant's QR code. Connect your box first, then scan it with the iPhone Camera.")) + #expect(app.accessLink == nil) + #expect(world.passkeys.registrations == 0) + + let pairing = PairModel(app: app) + let t = Task { await pairing.pair(world.pairingURL) } + await world.run(500) + await t.value + let siteId = try #require(app.site?.siteId) + // Another home listed first: the one this phone opens still wins. + let paired = try #require(app.sites.get(siteId)) + app.sites.put(StoredSite(siteId: "other", label: "Cabin", boxStaticKey: Data(repeating: 1, count: 32), rendezvousSecret: nil, pairingCode: nil, lanHint: nil, addedAtMs: 0, lastSeenAtMs: 0)) + app.sites.put(paired) + #expect(app.sites.all().first?.siteId == "other") + // Back on the scanner from the home, as "Scan a new pairing QR" is. + app.recovering = true + let again = PairModel(app: app) + await again.pair(link) + #expect(app.accessLink == OfferLink(link)) + #expect(!app.needsPairing) + #expect(app.site?.siteId == siteId) + #expect(world.passkeys.registrations == 1) + } + @Test func aConfigureWriteGoesThrough() async throws { let world = World() let app = world.app() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift index 669c1d88..9285c524 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift @@ -3,7 +3,9 @@ import Testing @testable import FTWKit /// A real offer from a local ftwcloudd, read the way the app reads it. Off -/// unless FTW_LIVE_OFFER_LINK names the consent page's link: +/// unless FTW_LIVE_OFFER_LINK names the consent page's link. The link goes +/// straight to review; FTW_LIVE_OFFER_CODE, if set, must be the code the +/// review shows: /// /// FTW_LIVE_OFFER_LINK='https://app.ftw.energy/a#…' FTW_LIVE_OFFER_CODE=ABCD-EFGH \ /// FTW_LIVE_OFFER_KEYS=':' swift test --filter LiveOfferTests @@ -19,13 +21,13 @@ import Testing #expect(!keys.isEmpty) let flow = OfferFlow(link: link, lookup: OfferLookup(base: URL(string: "https://127.0.0.1:18943")!) { _ in (Data(), 500) }, keys: keys) await flow.start(home: nil) - #expect(flow.step == .code) - flow.typed = env["FTW_LIVE_OFFER_CODE"] ?? "" - await flow.submit(home: nil) guard case .review(let offer) = flow.step else { Issue.record("no review: \(flow.step)") return } + if let code = env["FTW_LIVE_OFFER_CODE"] { + #expect(flow.pageCode == OfferCode.normalize(code).map(OfferCode.show)) + } #expect(offer.role == Contract.roleAgent) #expect(offer.key.count == 32) #expect(offer.home.isEmpty) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift index d1d547e4..b2c1cd27 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift @@ -154,35 +154,51 @@ import Testing // MARK: The flow - /// A link's offer is checked at once, then waits for the code on the - /// page: a link someone else sent never reaches Face ID on its own. - @Test func aLinkWaitsForTheCodeOnThePage() async throws { + /// A link, tapped or opened by the iPhone Camera from the page's QR + /// code, is checked at once and goes straight to review: no code to + /// type. The page's code is there to compare. + @Test func aLinkGoesStraightToReview() async throws { let key = SoftwareSigner() let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) let link = OfferLink(request: "r1", offer: try sign(offer(), key).base64url) - let flow = OfferFlow(link: link, lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) + let flow = OfferFlow(link: link, lookup: lookup { _ in + Issue.record("a link asked FTW Cloud") + return (Data(), 500) + }, keys: keys, wallMs: { 1_000 }) + #expect(flow.step == .checking) await flow.start(home: nil) - #expect(flow.step == .code) - flow.typed = "AAAA-BBBB" - await flow.submit(home: nil) - #expect(flow.step == .code) - #expect(flow.codeHelp == AccessWords.offer(.wrongCode)) - flow.typed = "k7qf-2mxd" - await flow.submit(home: nil) guard case .review(let o) = flow.step else { Issue.record("no review: \(flow.step)"); return } #expect(o.client == "Claude") + #expect(flow.typed.isEmpty) + #expect(flow.pageCode == "K7QF-2MXD") // Control is off until ticked, and the length is the longest asked. #expect(!flow.control) #expect(flow.lengthMs == 30 * 86_400_000) + // A code typed into a link's flow changes nothing. + flow.typed = "AAAA-BBBB" + await flow.submit(home: nil) + #expect(flow.step == .review(o)) } - @Test func aLinkWithABadOfferStopsBeforeTheCode() async throws { - let stranger = SoftwareSigner() + /// The checks a link always had: unsigned, expired, broken, support from + /// anyone but Sourceful, or a build that pins no key. None reaches review. + @Test func aBadLinkIsStillRefused() async throws { let pinned = SoftwareSigner() let keys = OfferKeys(pins: ["\(OfferKeys.kid(pinned.publicKey)):\(Base64url.encode(pinned.publicKey))"]) - let flow = OfferFlow(link: OfferLink(request: "r1", offer: try sign(offer(), stranger).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) - await flow.start(home: nil) - #expect(flow.step == .refused(.unsigned)) + func flow(_ text: String, keys: OfferKeys = keys) async -> OfferFlow { + let f = OfferFlow(link: OfferLink(request: "r1", offer: text), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) + await f.start(home: nil) + return f + } + #expect(await flow(try sign(offer(), SoftwareSigner()).base64url).step == .refused(.unsigned)) + var old = offer() + old.expMs = 1_000 + #expect(await flow(try sign(old, pinned).base64url).step == .refused(.expired)) + #expect(await flow("bm90LWFuLW9mZmVy").step == .refused(.malformed)) + var support = offer() + support.role = Contract.roleSupport + #expect(await flow(try sign(support, pinned).base64url).step == .refused(.notFromSourceful)) + #expect(await flow(try sign(offer(), pinned).base64url, keys: OfferKeys(pins: [])).step == .refused(.noPinnedKey)) } /// Typed with no link, the code fetches the offer from FTW Cloud. @@ -194,9 +210,15 @@ import Testing #expect(req.url?.absoluteString == "https://cloud.test/oauth/requests?code=K7QF2MXD") return (try JSONSerialization.data(withJSONObject: ["offer": wire, "status": "pending"]), 200) }, keys: keys, wallMs: { 1_000 }) + // No link: the code first, and start does nothing. + #expect(flow.step == .code) + await flow.start(home: nil) + #expect(flow.step == .code) flow.typed = "k7qf2mxd" await flow.submit(home: nil) guard case .review = flow.step else { Issue.record("no review: \(flow.step)"); return } + // The owner just typed it: nothing to compare. + #expect(flow.pageCode == nil) let gone = OfferFlow(link: nil, lookup: lookup { _ in (Data(), 404) }, keys: keys, wallMs: { 1_000 }) gone.typed = "K7QF2MXD" @@ -205,6 +227,57 @@ import Testing #expect(gone.codeHelp == AccessWords.offer(.notFound)) } + /// A typed code must be the offer's own. FTW Cloud is asked once, even + /// when Return and Continue both fire: the code works once. + @Test func aTypedCodeIsAskedOnceAndMustMatch() async throws { + let key = SoftwareSigner() + let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) + let wire = try sign(offer(), key).base64url + let asked = Asked() + let flow = OfferFlow(link: nil, lookup: lookup { _ in + asked.add() + return (try JSONSerialization.data(withJSONObject: ["offer": wire, "status": "pending"]), 200) + }, keys: keys, wallMs: { 1_000 }) + flow.typed = "AAAA-BBBB" + await flow.submit(home: nil) + #expect(flow.step == .refused(.wrongCode)) + #expect(asked.count == 1) + + let twice = OfferFlow(link: nil, lookup: lookup { _ in + asked.add() + return (try JSONSerialization.data(withJSONObject: ["offer": wire, "status": "pending"]), 200) + }, keys: keys, wallMs: { 1_000 }) + twice.typed = "K7QF2MXD" + async let first: Void = twice.submit(home: nil) + async let second: Void = twice.submit(home: nil) + _ = await (first, second) + guard case .review = twice.step else { Issue.record("no review: \(twice.step)"); return } + #expect(asked.count == 2) + } + + /// Coming back to the page runs `start` again; it changes nothing the + /// owner chose, and no other offer can be swapped in under the review. + @Test func startingAgainChangesNothing() async throws { + let key = SoftwareSigner() + let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) + var o = offer() + o.verified = false + o.scopes = ["ftw.dispatch.hold", "ftw.live.read"] + let flow = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) + await flow.start(home: nil) + guard case .review(let shown) = flow.step else { Issue.record("no review: \(flow.step)"); return } + flow.control = true + flow.trustsUnknown = true + flow.lengthMs = 86_400_000 + await flow.start(home: nil) + var other = offer() + other.client = "Someone else" + flow.present(try sign(other, key), home: nil) + #expect(flow.step == .review(shown)) + #expect(flow.control && flow.trustsUnknown) + #expect(flow.lengthMs == 86_400_000) + } + /// Approving checks the signed offer again, against the home open then /// and the clock then: a home picked after the review, or an offer /// that ran out while the owner read, is refused. @@ -216,8 +289,6 @@ import Testing var now = 1_000.0 let flow = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { now }) await flow.start(home: "homeA") - flow.typed = "K7QF2MXD" - await flow.submit(home: "homeA") guard case .review = flow.step else { Issue.record("no review: \(flow.step)"); return } let site = SiteModel(siteId: "homeB", build: "t", ua: "t", scheduler: ManualScheduler(), files: nil) let access = AccessModel(site: site, signedFor: SignedFor(boxKey: [], deviceID: "x"), keyring: nil) @@ -226,8 +297,6 @@ import Testing let late = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { now }) await late.start(home: "homeA") - late.typed = "K7QF2MXD" - await late.submit(home: "homeA") now += 10 * 60_000 await late.approve(access, home: "homeA") #expect(late.step == .refused(.expired)) @@ -241,8 +310,6 @@ import Testing o.verified = false let flow = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) await flow.start(home: nil) - flow.typed = "K7QF2MXD" - await flow.submit(home: nil) let site = SiteModel(siteId: "h", build: "t", ua: "t", scheduler: ManualScheduler(), files: nil) await flow.approve(AccessModel(site: site, signedFor: SignedFor(boxKey: [], deviceID: "x"), keyring: nil), home: nil) guard case .review = flow.step else { Issue.record("moved without the owner's word: \(flow.step)"); return } @@ -261,7 +328,7 @@ import Testing #expect(flow.step == .refused(.unknownHome)) let same = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) await same.start(home: "otherhome") - #expect(same.step == .code) + guard case .review = same.step else { Issue.record("no review: \(same.step)"); return } } // MARK: What the owner grants @@ -328,3 +395,11 @@ import Testing OfferLookup(base: URL(string: "https://cloud.test")!) { try answer($0) } } } + +/// How many times FTW Cloud's code lookup was asked. +final class Asked: @unchecked Sendable { + private let lock = NSLock() + private var n = 0 + var count: Int { lock.withLock { n } } + func add() { lock.withLock { n += 1 } } +} diff --git a/appleApp/FTWUITests/AccessE2E.swift b/appleApp/FTWUITests/AccessE2E.swift index 8dcb2156..42330402 100644 --- a/appleApp/FTWUITests/AccessE2E.swift +++ b/appleApp/FTWUITests/AccessE2E.swift @@ -5,7 +5,6 @@ import XCTest /// /// TEST_RUNNER_FTW_E2E_PAIR= /// TEST_RUNNER_FTW_E2E_LINK= -/// TEST_RUNNER_FTW_E2E_CODE= /// TEST_RUNNER_FTW_E2E_CLOUD=wss://127.0.0.1:18943/v1/app /// TEST_RUNNER_FTW_E2E_CLOUD_KEY= /// TEST_RUNNER_FTW_E2E_OFFER_KEYS=: @@ -20,7 +19,7 @@ final class AccessE2E: XCTestCase { @MainActor func testApproveThenRemove() throws { let env = ProcessInfo.processInfo.environment - guard let pair = env["FTW_E2E_PAIR"], let link = env["FTW_E2E_LINK"], let code = env["FTW_E2E_CODE"], + guard let pair = env["FTW_E2E_PAIR"], let link = env["FTW_E2E_LINK"], let cloud = env["FTW_E2E_CLOUD"], let key = env["FTW_E2E_CLOUD_KEY"], let offers = env["FTW_E2E_OFFER_KEYS"] else { throw XCTSkip("Set the TEST_RUNNER_FTW_E2E_* variables to run against the rig.") } @@ -49,20 +48,16 @@ final class AccessE2E: XCTestCase { } } // FTW Cloud's one screen at first start, over the consent link's - // page, which waits under it. + // page, which waits under it. The link opens the offer itself, with + // no code to type. let carryOn = app.buttons.matching(NSPredicate(format: "label == 'Continue' AND identifier != 'offer.continue'")).firstMatch - let field = app.textFields["offer.code"] - for _ in 0..<60 where !field.isHittable { + let host = app.staticTexts["offer.host"] + for _ in 0..<60 where !host.isHittable { if carryOn.exists, carryOn.isHittable { carryOn.tap() } Thread.sleep(forTimeInterval: 1) } - // The code from the page, then the offer. - XCTAssertTrue(field.waitForExistence(timeout: 5)) - field.tap() - shoot(app, "e2e-code") - field.typeText(code) - app.buttons["offer.continue"].tap() - XCTAssertTrue(app.staticTexts["offer.host"].waitForExistence(timeout: 15)) + XCTAssertTrue(host.waitForExistence(timeout: 15)) + XCTAssertFalse(app.textFields["offer.code"].exists) shoot(app, "e2e-offer") let control = app.switches["offer.control"] if control.waitForExistence(timeout: 3) {