import XCTest import CryptoKit @testable import Closer final class AnswerCryptoTests: XCTestCase { private let coupleId = "couple-test-456" private let userId = "user-test-789" private let questionId = "question-test-abc" private var key: CoupleKeyMaterial { CoupleKeyMaterial(rawBytes: Data(repeating: 0xAB, count: 32)) } func testEncryptDecryptRoundTrip() throws { let plaintext = "This is my private answer." let payload = try AnswerCrypto.encrypt( answerPlaintext: plaintext, userId: userId, questionId: questionId, coupleId: coupleId, key: key ) XCTAssertEqual(payload.schemaVersion, AnswerCrypto.schemaVersion) XCTAssertTrue(payload.ciphertext.hasPrefix(FieldEncryptor.prefix)) let recovered = try AnswerCrypto.decrypt(payload, coupleId: coupleId, key: key) XCTAssertEqual(recovered, plaintext) } func testEncodeDecodeRoundTrip() throws { let plaintext = "Round-trip via outer wrapper" let payload = try AnswerCrypto.encrypt( answerPlaintext: plaintext, userId: userId, questionId: questionId, coupleId: coupleId, key: key ) let encoded = try AnswerCrypto.encode(payload) XCTAssertTrue(encoded.hasPrefix(FieldEncryptor.prefix)) let decoded = try AnswerCrypto.decode(encoded) XCTAssertEqual(decoded.userId, userId) XCTAssertEqual(decoded.questionId, questionId) XCTAssertEqual(decoded.coupleId, coupleId) XCTAssertEqual(decoded.schemaVersion, AnswerCrypto.schemaVersion) let recovered = try AnswerCrypto.decrypt(decoded, coupleId: coupleId, key: key) XCTAssertEqual(recovered, plaintext) } func testAADIsCoupleIdOnly() throws { let plaintext = "AAD contract check" let payload = try AnswerCrypto.encrypt( answerPlaintext: plaintext, userId: userId, questionId: questionId, coupleId: coupleId, key: key ) // Strip the outer enc:v1: JSON wrapper so we can inspect the inner ciphertext. let innerBlob = try AnswerCrypto.decode(payload.ciphertext) let innerCiphertext = innerBlob.ciphertext guard innerCiphertext.hasPrefix(FieldEncryptor.prefix) else { XCTFail("Inner ciphertext missing enc:v1: prefix") return } let combined = Data(base64Encoded: String(innerCiphertext.dropFirst(FieldEncryptor.prefix.count))) XCTAssertNotNil(combined) XCTAssertGreaterThanOrEqual(combined?.count ?? 0, 28) // nonce(12) + tag(16) // The AAD is not part of the ciphertext, but we assert the contract by // round-tripping with the expected AAD bytes and rejecting any other AAD. let expectedAAD = coupleId.data(using: .utf8) XCTAssertNotNil(expectedAAD) let wrongAAD = "\(userId):\(questionId)".data(using: .utf8) XCTAssertThrowsError( try FieldEncryptor.decryptString( innerCiphertext, key: key.rawKey, aad: wrongAAD ) ) // Re-encrypting the same plaintext with the same key but a different coupleId // must produce a different inner ciphertext because the AAD changed. let otherPayload = try AnswerCrypto.encrypt( answerPlaintext: plaintext, userId: userId, questionId: questionId, coupleId: "other-couple", key: key ) let otherInnerBlob = try AnswerCrypto.decode(otherPayload.ciphertext) XCTAssertNotEqual(payload.ciphertext, otherPayload.ciphertext) XCTAssertNotEqual(innerCiphertext, otherInnerBlob.ciphertext) } 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. // For now we document the AAD contract that Android expects: // FieldEncryptor.kt uses AAD = coupleId.toByteArray(UTF_8). let expectedAAD = coupleId.data(using: .utf8) XCTAssertNotNil(expectedAAD) XCTAssertEqual(expectedAAD, coupleId.data(using: .utf8)) } func testAADMismatchRejects() throws { let plaintext = "AAD-bound answer" let payload = try AnswerCrypto.encrypt( answerPlaintext: plaintext, userId: userId, questionId: questionId, coupleId: coupleId, key: key ) // Tamper the coupleId in the payload so the AAD would differ. var tampered = payload tampered = SecureAnswerPayload( schemaVersion: payload.schemaVersion, coupleId: payload.coupleId + "x", userId: payload.userId, questionId: payload.questionId, ciphertext: payload.ciphertext, createdAt: payload.createdAt ) XCTAssertThrowsError(try AnswerCrypto.decrypt(tampered, coupleId: coupleId + "x", key: key)) } func testTamperedCiphertextRejects() throws { let plaintext = "Tamper me" let payload = try AnswerCrypto.encrypt( answerPlaintext: plaintext, userId: userId, questionId: questionId, coupleId: coupleId, key: key ) var chars = Array(payload.ciphertext) let prefixEnd = FieldEncryptor.prefix.count chars[prefixEnd + 5] ^= 0x01 let tamperedCiphertext = String(chars) let tamperedPayload = SecureAnswerPayload( schemaVersion: payload.schemaVersion, coupleId: payload.coupleId, userId: payload.userId, questionId: payload.questionId, ciphertext: tamperedCiphertext, createdAt: payload.createdAt ) XCTAssertThrowsError(try AnswerCrypto.decrypt(tamperedPayload, coupleId: coupleId, key: key)) } func testOuterWrapperTamperRejects() throws { let plaintext = "Outer tamper" let payload = try AnswerCrypto.encrypt( answerPlaintext: plaintext, userId: userId, questionId: questionId, coupleId: coupleId, key: key ) let encoded = try AnswerCrypto.encode(payload) var chars = Array(encoded) let prefixEnd = FieldEncryptor.prefix.count chars[prefixEnd + 3] ^= 0x01 let tampered = String(chars) XCTAssertThrowsError(try AnswerCrypto.decode(tampered)) } }