Login lock, lock account switching and lock hidden files no longer depend on each other: passGate prompts on its own flag, the two sub-locks can be set with login lock off, and disabling login lock leaves them alone. Turning any lock on or off asks for device auth (on also checks the device can). The iOS Share Extension's unlock rules follow. The avatar dropdown's additional accounts lose the 44px spacer so their avatars sit at the right edge. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
251 lines
11 KiB
Swift
251 lines
11 KiB
Swift
import Flutter
|
|
import UIKit
|
|
import XCTest
|
|
|
|
@testable import Runner
|
|
|
|
class RunnerTests: XCTestCase {
|
|
|
|
func testFileURLEncodesEachSegment() {
|
|
let url = WebDAV.fileURL(
|
|
serverUrl: "https://cloud.example.com", username: "alice",
|
|
remotePath: "/Tax Forms/2026 #1/a&b.pdf")
|
|
XCTAssertEqual(
|
|
url?.absoluteString,
|
|
"https://cloud.example.com/remote.php/dav/files/alice/Tax%20Forms/2026%20%231/a&b.pdf")
|
|
}
|
|
|
|
func testFileURLKeepsServerSubPathAndDropsTrailingSlash() {
|
|
let url = WebDAV.fileURL(
|
|
serverUrl: "https://example.com/nextcloud/", username: "bob@home", remotePath: "docs/x.txt")
|
|
XCTAssertEqual(
|
|
url?.absoluteString,
|
|
"https://example.com/nextcloud/remote.php/dav/files/bob@home/docs/x.txt")
|
|
}
|
|
|
|
func testFileURLForRoot() {
|
|
XCTAssertEqual(
|
|
WebDAV.fileURL(serverUrl: "https://c.io", username: "u", remotePath: "/")?.absoluteString,
|
|
"https://c.io/remote.php/dav/files/u")
|
|
}
|
|
|
|
func testJoinUsesOneSlash() {
|
|
XCTAssertEqual(WebDAV.join("/", "a.txt"), "/a.txt")
|
|
XCTAssertEqual(WebDAV.join("/docs", "a.txt"), "/docs/a.txt")
|
|
XCTAssertEqual(WebDAV.join("/docs/", "a.txt"), "/docs/a.txt")
|
|
}
|
|
|
|
func testUniqueURLNumbersCollisions() throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("noo-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a.txt")
|
|
FileManager.default.createFile(atPath: dir.appendingPathComponent("a.txt").path, contents: nil)
|
|
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (1).txt")
|
|
FileManager.default.createFile(atPath: dir.appendingPathComponent("a (1).txt").path, contents: nil)
|
|
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (2).txt")
|
|
FileManager.default.createFile(atPath: dir.appendingPathComponent("README").path, contents: nil)
|
|
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "README").lastPathComponent, "README (1)")
|
|
}
|
|
|
|
// MARK: - SharedInbox (Share Extension hand-off)
|
|
|
|
private func makeRoot() throws -> URL {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("noo-inbox-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
|
return root
|
|
}
|
|
|
|
private func makeSharedFile(in root: URL, name: String, contents: String = "hi") throws -> SharedItem {
|
|
let batch = try SharedInbox.makeBatchDirectory(in: root)
|
|
let file = batch.appendingPathComponent(name)
|
|
try contents.write(to: file, atomically: true, encoding: .utf8)
|
|
return SharedItem(path: file.path, name: name, mimeType: "text/plain", size: Int64(contents.utf8.count))
|
|
}
|
|
|
|
func testInboxConsumeReturnsAppendedItemsOnceThenNothing() throws {
|
|
let root = try makeRoot()
|
|
let a = try makeSharedFile(in: root, name: "a.txt")
|
|
let b = try makeSharedFile(in: root, name: "b.txt")
|
|
try SharedInbox.append([a], in: root)
|
|
try SharedInbox.append([b], in: root)
|
|
|
|
XCTAssertEqual(SharedInbox.consume(in: root), [a, b])
|
|
XCTAssertEqual(SharedInbox.consume(in: root), [], "each share is delivered once")
|
|
}
|
|
|
|
func testInboxConsumeDropsFilesThatNoLongerExist() throws {
|
|
let root = try makeRoot()
|
|
let kept = try makeSharedFile(in: root, name: "kept.txt")
|
|
let gone = try makeSharedFile(in: root, name: "gone.txt")
|
|
try SharedInbox.append([kept, gone], in: root)
|
|
try FileManager.default.removeItem(atPath: gone.path)
|
|
|
|
XCTAssertEqual(SharedInbox.consume(in: root), [kept])
|
|
}
|
|
|
|
func testInboxConsumeWithNothingPendingIsEmpty() throws {
|
|
XCTAssertEqual(SharedInbox.consume(in: try makeRoot()), [])
|
|
}
|
|
|
|
func testInboxAppendOfNothingCreatesNoManifest() throws {
|
|
let root = try makeRoot()
|
|
try SharedInbox.append([], in: root)
|
|
XCTAssertEqual(SharedInbox.consume(in: root), [])
|
|
}
|
|
|
|
// MARK: - DavFolderParser (Share Extension folder picker)
|
|
|
|
private func multistatus(_ responses: [(href: String, collection: Bool)]) -> Data {
|
|
let body = responses.map { r in
|
|
"""
|
|
<d:response><d:href>\(r.href)</d:href><d:propstat><d:prop>\
|
|
<d:resourcetype>\(r.collection ? "<d:collection/>" : "")</d:resourcetype>\
|
|
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
|
"""
|
|
}.joined()
|
|
return Data(
|
|
"<?xml version=\"1.0\"?><d:multistatus xmlns:d=\"DAV:\">\(body)</d:multistatus>".utf8)
|
|
}
|
|
|
|
func testFoldersListsOnlyChildFoldersSortedWithoutHidden() {
|
|
let xml = multistatus([
|
|
("/remote.php/dav/files/alice/Docs/", true),
|
|
("/remote.php/dav/files/alice/Docs/zeta/", true),
|
|
("/remote.php/dav/files/alice/Docs/Tax%20Forms/", true),
|
|
("/remote.php/dav/files/alice/Docs/note.txt", false),
|
|
("/remote.php/dav/files/alice/Docs/.git/", true),
|
|
])
|
|
XCTAssertEqual(
|
|
DavFolderParser.folders(from: xml, excluding: "/Docs"),
|
|
[DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")])
|
|
XCTAssertEqual(
|
|
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .include).map(\.name),
|
|
[".git", "Tax Forms", "zeta"])
|
|
XCTAssertEqual(
|
|
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .only).map(\.name),
|
|
[".git"])
|
|
}
|
|
|
|
func testHiddenFilterTreatsAnyDotSegmentAsHidden() {
|
|
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache"))
|
|
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache/inside/deeper"), "children of a hidden folder are hidden")
|
|
XCTAssertFalse(HiddenFilter.isHidden(path: "/Docs/Tax Forms"))
|
|
XCTAssertTrue(HiddenFilter.hide.shows(path: "/Docs"))
|
|
XCTAssertFalse(HiddenFilter.hide.shows(path: "/.git"))
|
|
XCTAssertTrue(HiddenFilter.only.shows(path: "/.git/hooks"))
|
|
XCTAssertFalse(HiddenFilter.only.shows(path: "/Docs"))
|
|
XCTAssertTrue(HiddenFilter.include.shows(path: "/Docs"))
|
|
XCTAssertEqual(HiddenFilter(raw: "include"), .include)
|
|
XCTAssertEqual(HiddenFilter(raw: "nonsense"), .hide, "unknown values fall back to hiding")
|
|
}
|
|
|
|
func testFoldersAtRootAndUnderServerSubPath() {
|
|
let xml = multistatus([
|
|
("/nextcloud/remote.php/dav/files/bob/", true),
|
|
("/nextcloud/remote.php/dav/files/bob/Photos/", true),
|
|
])
|
|
XCTAssertEqual(
|
|
DavFolderParser.folders(from: xml, excluding: "/"),
|
|
[DavFolder(name: "Photos", path: "/Photos")])
|
|
}
|
|
|
|
func testRelativePath() {
|
|
XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/B%20C/D/"), "/B C/D")
|
|
XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/"), "/")
|
|
XCTAssertNil(DavFolderParser.relativePath(fromHref: "/somewhere/else"))
|
|
}
|
|
|
|
func testFoldersIgnoresGarbage() {
|
|
XCTAssertEqual(DavFolderParser.folders(from: Data("not xml".utf8), excluding: "/"), [])
|
|
}
|
|
|
|
// MARK: - Shared account (Keychain group shared with the extension)
|
|
|
|
private func account(_ id: String, hidden: String = "hide") -> SharedAccount {
|
|
SharedAccount(
|
|
id: id, serverUrl: "https://\(id).example.com", username: id,
|
|
authHeader: "Basic \(id)", displayName: "\(id)@\(id).example.com", hiddenFilter: hidden)
|
|
}
|
|
|
|
func testSharedAccountsRoundTripThroughTheKeychain() throws {
|
|
let accounts = SharedAccounts(
|
|
accounts: [account("alice", hidden: "include"), account("bob")], activeId: "bob",
|
|
loginLockEnabled: true, lockAccountSwitching: true, lockHiddenFiles: false)
|
|
addTeardownBlock { SharedAccountStore.clear() }
|
|
|
|
SharedAccountStore.clear()
|
|
XCTAssertNil(SharedAccountStore.load())
|
|
try SharedAccountStore.save(accounts)
|
|
XCTAssertEqual(SharedAccountStore.load(), accounts)
|
|
|
|
let replacement = SharedAccounts(
|
|
accounts: [account("carol")], activeId: "carol",
|
|
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
|
|
try SharedAccountStore.save(replacement)
|
|
XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates")
|
|
|
|
SharedAccountStore.clear()
|
|
XCTAssertNil(SharedAccountStore.load())
|
|
}
|
|
|
|
func testSharedAccountsActiveFallsBackToTheFirstAccount() {
|
|
var shared = SharedAccounts(
|
|
accounts: [account("alice"), account("bob")], activeId: "bob",
|
|
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
|
|
XCTAssertEqual(shared.active?.id, "bob")
|
|
shared.activeId = "gone"
|
|
XCTAssertEqual(shared.active?.id, "alice")
|
|
shared.activeId = nil
|
|
XCTAssertEqual(shared.active?.id, "alice")
|
|
}
|
|
|
|
func testUnlockRulesAreIndependentOfLoginLock() {
|
|
func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) {
|
|
let shared = SharedAccounts(
|
|
accounts: [account("a")], activeId: "a",
|
|
loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden)
|
|
return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden)
|
|
}
|
|
XCTAssertTrue(rules(master: false, switching: true, hidden: false) == (true, false))
|
|
XCTAssertTrue(rules(master: false, switching: false, hidden: true) == (false, true))
|
|
XCTAssertTrue(rules(master: true, switching: false, hidden: false) == (false, false))
|
|
XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true))
|
|
}
|
|
|
|
// MARK: - TransferBatchStore
|
|
|
|
func testBatchSummaryFiresOnlyOnTheLastFile() {
|
|
let batchId = UUID().uuidString
|
|
TransferBatchStore.save(
|
|
TransferBatch(kind: .upload, remoteFolder: "/Docs", total: 3, succeeded: 0, failed: 0), id: batchId)
|
|
let info = TransferTaskInfo(kind: .upload, batch: batchId, name: "a", remoteFolder: "/Docs", stagedPath: nil)
|
|
|
|
XCTAssertNil(TransferBatchStore.record(info, success: true))
|
|
XCTAssertNil(TransferBatchStore.record(info, success: false))
|
|
let finished = TransferBatchStore.record(info, success: true)
|
|
XCTAssertEqual(finished?.succeeded, 2)
|
|
XCTAssertEqual(finished?.failed, 1)
|
|
XCTAssertEqual(finished?.remoteFolder, "/Docs")
|
|
}
|
|
|
|
func testFinishedUploadFoldersAreNotedOnceAndConsumed() {
|
|
_ = TransferBatchStore.consumeFinishedUploadFolders() // start clean
|
|
TransferBatchStore.noteFinishedUpload(folder: "/Docs")
|
|
TransferBatchStore.noteFinishedUpload(folder: "/Docs")
|
|
TransferBatchStore.noteFinishedUpload(folder: "/Photos")
|
|
|
|
XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), ["/Docs", "/Photos"])
|
|
XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), [])
|
|
}
|
|
|
|
func testNotificationFileCountWording() {
|
|
XCTAssertEqual(TransferNotifications.files(1), "1 file")
|
|
XCTAssertEqual(TransferNotifications.files(3), "3 files")
|
|
}
|
|
}
|