GameStoreRevocationLockTests.swift (4294B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 /// Revocation makes a shared game read-only for the removed participant. 8 /// The mutator refuses every local mutation — not just the sync emit — and 9 /// the store refuses to resign or latch a revoked copy into a terminal 10 /// completed state, so a revoked open puzzle can't fabricate progress or a 11 /// completion the shared game never reached. 12 @Suite("GameStore revocation lock", .isolatedNotificationState) 13 @MainActor 14 struct GameStoreRevocationLockTests { 15 16 private static let puzzleSource = """ 17 Title: Test Puzzle 18 Author: Test 19 20 21 AB 22 CD 23 24 25 A1. Across 1 ~ AB 26 A3. Across 3 ~ CD 27 D1. Down 1 ~ AC 28 D2. Down 2 ~ BD 29 """ 30 31 private func makeSharedGame( 32 revoked: Bool, 33 in ctx: NSManagedObjectContext 34 ) throws -> (GameEntity, UUID) { 35 let xd = try XD.parse(Self.puzzleSource) 36 let puzzle = Puzzle(xd: xd) 37 let gameID = UUID() 38 let entity = GameEntity(context: ctx) 39 entity.id = gameID 40 entity.title = "Test" 41 entity.puzzleSource = Self.puzzleSource 42 entity.createdAt = Date() 43 entity.updatedAt = Date() 44 entity.ckRecordName = "game-\(gameID.uuidString)" 45 entity.databaseScope = DatabaseScope.shared.rawValue 46 entity.isAccessRevoked = revoked 47 entity.populateCachedSummaryFields(from: puzzle) 48 try ctx.save() 49 return (entity, gameID) 50 } 51 52 @Test("Loading a revoked game returns a read-only mutator") 53 func loadedRevokedMutatorIsLocked() throws { 54 let persistence = makeTestPersistence() 55 let store = makeTestStore(persistence: persistence) 56 let (_, gameID) = try makeSharedGame(revoked: true, in: persistence.viewContext) 57 58 let (game, mutator) = try store.loadGame(id: gameID) 59 #expect(mutator.isAccessRevoked) 60 #expect(!mutator.isEditable) 61 62 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) 63 mutator.revealCells(game.puzzle.cells.flatMap { $0 }) 64 #expect(game.squares.allSatisfy { row in row.allSatisfy { $0.entry.isEmpty } }) 65 } 66 67 @Test("Mid-session revocation locks the open mutator against further input") 68 func liveRevocationLocksOpenMutator() throws { 69 let persistence = makeTestPersistence() 70 let store = makeTestStore(persistence: persistence) 71 let (_, gameID) = try makeSharedGame(revoked: false, in: persistence.viewContext) 72 73 let (game, mutator) = try store.loadGame(id: gameID) 74 mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) 75 #expect(game.squares[0][0].entry == "A") 76 77 store.markAccessRevoked(gameID: gameID) 78 79 mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false) 80 mutator.revealCells([game.puzzle.cells[1][1]]) 81 #expect(game.squares[0][1].entry == "") 82 #expect(game.squares[1][1].entry == "") 83 } 84 85 @Test("A revoked game cannot be marked completed or observed-completed") 86 func revokedGameRefusesCompletion() throws { 87 let persistence = makeTestPersistence() 88 let store = makeTestStore( 89 persistence: persistence, 90 authorIDProvider: { "alice" } 91 ) 92 let (entity, gameID) = try makeSharedGame(revoked: true, in: persistence.viewContext) 93 var journalCompletions = 0 94 store.onJournalComplete = { _, _, _, _ in journalCompletions += 1 } 95 96 #expect(try store.markCompleted(id: gameID) == false) 97 #expect(try store.markCompletedFromObservedSolvedState(id: gameID) == false) 98 99 #expect(entity.completedAt == nil) 100 #expect(entity.completedBy == nil) 101 #expect(!entity.hasPendingSave) 102 #expect(journalCompletions == 0) 103 } 104 105 @Test("A revoked game cannot be resigned") 106 func revokedGameRefusesResignation() throws { 107 let persistence = makeTestPersistence() 108 let store = makeTestStore(persistence: persistence) 109 let (entity, gameID) = try makeSharedGame(revoked: true, in: persistence.viewContext) 110 111 try store.resignGame(id: gameID) 112 113 #expect(entity.completedAt == nil) 114 let (game, _) = try store.loadGame(id: gameID) 115 #expect(game.squares.allSatisfy { row in row.allSatisfy { $0.entry.isEmpty } }) 116 } 117 }