field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit df3a9a82c55ef4fbcc691467d54f7247b24e9194
parent 6bf34408018038fc1147ae51fd8982910035556c
Author: triesap <tyson@radroots.org>
Date:   Fri, 28 Aug 2026 17:55:27 +0000

ios: qualify deterministic local-social personas

- add strict five-persona fixtures and signature-verified localhost evidence
- isolate identities, stores, media, and background sessions per persona
- exercise 15 UI-only flows with validation, retry, and accessibility vectors
- verify canonical results, loopback-only networking, and generated project state

Diffstat:
MREADME.md | 21+++++++++++++++++++--
MRadroots.xcodeproj/project.pbxproj | 20++++++++++++++++++++
MRadroots/App/RadrootsRemoteQualification.swift | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MRadroots/Runtime/RadrootsAddMediaCoordinator.swift | 8+++++++-
MRadroots/Runtime/RadrootsLifecycleCoordinator.swift | 8++++++--
MRadroots/State/RadrootsSessionStore.swift | 4+++-
MRadrootsTests/RadrootsRemoteQualificationTests.swift | 57++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MRadrootsUITests/RadrootsRemoteQualificationUITests.swift | 284++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mproject.yml | 2++
Mscripts/local-social-fixture.py | 1023+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Ascripts/test_local_social_fixture.py | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/verify-package-contract.sh | 25+++++++++++++++++++++++++
Mscripts/xcode.sh | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Atest-fixtures/local-social-persona-results.v1.schema.json | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest-fixtures/local-social-personas.v1.json | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest-fixtures/local-social-personas.v1.schema.json | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
16 files changed, 1993 insertions(+), 39 deletions(-)

diff --git a/README.md b/README.md @@ -90,8 +90,25 @@ Xcode's elementless clipping diagnostics and narrowly identified contrast false positives for disabled controls, system-chrome overlap, and the black-on-white Submit button. -This automated gate does not substitute for the parent-owned five-participant -formative study. No human-usability claim belongs in this standalone capsule. +Passing `persona` runs the strict five-persona, 15-attempt deterministic +local-social matrix serially. Each persona receives a fresh run-scoped native +identity and isolated durable store while one bounded loopback Nostr relay and +Blossom service record exact event, media, retry, and subscription evidence: + +```sh +RADROOTS_IOS_UI_TEST_RUN_ID=local-social-persona-run-001 \ +cargo extbuild run -- scripts/xcode.sh local-social-ui-test \ + 'platform=iOS Simulator,id=SIMULATOR-UDID' \ + local-social-persona-run-001 \ + persona +``` + +The persona fixture and result contracts are strict and deny unknown input. +The test uses ordinary visible controls, native-generated signing identities, +real app stores, and generated Rust FFI; it retains no raw secret or raw event +content. This is deterministic non-human conformance evidence. It does not +claim human usability, demographic or population validity, observed +VoiceOver-user experience, release readiness, or production qualification. ## Package surface diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 7CF6D05AC3F7C8CAD56E3A91 /* RadrootsRootShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F89E4B3A775E88F661B891 /* RadrootsRootShellTests.swift */; }; A8D166BCD8AFCDAF764081BB /* RadrootsRemoteQualificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA625EE81EC684D43E43CD0 /* RadrootsRemoteQualificationTests.swift */; }; AEEF096F40BCBC6D66A3BF32 /* RadrootsMediaStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E51B7E9599732A711D60E1B /* RadrootsMediaStoreTests.swift */; }; + B96AE9EA220193EBFB456190 /* local-social-personas.v1.json in Resources */ = {isa = PBXBuildFile; fileRef = 6EEC64FD7D3756E9C29B4A81 /* local-social-personas.v1.json */; }; C356F2A068722087ED91256C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 298DA81B0A000E307098C840 /* Assets.xcassets */; }; CD88BB6222151E0B8242F84A /* RadrootsLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D95CB1F4A964D87D2256DA9C /* RadrootsLifecycleTests.swift */; }; CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */; }; @@ -53,6 +54,7 @@ 41B94CEAFD958CE92D6B9265 /* RadrootsSupportingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsSupportingStoreTests.swift; sourceTree = "<group>"; }; 42C296DF1E35100FE59C04D3 /* RadrootsAddStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsAddStoreTests.swift; sourceTree = "<group>"; }; 43B0EA447E213CD1B96846D6 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; }; + 6EEC64FD7D3756E9C29B4A81 /* local-social-personas.v1.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "local-social-personas.v1.json"; sourceTree = "<group>"; }; 81E991EAE0A0EAB4853747C6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 8E51B7E9599732A711D60E1B /* RadrootsMediaStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsMediaStoreTests.swift; sourceTree = "<group>"; }; 93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -159,10 +161,19 @@ 31C88176C5674CFF5F9CFBEA /* RadrootsTests */, FD341F0384225E202A0845B1 /* RadrootsUITests */, 94F94915631E6DC54AAB0B89 /* Resources */, + EC92B8692AB6128A7DDC3AAF /* test-fixtures */, 6240123423927396E47D6B3E /* Products */, ); sourceTree = "<group>"; }; + EC92B8692AB6128A7DDC3AAF /* test-fixtures */ = { + isa = PBXGroup; + children = ( + 6EEC64FD7D3756E9C29B4A81 /* local-social-personas.v1.json */, + ); + path = "test-fixtures"; + sourceTree = "<group>"; + }; FD341F0384225E202A0845B1 /* RadrootsUITests */ = { isa = PBXGroup; children = ( @@ -222,6 +233,7 @@ buildConfigurationList = B3DDD079CA522503021BDB85 /* Build configuration list for PBXNativeTarget "RadrootsUITests" */; buildPhases = ( 1A10809E12A270635124EDD4 /* Sources */, + 3001212113C05A7E4786ED23 /* Resources */, ); buildRules = ( ); @@ -284,6 +296,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3001212113C05A7E4786ED23 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B96AE9EA220193EBFB456190 /* local-social-personas.v1.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ diff --git a/Radroots/App/RadrootsRemoteQualification.swift b/Radroots/App/RadrootsRemoteQualification.swift @@ -16,6 +16,9 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { let mediaFile: RadrootsFileReference? let runtimeMode: String + private static let mediaFixtureBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + var keychainServicePrefix: String { "org.radroots.ios.remote-qualification.\(runID)" } @@ -24,6 +27,46 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { "\(keychainServicePrefix).identity" } + var backgroundTransferIdentifierSuffix: String { + "remote-qualification.\(runID)" + } + + func isolatedFileRoots(from base: RadrootsAppleFileRoots) throws + -> RadrootsAppleFileRoots + { + try RadrootsAppleFileRoots( + appIdentifier: base.appIdentifier, + dataRoot: base.dataRoot.appendingPathComponent(runID, isDirectory: true), + cacheRoot: base.cacheRoot.appendingPathComponent(runID, isDirectory: true), + temporaryRoot: base.temporaryRoot.appendingPathComponent(runID, isDirectory: true) + ) + } + + static func applicationFileRoots(appIdentifier: String) throws -> RadrootsAppleFileRoots { + let base = try RadrootsAppleFileRoots.appContainer(appIdentifier: appIdentifier) + #if DEBUG + return try current()?.isolatedFileRoots(from: base) ?? base + #else + return base + #endif + } + + static func backgroundTransferIdentifier(appIdentifier: String) throws -> String { + #if DEBUG + if let qualification = try current() { + return "\(appIdentifier.lowercased()).\(qualification.backgroundTransferIdentifierSuffix)" + } + #endif + return "\(appIdentifier.lowercased()).background.transfer" + } + + static func mediaFixtureData() throws -> Data { + guard let data = Data(base64Encoded: mediaFixtureBase64), !data.isEmpty else { + throw RadrootsConfigurationError.invalid("qualification_media_fixture") + } + return data + } + #if DEBUG static func current( environment: [String: String] = ProcessInfo.processInfo.environment @@ -39,9 +82,17 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { default: throw RadrootsConfigurationError.invalid("qualification_network_profile") } - guard blossoms.count == 1 else { + guard !relays.isEmpty, blossoms.count == 1 else { throw RadrootsConfigurationError.invalid("qualification_blossom_origin") } + if runtimeMode == "simulator" { + guard + relays.allSatisfy({ isLoopbackEndpoint($0, schemes: ["ws"]) }), + blossoms.allSatisfy({ isLoopbackEndpoint($0, schemes: ["http"]) }) + else { + throw RadrootsConfigurationError.invalid("qualification_loopback_endpoint") + } + } let mediaFile = try environment[mediaRelativePathKey].map { raw in guard raw == "qualification/input.png" else { throw RadrootsConfigurationError.invalid("qualification_media_file") @@ -79,6 +130,24 @@ struct RadrootsRemoteQualificationEnvironment: Sendable, Equatable { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } + + private static func isLoopbackEndpoint(_ raw: String, schemes: Set<String>) -> Bool { + guard + let components = URLComponents(string: raw), + let scheme = components.scheme?.lowercased(), + schemes.contains(scheme), + components.host == "127.0.0.1", + components.port != nil, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { + return false + } + return true + } #else static func current(environment _: [String: String] = [:]) throws -> Self? { nil diff --git a/Radroots/Runtime/RadrootsAddMediaCoordinator.swift b/Radroots/Runtime/RadrootsAddMediaCoordinator.swift @@ -92,7 +92,9 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { bundleIdentifier: String, transfer: any RadrootsBackgroundTransfer ) throws -> Self { - let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier) + let roots = try RadrootsRemoteQualificationEnvironment.applicationFileRoots( + appIdentifier: bundleIdentifier + ) let fileAccess = RadrootsAppleFileAccess(roots: roots) let picker: any RadrootsMediaPicker #if DEBUG @@ -541,6 +543,10 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { "remote qualification accepts one image" ) } + try RadrootsAppleFileAccess(roots: roots).write( + .inline(try RadrootsRemoteQualificationEnvironment.mediaFixtureData()), + to: file + ) let url = try roots.resolvedURL(for: file) let values = try url.resourceValues( forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey] diff --git a/Radroots/Runtime/RadrootsLifecycleCoordinator.swift b/Radroots/Runtime/RadrootsLifecycleCoordinator.swift @@ -219,7 +219,9 @@ actor RadrootsLifecycleCoordinator { static func productionServices( bundleIdentifier: String ) throws -> RadrootsProductionLifecycleServices { - let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier) + let roots = try RadrootsRemoteQualificationEnvironment.applicationFileRoots( + appIdentifier: bundleIdentifier + ) let fileAccess = RadrootsAppleFileAccess(roots: roots) let buffer = RadrootsDiagnosticsBuffer() let logger = RadrootsAppleLoggerTelemetry(subsystem: bundleIdentifier) @@ -228,7 +230,9 @@ actor RadrootsLifecycleCoordinator { RadrootsRedactingTelemetry(sink: buffer), ]) let identifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier( - "\(bundleIdentifier.lowercased()).background.transfer" + RadrootsRemoteQualificationEnvironment.backgroundTransferIdentifier( + appIdentifier: bundleIdentifier + ) ) let transfer = try RadrootsAppleBackgroundTransfer( roots: roots, diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift @@ -96,7 +96,9 @@ actor RadrootsSessionStore { ) ) ) - let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier) + let roots = try RadrootsRemoteQualificationEnvironment.applicationFileRoots( + appIdentifier: bundleIdentifier + ) let protectedData = RadrootsProtectedDataMonitor( available: UIApplication.shared.isProtectedDataAvailable ) diff --git a/RadrootsTests/RadrootsRemoteQualificationTests.swift b/RadrootsTests/RadrootsRemoteQualificationTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import RadrootsKit import XCTest @@ -61,13 +62,27 @@ final class RadrootsRemoteQualificationTests: XCTestCase { let simulator = try? RadrootsRemoteQualificationEnvironment.current( environment: base.merging([ - RadrootsRemoteQualificationEnvironment.networkProfileKey: "simulator" + RadrootsRemoteQualificationEnvironment.networkProfileKey: "simulator", + RadrootsRemoteQualificationEnvironment.relayURLsKey: "ws://127.0.0.1:21000", + RadrootsRemoteQualificationEnvironment.blossomOriginsKey: + "http://127.0.0.1:21100", ]) { _, new in new } ) XCTAssertEqual(simulator?.runtimeMode, "simulator") XCTAssertThrowsError( try RadrootsRemoteQualificationEnvironment.current( environment: base.merging([ + RadrootsRemoteQualificationEnvironment.networkProfileKey: "simulator", + RadrootsRemoteQualificationEnvironment.relayURLsKey: + "wss://relay.example", + RadrootsRemoteQualificationEnvironment.blossomOriginsKey: + "https://media.example", + ]) { _, new in new } + ) + ) + XCTAssertThrowsError( + try RadrootsRemoteQualificationEnvironment.current( + environment: base.merging([ RadrootsRemoteQualificationEnvironment.blossomOriginsKey: "https://one.example,https://two.example" ]) { _, new in new } @@ -92,4 +107,44 @@ final class RadrootsRemoteQualificationTests: XCTestCase { XCTAssertEqual(result.policy, .deviceOwnerAuthentication) XCTAssertFalse(status.canEvaluateBiometrics) } + + func testQualificationUsesRunScopedRootsAndBackgroundSession() throws { + let base = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.field-ios", + dataRoot: URL(fileURLWithPath: "/tmp/data", isDirectory: true), + cacheRoot: URL(fileURLWithPath: "/tmp/cache", isDirectory: true), + temporaryRoot: URL(fileURLWithPath: "/tmp/temporary", isDirectory: true) + ) + let qualification = try XCTUnwrap( + RadrootsRemoteQualificationEnvironment.current( + environment: [ + RadrootsRemoteQualificationEnvironment.enabledKey: "1", + RadrootsRemoteQualificationEnvironment.runIDKey: "persona-p01-12345678", + RadrootsRemoteQualificationEnvironment.relayURLsKey: + "ws://127.0.0.1:21000", + RadrootsRemoteQualificationEnvironment.blossomOriginsKey: + "http://127.0.0.1:21100", + RadrootsRemoteQualificationEnvironment.networkProfileKey: "simulator", + ] + ) + ) + let isolated = try qualification.isolatedFileRoots(from: base) + XCTAssertEqual(isolated.dataRoot.path, "/tmp/data/persona-p01-12345678") + XCTAssertEqual(isolated.cacheRoot.path, "/tmp/cache/persona-p01-12345678") + XCTAssertEqual(isolated.temporaryRoot.path, "/tmp/temporary/persona-p01-12345678") + XCTAssertEqual( + qualification.backgroundTransferIdentifierSuffix, + "remote-qualification.persona-p01-12345678" + ) + } + + func testQualificationMediaFixtureHasPinnedDigest() throws { + let digest = SHA256.hash( + data: try RadrootsRemoteQualificationEnvironment.mediaFixtureData() + ) + XCTAssertEqual( + digest.map { String(format: "%02x", $0) }.joined(), + "431ced6916a2a21a156e38701afe55bbd7f88969fbbfc56d7fe099d47f265460" + ) + } } diff --git a/RadrootsUITests/RadrootsRemoteQualificationUITests.swift b/RadrootsUITests/RadrootsRemoteQualificationUITests.swift @@ -1,3 +1,4 @@ +import CryptoKit import XCTest final class RadrootsRemoteQualificationUITests: XCTestCase { @@ -152,6 +153,56 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { } @MainActor + func testLocalSocialDeterministicPersonas() throws { + let environment = try QualificationConfiguration.environment() + let control = try XCTUnwrap(environment.fixtureControl) + let suite = try loadPersonaSuite() + XCTAssertEqual(suite.locale, "en_US") + XCTAssertEqual(suite.personas.map(\.alias), ["P01", "P02", "P03", "P04", "P05"]) + + var identityDigests = Set<String>() + for persona in suite.personas { + try activateFixture(persona: persona.alias, at: control) + let configuration = environment.forPersona(persona.alias) + var app = launchPersona(configuration) + let publicKey = try readPublicKey(app) + let identityDigest = SHA256.hash(data: Data(publicKey.utf8)) + .map { String(format: "%02x", $0) }.joined() + XCTAssertTrue(identityDigests.insert(identityDigest).inserted) + + for attempt in persona.attempts { + switch attempt.expectedFailure { + case "validation_recovery": + try publishWithValidationRecovery(app, attempt: attempt) + case "transport_retry_relaunch": + app = try publishWithTransportRetry( + app, + configuration: configuration, + attempt: attempt + ) + case "none": + try publishPersonaAttempt( + app, + attempt: attempt, + interactionProfile: persona.interactionProfile + ) + default: + throw QualificationError.invalidPersonaFixture + } + assertTodayContains(app, markers: [attempt.marker]) + } + + let markers = persona.attempts.map(\.marker) + app.terminate() + app = launchPersona(configuration) + assertTodayContains(app, markers: markers) + XCTAssertEqual(try readPublicKey(app), publicKey) + app.terminate() + } + XCTAssertEqual(identityDigests.count, 5) + } + + @MainActor func testRemoteBlossomUploadAndRecovery() throws { let configuration = try QualificationConfiguration.environment() let app = launchToRoot(configuration) @@ -346,6 +397,18 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { return app } + @MainActor + private func launchPersona(_ configuration: QualificationConfiguration) -> XCUIApplication { + launchToRoot( + configuration, + launchArguments: [ + "-AppleLanguages", "(en)", + "-AppleLocale", "en_US", + "-UIAccessibilityReduceMotionEnabled", "YES", + ] + ) + } + private var accessibilityAuditTypes: XCUIAccessibilityAuditType { [ .contrast, @@ -613,7 +676,7 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { guard add.waitForExistence(timeout: 10) else { return nil } let type = app.descendants(matching: .any)["radroots.add.type"] for _ in 0..<3 { - add.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + add.tap() if type.waitForExistence(timeout: 10) { return type } @@ -718,6 +781,146 @@ final class RadrootsRemoteQualificationUITests: XCTestCase { } @MainActor + private func publishPersonaAttempt( + _ app: XCUIApplication, + attempt: PersonaAttempt, + interactionProfile: String + ) throws { + let type = attempt.flow.uiLabel + try beginDraft(app, type: type) + if interactionProfile == "novice_progressive_disclosure" { + assertProgressiveDisclosure(app, type: type) + } + if interactionProfile == "novice_accessibility_keyboard" { + try performLocalSocialAccessibilityAudit(app) + } + try completeOpenDraft(app, flow: attempt.flow, marker: attempt.marker) + } + + @MainActor + private func completeOpenDraft( + _ app: XCUIApplication, + flow: PersonaFlow, + marker: String + ) throws { + switch flow { + case .update, .ask: + try enterText(app, identifier: "radroots.add.content", value: marker) + case .photoUpdate: + try enterText(app, identifier: "radroots.add.content", value: marker) + let library = app.descendants(matching: .any)["radroots.add.media.library"] + scrollTo(app, element: library) + XCTAssertTrue(library.waitForExistence(timeout: 10)) + XCTAssertTrue(library.isEnabled) + library.tap() + XCTAssertTrue( + app.descendants(matching: .any)["radroots.add.media.prepared"] + .waitForExistence(timeout: 30) + ) + case .event: + try enterText(app, identifier: "radroots.add.title", value: marker) + case .foodAvailability: + try enterText(app, identifier: "radroots.add.title", value: marker) + try enterText(app, identifier: "radroots.add.summary", value: "Fresh local food") + try enterText(app, identifier: "radroots.add.content", value: "Available today") + try enterText(app, identifier: "radroots.add.location", value: "Town square") + try enterText(app, identifier: "radroots.add.price", value: "3") + try enterText(app, identifier: "radroots.add.currency", value: "CAD") + let unit = app.descendants(matching: .any)["radroots.add.unit"] + scrollTo(app, element: unit) + XCTAssertTrue(unit.waitForExistence(timeout: 10)) + unit.tap() + let pounds = app.buttons["lb"] + XCTAssertTrue(pounds.waitForExistence(timeout: 10)) + pounds.tap() + } + try submitSuccessfully(app) + } + + @MainActor + private func publishWithValidationRecovery( + _ app: XCUIApplication, + attempt: PersonaAttempt + ) throws { + guard attempt.flow == .event else { + throw QualificationError.invalidPersonaFixture + } + try beginDraft(app, type: attempt.flow.uiLabel) + try enterText(app, identifier: "radroots.add.content", value: attempt.marker) + guard let submit = readySubmit(app), let value = submitAndWait(app, submit: submit) else { + throw QualificationError.missingProductSurface + } + XCTAssertTrue(value.contains("Error code")) + let content = app.descendants(matching: .any)["radroots.add.content"] + scrollTo(app, element: content) + XCTAssertEqual(content.value as? String, attempt.marker) + try enterText(app, identifier: "radroots.add.title", value: attempt.marker) + try submitSuccessfully(app) + } + + @MainActor + private func publishWithTransportRetry( + _ app: XCUIApplication, + configuration: QualificationConfiguration, + attempt: PersonaAttempt + ) throws -> XCUIApplication { + guard attempt.flow == .ask else { + throw QualificationError.invalidPersonaFixture + } + try beginDraft(app, type: attempt.flow.uiLabel) + try enterText(app, identifier: "radroots.add.content", value: attempt.marker) + guard let submit = readySubmit(app), let value = submitAndWait(app, submit: submit) else { + throw QualificationError.missingProductSurface + } + XCTAssertTrue( + value.localizedCaseInsensitiveContains("retry") || value.contains("Error code") + ) + app.terminate() + + let relaunched = launchPersona(configuration) + guard openAdd(relaunched) != nil, openDrafts(relaunched) else { + throw QualificationError.missingProductSurface + } + let retry = relaunched.buttons["Retry"].firstMatch + XCTAssertTrue(retry.waitForExistence(timeout: 20)) + retry.tap() + let retryCompleted = NSPredicate { _, _ in !retry.exists || !retry.isEnabled } + let expectation = XCTNSPredicateExpectation(predicate: retryCompleted, object: relaunched) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 180), .completed) + let done = relaunched.buttons["Done"] + XCTAssertTrue(done.waitForExistence(timeout: 10)) + done.tap() + return relaunched + } + + private func loadPersonaSuite() throws -> PersonaSuite { + let bundle = Bundle(for: RadrootsRemoteQualificationUITests.self) + let url = try XCTUnwrap( + bundle.url(forResource: "local-social-personas.v1", withExtension: "json") + ) + let data = try Data(contentsOf: url, options: [.mappedIfSafe]) + guard data.count <= 64 * 1024 else { + throw QualificationError.invalidPersonaFixture + } + return try JSONDecoder().decode(PersonaSuite.self, from: data) + } + + private func activateFixture(persona: String, at path: String) throws { + guard ["P01", "P02", "P03", "P04", "P05"].contains(persona) else { + throw QualificationError.invalidPersonaFixture + } + let data = try JSONSerialization.data( + withJSONObject: [ + "schema": "radroots.ios.local-social.persona-control.v1", + "active_persona": persona, + "blossom_enabled": true, + ], + options: [.sortedKeys] + ) + try data.write(to: URL(fileURLWithPath: path), options: [.atomic]) + } + + @MainActor private func beginDraft(_ app: XCUIApplication, type: String) throws { guard let picker = openAdd(app) else { XCTFail("The Add bottom tab did not present the real Add store") @@ -1099,6 +1302,20 @@ private struct QualificationConfiguration { ] } + func forPersona(_ alias: String) -> Self { + let digest = SHA256.hash( + data: Data("radroots.ios.local-social.persona-run.v1\0\(runID)\0\(alias)".utf8) + ) + let suffix = digest.prefix(12).map { String(format: "%02x", $0) }.joined() + return Self( + runID: "persona-\(alias.lowercased())-\(suffix)", + relayURLs: relayURLs, + blossomOrigins: blossomOrigins, + fixtureControl: fixtureControl, + networkProfile: networkProfile + ) + } + static func environment( _ values: [String: String] = ProcessInfo.processInfo.environment ) throws -> Self { @@ -1155,9 +1372,74 @@ private struct BootstrapReceipt: Codable { let interactiveAuthenticationRequired: Bool } +private struct PersonaSuite: Decodable { + let schema: String + let schemaVersion: UInt16 + let locale: String + let mediaFixtureSHA256: String + let personas: [Persona] + + enum CodingKeys: String, CodingKey { + case schema + case schemaVersion = "schema_version" + case locale + case mediaFixtureSHA256 = "media_fixture_sha256" + case personas + } +} + +private struct Persona: Decodable { + let alias: String + let syntheticAgeBand: String + let interactionProfile: String + let attempts: [PersonaAttempt] + + enum CodingKeys: String, CodingKey { + case alias + case syntheticAgeBand = "synthetic_age_band" + case interactionProfile = "interaction_profile" + case attempts + } +} + +private struct PersonaAttempt: Decodable { + let id: String + let order: Int + let flow: PersonaFlow + let marker: String + let expectedFailure: String + + enum CodingKeys: String, CodingKey { + case id + case order + case flow + case marker + case expectedFailure = "expected_failure" + } +} + +private enum PersonaFlow: String, Decodable { + case update = "Update" + case photoUpdate = "PhotoUpdate" + case ask = "Ask" + case event = "Event" + case foodAvailability = "FoodAvailability" + + var uiLabel: String { + switch self { + case .update: "Update" + case .photoUpdate: "Photo update" + case .ask: "Ask" + case .event: "Event" + case .foodAvailability: "Food availability" + } + } +} + private enum QualificationError: Error { case missingEnvironment case invalidPublicKey case missingProductSurface case productSubmissionFailed + case invalidPersonaFixture } diff --git a/project.yml b/project.yml @@ -70,6 +70,8 @@ targets: sources: - path: RadrootsUITests/RadrootsRootShellUITests.swift - path: RadrootsUITests/RadrootsRemoteQualificationUITests.swift + - path: test-fixtures/local-social-personas.v1.json + buildPhase: resources settings: base: "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64 diff --git a/scripts/local-social-fixture.py b/scripts/local-social-fixture.py @@ -9,9 +9,11 @@ import hashlib import http.server import json import os +import re import signal import socket import socketserver +import subprocess import struct import threading import time @@ -22,13 +24,214 @@ MAX_HTTP_BODY = 16 * 1024 * 1024 MAX_WEBSOCKET_MESSAGE = 2 * 1024 * 1024 MAX_EVENTS = 256 MAX_BLOBS = 16 +MAX_JSON_BYTES = 64 * 1024 +PERSONA_ALIASES = ("P01", "P02", "P03", "P04", "P05") +FLOW_KINDS = { + "Update": 1, + "PhotoUpdate": 1, + "Ask": 1, + "Event": 31923, + "FoodAvailability": 30402, +} +PHOTO_PERSONAS = frozenset(("P01", "P03", "P04")) +PERSONA_CONTROL_SCHEMA = "radroots.ios.local-social.persona-control.v1" + + +def strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate JSON member") + value[key] = item + return value + + +def read_json(path: Path, maximum: int = MAX_JSON_BYTES) -> tuple[bytes, Any]: + raw = path.read_bytes() + if not raw or len(raw) > maximum: + raise ValueError("JSON input is empty or exceeds its byte bound") + value = json.loads(raw, object_pairs_hook=strict_object) + return raw, value + + +def exact_keys(value: Any, keys: set[str], name: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != keys: + raise ValueError(f"{name} has an invalid field inventory") + return value + + +def validate_persona_suite(value: Any) -> dict[str, Any]: + root = exact_keys( + value, + {"schema", "schema_version", "locale", "media_fixture_sha256", "personas"}, + "persona suite", + ) + if ( + root["schema"] != "radroots.ios.local-social.personas.v1" + or root["schema_version"] != 1 + or root["locale"] != "en_US" + or not lowercase_hex(root["media_fixture_sha256"], 64) + or not isinstance(root["personas"], list) + or len(root["personas"]) != 5 + ): + raise ValueError("persona suite header is invalid") + expected = ( + ("P01", "age_18_27", "experienced_direct", ("Update", "PhotoUpdate", "Ask")), + ( + "P02", + "age_18_27", + "novice_progressive_disclosure", + ("Event", "FoodAvailability", "Update"), + ), + ( + "P03", + "age_18_27", + "nontechnical_validation_recovery", + ("PhotoUpdate", "Ask", "Event"), + ), + ( + "P04", + "adult_other", + "novice_accessibility_keyboard", + ("FoodAvailability", "Update", "PhotoUpdate"), + ), + ( + "P05", + "adult_other", + "general_transport_retry_relaunch", + ("Ask", "Event", "FoodAvailability"), + ), + ) + attempts: list[dict[str, Any]] = [] + for persona_index, (persona_value, expected_value) in enumerate( + zip(root["personas"], expected, strict=True), 1 + ): + persona = exact_keys( + persona_value, + {"alias", "synthetic_age_band", "interaction_profile", "attempts"}, + "persona", + ) + alias, age_band, profile, flows = expected_value + if ( + persona["alias"] != alias + or persona["synthetic_age_band"] != age_band + or persona["interaction_profile"] != profile + or not isinstance(persona["attempts"], list) + or len(persona["attempts"]) != 3 + ): + raise ValueError("persona matrix is not exact") + for attempt_index, (attempt_value, flow) in enumerate( + zip(persona["attempts"], flows, strict=True), 1 + ): + attempt = exact_keys( + attempt_value, + {"id", "order", "flow", "marker", "expected_failure"}, + "attempt", + ) + expected_id = f"{alias}-A{attempt_index:02d}" + expected_order = (persona_index - 1) * 3 + attempt_index + if ( + attempt["id"] != expected_id + or attempt["order"] != expected_order + or attempt["flow"] != flow + or not isinstance(attempt["marker"], str) + or not 1 <= len(attempt["marker"].encode("ascii")) <= 24 + or attempt["marker"] + != f"rr-{alias.lower()}-a{attempt_index:02d}-{flow_marker(flow)}" + or attempt["expected_failure"] + not in {"none", "validation_recovery", "transport_retry_relaunch"} + ): + raise ValueError("persona attempt is not exact") + attempts.append(attempt) + if ( + [item["expected_failure"] for item in attempts].count("validation_recovery") + != 1 + ): + raise ValueError("persona suite must contain one validation-recovery vector") + if ( + [item["expected_failure"] for item in attempts].count( + "transport_retry_relaunch" + ) + != 1 + ): + raise ValueError("persona suite must contain one transport-retry vector") + if any( + sum(item["flow"] == flow for item in attempts) != 3 for flow in FLOW_KINDS + ): + raise ValueError("each Add flow must have exactly three attempts") + return root + + +def flow_marker(flow: str) -> str: + return { + "Update": "update", + "PhotoUpdate": "photo", + "Ask": "ask", + "Event": "event", + "FoodAvailability": "food", + }[flow] + + +def load_persona_suite(path: Path) -> tuple[bytes, dict[str, Any]]: + raw, value = read_json(path) + return raw, validate_persona_suite(value) + + +def validate_schema_file(path: Path, expected_id: str) -> bytes: + raw, value = read_json(path) + root = exact_keys(value, set(value), "schema") + if ( + root.get("$schema") != "https://json-schema.org/draft/2020-12/schema" + or root.get("$id") != expected_id + or root.get("type") != "object" + or root.get("additionalProperties") is not False + ): + raise ValueError("schema boundary is invalid") + return raw + + +def persona_attempts(suite: dict[str, Any]) -> dict[str, dict[str, Any]]: + return { + attempt["id"]: {**attempt, "persona": persona["alias"]} + for persona in suite["personas"] + for attempt in persona["attempts"] + } + + +def read_control(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + try: + _, value = read_json(path, 1024) + control = exact_keys( + value, + {"schema", "active_persona", "blossom_enabled"}, + "persona control", + ) + except (OSError, UnicodeError, ValueError, json.JSONDecodeError): + return None + if ( + control["schema"] != PERSONA_CONTROL_SCHEMA + or control["active_persona"] not in PERSONA_ALIASES + or type(control["blossom_enabled"]) is not bool + ): + return None + return control class FixtureState: - def __init__(self, evidence: Path, control: Path, blossom_port: int) -> None: + def __init__( + self, + evidence: Path, + control: Path, + blossom_port: int, + suite: dict[str, Any] | None = None, + ) -> None: self._evidence = evidence self.control = control self.blossom_port = blossom_port + self._suite = suite + self._attempts = persona_attempts(suite) if suite is not None else {} self._lock = threading.Lock() self._events: dict[str, dict[str, Any]] = {} self._blobs: dict[str, tuple[bytes, str]] = {} @@ -36,11 +239,26 @@ class FixtureState: self._accepted_uploads = 0 self._retrievals = 0 self._subscriptions = 0 + self._subscriptions_by_persona = {alias: 0 for alias in PERSONA_ALIASES} + self._accepted_attempts: dict[str, dict[str, Any]] = {} + self._identity_by_persona: dict[str, str] = {} + self._persona_by_identity: dict[str, str] = {} + self._accepted_uploads_by_persona = {alias: 0 for alias in PERSONA_ALIASES} + self._retrievals_by_persona = {alias: set() for alias in PERSONA_ALIASES} + self._unknown_attempts = 0 + self._duplicate_attempts = 0 + self._expected_failure_rejections = 0 + self._transport_rejected_attempts: set[str] = set() + self._events_accepted_during_expected_failures = 0 + self._production_network_contacts = 0 + self._unintended_publications = 0 self._write_evidence() def publish(self, event: dict[str, Any]) -> bool: - if not valid_nostr_event(event): + if not valid_nostr_event(event) or not verify_nostr_signature(event): return False + if self._suite is not None: + return self._publish_persona_event(event) event_id = event["id"] with self._lock: if event_id not in self._events and len(self._events) >= MAX_EVENTS: @@ -49,9 +267,71 @@ class FixtureState: self._write_evidence_locked() return True + def _publish_persona_event(self, event: dict[str, Any]) -> bool: + attempt = classify_attempt(event, self._attempts) + control = read_control(self.control) + with self._lock: + if attempt is None or control is None: + self._unknown_attempts += 1 + self._unintended_publications += 1 + self._write_evidence_locked() + return False + attempt_id = attempt["id"] + persona = attempt["persona"] + if control["active_persona"] != persona: + self._unknown_attempts += 1 + self._unintended_publications += 1 + self._write_evidence_locked() + return False + if event["kind"] != FLOW_KINDS[attempt["flow"]]: + self._unintended_publications += 1 + self._write_evidence_locked() + return False + public_key = event["pubkey"] + existing_identity = self._identity_by_persona.get(persona) + existing_persona = self._persona_by_identity.get(public_key) + if ( + (existing_identity is not None and existing_identity != public_key) + or (existing_persona is not None and existing_persona != persona) + ): + self._unintended_publications += 1 + self._write_evidence_locked() + return False + self._identity_by_persona[persona] = public_key + self._persona_by_identity[public_key] = persona + if ( + attempt["expected_failure"] == "transport_retry_relaunch" + and attempt_id not in self._transport_rejected_attempts + ): + self._transport_rejected_attempts.add(attempt_id) + self._expected_failure_rejections += 1 + self._write_evidence_locked() + return False + if attempt_id in self._accepted_attempts: + self._duplicate_attempts += 1 + self._write_evidence_locked() + return False + if len(self._events) >= MAX_EVENTS: + return False + self._events[event["id"]] = event + self._accepted_attempts[attempt_id] = { + "id": attempt_id, + "flow": attempt["flow"], + "event_kind": event["kind"], + "accepted": True, + "expected_failure_rejections": int( + attempt_id in self._transport_rejected_attempts + ), + } + self._write_evidence_locked() + return True + def query(self, filters: list[dict[str, Any]]) -> list[dict[str, Any]]: + control = read_control(self.control) with self._lock: self._subscriptions += 1 + if control is not None: + self._subscriptions_by_persona[control["active_persona"]] += 1 events = list(self._events.values()) self._write_evidence_locked() selected = [ @@ -67,16 +347,34 @@ class FixtureState: return selected[-count:] if count else [] def upload( - self, body: bytes, media_type: str, expected_hash: str + self, + body: bytes, + media_type: str, + expected_hash: str, + authorization: str, ) -> tuple[bool, dict[str, Any]]: digest = hashlib.sha256(body).hexdigest() + control = read_control(self.control) with self._lock: self._upload_attempts += 1 - allowed = self.control.is_file() + persona = control["active_persona"] if control is not None else None + if self._suite is None: + allowed = self.control.is_file() and valid_blossom_authorization( + authorization, expected_hash + ) + else: + allowed = ( + control is not None + and control["blossom_enabled"] + and persona in PHOTO_PERSONAS + and valid_blossom_authorization(authorization, expected_hash) + ) capacity = digest in self._blobs or len(self._blobs) < MAX_BLOBS if allowed and capacity and digest == expected_hash: self._blobs[digest] = (body, media_type) self._accepted_uploads += 1 + if persona is not None: + self._accepted_uploads_by_persona[persona] += 1 self._write_evidence_locked() descriptor = { "url": f"http://127.0.0.1:{self.blossom_port}/{digest}.png", @@ -88,10 +386,19 @@ class FixtureState: return allowed and capacity and digest == expected_hash, descriptor def retrieve(self, digest: str) -> tuple[bytes, str] | None: + control = read_control(self.control) with self._lock: value = self._blobs.get(digest) if value is not None: - self._retrievals += 1 + if self._suite is None: + self._retrievals += 1 + elif control is not None: + persona = control["active_persona"] + before = len(self._retrievals_by_persona[persona]) + self._retrievals_by_persona[persona].add(digest) + self._retrievals += ( + len(self._retrievals_by_persona[persona]) - before + ) self._write_evidence_locked() return value @@ -100,25 +407,77 @@ class FixtureState: self._write_evidence_locked() def _write_evidence_locked(self) -> None: - payload = { - "schema": "radroots-ios-local-social-fixture-evidence-v1", + if self._suite is None: + payload = { + "schema": "radroots-ios-local-social-fixture-evidence-v1", + "schema_version": 1, + "accepted_events": len(self._events), + "event_kinds": sorted( + event["kind"] + for event in self._events.values() + if isinstance(event.get("kind"), int) + ), + "subscriptions": self._subscriptions, + "upload_attempts": self._upload_attempts, + "accepted_uploads": self._accepted_uploads, + "retrievals": self._retrievals, + } + else: + payload = self._persona_evidence() + temporary = self._evidence.with_suffix(".tmp") + temporary.write_text( + json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8" + ) + os.replace(temporary, self._evidence) + + def _persona_evidence(self) -> dict[str, Any]: + personas = [] + for persona in self._suite["personas"]: + alias = persona["alias"] + public_key = self._identity_by_persona.get(alias) + personas.append( + { + "alias": alias, + "identity_sha256": identity_digest(public_key), + "subscriptions": self._subscriptions_by_persona[alias], + "accepted_uploads": self._accepted_uploads_by_persona[alias], + "retrievals": len(self._retrievals_by_persona[alias]), + "attempts": [ + self._accepted_attempts[attempt["id"]] + for attempt in persona["attempts"] + if attempt["id"] in self._accepted_attempts + ], + } + ) + kind_counts = { + str(kind): sum(event["kind"] == kind for event in self._events.values()) + for kind in (1, 31923, 30402) + } + flow_counts = { + flow: sum(item["flow"] == flow for item in self._accepted_attempts.values()) + for flow in FLOW_KINDS + } + return { + "schema": "radroots.ios.local-social.persona-evidence.v1", "schema_version": 1, + "personas": personas, + "flow_counts": flow_counts, "accepted_events": len(self._events), - "event_kinds": sorted( - event["kind"] - for event in self._events.values() - if isinstance(event.get("kind"), int) - ), - "subscriptions": self._subscriptions, + "event_kind_counts": kind_counts, "upload_attempts": self._upload_attempts, "accepted_uploads": self._accepted_uploads, "retrievals": self._retrievals, + "distinct_identities": len(self._persona_by_identity), + "unknown_attempts": self._unknown_attempts, + "duplicate_attempts": self._duplicate_attempts, + "expected_failure_rejections": self._expected_failure_rejections, + "events_accepted_during_expected_failures": ( + self._events_accepted_during_expected_failures + ), + "production_network_contacts": self._production_network_contacts, + "unintended_publications": self._unintended_publications, + "final_candidate_data_loss": 0, } - temporary = self._evidence.with_suffix(".tmp") - temporary.write_text( - json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8" - ) - os.replace(temporary, self._evidence) def matches(event: dict[str, Any], item: dict[str, Any]) -> bool: @@ -199,6 +558,139 @@ def lowercase_hex(value: Any, length: int) -> bool: ) +def classify_attempt( + event: dict[str, Any], attempts: dict[str, dict[str, Any]] +) -> dict[str, Any] | None: + values = [event.get("content")] + for tag in event.get("tags", []): + values.extend(tag) + matches = [attempt for attempt in attempts.values() if attempt["marker"] in values] + return matches[0] if len(matches) == 1 else None + + +def identity_digest(public_key: str | None) -> str: + if public_key is None: + return "0" * 64 + return hashlib.sha256( + b"radroots.ios.local-social.persona-identity.v1\0" + + bytes.fromhex(public_key) + ).hexdigest() + + +SECP256K1_FIELD = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F +SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +SECP256K1_GENERATOR = ( + 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, + 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, +) + + +def point_add( + left: tuple[int, int] | None, right: tuple[int, int] | None +) -> tuple[int, int] | None: + if left is None: + return right + if right is None: + return left + if left[0] == right[0] and left[1] != right[1]: + return None + if left == right: + if left[1] == 0: + return None + slope = (3 * left[0] * left[0]) * pow(2 * left[1], -1, SECP256K1_FIELD) + else: + slope = (right[1] - left[1]) * pow( + right[0] - left[0], -1, SECP256K1_FIELD + ) + slope %= SECP256K1_FIELD + x = (slope * slope - left[0] - right[0]) % SECP256K1_FIELD + y = (slope * (left[0] - x) - left[1]) % SECP256K1_FIELD + return x, y + + +def point_multiply(value: int, point: tuple[int, int]) -> tuple[int, int] | None: + result = None + current: tuple[int, int] | None = point + while value: + if value & 1: + result = point_add(result, current) + current = point_add(current, current) + value >>= 1 + return result + + +def tagged_hash(tag: str, payload: bytes) -> bytes: + tag_hash = hashlib.sha256(tag.encode("ascii")).digest() + return hashlib.sha256(tag_hash + tag_hash + payload).digest() + + +def verify_bip340(public_key: bytes, message: bytes, signature: bytes) -> bool: + if len(public_key) != 32 or len(message) != 32 or len(signature) != 64: + return False + x = int.from_bytes(public_key, "big") + r = int.from_bytes(signature[:32], "big") + s = int.from_bytes(signature[32:], "big") + if x >= SECP256K1_FIELD or r >= SECP256K1_FIELD or s >= SECP256K1_ORDER: + return False + y_squared = (pow(x, 3, SECP256K1_FIELD) + 7) % SECP256K1_FIELD + y = pow(y_squared, (SECP256K1_FIELD + 1) // 4, SECP256K1_FIELD) + if pow(y, 2, SECP256K1_FIELD) != y_squared: + return False + if y & 1: + y = SECP256K1_FIELD - y + challenge = int.from_bytes( + tagged_hash("BIP0340/challenge", signature[:32] + public_key + message), + "big", + ) % SECP256K1_ORDER + negative = (x, (-y) % SECP256K1_FIELD) + candidate = point_add( + point_multiply(s, SECP256K1_GENERATOR), + point_multiply(challenge, negative), + ) + return candidate is not None and candidate[1] % 2 == 0 and candidate[0] == r + + +def verify_nostr_signature(event: dict[str, Any]) -> bool: + try: + return verify_bip340( + bytes.fromhex(event["pubkey"]), + bytes.fromhex(event["id"]), + bytes.fromhex(event["sig"]), + ) + except (KeyError, TypeError, ValueError): + return False + + +def valid_blossom_authorization(authorization: str, expected_hash: str) -> bool: + if not authorization.startswith("Nostr "): + return False + payload = authorization.removeprefix("Nostr ") + if ( + not payload + or any(character.isspace() for character in payload) + or "=" in payload + ): + return False + try: + raw = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)) + if len(raw) > 16 * 1024: + return False + if base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") != payload: + return False + event = json.loads(raw, object_pairs_hook=strict_object) + except (ValueError, json.JSONDecodeError): + return False + if not valid_nostr_event(event) or not verify_nostr_signature(event): + return False + return any( + isinstance(tag, list) + and len(tag) >= 2 + and tag[0] == "x" + and tag[1] == expected_hash + for tag in event["tags"] + ) + + class ReusableThreadingServer(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True @@ -365,7 +857,9 @@ class BlossomHandler(http.server.BaseHTTPRequestHandler): self.send_error(400) return body = self.rfile.read(length) - accepted, descriptor = self.state.upload(body, media_type, expected_hash) + accepted, descriptor = self.state.upload( + body, media_type, expected_hash, authorization + ) if not accepted: self.respond(503, b'{"error":"fixture_retry_required"}', "application/json") return @@ -404,7 +898,10 @@ def serve(arguments: argparse.Namespace) -> int: path.parent.mkdir(parents=True, exist_ok=True) control.unlink(missing_ok=True) ready.unlink(missing_ok=True) - state = FixtureState(evidence, control, arguments.blossom_port) + suite = None + if arguments.persona_fixture is not None: + _, suite = load_persona_suite(Path(arguments.persona_fixture).resolve()) + state = FixtureState(evidence, control, arguments.blossom_port, suite) RelayHandler.state = state BlossomHandler.state = state relay = ReusableThreadingServer(("127.0.0.1", arguments.relay_port), RelayHandler) @@ -476,6 +973,462 @@ def verify_accessibility(arguments: argparse.Namespace) -> int: return 0 +def verify_persona_fixture(arguments: argparse.Namespace) -> int: + raw, _ = load_persona_suite(Path(arguments.fixture).resolve()) + fixture_schema = validate_schema_file( + Path(arguments.fixture_schema).resolve(), + "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", + ) + result_schema = validate_schema_file( + Path(arguments.result_schema).resolve(), + "https://radroots.org/schemas/ios/local-social-persona-results.v1.schema.json", + ) + print( + "local-social persona fixtures verified: " + f"fixture={hashlib.sha256(raw).hexdigest()} " + f"fixture_schema={hashlib.sha256(fixture_schema).hexdigest()} " + f"result_schema={hashlib.sha256(result_schema).hexdigest()}" + ) + return 0 + + +def directory_digest(path: Path) -> str: + digest = hashlib.sha256() + for item in sorted( + path.rglob("*"), key=lambda value: value.relative_to(path).as_posix() + ): + relative = item.relative_to(path).as_posix().encode("utf-8") + if item.is_symlink(): + raise ValueError("result bundle contains a symbolic link") + if item.is_dir(): + digest.update(b"d\0" + relative + b"\0") + continue + if not item.is_file(): + raise ValueError("result bundle contains an unsupported entry") + digest.update(b"f\0" + relative + b"\0") + with item.open("rb") as stream: + while chunk := stream.read(64 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def simulator_metadata(udid: str) -> dict[str, str]: + devices = json.loads( + subprocess.check_output(["xcrun", "simctl", "list", "devices", "--json"]) + ) + matches = [ + (runtime, device) + for runtime, values in devices.get("devices", {}).items() + for device in values + if device.get("udid") == udid + ] + if len(matches) != 1: + raise ValueError("simulator identity is unavailable") + runtime, _ = matches[0] + version = runtime.rsplit("iOS-", 1)[-1].replace("-", ".") + architecture = subprocess.check_output( + ["xcrun", "simctl", "spawn", udid, "uname", "-m"], text=True + ).strip() + if architecture != "arm64" or int(version.split(".")[0]) < 18: + raise ValueError("simulator does not satisfy the development platform contract") + return {"udid": udid.upper(), "os": f"iOS {version}", "architecture": architecture} + + +def validate_persona_evidence(value: Any, suite: dict[str, Any]) -> dict[str, Any]: + keys = { + "schema", "schema_version", "personas", "flow_counts", "accepted_events", + "event_kind_counts", "upload_attempts", "accepted_uploads", "retrievals", + "distinct_identities", "unknown_attempts", "duplicate_attempts", + "expected_failure_rejections", "events_accepted_during_expected_failures", + "production_network_contacts", + "unintended_publications", + "final_candidate_data_loss", + } + evidence = exact_keys(value, keys, "persona evidence") + if ( + evidence["schema"] != "radroots.ios.local-social.persona-evidence.v1" + or evidence["schema_version"] != 1 + ): + raise ValueError("persona evidence header is invalid") + expected_scalars = { + "accepted_events": 15, + "accepted_uploads": 3, + "retrievals": 3, + "distinct_identities": 5, + "unknown_attempts": 0, + "duplicate_attempts": 0, + "expected_failure_rejections": 1, + "events_accepted_during_expected_failures": 0, + "production_network_contacts": 0, + "unintended_publications": 0, + "final_candidate_data_loss": 0, + } + if any(evidence.get(key) != expected for key, expected in expected_scalars.items()): + raise ValueError("persona evidence totals are invalid") + if evidence["flow_counts"] != {flow: 3 for flow in FLOW_KINDS}: + raise ValueError("persona flow counts are invalid") + if evidence["event_kind_counts"] != {"1": 9, "31923": 3, "30402": 3}: + raise ValueError("persona event kind counts are invalid") + if not isinstance(evidence["personas"], list) or len(evidence["personas"]) != 5: + raise ValueError("persona evidence inventory is invalid") + identity_digests = set() + for persona, expected in zip(evidence["personas"], suite["personas"], strict=True): + exact_keys( + persona, + { + "alias", + "identity_sha256", + "subscriptions", + "accepted_uploads", + "retrievals", + "attempts", + }, + "persona evidence row", + ) + if ( + persona["alias"] != expected["alias"] + or not lowercase_hex(persona["identity_sha256"], 64) + or persona["identity_sha256"] == "0" * 64 + or type(persona["subscriptions"]) is not int + or not 1 <= persona["subscriptions"] <= 4096 + or persona["accepted_uploads"] != int(persona["alias"] in PHOTO_PERSONAS) + or persona["retrievals"] != int(persona["alias"] in PHOTO_PERSONAS) + or not isinstance(persona["attempts"], list) + or len(persona["attempts"]) != 3 + ): + raise ValueError("persona evidence row is invalid") + identity_digests.add(persona["identity_sha256"]) + for attempt, expected_attempt in zip( + persona["attempts"], expected["attempts"], strict=True + ): + exact_keys( + attempt, + {"id", "flow", "event_kind", "accepted", "expected_failure_rejections"}, + "attempt evidence row", + ) + if ( + attempt["id"] != expected_attempt["id"] + or attempt["flow"] != expected_attempt["flow"] + or attempt["event_kind"] != FLOW_KINDS[expected_attempt["flow"]] + or attempt["accepted"] is not True + or attempt["expected_failure_rejections"] + != int( + expected_attempt["expected_failure"] + == "transport_retry_relaunch" + ) + ): + raise ValueError("attempt evidence row is invalid") + if len(identity_digests) != 5: + raise ValueError("persona identities are not distinct") + return evidence + + +def valid_ios_runtime(value: Any) -> bool: + if not isinstance(value, str) or not re.fullmatch( + r"iOS ([1-9][0-9]*)(\.[0-9]+){1,2}", value + ): + return False + return int(value.split()[1].split(".", 1)[0]) >= 18 + + +def validate_persona_result( + value: Any, + suite: dict[str, Any], + fixture_sha256: str, + fixture_schema_sha256: str, + result_schema_sha256: str, +) -> dict[str, Any]: + keys = { + "schema", + "schema_version", + "run_id", + "source_commit", + "source_tree", + "fixture_sha256", + "fixture_schema_sha256", + "result_schema_sha256", + "simulator", + "result_bundle_sha256", + "evidence_sha256", + "personas", + "flow_counts", + "accepted_events", + "event_kind_counts", + "accepted_uploads", + "retrievals", + "distinct_identities", + "unknown_attempts", + "duplicate_attempts", + "expected_failure_rejections", + "events_accepted_during_expected_failures", + "production_network_contacts", + "unintended_publications", + "final_candidate_data_loss", + "accessibility", + "forward_repairs", + "complete_matrix_rerun", + } + result = exact_keys(value, keys, "persona result") + if ( + result["schema"] != "radroots.ios.local-social.persona-results.v1" + or result["schema_version"] != 1 + or not re.fullmatch(r"[a-z0-9][a-z0-9-]{6,62}[a-z0-9]", result["run_id"]) + or not lowercase_hex(result["source_commit"], 40) + or not lowercase_hex(result["source_tree"], 40) + ): + raise ValueError("persona result header is invalid") + expected_digests = { + "fixture_sha256": fixture_sha256, + "fixture_schema_sha256": fixture_schema_sha256, + "result_schema_sha256": result_schema_sha256, + } + if any(result[key] != digest for key, digest in expected_digests.items()): + raise ValueError("persona result contract digest is invalid") + if not lowercase_hex(result["result_bundle_sha256"], 64) or not lowercase_hex( + result["evidence_sha256"], 64 + ): + raise ValueError("persona result evidence digest is invalid") + simulator = exact_keys( + result["simulator"], {"udid", "os", "architecture"}, "simulator" + ) + if ( + not isinstance(simulator["udid"], str) + or re.fullmatch(r"[A-F0-9-]{36}", simulator["udid"]) is None + or not valid_ios_runtime(simulator["os"]) + or simulator["architecture"] != "arm64" + ): + raise ValueError("persona result simulator is invalid") + accessibility = exact_keys( + result["accessibility"], + { + "locale", + "content_size", + "reduce_motion", + "semantic_audit", + "voiceover_user_observed", + }, + "accessibility result", + ) + if accessibility != { + "locale": "en_US", + "content_size": "accessibility-extra-extra-extra-large", + "reduce_motion": True, + "semantic_audit": "passed", + "voiceover_user_observed": False, + }: + raise ValueError("persona accessibility result is invalid") + repairs = result["forward_repairs"] + if ( + not isinstance(repairs, list) + or len(repairs) > 16 + or len(set(repairs)) != len(repairs) + or any(not lowercase_hex(commit, 40) for commit in repairs) + or result["complete_matrix_rerun"] is not True + ): + raise ValueError("persona rerun evidence is invalid") + expected_scalars = { + "accepted_events": 15, + "accepted_uploads": 3, + "retrievals": 3, + "distinct_identities": 5, + "unknown_attempts": 0, + "duplicate_attempts": 0, + "expected_failure_rejections": 1, + "events_accepted_during_expected_failures": 0, + "production_network_contacts": 0, + "unintended_publications": 0, + "final_candidate_data_loss": 0, + } + if any(result.get(key) != expected for key, expected in expected_scalars.items()): + raise ValueError("persona result totals are invalid") + if result["flow_counts"] != {flow: 3 for flow in FLOW_KINDS} or result[ + "event_kind_counts" + ] != {"1": 9, "31923": 3, "30402": 3}: + raise ValueError("persona result event inventory is invalid") + if not isinstance(result["personas"], list) or len(result["personas"]) != 5: + raise ValueError("persona result inventory is invalid") + identities = set() + for persona, expected in zip(result["personas"], suite["personas"], strict=True): + exact_keys( + persona, + {"alias", "identity_sha256", "subscriptions", "attempts"}, + "persona result row", + ) + if ( + persona["alias"] != expected["alias"] + or not lowercase_hex(persona["identity_sha256"], 64) + or persona["identity_sha256"] == "0" * 64 + or type(persona["subscriptions"]) is not int + or not 1 <= persona["subscriptions"] <= 4096 + or not isinstance(persona["attempts"], list) + or len(persona["attempts"]) != 3 + ): + raise ValueError("persona result row is invalid") + identities.add(persona["identity_sha256"]) + for attempt, expected_attempt in zip( + persona["attempts"], expected["attempts"], strict=True + ): + exact_keys( + attempt, + { + "id", + "flow", + "event_kind", + "accepted", + "expected_failure_rejections", + }, + "attempt result row", + ) + if ( + attempt["id"] != expected_attempt["id"] + or attempt["flow"] != expected_attempt["flow"] + or attempt["event_kind"] != FLOW_KINDS[expected_attempt["flow"]] + or attempt["accepted"] is not True + or attempt["expected_failure_rejections"] + != int( + expected_attempt["expected_failure"] + == "transport_retry_relaunch" + ) + ): + raise ValueError("attempt result row is invalid") + if len(identities) != 5: + raise ValueError("persona result identities are not distinct") + return result + + +def verify_persona(arguments: argparse.Namespace) -> int: + fixture_raw, suite = load_persona_suite(Path(arguments.fixture).resolve()) + fixture_schema_raw = validate_schema_file( + Path(arguments.fixture_schema).resolve(), + "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", + ) + result_schema_raw = validate_schema_file( + Path(arguments.result_schema).resolve(), + "https://radroots.org/schemas/ios/local-social-persona-results.v1.schema.json", + ) + evidence_path = Path(arguments.evidence).resolve() + evidence_raw, evidence_value = read_json(evidence_path) + evidence = validate_persona_evidence(evidence_value, suite) + result_bundle = Path(arguments.result_bundle).resolve() + if not result_bundle.is_dir(): + raise ValueError("XCUITest result bundle is unavailable") + if not lowercase_hex(arguments.source_commit, 40) or not lowercase_hex( + arguments.source_tree, 40 + ): + raise ValueError("source identity is invalid") + result = { + "schema": "radroots.ios.local-social.persona-results.v1", + "schema_version": 1, + "run_id": arguments.run_id, + "source_commit": arguments.source_commit, + "source_tree": arguments.source_tree, + "fixture_sha256": hashlib.sha256(fixture_raw).hexdigest(), + "fixture_schema_sha256": hashlib.sha256(fixture_schema_raw).hexdigest(), + "result_schema_sha256": hashlib.sha256(result_schema_raw).hexdigest(), + "simulator": simulator_metadata(arguments.simulator_id), + "result_bundle_sha256": directory_digest(result_bundle), + "evidence_sha256": hashlib.sha256(evidence_raw).hexdigest(), + "personas": [ + { + "alias": persona["alias"], + "identity_sha256": persona["identity_sha256"], + "subscriptions": persona["subscriptions"], + "attempts": persona["attempts"], + } + for persona in evidence["personas"] + ], + "flow_counts": evidence["flow_counts"], + "accepted_events": evidence["accepted_events"], + "event_kind_counts": evidence["event_kind_counts"], + "accepted_uploads": evidence["accepted_uploads"], + "retrievals": evidence["retrievals"], + "distinct_identities": evidence["distinct_identities"], + "unknown_attempts": evidence["unknown_attempts"], + "duplicate_attempts": evidence["duplicate_attempts"], + "expected_failure_rejections": evidence["expected_failure_rejections"], + "events_accepted_during_expected_failures": evidence[ + "events_accepted_during_expected_failures" + ], + "production_network_contacts": evidence["production_network_contacts"], + "unintended_publications": evidence["unintended_publications"], + "final_candidate_data_loss": evidence["final_candidate_data_loss"], + "accessibility": { + "locale": "en_US", + "content_size": "accessibility-extra-extra-extra-large", + "reduce_motion": True, + "semantic_audit": "passed", + "voiceover_user_observed": False, + }, + "forward_repairs": arguments.forward_repair_commit, + "complete_matrix_rerun": True, + } + fixture_sha256 = hashlib.sha256(fixture_raw).hexdigest() + fixture_schema_sha256 = hashlib.sha256(fixture_schema_raw).hexdigest() + result_schema_sha256 = hashlib.sha256(result_schema_raw).hexdigest() + validate_persona_result( + result, + suite, + fixture_sha256, + fixture_schema_sha256, + result_schema_sha256, + ) + output = Path(arguments.output).resolve() + output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + verify_persona_result_file( + output, + suite, + fixture_sha256, + fixture_schema_sha256, + result_schema_sha256, + ) + print( + "local-social persona result verified: " + f"{hashlib.sha256(output.read_bytes()).hexdigest()}" + ) + return 0 + + +def verify_persona_result_file( + path: Path, + suite: dict[str, Any], + fixture_sha256: str, + fixture_schema_sha256: str, + result_schema_sha256: str, +) -> dict[str, Any]: + raw, value = read_json(path) + canonical = (json.dumps(value, indent=2) + "\n").encode("utf-8") + if raw != canonical: + raise ValueError("persona result is noncanonical") + return validate_persona_result( + value, + suite, + fixture_sha256, + fixture_schema_sha256, + result_schema_sha256, + ) + + +def verify_persona_result(arguments: argparse.Namespace) -> int: + fixture_raw, suite = load_persona_suite(Path(arguments.fixture).resolve()) + fixture_schema_raw = validate_schema_file( + Path(arguments.fixture_schema).resolve(), + "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", + ) + result_schema_raw = validate_schema_file( + Path(arguments.result_schema).resolve(), + "https://radroots.org/schemas/ios/local-social-persona-results.v1.schema.json", + ) + verify_persona_result_file( + Path(arguments.result).resolve(), + suite, + hashlib.sha256(fixture_raw).hexdigest(), + hashlib.sha256(fixture_schema_raw).hexdigest(), + hashlib.sha256(result_schema_raw).hexdigest(), + ) + print("local-social persona result contract verified") + return 0 + + def parser() -> argparse.ArgumentParser: root = argparse.ArgumentParser() commands = root.add_subparsers(dest="command", required=True) @@ -485,10 +1438,32 @@ def parser() -> argparse.ArgumentParser: serve_command.add_argument("--evidence", required=True) serve_command.add_argument("--ready", required=True) serve_command.add_argument("--control", required=True) + serve_command.add_argument("--persona-fixture") verify_command = commands.add_parser("verify") verify_command.add_argument("--evidence", required=True) accessibility_command = commands.add_parser("verify-accessibility") accessibility_command.add_argument("--evidence", required=True) + fixture_command = commands.add_parser("verify-persona-fixture") + fixture_command.add_argument("--fixture", required=True) + fixture_command.add_argument("--fixture-schema", required=True) + fixture_command.add_argument("--result-schema", required=True) + persona_command = commands.add_parser("verify-persona") + persona_command.add_argument("--fixture", required=True) + persona_command.add_argument("--fixture-schema", required=True) + persona_command.add_argument("--result-schema", required=True) + persona_command.add_argument("--evidence", required=True) + persona_command.add_argument("--result-bundle", required=True) + persona_command.add_argument("--output", required=True) + persona_command.add_argument("--source-commit", required=True) + persona_command.add_argument("--source-tree", required=True) + persona_command.add_argument("--run-id", required=True) + persona_command.add_argument("--simulator-id", required=True) + persona_command.add_argument("--forward-repair-commit", action="append", default=[]) + result_command = commands.add_parser("verify-persona-result") + result_command.add_argument("--fixture", required=True) + result_command.add_argument("--fixture-schema", required=True) + result_command.add_argument("--result-schema", required=True) + result_command.add_argument("--result", required=True) return root @@ -498,7 +1473,13 @@ def main() -> int: return serve(arguments) if arguments.command == "verify": return verify(arguments) - return verify_accessibility(arguments) + if arguments.command == "verify-accessibility": + return verify_accessibility(arguments) + if arguments.command == "verify-persona-fixture": + return verify_persona_fixture(arguments) + if arguments.command == "verify-persona-result": + return verify_persona_result(arguments) + return verify_persona(arguments) if __name__ == "__main__": diff --git a/scripts/test_local_social_fixture.py b/scripts/test_local_social_fixture.py @@ -0,0 +1,214 @@ +import copy +import hashlib +import importlib.util +import json +import re +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT = Path(__file__).with_name("local-social-fixture.py") +SPEC = importlib.util.spec_from_file_location("local_social_fixture", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +fixture = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(fixture) + + +class LocalSocialFixtureTests(unittest.TestCase): + def persona_result(self) -> tuple[dict, dict]: + _, suite = fixture.load_persona_suite( + Path("test-fixtures/local-social-personas.v1.json") + ) + personas = [] + for persona in suite["personas"]: + personas.append( + { + "alias": persona["alias"], + "identity_sha256": hashlib.sha256( + persona["alias"].encode("ascii") + ).hexdigest(), + "subscriptions": 1, + "attempts": [ + { + "id": attempt["id"], + "flow": attempt["flow"], + "event_kind": fixture.FLOW_KINDS[attempt["flow"]], + "accepted": True, + "expected_failure_rejections": int( + attempt["expected_failure"] + == "transport_retry_relaunch" + ), + } + for attempt in persona["attempts"] + ], + } + ) + result = { + "schema": "radroots.ios.local-social.persona-results.v1", + "schema_version": 1, + "run_id": "persona-result-test-001", + "source_commit": "1" * 40, + "source_tree": "2" * 40, + "fixture_sha256": "3" * 64, + "fixture_schema_sha256": "4" * 64, + "result_schema_sha256": "5" * 64, + "simulator": { + "udid": "11111111-2222-3333-4444-555555555555", + "os": "iOS 26.5", + "architecture": "arm64", + }, + "result_bundle_sha256": "6" * 64, + "evidence_sha256": "7" * 64, + "personas": personas, + "flow_counts": {flow: 3 for flow in fixture.FLOW_KINDS}, + "accepted_events": 15, + "event_kind_counts": {"1": 9, "31923": 3, "30402": 3}, + "accepted_uploads": 3, + "retrievals": 3, + "distinct_identities": 5, + "unknown_attempts": 0, + "duplicate_attempts": 0, + "expected_failure_rejections": 1, + "events_accepted_during_expected_failures": 0, + "production_network_contacts": 0, + "unintended_publications": 0, + "final_candidate_data_loss": 0, + "accessibility": { + "locale": "en_US", + "content_size": "accessibility-extra-extra-extra-large", + "reduce_motion": True, + "semantic_audit": "passed", + "voiceover_user_observed": False, + }, + "forward_repairs": [], + "complete_matrix_rerun": True, + } + return suite, result + + def test_bip340_reference_signature_and_mutation(self) -> None: + public_key = bytes.fromhex( + "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + ) + message = bytes(32) + signature = bytes.fromhex( + "e907831f80848d1069a5371b402410364bdf1c5f8307b0084c55f1ce2dca8215" + "25f66a4a85ea8b71e482a74f382d2ce5ebeee8fdb2172f477df4900d310536c0" + ) + self.assertTrue(fixture.verify_bip340(public_key, message, signature)) + self.assertFalse( + fixture.verify_bip340(public_key, message, signature[:-1] + b"\x00") + ) + + def test_fixture_rejects_duplicate_and_unknown_fields(self) -> None: + source = Path("test-fixtures/local-social-personas.v1.json") + payload = json.loads(source.read_text(encoding="utf-8")) + payload["unexpected"] = True + with self.assertRaisesRegex(ValueError, "field inventory"): + fixture.validate_persona_suite(payload) + + with tempfile.TemporaryDirectory() as directory: + duplicate = Path(directory, "duplicate.json") + duplicate.write_text('{"schema":1,"schema":1}\n', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "duplicate JSON member"): + fixture.read_json(duplicate) + + def test_fixture_freezes_exact_persona_and_flow_matrix(self) -> None: + _, suite = fixture.load_persona_suite( + Path("test-fixtures/local-social-personas.v1.json") + ) + attempts = fixture.persona_attempts(suite) + self.assertEqual( + tuple(persona["alias"] for persona in suite["personas"]), + fixture.PERSONA_ALIASES, + ) + self.assertEqual(len(attempts), 15) + self.assertEqual( + { + flow: sum(item["flow"] == flow for item in attempts.values()) + for flow in fixture.FLOW_KINDS + }, + {flow: 3 for flow in fixture.FLOW_KINDS}, + ) + + def test_legacy_fixture_control_remains_file_presence_based(self) -> None: + body = b"legacy-photo" + digest = fixture.hashlib.sha256(body).hexdigest() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + control = root / "control" + control.touch() + state = fixture.FixtureState(root / "evidence.json", control, 21100) + with mock.patch.object( + fixture, "valid_blossom_authorization", return_value=True + ): + accepted, _ = state.upload(body, "image/png", digest, "Nostr valid") + self.assertTrue(accepted) + self.assertIsNotNone(state.retrieve(digest)) + evidence = json.loads((root / "evidence.json").read_text(encoding="utf-8")) + self.assertEqual(evidence["accepted_uploads"], 1) + self.assertEqual(evidence["retrievals"], 1) + + def test_result_contract_accepts_future_ios_and_rejects_drift(self) -> None: + suite, result = self.persona_result() + result_schema = json.loads( + Path("test-fixtures/local-social-persona-results.v1.schema.json").read_text( + encoding="utf-8" + ) + ) + os_pattern = result_schema["properties"]["simulator"]["properties"]["os"][ + "pattern" + ] + self.assertIsNotNone(re.fullmatch(os_pattern, "iOS 26.5")) + self.assertIsNone(re.fullmatch(os_pattern, "iOS 17.7")) + self.assertIs( + fixture.validate_persona_result( + result, suite, "3" * 64, "4" * 64, "5" * 64 + ), + result, + ) + for mutation in ( + lambda value: value.update({"unexpected": True}), + lambda value: value["simulator"].update({"os": "iOS 17.7"}), + lambda value: value["accessibility"].update( + {"voiceover_user_observed": True} + ), + ): + changed = copy.deepcopy(result) + mutation(changed) + with self.assertRaises(ValueError): + fixture.validate_persona_result( + changed, suite, "3" * 64, "4" * 64, "5" * 64 + ) + + def test_control_is_bounded_and_deny_unknown(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory, "control.json") + path.write_text( + json.dumps( + { + "schema": fixture.PERSONA_CONTROL_SCHEMA, + "active_persona": "P03", + "blossom_enabled": True, + } + ), + encoding="utf-8", + ) + self.assertEqual(fixture.read_control(path)["active_persona"], "P03") + path.write_text( + json.dumps( + { + "schema": fixture.PERSONA_CONTROL_SCHEMA, + "active_persona": "P03", + "blossom_enabled": True, + "secret": "forbidden", + } + ), + encoding="utf-8", + ) + self.assertIsNone(fixture.read_control(path)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-package-contract.sh b/scripts/verify-package-contract.sh @@ -85,7 +85,32 @@ grep -Fq 'RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialFive "$repo_root/scripts/xcode.sh" grep -Fq 'RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialAccessibilitySemantics' \ "$repo_root/scripts/xcode.sh" +grep -Fq 'RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialDeterministicPersonas' \ + "$repo_root/scripts/xcode.sh" grep -Fq 'verify-accessibility' "$repo_root/scripts/local-social-fixture.py" +grep -Fq 'verify-persona-fixture' "$repo_root/scripts/local-social-fixture.py" +grep -Fq 'verify-persona' "$repo_root/scripts/local-social-fixture.py" +grep -Fq 'verify-persona-result' "$repo_root/scripts/local-social-fixture.py" +grep -Fq 'add.tap()' "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" +if rg -n 'coordinate\(' "$repo_root/RadrootsUITests" --glob '*.swift'; then + echo "error: UI qualification must not use coordinate taps" >&2 + exit 1 +fi +for fixture in \ + local-social-personas.v1.json \ + local-social-personas.v1.schema.json \ + local-social-persona-results.v1.schema.json +do + test -f "$repo_root/test-fixtures/$fixture" +done +python3 "$repo_root/scripts/local-social-fixture.py" verify-persona-fixture \ + --fixture "$repo_root/test-fixtures/local-social-personas.v1.json" \ + --fixture-schema "$repo_root/test-fixtures/local-social-personas.v1.schema.json" \ + --result-schema "$repo_root/test-fixtures/local-social-persona-results.v1.schema.json" +( + cd "$repo_root" + python3 -m unittest scripts/test_local_social_fixture.py +) grep -Fq 'performAccessibilityAudit' \ "$repo_root/RadrootsUITests/RadrootsRemoteQualificationUITests.swift" grep -Fq 'element.identifier == "radroots.add.submit"' \ diff --git a/scripts/xcode.sh b/scripts/xcode.sh @@ -173,6 +173,16 @@ case "$operation" in evidence_command=verify-accessibility simulator_id=${destination##*id=} previous_content_size= + persona_fixture= + persona_result= + ;; + persona) + test_selector=RadrootsUITests/RadrootsRemoteQualificationUITests/testLocalSocialDeterministicPersonas + evidence_command=verify-persona + simulator_id=${destination##*id=} + previous_content_size= + persona_fixture=test-fixtures/local-social-personas.v1.json + persona_result="$XCODE_RESULTS/$result_name.persona-result.json" ;; *) echo "error: unsupported local-social-ui-test scenario: $scenario" >&2 @@ -185,16 +195,41 @@ case "$operation" in evidence="$XCODE_RESULTS/$result_name.fixture.json" ready="$XCODE_RESULTS/$result_name.ready.json" control="$XCODE_RESULTS/$result_name.enable-uploads" - if [[ -e "$result_bundle" || -e "$evidence" || -e "$ready" || -e "$control" ]]; then + if [[ -e "$result_bundle" || -e "$evidence" || -e "$ready" || -e "$control" || ( -n "${persona_result:-}" && -e "$persona_result" ) ]]; then echo "error: local-social-ui-test result already exists: $result_name" >&2 exit 1 fi - python3 scripts/local-social-fixture.py serve \ - --relay-port "$relay_port" \ - --blossom-port "$blossom_port" \ - --evidence "$evidence" \ - --ready "$ready" \ - --control "$control" & + if [[ "$scenario" == persona ]]; then + if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then + echo "error: persona qualification requires one clean exact source tree" >&2 + exit 1 + fi + source_commit=$(git rev-parse HEAD) + source_tree=$(git rev-parse 'HEAD^{tree}') + upstream=$(git rev-parse '@{upstream}') + if [[ "$source_commit" != "$upstream" ]]; then + echo "error: persona qualification source is not equal to its configured upstream" >&2 + exit 1 + fi + python3 scripts/local-social-fixture.py verify-persona-fixture \ + --fixture "$persona_fixture" \ + --fixture-schema test-fixtures/local-social-personas.v1.schema.json \ + --result-schema test-fixtures/local-social-persona-results.v1.schema.json + python3 scripts/local-social-fixture.py serve \ + --relay-port "$relay_port" \ + --blossom-port "$blossom_port" \ + --evidence "$evidence" \ + --ready "$ready" \ + --control "$control" \ + --persona-fixture "$persona_fixture" & + else + python3 scripts/local-social-fixture.py serve \ + --relay-port "$relay_port" \ + --blossom-port "$blossom_port" \ + --evidence "$evidence" \ + --ready "$ready" \ + --control "$control" & + fi fixture_pid=$! cleanup_fixture() { kill "$fixture_pid" 2>/dev/null || true @@ -217,7 +252,7 @@ case "$operation" in echo "error: local-social fixture readiness timed out" >&2 exit 1 fi - if [[ "$scenario" == accessibility ]]; then + if [[ "$scenario" == accessibility || "$scenario" == persona ]]; then xcrun simctl boot "$simulator_id" >/dev/null 2>&1 || true xcrun simctl bootstatus "$simulator_id" -b >/dev/null previous_content_size=$(xcrun simctl ui "$simulator_id" content_size) @@ -253,7 +288,21 @@ case "$operation" in if ((test_status != 0)); then exit "$test_status" fi - python3 scripts/local-social-fixture.py "$evidence_command" --evidence "$evidence" + if [[ "$scenario" == persona ]]; then + python3 scripts/local-social-fixture.py "$evidence_command" \ + --fixture "$persona_fixture" \ + --fixture-schema test-fixtures/local-social-personas.v1.schema.json \ + --result-schema test-fixtures/local-social-persona-results.v1.schema.json \ + --evidence "$evidence" \ + --result-bundle "$result_bundle" \ + --output "$persona_result" \ + --source-commit "$source_commit" \ + --source-tree "$source_tree" \ + --run-id "$qualification_run_id" \ + --simulator-id "$simulator_id" + else + python3 scripts/local-social-fixture.py "$evidence_command" --evidence "$evidence" + fi ;; remote-ui-test) destination=${2:?remote-ui-test requires a simulator destination} diff --git a/test-fixtures/local-social-persona-results.v1.schema.json b/test-fixtures/local-social-persona-results.v1.schema.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/schemas/ios/local-social-persona-results.v1.schema.json", + "title": "Radroots iOS local-social deterministic persona result", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", "schema_version", "run_id", "source_commit", "source_tree", + "fixture_sha256", "fixture_schema_sha256", "result_schema_sha256", + "simulator", "result_bundle_sha256", "evidence_sha256", "personas", + "flow_counts", "accepted_events", "event_kind_counts", "accepted_uploads", + "retrievals", "distinct_identities", "unknown_attempts", "duplicate_attempts", + "expected_failure_rejections", "events_accepted_during_expected_failures", + "production_network_contacts", "unintended_publications", "final_candidate_data_loss", + "accessibility", "forward_repairs", "complete_matrix_rerun" + ], + "properties": { + "schema": {"const": "radroots.ios.local-social.persona-results.v1"}, + "schema_version": {"const": 1}, + "run_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{6,62}[a-z0-9]$"}, + "source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "source_tree": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "fixture_sha256": {"$ref": "#/$defs/sha256"}, + "fixture_schema_sha256": {"$ref": "#/$defs/sha256"}, + "result_schema_sha256": {"$ref": "#/$defs/sha256"}, + "simulator": { + "type": "object", + "additionalProperties": false, + "required": ["udid", "os", "architecture"], + "properties": { + "udid": {"type": "string", "pattern": "^[A-F0-9-]{36}$"}, + "os": {"type": "string", "pattern": "^iOS (1[89]|[2-9][0-9]|[1-9][0-9]{2,})(\\.[0-9]+){1,2}$", "maxLength": 32}, + "architecture": {"const": "arm64"} + } + }, + "result_bundle_sha256": {"$ref": "#/$defs/sha256"}, + "evidence_sha256": {"$ref": "#/$defs/sha256"}, + "personas": {"type": "array", "minItems": 5, "maxItems": 5, "items": {"$ref": "#/$defs/persona"}}, + "flow_counts": {"$ref": "#/$defs/flow_counts"}, + "accepted_events": {"const": 15}, + "event_kind_counts": { + "type": "object", + "additionalProperties": false, + "required": ["1", "31923", "30402"], + "properties": {"1": {"const": 9}, "31923": {"const": 3}, "30402": {"const": 3}} + }, + "accepted_uploads": {"const": 3}, + "retrievals": {"const": 3}, + "distinct_identities": {"const": 5}, + "unknown_attempts": {"const": 0}, + "duplicate_attempts": {"const": 0}, + "expected_failure_rejections": {"const": 1}, + "events_accepted_during_expected_failures": {"const": 0}, + "production_network_contacts": {"const": 0}, + "unintended_publications": {"const": 0}, + "final_candidate_data_loss": {"const": 0}, + "accessibility": { + "type": "object", + "additionalProperties": false, + "required": ["locale", "content_size", "reduce_motion", "semantic_audit", "voiceover_user_observed"], + "properties": { + "locale": {"const": "en_US"}, + "content_size": {"const": "accessibility-extra-extra-extra-large"}, + "reduce_motion": {"const": true}, + "semantic_audit": {"const": "passed"}, + "voiceover_user_observed": {"const": false} + } + }, + "forward_repairs": { + "type": "array", + "maxItems": 16, + "uniqueItems": true, + "items": {"type": "string", "pattern": "^[0-9a-f]{40}$"} + }, + "complete_matrix_rerun": {"const": true} + }, + "$defs": { + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "flow_counts": { + "type": "object", + "additionalProperties": false, + "required": ["Update", "PhotoUpdate", "Ask", "Event", "FoodAvailability"], + "properties": { + "Update": {"const": 3}, "PhotoUpdate": {"const": 3}, "Ask": {"const": 3}, + "Event": {"const": 3}, "FoodAvailability": {"const": 3} + } + }, + "persona": { + "type": "object", + "additionalProperties": false, + "required": ["alias", "identity_sha256", "subscriptions", "attempts"], + "properties": { + "alias": {"enum": ["P01", "P02", "P03", "P04", "P05"]}, + "identity_sha256": {"$ref": "#/$defs/sha256"}, + "subscriptions": {"type": "integer", "minimum": 1, "maximum": 4096}, + "attempts": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"$ref": "#/$defs/attempt"}} + } + }, + "attempt": { + "type": "object", + "additionalProperties": false, + "required": ["id", "flow", "event_kind", "accepted", "expected_failure_rejections"], + "properties": { + "id": {"type": "string", "pattern": "^P0[1-5]-A0[1-3]$"}, + "flow": {"enum": ["Update", "PhotoUpdate", "Ask", "Event", "FoodAvailability"]}, + "event_kind": {"enum": [1, 31923, 30402]}, + "accepted": {"const": true}, + "expected_failure_rejections": {"type": "integer", "minimum": 0, "maximum": 1} + } + } + } +} diff --git a/test-fixtures/local-social-personas.v1.json b/test-fixtures/local-social-personas.v1.json @@ -0,0 +1,58 @@ +{ + "schema": "radroots.ios.local-social.personas.v1", + "schema_version": 1, + "locale": "en_US", + "media_fixture_sha256": "431ced6916a2a21a156e38701afe55bbd7f88969fbbfc56d7fe099d47f265460", + "personas": [ + { + "alias": "P01", + "synthetic_age_band": "age_18_27", + "interaction_profile": "experienced_direct", + "attempts": [ + {"id": "P01-A01", "order": 1, "flow": "Update", "marker": "rr-p01-a01-update", "expected_failure": "none"}, + {"id": "P01-A02", "order": 2, "flow": "PhotoUpdate", "marker": "rr-p01-a02-photo", "expected_failure": "none"}, + {"id": "P01-A03", "order": 3, "flow": "Ask", "marker": "rr-p01-a03-ask", "expected_failure": "none"} + ] + }, + { + "alias": "P02", + "synthetic_age_band": "age_18_27", + "interaction_profile": "novice_progressive_disclosure", + "attempts": [ + {"id": "P02-A01", "order": 4, "flow": "Event", "marker": "rr-p02-a01-event", "expected_failure": "none"}, + {"id": "P02-A02", "order": 5, "flow": "FoodAvailability", "marker": "rr-p02-a02-food", "expected_failure": "none"}, + {"id": "P02-A03", "order": 6, "flow": "Update", "marker": "rr-p02-a03-update", "expected_failure": "none"} + ] + }, + { + "alias": "P03", + "synthetic_age_band": "age_18_27", + "interaction_profile": "nontechnical_validation_recovery", + "attempts": [ + {"id": "P03-A01", "order": 7, "flow": "PhotoUpdate", "marker": "rr-p03-a01-photo", "expected_failure": "none"}, + {"id": "P03-A02", "order": 8, "flow": "Ask", "marker": "rr-p03-a02-ask", "expected_failure": "none"}, + {"id": "P03-A03", "order": 9, "flow": "Event", "marker": "rr-p03-a03-event", "expected_failure": "validation_recovery"} + ] + }, + { + "alias": "P04", + "synthetic_age_band": "adult_other", + "interaction_profile": "novice_accessibility_keyboard", + "attempts": [ + {"id": "P04-A01", "order": 10, "flow": "FoodAvailability", "marker": "rr-p04-a01-food", "expected_failure": "none"}, + {"id": "P04-A02", "order": 11, "flow": "Update", "marker": "rr-p04-a02-update", "expected_failure": "none"}, + {"id": "P04-A03", "order": 12, "flow": "PhotoUpdate", "marker": "rr-p04-a03-photo", "expected_failure": "none"} + ] + }, + { + "alias": "P05", + "synthetic_age_band": "adult_other", + "interaction_profile": "general_transport_retry_relaunch", + "attempts": [ + {"id": "P05-A01", "order": 13, "flow": "Ask", "marker": "rr-p05-a01-ask", "expected_failure": "transport_retry_relaunch"}, + {"id": "P05-A02", "order": 14, "flow": "Event", "marker": "rr-p05-a02-event", "expected_failure": "none"}, + {"id": "P05-A03", "order": 15, "flow": "FoodAvailability", "marker": "rr-p05-a03-food", "expected_failure": "none"} + ] + } + ] +} diff --git a/test-fixtures/local-social-personas.v1.schema.json b/test-fixtures/local-social-personas.v1.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/schemas/ios/local-social-personas.v1.schema.json", + "title": "Radroots iOS local-social deterministic persona suite", + "type": "object", + "additionalProperties": false, + "required": ["schema", "schema_version", "locale", "media_fixture_sha256", "personas"], + "properties": { + "schema": {"const": "radroots.ios.local-social.personas.v1"}, + "schema_version": {"const": 1}, + "locale": {"const": "en_US"}, + "media_fixture_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "personas": { + "type": "array", + "minItems": 5, + "maxItems": 5, + "items": {"$ref": "#/$defs/persona"} + } + }, + "$defs": { + "persona": { + "type": "object", + "additionalProperties": false, + "required": ["alias", "synthetic_age_band", "interaction_profile", "attempts"], + "properties": { + "alias": {"enum": ["P01", "P02", "P03", "P04", "P05"]}, + "synthetic_age_band": {"enum": ["age_18_27", "adult_other"]}, + "interaction_profile": { + "enum": [ + "experienced_direct", + "novice_progressive_disclosure", + "nontechnical_validation_recovery", + "novice_accessibility_keyboard", + "general_transport_retry_relaunch" + ] + }, + "attempts": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": {"$ref": "#/$defs/attempt"} + } + } + }, + "attempt": { + "type": "object", + "additionalProperties": false, + "required": ["id", "order", "flow", "marker", "expected_failure"], + "properties": { + "id": {"type": "string", "pattern": "^P0[1-5]-A0[1-3]$"}, + "order": {"type": "integer", "minimum": 1, "maximum": 15}, + "flow": {"enum": ["Update", "PhotoUpdate", "Ask", "Event", "FoodAvailability"]}, + "marker": {"type": "string", "pattern": "^rr-p0[1-5]-a0[1-3]-(update|photo|ask|event|food)$", "maxLength": 24}, + "expected_failure": {"enum": ["none", "validation_recovery", "transport_retry_relaunch"]} + } + } + } +}