Commit 9e8931e4c5
Verified · cmc
Layout: unified · split
gitbay/Auth/SessionStore.swift +21 −3
| @@ -78,12 +78,25 @@ final class SessionStore { | |||
| 78 | watchForRevocation(candidate, account: account) | 78 | watchForRevocation(candidate, account: account) |
| 79 | } | 79 | } |
| 80 | 80 | ||
| 81 | /// A client for any signed-in account, active or not. Push | ||
| 82 | /// registration talks to every account on the device, and `store` | ||
| 83 | /// and `makeClient` are private. Vending does not change `current`. | ||
| 84 | func client(for account: Account) -> GitbayClient? { | ||
| 85 | guard let token = store.token(for: account.id) else { return nil } | ||
| 86 | return makeClient(account.instance, token) | ||
| 87 | } | ||
| 88 | |||
| 89 | /// Runs before an account is removed, while its token still exists. | ||
| 90 | /// `gitbayApp` points this at push deregistration; this type knows | ||
| 91 | /// nothing about push beyond that something wants to run first. | ||
| 92 | var willRemoveAccount: ((Account) async -> Void)? | ||
| 93 | |||
| 81 | /// Switch to another stored account. Returns false if its token is | 94 | /// Switch to another stored account. Returns false if its token is |
| 82 | /// gone from the Keychain, in which case the account is dropped too. | 95 | /// gone from the Keychain, in which case the account is dropped too. |
| 83 | @discardableResult | 96 | @discardableResult |
| 84 | func activate(_ account: Account) -> Bool { | 97 | func activate(_ account: Account) -> Bool { |
| 85 | guard let token = store.token(for: account.id) else { | 98 | guard let token = store.token(for: account.id) else { |
| 86 | remove(account) | 99 | removeAccountState(account) |
| 87 | return false | 100 | return false |
| 88 | } | 101 | } |
| 89 | activate(account, token: token) | 102 | activate(account, token: token) |
| @@ -112,7 +125,12 @@ final class SessionStore { | |||
| 112 | 125 | ||
| 113 | /// Forget an account: token out of the Keychain, account out of the | 126 | /// Forget an account: token out of the Keychain, account out of the |
| 114 | /// list, and if it was active, over to the next one or signed out. | 127 | /// list, and if it was active, over to the next one or signed out. |
| 115 | func remove(_ account: Account) { | 128 | func remove(_ account: Account) async { |
| 129 | await willRemoveAccount?(account) | ||
| 130 | removeAccountState(account) | ||
| 131 | } | ||
| 132 | |||
| 133 | private func removeAccountState(_ account: Account) { | ||
| 116 | store.deleteToken(for: account.id) | 134 | store.deleteToken(for: account.id) |
| 117 | accounts.removeAll { $0.id == account.id } | 135 | accounts.removeAll { $0.id == account.id } |
| 118 | try? store.saveAccounts(accounts) | 136 | try? store.saveAccounts(accounts) |
| @@ -127,7 +145,7 @@ final class SessionStore { | |||
| 127 | /// The server said 401: the token is expired or revoked. Sign the | 145 | /// The server said 401: the token is expired or revoked. Sign the |
| 128 | /// account out cleanly and say why — never crash, never loop. | 146 | /// account out cleanly and say why — never crash, never loop. |
| 129 | func expire(_ account: Account) { | 147 | func expire(_ account: Account) { |
| 130 | remove(account) | 148 | removeAccountState(account) |
| 131 | if current == nil { | 149 | if current == nil { |
| 132 | signedOutMessage = | 150 | signedOutMessage = |
| 133 | "The token for \(account.label) is no longer valid. Mint a new one and sign in again." | 151 | "The token for \(account.label) is no longer valid. Mint a new one and sign in again." |
gitbay/Views/Repos/RepoListView.swift +1 −1
| @@ -280,7 +280,7 @@ struct AccountMenu: ToolbarContent { | |||
| 280 | NavigationLink("Add Account", value: RepoRoute.addAccount) | 280 | NavigationLink("Add Account", value: RepoRoute.addAccount) |
| 281 | if let current = session.current { | 281 | if let current = session.current { |
| 282 | Button("Sign Out", role: .destructive) { | 282 | Button("Sign Out", role: .destructive) { |
| 283 | session.remove(current) | 283 | Task { await session.remove(current) } |
| 284 | } | 284 | } |
| 285 | } | 285 | } |
| 286 | } | 286 | } |
gitbayTests/SessionClientTests.swift added +59
| @@ -0,0 +1,59 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private let whoami = """ | ||
| 6 | {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} | ||
| 7 | """ | ||
| 8 | |||
| 9 | @MainActor | ||
| 10 | private func makeSession() -> (SessionStore, StubProtocol.Box) { | ||
| 11 | let box = StubProtocol.box() | ||
| 12 | let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! | ||
| 13 | let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in | ||
| 14 | GitbayClient(instance: instance, token: token, session: box.session()) | ||
| 15 | } | ||
| 16 | return (session, box) | ||
| 17 | } | ||
| 18 | |||
| 19 | /// Signs two accounts in, on different instances. | ||
| 20 | @MainActor | ||
| 21 | private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) { | ||
| 22 | let (session, box) = makeSession() | ||
| 23 | box.enqueue(.init(status: 200, json: whoami)) | ||
| 24 | try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") | ||
| 25 | box.enqueue(.init(status: 200, json: whoami)) | ||
| 26 | try await session.signIn(instanceURL: "https://dev.local", token: "t2") | ||
| 27 | return (session, box) | ||
| 28 | } | ||
| 29 | |||
| 30 | @Test @MainActor func sessionVendsAClientForAnyStoredAccount() async throws { | ||
| 31 | let (session, _) = try await twoAccounts() | ||
| 32 | let inactive = try #require(session.accounts.first { $0.id != session.current?.id }) | ||
| 33 | #expect(session.client(for: inactive) != nil) | ||
| 34 | // Vending does not change which account is active. | ||
| 35 | #expect(session.current?.id != inactive.id) | ||
| 36 | } | ||
| 37 | |||
| 38 | @Test @MainActor func sessionVendsNothingForAnUnknownAccount() async throws { | ||
| 39 | let (session, _) = try await twoAccounts() | ||
| 40 | let stranger = Account(instance: try GitbayInstance(url: "https://nowhere.example"), username: "x") | ||
| 41 | #expect(session.client(for: stranger) == nil) | ||
| 42 | } | ||
| 43 | |||
| 44 | // The hook runs before the token is discarded: deregistering a device | ||
| 45 | // needs the credential remove() is about to delete. | ||
| 46 | @Test @MainActor func removeAwaitsTheHookBeforeDiscardingTheToken() async throws { | ||
| 47 | let (session, _) = try await twoAccounts() | ||
| 48 | let victim = try #require(session.accounts.first) | ||
| 49 | var sawTokenDuringHook: Bool? | ||
| 50 | // [weak session] because this closure is stored ON session; the | ||
| 51 | // production wiring in gitbayApp captures the registrar weakly for | ||
| 52 | // the same reason. | ||
| 53 | session.willRemoveAccount = { [weak session] account in | ||
| 54 | sawTokenDuringHook = session?.client(for: account) != nil | ||
| 55 | } | ||
| 56 | await session.remove(victim) | ||
| 57 | #expect(sawTokenDuringHook == true) | ||
| 58 | #expect(session.client(for: victim) == nil) | ||
| 59 | } | ||
gitbayTests/SessionStoreTests.swift +1 −1
| @@ -131,7 +131,7 @@ struct SessionStoreTests { | |||
| 131 | try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") | 131 | try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") |
| 132 | let active = try #require(session.current) | 132 | let active = try #require(session.current) |
| 133 | 133 | ||
| 134 | session.remove(active) | 134 | await session.remove(active) |
| 135 | 135 | ||
| 136 | #expect(session.current?.instance.baseURL.host() == "gitbay.org") | 136 | #expect(session.current?.instance.baseURL.host() == "gitbay.org") |
| 137 | #expect(session.accounts.count == 1) | 137 | #expect(session.accounts.count == 1) |