Moves socket control password from the macOS login keychain to a plain file at ~/Library/Application Support/cmux/socket-control-password. This eliminates the system keychain prompt that interrupts users on first launch or after keychain changes. - Directory created with 0700, file written with 0600 permissions - One-time migration copies existing keychain password to the file, deletes the keychain entry, and records a migration version in UserDefaults so it runs only once - CLI SocketPasswordResolver also reads from the file path - Security framework import is now conditional (#if canImport) - Adds SocketControlPasswordStoreTests covering round-trip, env priority, path resolution, and migration behavior Fixes https://github.com/manaflow-ai/cmux/issues/541
111 lines
4.9 KiB
Swift
111 lines
4.9 KiB
Swift
import XCTest
|
|
|
|
#if canImport(cmux_DEV)
|
|
@testable import cmux_DEV
|
|
#elseif canImport(cmux)
|
|
@testable import cmux
|
|
#endif
|
|
|
|
final class SocketControlPasswordStoreTests: XCTestCase {
|
|
func testSaveLoadAndClearRoundTripUsesFileStorage() throws {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
|
|
|
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
|
|
|
|
XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
|
|
|
|
try SocketControlPasswordStore.savePassword("hunter2", fileURL: fileURL)
|
|
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "hunter2")
|
|
XCTAssertTrue(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
|
|
|
|
try SocketControlPasswordStore.clearPassword(fileURL: fileURL)
|
|
XCTAssertNil(try SocketControlPasswordStore.loadPassword(fileURL: fileURL))
|
|
XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
|
|
}
|
|
|
|
func testConfiguredPasswordPrefersEnvironmentOverStoredFile() throws {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
|
|
|
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
|
|
try SocketControlPasswordStore.savePassword("stored-secret", fileURL: fileURL)
|
|
|
|
let environment = [SocketControlSettings.socketPasswordEnvKey: "env-secret"]
|
|
let configured = SocketControlPasswordStore.configuredPassword(
|
|
environment: environment,
|
|
fileURL: fileURL
|
|
)
|
|
XCTAssertEqual(configured, "env-secret")
|
|
}
|
|
|
|
func testDefaultPasswordFileURLUsesCmuxAppSupportPath() throws {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
|
|
|
let resolved = SocketControlPasswordStore.defaultPasswordFileURL(appSupportDirectory: tempDir)
|
|
XCTAssertEqual(
|
|
resolved?.path,
|
|
tempDir.appendingPathComponent("cmux", isDirectory: true)
|
|
.appendingPathComponent("socket-control-password", isDirectory: false).path
|
|
)
|
|
}
|
|
|
|
func testLegacyKeychainMigrationCopiesPasswordDeletesLegacyAndRunsOnlyOnce() throws {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
|
|
|
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
|
|
let defaultsSuiteName = "cmux-socket-password-migration-tests-\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: defaultsSuiteName) else {
|
|
XCTFail("Expected isolated UserDefaults suite for migration test")
|
|
return
|
|
}
|
|
defer { defaults.removePersistentDomain(forName: defaultsSuiteName) }
|
|
|
|
var lookupCount = 0
|
|
var deleteCount = 0
|
|
|
|
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(
|
|
defaults: defaults,
|
|
fileURL: fileURL,
|
|
loadLegacyPassword: {
|
|
lookupCount += 1
|
|
return "legacy-secret"
|
|
},
|
|
deleteLegacyPassword: {
|
|
deleteCount += 1
|
|
return true
|
|
}
|
|
)
|
|
|
|
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret")
|
|
XCTAssertEqual(lookupCount, 1)
|
|
XCTAssertEqual(deleteCount, 1)
|
|
|
|
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(
|
|
defaults: defaults,
|
|
fileURL: fileURL,
|
|
loadLegacyPassword: {
|
|
lookupCount += 1
|
|
return "new-value"
|
|
},
|
|
deleteLegacyPassword: {
|
|
deleteCount += 1
|
|
return true
|
|
}
|
|
)
|
|
|
|
XCTAssertEqual(lookupCount, 1)
|
|
XCTAssertEqual(deleteCount, 1)
|
|
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret")
|
|
}
|
|
}
|