Two leak paths: (a) AtomicFile.write renames the temp file onto the probe path and THEN fsyncs the containing directory — if that last fsync throws, the probe file already exists on disk but the old code returned false without ever attempting removal; (b) if the explicit removeItem call itself threw, there was no retry, so a transient File Provider removal failure left the file behind permanently. Fix: an unconditional `defer` now checks fileExists and retries removeItem regardless of which branch returned early. The function only reports true when the explicit write, fsync (inside AtomicFile.write), AND removal all succeeded AND the file is confirmed gone afterward. New test: a FileManager subclass whose removeItem(at:) throws on its first call (AtomicFile.write itself never touches this injected FileManager — it uses raw Darwin/POSIX calls, not FileManager, so this only intercepts the explicit removal + the defer's retry) asserts the function returns false AND no probe file remains — verified this actually needs the defer by temporarily removing it and confirming the same test then fails with a leftover ".redline-probe-<uuid>" file (see this branch's history for the discarded revert). The directory-fsync failure path has no injectable seam (raw fsync(2) on an already-open fd, not parameterized by any FileManager or other substitutable dependency, and not reproducible via chmod or other standard test techniques) — documented in the test rather than simulated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
228 lines
9.6 KiB
Swift
228 lines
9.6 KiB
Swift
import Foundation
|
|
import Testing
|
|
import ShotdeckCore
|
|
|
|
@Test
|
|
func transportDefaultsToAirDropWhenUnset() throws {
|
|
let suite = try makeTransportDefaultsSuite()
|
|
defer { tearDownTransportSuite(suite) }
|
|
|
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
|
|
}
|
|
|
|
@Test
|
|
func setTransportRoundTrips() throws {
|
|
let suite = try makeTransportDefaultsSuite()
|
|
defer { tearDownTransportSuite(suite) }
|
|
|
|
TransportSettings.setTransport(.oneDrive, defaults: suite.defaults)
|
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .oneDrive)
|
|
|
|
TransportSettings.setTransport(.airDrop, defaults: suite.defaults)
|
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
|
|
}
|
|
|
|
@Test
|
|
func garbageStoredTransportFallsBackToAirDrop() throws {
|
|
let suite = try makeTransportDefaultsSuite()
|
|
defer { tearDownTransportSuite(suite) }
|
|
|
|
suite.defaults.set("not-a-real-transport", forKey: TransportSettings.transportDefaultsKey)
|
|
#expect(TransportSettings.transport(defaults: suite.defaults) == .airDrop)
|
|
}
|
|
|
|
@Test
|
|
func oneDriveFolderStoreAndReset() throws {
|
|
let suite = try makeTransportDefaultsSuite()
|
|
defer { tearDownTransportSuite(suite) }
|
|
let folder = try makeTransportTemporaryDirectory(prefix: "shotdeck-onedrive-folder")
|
|
defer { try? FileManager.default.removeItem(at: folder) }
|
|
|
|
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == nil)
|
|
|
|
TransportSettings.setOneDriveFolder(folder, defaults: suite.defaults)
|
|
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == folder.path)
|
|
|
|
TransportSettings.resetOneDriveFolder(defaults: suite.defaults)
|
|
#expect(TransportSettings.storedOneDriveFolderPath(defaults: suite.defaults) == nil)
|
|
}
|
|
|
|
@Test
|
|
func effectiveFoldersForAirDropMatchesFolderSettings() throws {
|
|
let suite = try makeTransportDefaultsSuite()
|
|
defer { tearDownTransportSuite(suite) }
|
|
let outbox = try makeTransportTemporaryDirectory(prefix: "shotdeck-effective-outbox")
|
|
defer { try? FileManager.default.removeItem(at: outbox) }
|
|
FolderSettings.setOutbox(outbox, defaults: suite.defaults)
|
|
|
|
let effective = TransportSettings.effectiveFolders(defaults: suite.defaults)
|
|
let expected = FolderSettings.resolve(defaults: suite.defaults)
|
|
|
|
#expect(effective.transport == .airDrop)
|
|
#expect(effective.outbox.path == expected.outbox.path)
|
|
#expect(effective.watch.path == expected.watch.path)
|
|
}
|
|
|
|
@Test
|
|
func effectiveFoldersForOneDriveWithAResolvableFolderUsesItForBoth() throws {
|
|
let suite = try makeTransportDefaultsSuite()
|
|
defer { tearDownTransportSuite(suite) }
|
|
let folder = try makeTransportTemporaryDirectory(prefix: "shotdeck-effective-onedrive")
|
|
defer { try? FileManager.default.removeItem(at: folder) }
|
|
|
|
TransportSettings.setTransport(.oneDrive, defaults: suite.defaults)
|
|
TransportSettings.setOneDriveFolder(folder, defaults: suite.defaults)
|
|
|
|
let effective = TransportSettings.effectiveFolders(defaults: suite.defaults)
|
|
|
|
#expect(effective.transport == .oneDrive)
|
|
#expect(effective.outbox.path == folder.path)
|
|
#expect(effective.watch.path == folder.path)
|
|
#expect(effective.outbox.path == effective.watch.path)
|
|
}
|
|
|
|
@Test
|
|
func oneDriveFolderUnavailableErrorDescriptionContainsThePath() throws {
|
|
let path = "/Users/example/Library/CloudStorage/OneDrive-Example/Redline"
|
|
let error = ShotdeckError.oneDriveFolderUnavailable(path: path)
|
|
let description = try #require(error.errorDescription)
|
|
#expect(!description.isEmpty)
|
|
#expect(description.contains(path))
|
|
}
|
|
|
|
@Test
|
|
func isWritableDirectoryTrueForAnOrdinaryWritableDirectory() throws {
|
|
let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-writable")
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
#expect(OneDriveLocator.isWritableDirectory(at: dir))
|
|
}
|
|
|
|
@Test
|
|
func isWritableDirectoryFalseForAnExistingButUnwritableDirectory() throws {
|
|
let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-unwritable")
|
|
defer {
|
|
// Restore perms BEFORE removal — an unwritable dir can't otherwise be cleaned up.
|
|
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dir.path)
|
|
try? FileManager.default.removeItem(at: dir)
|
|
}
|
|
#expect(OneDriveLocator.isWritableDirectory(at: dir)) // sanity check before chmod
|
|
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: dir.path)
|
|
#expect(!OneDriveLocator.isWritableDirectory(at: dir))
|
|
}
|
|
|
|
@Test
|
|
func isWritableDirectoryFalseForAPlainFileAndForANonexistentPath() throws {
|
|
let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-writable-check-parent")
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
let filePath = dir.appendingPathComponent("plain-file.txt")
|
|
FileManager.default.createFile(atPath: filePath.path, contents: Data("x".utf8))
|
|
|
|
#expect(!OneDriveLocator.isWritableDirectory(at: filePath))
|
|
#expect(!OneDriveLocator.isWritableDirectory(at: dir.appendingPathComponent("does-not-exist")))
|
|
}
|
|
|
|
@Test
|
|
func probeWritableTrueForAnOrdinaryWritableDirectoryAndLeavesNoProbeFileBehind() throws {
|
|
let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-probe-writable")
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
#expect(OneDriveLocator.probeWritable(at: dir))
|
|
|
|
let leftovers = try FileManager.default.contentsOfDirectory(atPath: dir.path)
|
|
#expect(leftovers.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func probeWritableFalseForAChmod500Directory() throws {
|
|
// The File Provider edge case this probe exists for: isWritableDirectory can be
|
|
// true (as verified by the isWritableDirectory tests above) while an actual write
|
|
// still fails. A chmod 500 directory reproduces that "looks writable, isn't"
|
|
// shape closely enough to prove the probe itself does a real write, not just
|
|
// another permissions-bit check.
|
|
let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-probe-unwritable")
|
|
defer {
|
|
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dir.path)
|
|
try? FileManager.default.removeItem(at: dir)
|
|
}
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: dir.path)
|
|
|
|
#expect(!OneDriveLocator.probeWritable(at: dir))
|
|
}
|
|
|
|
@Test
|
|
func probeWritableFalseForAPlainFilePath() throws {
|
|
let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-probe-file-parent")
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
let filePath = dir.appendingPathComponent("plain-file.txt")
|
|
FileManager.default.createFile(atPath: filePath.path, contents: Data("x".utf8))
|
|
|
|
#expect(!OneDriveLocator.probeWritable(at: filePath))
|
|
}
|
|
|
|
/// The write itself succeeds (a real probe file lands on disk via AtomicFile.write,
|
|
/// which never touches this injected FileManager — it uses raw POSIX calls), but the
|
|
/// FIRST call to `removeItem(at:)` throws, simulating a transient File Provider
|
|
/// removal failure. probeWritable's own defer-based cleanup must retry and succeed
|
|
/// (the second call through this same override falls through to `super`), so no
|
|
/// probe file is left behind even though the function correctly still reports false
|
|
/// (the removal it explicitly attempted did fail).
|
|
private final class ThrowOnceOnRemoveFileManager: FileManager, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var hasThrown = false
|
|
|
|
override func removeItem(at URL: URL) throws {
|
|
lock.lock()
|
|
let shouldThrow = !hasThrown
|
|
hasThrown = true
|
|
lock.unlock()
|
|
if shouldThrow {
|
|
throw NSError(domain: "ShotdeckCoreTests.ThrowOnceOnRemove", code: 1)
|
|
}
|
|
try super.removeItem(at: URL)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func probeWritableFalseAndLeavesNoProbeFileWhenRemoveItemThrowsOnce() throws {
|
|
// Directory fsync failure (the OTHER way probeWritable's cleanup can be needed) has
|
|
// no injectable seam: AtomicFile.write's directory fsync is a raw Darwin fsync(2)
|
|
// call on an already-open file descriptor, not parameterized by any FileManager or
|
|
// other dependency this test can substitute, and there is no portable way to make
|
|
// fsync(2) itself fail via chmod or other standard test techniques (fsync failures
|
|
// are OS/filesystem/hardware-level events). Covering the removeItem-throws path
|
|
// (below) is what this test does; the fsync-throws path is covered by code
|
|
// inspection only — the same `defer` block guards both.
|
|
let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-probe-remove-throws")
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
let injectedFileManager = ThrowOnceOnRemoveFileManager()
|
|
|
|
#expect(!OneDriveLocator.probeWritable(at: dir, fileManager: injectedFileManager))
|
|
|
|
let leftovers = try FileManager.default.contentsOfDirectory(atPath: dir.path)
|
|
#expect(leftovers.isEmpty)
|
|
}
|
|
|
|
struct TransportDefaultsSuite {
|
|
let name: String
|
|
let defaults: UserDefaults
|
|
}
|
|
|
|
func makeTransportDefaultsSuite() throws -> TransportDefaultsSuite {
|
|
let name = "shotdeck-transport-test-\(UUID().uuidString)"
|
|
let defaults = try #require(UserDefaults(suiteName: name))
|
|
defaults.removePersistentDomain(forName: name)
|
|
return TransportDefaultsSuite(name: name, defaults: defaults)
|
|
}
|
|
|
|
func tearDownTransportSuite(_ suite: TransportDefaultsSuite) {
|
|
suite.defaults.removePersistentDomain(forName: suite.name)
|
|
}
|
|
|
|
func makeTransportTemporaryDirectory(prefix: String) throws -> URL {
|
|
let url = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
return url
|
|
}
|