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 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) } }