Files
steipete--codexbar/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift
2026-07-13 12:22:33 +08:00

774 lines
32 KiB
Swift

import Foundation
import Testing
@testable import CodexBarCore
@Suite(.serialized)
struct ClaudeOAuthDelegatedRefreshCoordinatorTests {
private enum StubError: Error, LocalizedError {
case failed
var errorDescription: String? {
switch self {
case .failed:
"failed"
}
}
}
private func makeCredentialsData(accessToken: String, expiresAt: Date) -> Data {
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
let json = """
{
"claudeAiOauth": {
"accessToken": "\(accessToken)",
"expiresAt": \(millis),
"scopes": ["user:profile"]
}
}
"""
return Data(json.utf8)
}
private func withCoordinatorOverrides<T>(
isolateState: Bool = true,
cliAvailable: Bool? = nil,
touchAuthPath: (@Sendable (TimeInterval, [String: String]) async throws -> Void)? = nil,
keychainFingerprint: (@Sendable () -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?)? = nil,
operation: () async throws -> T) async rethrows -> T
{
if isolateState {
return try await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
return try await ClaudeOAuthDelegatedRefreshCoordinator.withKeychainFingerprintOverrideForTesting(
keychainFingerprint)
{
try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting(
cliAvailable)
{
try await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting(
touchAuthPath)
{
try await operation()
}
}
}
}
}
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
return try await ClaudeOAuthDelegatedRefreshCoordinator.withKeychainFingerprintOverrideForTesting(
keychainFingerprint)
{
try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting(cliAvailable) {
try await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting(
touchAuthPath)
{
try await operation()
}
}
}
}
@Test
func `cooldown prevents repeated attempts`() async {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
final class FingerprintBox: @unchecked Sendable {
var fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?
init(_ fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?) {
self.fingerprint = fingerprint
}
}
let box = FingerprintBox(ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "ref1"))
let start = Date(timeIntervalSince1970: 10000)
let (first, second) = await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityFramework)
{
await self.withCoordinatorOverrides(
cliAvailable: true,
touchAuthPath: { _, _ in
box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 2,
createdAt: 2,
persistentRefHash: "ref2")
},
keychainFingerprint: { box.fingerprint },
operation: {
let first = await ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: start, timeout: 0.1)
let second = await ClaudeOAuthDelegatedRefreshCoordinator
.attempt(now: start.addingTimeInterval(30), timeout: 0.1)
return (first, second)
})
}
#expect(first == .attemptedSucceeded)
#expect(second == .skippedByCooldown)
}
@Test
func `cli unavailable returns cli unavailable`() async {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
let outcome = await self.withCoordinatorOverrides(cliAvailable: false, operation: {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 20000),
timeout: 0.1)
})
#expect(outcome == .cliUnavailable)
}
@Test
func `successful auth touch reports attempted succeeded`() async {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
final class FingerprintBox: @unchecked Sendable {
var fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?
init(_ fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?) {
self.fingerprint = fingerprint
}
}
let box = FingerprintBox(ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 10,
createdAt: 10,
persistentRefHash: "refA"))
let outcome = await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityFramework)
{
await self.withCoordinatorOverrides(
cliAvailable: true,
touchAuthPath: { _, _ in
box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 11,
createdAt: 11,
persistentRefHash: "refB")
},
keychainFingerprint: { box.fingerprint },
operation: {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 30000),
timeout: 0.1)
})
}
#expect(outcome == .attemptedSucceeded)
}
@Test
func `failed auth touch reports attempted failed`() async throws {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
let outcome = try await self.withCoordinatorOverrides(
cliAvailable: true,
touchAuthPath: { _, _ in
throw StubError.failed
},
keychainFingerprint: {
ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 20,
createdAt: 20,
persistentRefHash: "refX")
},
operation: {
await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 40000),
timeout: 0.1)
}
}
})
guard case let .attemptedFailed(message) = outcome else {
Issue.record("Expected .attemptedFailed outcome")
return
}
#expect(message.contains("failed"))
}
@Test
func `environment CLI override avoids CLI unavailable`() async throws {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
let stubCLI = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let script = "#!/bin/sh\nexit 0\n"
try script.write(to: stubCLI, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stubCLI.path)
let outcome = try await self.withCoordinatorOverrides(
touchAuthPath: { _, environment in
#expect(environment["CLAUDE_CLI_PATH"] == stubCLI.path)
throw StubError.failed
},
keychainFingerprint: {
ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 20,
createdAt: 20,
persistentRefHash: "ref-env")
},
operation: {
await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 45000),
timeout: 0.1,
environment: ["CLAUDE_CLI_PATH": stubCLI.path])
}
}
})
guard case let .attemptedFailed(message) = outcome else {
Issue.record("Expected env-provided CLI override to reach touch attempt")
return
}
#expect(message.contains("failed"))
}
@Test
func `concurrent attempts join in flight`() async {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
actor Gate {
private var startedContinuations: [CheckedContinuation<Void, Never>] = []
private var releaseContinuations: [CheckedContinuation<Void, Never>] = []
private var hasStarted = false
private var isReleased = false
func markStarted() {
self.hasStarted = true
let continuations = self.startedContinuations
self.startedContinuations.removeAll()
continuations.forEach { $0.resume() }
}
func waitStarted() async {
if self.hasStarted { return }
await withCheckedContinuation { cont in
self.startedContinuations.append(cont)
}
}
func release() {
self.isReleased = true
let continuations = self.releaseContinuations
self.releaseContinuations.removeAll()
continuations.forEach { $0.resume() }
}
func waitRelease() async {
if self.isReleased { return }
await withCheckedContinuation { cont in
self.releaseContinuations.append(cont)
}
}
}
final class FingerprintBox: @unchecked Sendable {
var fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?
init(_ fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?) {
self.fingerprint = fingerprint
}
}
final class CounterBox: @unchecked Sendable {
private let lock = NSLock()
private(set) var count: Int = 0
func increment() {
self.lock.lock()
self.count += 1
self.lock.unlock()
}
}
let counter = CounterBox()
let gate = Gate()
let box = FingerprintBox(ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "ref1"))
let now = Date(timeIntervalSince1970: 50000)
let outcomes = await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
await self.withCoordinatorOverrides(
isolateState: false,
cliAvailable: true,
touchAuthPath: { _, _ in
counter.increment()
await gate.markStarted()
await gate.waitRelease()
box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 2,
createdAt: 2,
persistentRefHash: "ref2")
try? await Task.sleep(nanoseconds: 50_000_000)
},
keychainFingerprint: { box.fingerprint },
operation: {
let first = Task {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: now, timeout: 2)
}
await gate.waitStarted()
let second = Task {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: now.addingTimeInterval(30),
timeout: 2)
}
await gate.release()
return await [first.value, second.value]
})
}
}
#expect(outcomes.allSatisfy { $0 == .attemptedSucceeded })
#expect(counter.count == 1)
}
@Test
func `user action retries after joining failed background attempt`() async throws {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
actor Gate {
private var releaseContinuation: CheckedContinuation<Void, Never>?
private var startedContinuation: CheckedContinuation<Void, Never>?
private var joinedContinuation: CheckedContinuation<Void, Never>?
private var hasStarted = false
private var isReleased = false
private var hasJoined = false
func markStarted() {
self.hasStarted = true
self.startedContinuation?.resume()
self.startedContinuation = nil
}
func waitStarted() async {
if self.hasStarted { return }
await withCheckedContinuation { self.startedContinuation = $0 }
}
func release() {
self.isReleased = true
self.releaseContinuation?.resume()
self.releaseContinuation = nil
}
func waitRelease() async {
if self.isReleased { return }
await withCheckedContinuation { self.releaseContinuation = $0 }
}
func markJoined() {
self.hasJoined = true
self.joinedContinuation?.resume()
self.joinedContinuation = nil
}
func waitJoined() async {
if self.hasJoined { return }
await withCheckedContinuation { self.joinedContinuation = $0 }
}
}
final class StateBox: @unchecked Sendable {
private let lock = NSLock()
private var touchCount = 0
private var fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "before")
func beginTouch() -> Int {
self.lock.lock()
defer { self.lock.unlock() }
self.touchCount += 1
return self.touchCount
}
func markChanged() {
self.lock.lock()
self.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 2,
createdAt: 2,
persistentRefHash: "after")
self.lock.unlock()
}
func snapshot() -> (Int, ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint) {
self.lock.lock()
defer { self.lock.unlock() }
return (self.touchCount, self.fingerprint)
}
}
let gate = Gate()
let state = StateBox()
let outcomes = try await KeychainAccessGate.withTaskOverrideForTesting(false) {
try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
try await self.withCoordinatorOverrides(
isolateState: false,
cliAvailable: true,
touchAuthPath: { _, _ in
if state.beginTouch() == 1 {
await gate.markStarted()
await gate.waitRelease()
throw StubError.failed
}
state.markChanged()
},
keychainFingerprint: { state.snapshot().1 },
operation: {
await ClaudeOAuthDelegatedRefreshCoordinator
.withUserInitiatedBackgroundJoinObserverForTesting {
Task { await gate.markJoined() }
} operation: {
let background = Task {
await ProviderInteractionContext.$current.withValue(.background) {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 51000),
timeout: 2)
}
}
await gate.waitStarted()
let userInitiated = Task {
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 51001),
timeout: 2)
}
}
await gate.waitJoined()
await gate.release()
return await (background.value, userInitiated.value)
}
})
}
}
guard case .attemptedFailed = outcomes.0 else {
Issue.record("Expected the background attempt to fail")
return
}
#expect(outcomes.1 == .attemptedSucceeded)
#expect(state.snapshot().0 == 2)
}
@Test
func `experimental strategy does not use security framework fingerprint observation`() async {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityCLIExperimental)
{
final class CounterBox: @unchecked Sendable {
private let lock = NSLock()
private(set) var count: Int = 0
func increment() {
self.lock.lock()
self.count += 1
self.lock.unlock()
}
}
let fingerprintCounter = CounterBox()
let securityData = self.makeCredentialsData(
accessToken: "security-token-a",
expiresAt: Date(timeIntervalSinceNow: 3600))
let outcome = await self.withCoordinatorOverrides(
cliAvailable: true,
touchAuthPath: { _, _ in },
keychainFingerprint: {
fingerprintCounter.increment()
return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "framework-fingerprint")
},
operation: {
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 60000),
timeout: 0.1)
}
})
guard case .attemptedFailed = outcome else {
Issue.record("Expected .attemptedFailed outcome")
return
}
#expect(fingerprintCounter.count < 1)
}
}
}
@Test
func `experimental strategy observes security CLI change after touch`() async {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityCLIExperimental)
{
final class DataBox: @unchecked Sendable {
private let lock = NSLock()
private var _data: Data?
init(data: Data?) {
self._data = data
}
func load() -> Data? {
self.lock.lock()
defer { self.lock.unlock() }
return self._data
}
func store(_ data: Data?) {
self.lock.lock()
self._data = data
self.lock.unlock()
}
}
final class CounterBox: @unchecked Sendable {
private let lock = NSLock()
private(set) var count: Int = 0
func increment() {
self.lock.lock()
self.count += 1
self.lock.unlock()
}
}
let fingerprintCounter = CounterBox()
let beforeData = self.makeCredentialsData(
accessToken: "security-token-before",
expiresAt: Date(timeIntervalSinceNow: -60))
let afterData = self.makeCredentialsData(
accessToken: "security-token-after",
expiresAt: Date(timeIntervalSinceNow: 3600))
let dataBox = DataBox(data: beforeData)
let outcome = await self.withCoordinatorOverrides(
cliAvailable: true,
touchAuthPath: { _, _ in
dataBox.store(afterData)
},
keychainFingerprint: {
fingerprintCounter.increment()
return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 11,
createdAt: 11,
persistentRefHash: "framework-fingerprint")
},
operation: {
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
.dynamic { _ in dataBox.load() })
{
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 61000),
timeout: 0.1)
}
})
#expect(outcome == .attemptedSucceeded)
#expect(fingerprintCounter.count < 1)
}
}
}
@Test
func `experimental strategy missing baseline does not auto succeed when later read succeeds`() async {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityCLIExperimental)
{
final class DataBox: @unchecked Sendable {
private let lock = NSLock()
private var _data: Data?
init(data: Data?) {
self._data = data
}
func load() -> Data? {
self.lock.lock()
defer { self.lock.unlock() }
return self._data
}
func store(_ data: Data?) {
self.lock.lock()
self._data = data
self.lock.unlock()
}
}
final class CounterBox: @unchecked Sendable {
private let lock = NSLock()
private(set) var count: Int = 0
func increment() {
self.lock.lock()
self.count += 1
self.lock.unlock()
}
}
let fingerprintCounter = CounterBox()
let afterData = self.makeCredentialsData(
accessToken: "security-token-after-baseline-miss",
expiresAt: Date(timeIntervalSinceNow: 3600))
let dataBox = DataBox(data: nil)
let outcome = await self.withCoordinatorOverrides(
cliAvailable: true,
touchAuthPath: { _, _ in
dataBox.store(afterData)
},
keychainFingerprint: {
fingerprintCounter.increment()
return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 21,
createdAt: 21,
persistentRefHash: "framework-fingerprint")
},
operation: {
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
.dynamic { _ in dataBox.load() })
{
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 61500),
timeout: 0.1)
}
})
guard case .attemptedFailed = outcome else {
Issue.record("Expected .attemptedFailed outcome when baseline is unavailable")
return
}
#expect(fingerprintCounter.count < 1)
}
}
}
@Test
func `experimental strategy observation skips security CLI when global keychain disabled`() async {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityCLIExperimental)
{
final class CounterBox: @unchecked Sendable {
private let lock = NSLock()
private(set) var count: Int = 0
func increment() {
self.lock.lock()
self.count += 1
self.lock.unlock()
}
}
let securityReadCounter = CounterBox()
let securityData = self.makeCredentialsData(
accessToken: "security-should-not-be-read",
expiresAt: Date(timeIntervalSinceNow: 3600))
let outcome = await self.withCoordinatorOverrides(
cliAvailable: true,
touchAuthPath: { _, _ in },
operation: {
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in
securityReadCounter.increment()
return securityData
}) {
await KeychainAccessGate.withTaskOverrideForTesting(true) {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 62000),
timeout: 0.1)
}
}
})
guard case .attemptedFailed = outcome else {
Issue.record("Expected .attemptedFailed outcome")
return
}
#expect(securityReadCounter.count < 1)
}
}
@Test
func `experimental strategy blocks background mcp O auth but lets user action retry`() async {
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityCLIExperimental)
{
final class StateBox: @unchecked Sendable {
private let lock = NSLock()
private var touchCount = 0
func touch() {
self.lock.lock()
self.touchCount += 1
self.lock.unlock()
}
func count() -> Int {
self.lock.lock()
defer { self.lock.unlock() }
return self.touchCount
}
}
let state = StateBox()
let mcpOAuthOnly = Data("""
{
"mcpOAuth": {
"plugin:slack:slack": { "accessToken": "" }
}
}
""".utf8)
let refreshedCredentials = self.makeCredentialsData(
accessToken: "refreshed-after-user-action",
expiresAt: Date(timeIntervalSinceNow: 3600))
let outcomes = await self.withCoordinatorOverrides(
cliAvailable: true,
touchAuthPath: { _, _ in state.touch() },
operation: {
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in
state.count() > 0 ? refreshedCredentials : mcpOAuthOnly
}) {
let background = await ProviderInteractionContext.$current.withValue(.background) {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 63000),
timeout: 0.1)
}
let backgroundTouchCount = state.count()
let userInitiated = await ProviderInteractionContext.$current.withValue(.userInitiated) {
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
now: Date(timeIntervalSince1970: 63001),
timeout: 0.1)
}
return (background, backgroundTouchCount, userInitiated)
}
})
guard case let .attemptedFailed(message) = outcomes.0 else {
Issue.record("Expected background .attemptedFailed outcome")
return
}
#expect(message.contains("MCP OAuth"))
#expect(outcomes.1 == 0)
#expect(outcomes.2 == .attemptedSucceeded)
#expect(state.count() == 1)
}
}
}
}