import XCTest import CryptoKit @testable import Closer final class SealedAnswerCryptoTests: XCTestCase { private let coupleId = "couple-sealed-123" private let userId = "user-sealed-456" private let questionId = "question-sealed-789" private var samplePlaintext: SealedAnswerPlaintext { SealedAnswerPlaintext( writtenText: "My secret answer", selectedOptionIds: ["beta", "alpha", "gamma"], scaleValue: 7 ) } func testRoundTrip() throws { let key = try SealedAnswerCrypto.generateOneTimeKey() let payload = try SealedAnswerCrypto.encrypt( plaintext: samplePlaintext, oneTimeKey: key, coupleId: coupleId, userId: userId, questionId: questionId ) XCTAssertEqual(payload.schemaVersion, SealedAnswerCrypto.schemaVersion) XCTAssertTrue(payload.ciphertext.hasPrefix(SealedAnswerCrypto.sealedPrefix)) XCTAssertTrue(payload.commitment.hasPrefix(SealedAnswerCrypto.commitmentPrefix)) let recovered = try SealedAnswerCrypto.decrypt(payload, oneTimeKey: key) XCTAssertEqual(recovered, samplePlaintext) XCTAssertTrue(try SealedAnswerCrypto.verifyCommitment(payload, plaintext: recovered)) } func testCanonicalJSONByteStability() throws { // Ground truth: Android's manual canonical builder for this input. let plaintext = SealedAnswerPlaintext( writtenText: "Line\nTab\tQuote\"Back\\", selectedOptionIds: ["z", "a", "m"], scaleValue: 3 ) let canonical = SealedAnswerCrypto.canonicalJSON(plaintext) let expected = "{\"scaleValue\":3,\"selectedOptionIds\":[\"a\",\"m\",\"z\"],\"writtenText\":\"Line\\nTab\\tQuote\\\"Back\\\\\"}" XCTAssertEqual(canonical, expected) } func testCommitmentFormatAndLength() throws { let commitment = try SealedAnswerCrypto.commit( plaintext: samplePlaintext, coupleId: coupleId, questionId: questionId, userId: userId ) XCTAssertTrue(commitment.hasPrefix("sha256:")) // 7 + 43 chars = 50 chars. XCTAssertEqual(commitment.count, 50) } func testAADMismatchRejects() throws { let key = try SealedAnswerCrypto.generateOneTimeKey() var payload = try SealedAnswerCrypto.encrypt( plaintext: samplePlaintext, oneTimeKey: key, coupleId: coupleId, userId: userId, questionId: questionId ) payload = SealedAnswerPayload( schemaVersion: payload.schemaVersion, coupleId: payload.coupleId, userId: payload.userId, questionId: payload.questionId + "x", commitment: payload.commitment, ciphertext: payload.ciphertext, nonce: payload.nonce, createdAt: payload.createdAt ) XCTAssertThrowsError(try SealedAnswerCrypto.decrypt(payload, oneTimeKey: key)) } func testTamperedCiphertextRejects() throws { let key = try SealedAnswerCrypto.generateOneTimeKey() let payload = try SealedAnswerCrypto.encrypt( plaintext: samplePlaintext, oneTimeKey: key, coupleId: coupleId, userId: userId, questionId: questionId ) var chars = Array(payload.ciphertext) let prefixEnd = SealedAnswerCrypto.sealedPrefix.count chars[prefixEnd + 5] ^= 0x01 let tampered = String(chars) let tamperedPayload = SealedAnswerPayload( schemaVersion: payload.schemaVersion, coupleId: payload.coupleId, userId: payload.userId, questionId: payload.questionId, commitment: payload.commitment, ciphertext: tampered, nonce: payload.nonce, createdAt: payload.createdAt ) XCTAssertThrowsError(try SealedAnswerCrypto.decrypt(tamperedPayload, oneTimeKey: key)) } func testTamperedCommitmentFailsVerification() throws { let key = try SealedAnswerCrypto.generateOneTimeKey() let payload = try SealedAnswerCrypto.encrypt( plaintext: samplePlaintext, oneTimeKey: key, coupleId: coupleId, userId: userId, questionId: questionId ) let tamperedCommitment = payload.commitment + "x" let tamperedPayload = SealedAnswerPayload( schemaVersion: payload.schemaVersion, coupleId: payload.coupleId, userId: payload.userId, questionId: payload.questionId, commitment: tamperedCommitment, ciphertext: payload.ciphertext, nonce: payload.nonce, createdAt: payload.createdAt ) let recovered = try SealedAnswerCrypto.decrypt(tamperedPayload, oneTimeKey: key) XCTAssertFalse(try SealedAnswerCrypto.verifyCommitment(tamperedPayload, plaintext: recovered)) } func testEncodeDecodeRoundTrip() throws { let key = try SealedAnswerCrypto.generateOneTimeKey() let payload = try SealedAnswerCrypto.encrypt( plaintext: samplePlaintext, oneTimeKey: key, coupleId: coupleId, userId: userId, questionId: questionId ) let encoded = try SealedAnswerCrypto.encode(payload) XCTAssertTrue(encoded.hasPrefix(SealedAnswerCrypto.sealedPrefix)) let decoded = try SealedAnswerCrypto.decode(encoded) XCTAssertEqual(decoded.userId, userId) XCTAssertEqual(decoded.questionId, questionId) XCTAssertEqual(decoded.schemaVersion, SealedAnswerCrypto.schemaVersion) let recovered = try SealedAnswerCrypto.decrypt(decoded, oneTimeKey: key) XCTAssertEqual(recovered, samplePlaintext) } func testCrossPlatformInteropPlaceholder() { // Real BouncyCastle ↔ CryptoKit cross-platform verification requires a Mac/CI run. // The placeholder will be replaced in Batch 6 once the paired CI fixture exists. XCTAssertEqual(SealedAnswerCrypto.sealedPrefix, "sealed:v1:") } func testFixtureFileLoadsAndProducesCanonicalJSON() throws { let fixtures = try loadSealedFixtures() for fixture in fixtures { let plaintext = fixtureInputToPlaintext(fixture.input) let actual = SealedAnswerCrypto.canonicalJSON(plaintext) if fixture.expectedCanonicalJSON == "TODO_ANDROID_RUN" { XCTSkip("Fixture \(fixture.name) needs Android canonical output") } else { XCTAssertEqual(actual, fixture.expectedCanonicalJSON, "Fixture \(fixture.name)") } } } func testFixtureFileProducesMatchingCommitment() throws { let fixtures = try loadSealedFixtures() for fixture in fixtures { let plaintext = fixtureInputToPlaintext(fixture.input) let actual = try SealedAnswerCrypto.commit( plaintext: plaintext, coupleId: fixture.input.coupleId, questionId: fixture.input.questionId, userId: fixture.input.userId ) if fixture.expectedCommitmentSHA256 == "TODO_ANDROID_RUN" { XCTSkip("Fixture \(fixture.name) needs Android commitment output") } else { XCTAssertEqual(actual, fixture.expectedCommitmentSHA256, "Fixture \(fixture.name)") } } } // MARK: - Fixture helpers private struct SealedFixture: Decodable { let name: String let input: SealedFixtureInput let expectedCanonicalJSON: String let expectedCommitmentSHA256: String enum CodingKeys: String, CodingKey { case name case input case expectedCanonicalJSON = "expected_canonical_json" case expectedCommitmentSHA256 = "expected_commitment_sha256" } } private struct SealedFixtureInput: Decodable { let plaintext: String let questionId: String let userId: String let coupleId: String } private func loadSealedFixtures() throws -> [SealedFixture] { let bundle = Bundle(for: type(of: self)) guard let url = bundle.url( forResource: "sealed_answer_canonical_fixtures", withExtension: "json" ) else { throw XCTSkip("sealed_answer_canonical_fixtures.json not found in test bundle") } let data = try Data(contentsOf: url) return try JSONDecoder().decode([SealedFixture].self, from: data) } private func fixtureInputToPlaintext(_ input: SealedFixtureInput) -> SealedAnswerPlaintext { // The plaintext string is treated as writtenText for these fixtures. return SealedAnswerPlaintext( writtenText: input.plaintext, selectedOptionIds: [], scaleValue: Int(input.plaintext) ) } func testEmptySelectedOptionsAndNullText() throws { let plaintext = SealedAnswerPlaintext( writtenText: nil, selectedOptionIds: [], scaleValue: nil ) let canonical = SealedAnswerCrypto.canonicalJSON(plaintext) XCTAssertEqual(canonical, "{\"scaleValue\":null,\"selectedOptionIds\":[],\"writtenText\":null}") let key = try SealedAnswerCrypto.generateOneTimeKey() let payload = try SealedAnswerCrypto.encrypt( plaintext: plaintext, oneTimeKey: key, coupleId: coupleId, userId: userId, questionId: questionId ) let recovered = try SealedAnswerCrypto.decrypt(payload, oneTimeKey: key) XCTAssertEqual(recovered, plaintext) } }