Commit 3337af385a
Verified · cmc
Layout: unified · split
gitbay/Account/TokensViewModel.swift added +103
| @@ -0,0 +1,103 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// One API token (`token list`): a name, what it may do, and when it was | ||
| 5 | /// used and stops working. The secret is shown once, when it is minted. | ||
| 6 | nonisolated struct APIToken: Decodable, Sendable, Hashable, Identifiable { | ||
| 7 | let name: String | ||
| 8 | let scope: String | ||
| 9 | let createdAt: Date | ||
| 10 | let expiresAt: Date? | ||
| 11 | let lastUsedAt: Date? | ||
| 12 | /// The token or key that minted it, when one did rather than you. | ||
| 13 | let createdBy: String? | ||
| 14 | |||
| 15 | enum CodingKeys: String, CodingKey { | ||
| 16 | case name, scope | ||
| 17 | case createdAt = "created_at" | ||
| 18 | case expiresAt = "expires_at" | ||
| 19 | case lastUsedAt = "last_used_at" | ||
| 20 | case createdBy = "created_by" | ||
| 21 | } | ||
| 22 | |||
| 23 | var id: String { name } | ||
| 24 | |||
| 25 | func isExpired(at now: Date) -> Bool { | ||
| 26 | expiresAt.map { $0 <= now } ?? false | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | /// `token list`, `token create` and `token revoke [--created]`. | ||
| 31 | @Observable | ||
| 32 | @MainActor | ||
| 33 | final class TokensViewModel { | ||
| 34 | |||
| 35 | /// What a revoked token had minted: tokens by name, keys by fingerprint. | ||
| 36 | nonisolated struct Created: Decodable, Sendable, Hashable { | ||
| 37 | let tokens: [String]? | ||
| 38 | let keys: [String]? | ||
| 39 | var isEmpty: Bool { (tokens ?? []).isEmpty && (keys ?? []).isEmpty } | ||
| 40 | } | ||
| 41 | |||
| 42 | nonisolated private struct Minted: Decodable, Sendable { | ||
| 43 | let token: String | ||
| 44 | } | ||
| 45 | |||
| 46 | nonisolated private struct Revoked: Decodable, Sendable { | ||
| 47 | let created: Created | ||
| 48 | } | ||
| 49 | |||
| 50 | private(set) var state: LoadState<[APIToken]> = .loading | ||
| 51 | private(set) var actionError: String? | ||
| 52 | private(set) var working = false | ||
| 53 | |||
| 54 | private let client: GitbayClient | ||
| 55 | |||
| 56 | init(client: GitbayClient) { | ||
| 57 | self.client = client | ||
| 58 | } | ||
| 59 | |||
| 60 | func load() async { | ||
| 61 | do { | ||
| 62 | let tokens = try await client.readList(["token", "list"], of: APIToken.self) | ||
| 63 | state = tokens.isEmpty ? .empty("No API tokens.") : .loaded(tokens) | ||
| 64 | } catch { | ||
| 65 | state = .from(error) | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | /// `token create --name <n> --scope <s> [--ttl <d>]`. Returns the | ||
| 70 | /// secret, which the server shows this once and never again. | ||
| 71 | func create(name: String, scope: String, ttl: String?) async -> String? { | ||
| 72 | var argv = ["token", "create", "--name", name.trimmingCharacters(in: .whitespaces), "--scope", scope] | ||
| 73 | if let ttl { argv += ["--ttl", ttl] } | ||
| 74 | let minted: Minted? = await perform { try await self.client.run(argv, as: Minted.self) } | ||
| 75 | return minted?.token | ||
| 76 | } | ||
| 77 | |||
| 78 | /// `token revoke <name> [--created]`. Returns what the token had | ||
| 79 | /// minted: revoked with it, or still in place. | ||
| 80 | func revoke(_ token: APIToken, withCreated: Bool) async -> Created? { | ||
| 81 | var argv = ["token", "revoke", token.name] | ||
| 82 | if withCreated { argv.append("--created") } | ||
| 83 | let revoked: Revoked? = await perform { try await self.client.run(argv, as: Revoked.self) } | ||
| 84 | return revoked?.created | ||
| 85 | } | ||
| 86 | |||
| 87 | private func perform<T: Sendable>(_ body: () async throws -> T?) async -> T? { | ||
| 88 | working = true | ||
| 89 | actionError = nil | ||
| 90 | defer { working = false } | ||
| 91 | do { | ||
| 92 | let result = try await body() | ||
| 93 | await load() | ||
| 94 | return result | ||
| 95 | } catch let error as GitbayError { | ||
| 96 | // The name is typed, so a usage refusal of it is shown. | ||
| 97 | actionError = error.messageForTypedInput | ||
| 98 | } catch { | ||
| 99 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 100 | } | ||
| 101 | return nil | ||
| 102 | } | ||
| 103 | } | ||
gitbay/ContentView.swift +2
| @@ -337,6 +337,8 @@ private struct RouteDestinations: ViewModifier { | |||
| 337 | ProfileView(client: client, name: name) | 337 | ProfileView(client: client, name: name) |
| 338 | case .account: | 338 | case .account: |
| 339 | AccountView(client: client) | 339 | AccountView(client: client) |
| 340 | case .tokens: | ||
| 341 | TokensView(client: client) | ||
| 340 | case .addAccount: | 342 | case .addAccount: |
| 341 | SignInView() | 343 | SignInView() |
| 342 | case .notifications: | 344 | case .notifications: |
gitbay/Views/Account/AccountView.swift +2 −2
| @@ -1,7 +1,7 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | /// SSH keys, PGP keys, and email verification. Tokens are minted on the | 3 | /// SSH keys, PGP keys, email verification and notification settings. |
| 4 | /// web settings page or over SSH, not here. | 4 | /// API tokens have their own screen. |
| 5 | struct AccountView: View { | 5 | struct AccountView: View { |
| 6 | 6 | ||
| 7 | @State private var model: AccountViewModel | 7 | @State private var model: AccountViewModel |
gitbay/Views/Account/TokensView.swift added +217
| @@ -0,0 +1,217 @@ | |||
| 1 | import SwiftUI | ||
| 2 | import UIKit | ||
| 3 | |||
| 4 | /// Your API tokens: what each may do, when it was used and stops | ||
| 5 | /// working, and who minted it. Mint one from the + button; swipe to revoke. | ||
| 6 | struct TokensView: View { | ||
| 7 | |||
| 8 | @State private var model: TokensViewModel | ||
| 9 | @State private var minting = false | ||
| 10 | @State private var minted: String? | ||
| 11 | @State private var revoking: APIToken? | ||
| 12 | /// What the last revoked token had minted, and whether it went too. | ||
| 13 | @State private var revokedReport: String? | ||
| 14 | |||
| 15 | init(client: GitbayClient) { | ||
| 16 | _model = State(initialValue: TokensViewModel(client: client)) | ||
| 17 | } | ||
| 18 | |||
| 19 | var body: some View { | ||
| 20 | List { | ||
| 21 | if let error = model.actionError, !minting { | ||
| 22 | Section { GBNotice(error, .gbWarn) } | ||
| 23 | } | ||
| 24 | if let report = revokedReport { | ||
| 25 | Section { GBNotice(report, .gbOK) } | ||
| 26 | } | ||
| 27 | ForEach(model.state.value ?? []) { token in | ||
| 28 | row(token) | ||
| 29 | .swipeActions { | ||
| 30 | Button("Revoke", role: .destructive) { revoking = token } | ||
| 31 | } | ||
| 32 | .contextMenu { | ||
| 33 | Button(role: .destructive) { | ||
| 34 | revoking = token | ||
| 35 | } label: { | ||
| 36 | Label("Revoke", systemImage: "xmark.circle") | ||
| 37 | } | ||
| 38 | } | ||
| 39 | } | ||
| 40 | } | ||
| 41 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 42 | .navigationTitle("API Tokens") | ||
| 43 | .navigationBarTitleDisplayMode(.inline) | ||
| 44 | .toolbar { | ||
| 45 | ToolbarItem(placement: .topBarTrailing) { | ||
| 46 | Button { | ||
| 47 | minting = true | ||
| 48 | } label: { | ||
| 49 | Image(systemName: "plus") | ||
| 50 | } | ||
| 51 | .accessibilityLabel("New Token") | ||
| 52 | .accessibilityIdentifier("token-create") | ||
| 53 | } | ||
| 54 | } | ||
| 55 | .confirmationDialog( | ||
| 56 | revoking.map { "Revoke \($0.name)?" } ?? "", | ||
| 57 | isPresented: Binding(get: { revoking != nil }, set: { if !$0 { revoking = nil } }), | ||
| 58 | titleVisibility: .visible, | ||
| 59 | presenting: revoking | ||
| 60 | ) { token in | ||
| 61 | Button("Revoke", role: .destructive) { revoke(token, withCreated: false) } | ||
| 62 | Button("Revoke with what it created", role: .destructive) { revoke(token, withCreated: true) } | ||
| 63 | Button("Cancel", role: .cancel) {} | ||
| 64 | } message: { _ in | ||
| 65 | Text("Anything using it stops working at once. If this app signed in with it, the app signs out. What it created, tokens and keys, stays unless you revoke it too.") | ||
| 66 | } | ||
| 67 | .sheet(isPresented: $minting, onDismiss: { minted = nil }) { | ||
| 68 | MintTokenSheet(model: model, minted: $minted) | ||
| 69 | } | ||
| 70 | .task { await model.load() } | ||
| 71 | .refreshable { await model.load() } | ||
| 72 | } | ||
| 73 | |||
| 74 | private func row(_ token: APIToken) -> some View { | ||
| 75 | VStack(alignment: .leading, spacing: 3) { | ||
| 76 | HStack(spacing: 6) { | ||
| 77 | Text(token.name) | ||
| 78 | .font(.gbSans(.subheadline).weight(.semibold)) | ||
| 79 | GBChip(token.scope, .secondary) | ||
| 80 | if let by = token.createdBy { | ||
| 81 | Text("by \(by)") | ||
| 82 | .font(.gbSans(.caption2)) | ||
| 83 | .foregroundStyle(.secondary) | ||
| 84 | } | ||
| 85 | } | ||
| 86 | HStack(spacing: 4) { | ||
| 87 | if let used = token.lastUsedAt { | ||
| 88 | Text("used \(used.formatted(.relative(presentation: .named)))") | ||
| 89 | } else { | ||
| 90 | Text("never used") | ||
| 91 | } | ||
| 92 | Text("·") | ||
| 93 | if let expires = token.expiresAt { | ||
| 94 | if token.isExpired(at: .now) { | ||
| 95 | Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))") | ||
| 96 | .foregroundStyle(Color.gbBad) | ||
| 97 | } else { | ||
| 98 | Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))") | ||
| 99 | } | ||
| 100 | } else { | ||
| 101 | Text("does not expire") | ||
| 102 | } | ||
| 103 | } | ||
| 104 | .font(.gbSans(.caption2)) | ||
| 105 | .foregroundStyle(.secondary) | ||
| 106 | } | ||
| 107 | .padding(.vertical, 2) | ||
| 108 | .accessibilityElement(children: .combine) | ||
| 109 | .accessibilityIdentifier("token-\(token.name)") | ||
| 110 | } | ||
| 111 | |||
| 112 | private func revoke(_ token: APIToken, withCreated: Bool) { | ||
| 113 | revokedReport = nil | ||
| 114 | Task { | ||
| 115 | guard let created = await model.revoke(token, withCreated: withCreated) else { return } | ||
| 116 | let made = (created.tokens ?? []).map { "token \($0)" } + (created.keys ?? []).map { "key \($0)" } | ||
| 117 | if made.isEmpty { | ||
| 118 | revokedReport = "Revoked \(token.name)." | ||
| 119 | } else if withCreated { | ||
| 120 | revokedReport = "Revoked \(token.name), and what it created: " + made.joined(separator: ", ") + "." | ||
| 121 | } else { | ||
| 122 | revokedReport = "Revoked \(token.name). It created these, still in place: " + made.joined(separator: ", ") + "." | ||
| 123 | } | ||
| 124 | } | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | /// Name, scope and lifetime, then the token itself, shown this once. | ||
| 129 | private struct MintTokenSheet: View { | ||
| 130 | |||
| 131 | let model: TokensViewModel | ||
| 132 | @Binding var minted: String? | ||
| 133 | |||
| 134 | @State private var name = "" | ||
| 135 | @State private var scope = "full" | ||
| 136 | @State private var ttl: String? | ||
| 137 | @State private var sent = false | ||
| 138 | @State private var copied = false | ||
| 139 | @Environment(\.dismiss) private var dismiss | ||
| 140 | |||
| 141 | var body: some View { | ||
| 142 | NavigationStack { | ||
| 143 | Form { | ||
| 144 | if let minted { | ||
| 145 | Section { | ||
| 146 | Text(minted) | ||
| 147 | .font(.gbMono(.subheadline)) | ||
| 148 | .textSelection(.enabled) | ||
| 149 | .accessibilityIdentifier("token-secret") | ||
| 150 | Button(copied ? "Copied" : "Copy") { | ||
| 151 | UIPasteboard.general.string = minted | ||
| 152 | copied = true | ||
| 153 | } | ||
| 154 | .accessibilityIdentifier("token-copy") | ||
| 155 | } header: { | ||
| 156 | Text("\(name) · \(scope)") | ||
| 157 | } footer: { | ||
| 158 | Text("This is the only time it is shown. Store it now.") | ||
| 159 | } | ||
| 160 | } else { | ||
| 161 | Section("Name") { | ||
| 162 | TextField("laptop", text: $name) | ||
| 163 | .autocorrectionDisabled() | ||
| 164 | .textInputAutocapitalization(.never) | ||
| 165 | .accessibilityIdentifier("token-name") | ||
| 166 | } | ||
| 167 | Section { | ||
| 168 | Picker("Scope", selection: $scope) { | ||
| 169 | Text("full").tag("full") | ||
| 170 | Text("read").tag("read") | ||
| 171 | } | ||
| 172 | .pickerStyle(.segmented) | ||
| 173 | Picker("Expires", selection: $ttl) { | ||
| 174 | Text("Never").tag(String?.none) | ||
| 175 | Text("In 30 days").tag(String?.some("30d")) | ||
| 176 | Text("In 90 days").tag(String?.some("90d")) | ||
| 177 | Text("In a year").tag(String?.some("365d")) | ||
| 178 | } | ||
| 179 | } footer: { | ||
| 180 | Text("Full scope can change anything you can; read can only look. An expiring token cannot mint tokens or keys.") | ||
| 181 | } | ||
| 182 | if sent, let error = model.actionError { | ||
| 183 | Section { GBNotice(error) } | ||
| 184 | } | ||
| 185 | } | ||
| 186 | } | ||
| 187 | .navigationTitle(minted == nil ? "New Token" : "Your Token") | ||
| 188 | .navigationBarTitleDisplayMode(.inline) | ||
| 189 | .toolbar { | ||
| 190 | if minted != nil { | ||
| 191 | ToolbarItem(placement: .confirmationAction) { | ||
| 192 | Button("Done") { dismiss() } | ||
| 193 | .accessibilityIdentifier("token-done") | ||
| 194 | } | ||
| 195 | } else { | ||
| 196 | ToolbarItem(placement: .cancellationAction) { | ||
| 197 | Button("Cancel") { dismiss() } | ||
| 198 | } | ||
| 199 | ToolbarItem(placement: .confirmationAction) { | ||
| 200 | if model.working { | ||
| 201 | ProgressView() | ||
| 202 | } else { | ||
| 203 | Button("Create") { | ||
| 204 | sent = true | ||
| 205 | Task { minted = await model.create(name: name, scope: scope, ttl: ttl) } | ||
| 206 | } | ||
| 207 | .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) | ||
| 208 | .accessibilityIdentifier("token-submit") | ||
| 209 | } | ||
| 210 | } | ||
| 211 | } | ||
| 212 | } | ||
| 213 | // The token is on screen once; a swipe must not lose it. | ||
| 214 | .interactiveDismissDisabled(model.working || minted != nil) | ||
| 215 | } | ||
| 216 | } | ||
| 217 | } | ||
gitbay/Views/Repos/RepoListView.swift +3
| @@ -268,6 +268,9 @@ struct AccountMenu: ToolbarContent { | |||
| 268 | NavigationLink(value: RepoRoute.account) { | 268 | NavigationLink(value: RepoRoute.account) { |
| 269 | Label("Keys & Email", systemImage: "key") | 269 | Label("Keys & Email", systemImage: "key") |
| 270 | } | 270 | } |
| 271 | NavigationLink(value: RepoRoute.tokens) { | ||
| 272 | Label("API Tokens", systemImage: "key.horizontal") | ||
| 273 | } | ||
| 271 | NavigationLink(value: SnippetRoute.list(owner: nil)) { | 274 | NavigationLink(value: SnippetRoute.list(owner: nil)) { |
| 272 | Label("Snippets", systemImage: "scissors") | 275 | Label("Snippets", systemImage: "scissors") |
| 273 | } | 276 | } |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -27,6 +27,7 @@ nonisolated enum RepoRoute: Hashable { | |||
| 27 | case webhookDeliveries(repo: String) | 27 | case webhookDeliveries(repo: String) |
| 28 | case profile(String) | 28 | case profile(String) |
| 29 | case account | 29 | case account |
| 30 | case tokens | ||
| 30 | case addAccount | 31 | case addAccount |
| 31 | case notifications | 32 | case notifications |
| 32 | case admin | 33 | case admin |
gitbayTests/TokenTests.swift added +106
| @@ -0,0 +1,106 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 6 | let box = StubProtocol.box() | ||
| 7 | let client = GitbayClient( | ||
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 9 | token: "test-token", | ||
| 10 | session: box.session() | ||
| 11 | ) | ||
| 12 | return (client, box) | ||
| 13 | } | ||
| 14 | |||
| 15 | private func posted(_ stub: StubProtocol.Box) throws -> [[String]] { | ||
| 16 | try stub.seen.filter { $0.method == "POST" }.map { seen in | ||
| 17 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 18 | return try #require(body["argv"] as? [String]) | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 22 | private let listJSON = """ | ||
| 23 | {"protocol_version":1,"data":[\ | ||
| 24 | {"name":"iphone","scope":"full","created_at":"2026-09-01T10:00:00.000Z","last_used_at":"2026-10-14T07:00:00Z"},\ | ||
| 25 | {"name":"ci","scope":"read","created_at":"2026-09-02T10:00:00.000Z","expires_at":"2026-01-01T00:00:00Z","created_by":"iphone"}\ | ||
| 26 | ],"exit_code":0} | ||
| 27 | """ | ||
| 28 | |||
| 29 | @MainActor | ||
| 30 | struct TokenTests { | ||
| 31 | |||
| 32 | private func loaded() async throws -> (TokensViewModel, StubProtocol.Box) { | ||
| 33 | let (client, stub) = try makeClient() | ||
| 34 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | ||
| 35 | let model = TokensViewModel(client: client) | ||
| 36 | await model.load() | ||
| 37 | return (model, stub) | ||
| 38 | } | ||
| 39 | |||
| 40 | @Test func tokensListWithUseExpiryAndWhoMadeThem() async throws { | ||
| 41 | let (model, _) = try await loaded() | ||
| 42 | let tokens = try #require(model.state.value) | ||
| 43 | #expect(tokens.map(\.name) == ["iphone", "ci"]) | ||
| 44 | #expect(tokens[0].lastUsedAt != nil) | ||
| 45 | #expect(tokens[1].createdBy == "iphone") | ||
| 46 | #expect(tokens[1].isExpired(at: .now)) | ||
| 47 | } | ||
| 48 | |||
| 49 | @Test func mintingReturnsTheTokenOnceAndReloads() async throws { | ||
| 50 | let (model, stub) = try await loaded() | ||
| 51 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"name":"laptop","scope":"read","token":"gbt_secret"},"exit_code":0}"#, match: "cmd")) | ||
| 52 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | ||
| 53 | |||
| 54 | let minted = await model.create(name: " laptop ", scope: "read", ttl: "30d") | ||
| 55 | |||
| 56 | #expect(minted == "gbt_secret") | ||
| 57 | #expect(try posted(stub) == [["token", "create", "--name", "laptop", "--scope", "read", "--ttl", "30d"]]) | ||
| 58 | } | ||
| 59 | |||
| 60 | @Test func aTokenThatNeverExpiresSendsNoTTL() async throws { | ||
| 61 | let (model, stub) = try await loaded() | ||
| 62 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"name":"x","scope":"full","token":"t"},"exit_code":0}"#, match: "cmd")) | ||
| 63 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | ||
| 64 | |||
| 65 | _ = await model.create(name: "x", scope: "full", ttl: nil) | ||
| 66 | |||
| 67 | #expect(try posted(stub) == [["token", "create", "--name", "x", "--scope", "full"]]) | ||
| 68 | } | ||
| 69 | |||
| 70 | /// An expiring token cannot mint one; the server says so, and the | ||
| 71 | /// app's sentence for that refusal says what to do instead. | ||
| 72 | @Test func anExpiringSessionIsToldWhy() async throws { | ||
| 73 | let (model, stub) = try await loaded() | ||
| 74 | stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"token create creates a credential, and the one this request came with expires; use a token or key without an expiry","exit_code":4}"#, match: "cmd")) | ||
| 75 | |||
| 76 | #expect(await model.create(name: "x", scope: "full", ttl: nil) == nil) | ||
| 77 | #expect(model.actionError?.contains("without an expiry") == true) | ||
| 78 | } | ||
| 79 | |||
| 80 | @Test func revokingCanTakeWhatTheTokenCreated() async throws { | ||
| 81 | let (model, stub) = try await loaded() | ||
| 82 | stub.enqueue(.init(status: 200, json: """ | ||
| 83 | {"protocol_version":1,"data":{"revoked":"iphone","created":{"tokens":["ci"],"keys":["SHA256:k"]},"created_revoked":true},"exit_code":0} | ||
| 84 | """, match: "cmd")) | ||
| 85 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | ||
| 86 | let token = try #require(model.state.value?.first) | ||
| 87 | |||
| 88 | let created = await model.revoke(token, withCreated: true) | ||
| 89 | |||
| 90 | #expect(try posted(stub) == [["token", "revoke", "iphone", "--created"]]) | ||
| 91 | #expect(created?.tokens == ["ci"]) | ||
| 92 | #expect(created?.keys == ["SHA256:k"]) | ||
| 93 | } | ||
| 94 | |||
| 95 | @Test func revokingAloneLeavesWhatItCreated() async throws { | ||
| 96 | let (model, stub) = try await loaded() | ||
| 97 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"revoked":"ci","created":{"tokens":null,"keys":null},"created_revoked":false},"exit_code":0}"#, match: "cmd")) | ||
| 98 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | ||
| 99 | let token = try #require(model.state.value?.last) | ||
| 100 | |||
| 101 | let created = await model.revoke(token, withCreated: false) | ||
| 102 | |||
| 103 | #expect(try posted(stub) == [["token", "revoke", "ci"]]) | ||
| 104 | #expect(created?.isEmpty == true) | ||
| 105 | } | ||
| 106 | } | ||