diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 71f9642..1cb7d74 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -670,6 +670,20 @@ func serve(args []string) (serveErr error) { return fmt.Errorf("read initial hunting AP: %w", err) } gameplayStore := stateio.EntrySnapshotStore{Entries: stateRepository, Domain: "missions", Bucket: "gameplay"} + contentOpeningDesign, err := gamedata.LoadContentOpeningDesign(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load content opening GameData: %w", err) + } + contentOpenService, err := player.NewContentOpenService(contentOpeningDesign, ownedItems, gameplayStore, func() (uint64, error) { + experience, err := missionService.AchievementExperience() + if err != nil { + return 0, err + } + return levelDesign.Level(experience), nil + }) + if err != nil { + return fmt.Errorf("load content opening state: %w", err) + } huntingService, err := hunting.Open(gameplayStore, gameData, *gameDataVersion, ownedItems, wallet, worldService.CurrentPackID, freeHuntingAP, bonusHuntingAP) if err != nil { @@ -1115,6 +1129,7 @@ func serve(args []string) (serveErr error) { costumePotentialService, costumeBurstService, friendshipService, + contentOpenService, masterTitleService, recruitService, foodService, diff --git a/go/internal/server/gamedata/content_open.go b/go/internal/server/gamedata/content_open.go new file mode 100644 index 0000000..75c43d7 --- /dev/null +++ b/go/internal/server/gamedata/content_open.go @@ -0,0 +1,106 @@ +package gamedata + +import ( + "database/sql" + "fmt" +) + +// ContentOpeningDesign describes the rapport prologue's prerequisite and +// completion marker. The client maps ContentOpenRequest type 1 to group 23, +// detail 2; ticket identities and level requirements remain version data. +type ContentOpeningDesign struct { + Prerequisite, Completion ContentOpenRule +} + +func LoadContentOpeningDesign(root, version string) (*ContentOpeningDesign, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + return loadContentOpeningDesign(db) +} + +func loadContentOpeningDesign(db *sql.DB) (*ContentOpeningDesign, error) { + if db == nil { + return nil, fmt.Errorf("gamedata: nil content opening database") + } + tickets := map[uint64]uint64{} + rows, err := db.Query("SELECT id,ProtoBuf FROM ContentTicketTable ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + break + } + var protoID, typ uint64 + protoID, err = friendshipScalar(raw, 4) + if err == nil { + typ, err = friendshipScalar(raw, 8) + } + if err != nil || id == 0 || protoID != id || typ < 1 || typ > 6 { + err = fmt.Errorf("gamedata: invalid content ticket %d", id) + break + } + if _, exists := tickets[id]; exists { + err = fmt.Errorf("gamedata: duplicate content ticket %d", id) + break + } + tickets[id] = typ + } + if err == nil { + err = rows.Err() + } + rows.Close() + if err != nil { + return nil, err + } + rows, err = db.Query("SELECT groupId,id,ticketId,ProtoBuf FROM ContentOpenTable ORDER BY groupId,id") + if err != nil { + return nil, err + } + defer rows.Close() + d := &ContentOpeningDesign{} + seen := map[[2]uint64]bool{} + for rows.Next() { + var group, id, ticket uint64 + var raw []byte + if err := rows.Scan(&group, &id, &ticket, &raw); err != nil { + return nil, err + } + values := make([]uint64, 6) + for i := range values { + values[i], err = friendshipScalar(raw, i+1) + if err != nil { + return nil, fmt.Errorf("gamedata: content opening %d/%d: %w", group, id, err) + } + } + key := [2]uint64{group, id} + if group == 0 || id == 0 || values[0] != group || values[1] != id || ticket == 0 || values[5] != ticket || tickets[ticket] == 0 || seen[key] { + return nil, fmt.Errorf("gamedata: invalid content opening %d/%d identity or ticket", group, id) + } + seen[key] = true + if group != 23 || (id != 1 && id != 2) { + continue + } + if tickets[ticket] != 1 { + return nil, fmt.Errorf("gamedata: rapport opening %d requires permanent content ticket", id) + } + rule := ContentOpenRule{TutorialID: values[3], SquadLevel: values[4], TicketID: ticket} + if id == 1 { + d.Prerequisite = rule + } else { + d.Completion = rule + } + } + if err := rows.Err(); err != nil { + return nil, err + } + if !seen[[2]uint64{23, 1}] || !seen[[2]uint64{23, 2}] || d.Prerequisite.TicketID == d.Completion.TicketID { + return nil, fmt.Errorf("gamedata: missing or indistinguishable rapport opening rules") + } + return d, nil +} diff --git a/go/internal/server/gamedata/content_open_test.go b/go/internal/server/gamedata/content_open_test.go new file mode 100644 index 0000000..f580495 --- /dev/null +++ b/go/internal/server/gamedata/content_open_test.go @@ -0,0 +1,66 @@ +package gamedata + +import ( + "database/sql" + "testing" +) + +func contentOpeningTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { db.Close() }) + for _, q := range []string{"CREATE TABLE ContentOpenTable(groupId INTEGER,id INTEGER,ticketId INTEGER,ProtoBuf BLOB)", "CREATE TABLE ContentTicketTable(id INTEGER,ProtoBuf BLOB)"} { + if _, err := db.Exec(q); err != nil { + t.Fatal(err) + } + } + for _, id := range []uint64{901, 902} { + if _, err := db.Exec("INSERT INTO ContentTicketTable VALUES(?,?)", id, friendshipTestProto(map[int][]uint64{4: {id}, 8: {1}})); err != nil { + t.Fatal(err) + } + } + for _, id := range []uint64{1, 2} { + if _, err := db.Exec("INSERT INTO ContentOpenTable VALUES(23,?,?,?)", id, 900+id, friendshipTestProto(map[int][]uint64{1: {23}, 2: {id}, 4: {100 + id}, 5: {10 + id}, 6: {900 + id}})); err != nil { + t.Fatal(err) + } + } + return db +} + +func TestContentOpeningUsesVersionTicketsAndRequirements(t *testing.T) { + d, err := loadContentOpeningDesign(contentOpeningTestDB(t)) + if err != nil { + t.Fatal(err) + } + if d.Prerequisite != (ContentOpenRule{TicketID: 901, SquadLevel: 11, TutorialID: 101}) || d.Completion != (ContentOpenRule{TicketID: 902, SquadLevel: 12, TutorialID: 102}) { + t.Fatalf("design=%+v", d) + } +} + +func TestContentOpeningRejectsIncompleteAndInvalidCatalog(t *testing.T) { + for _, tc := range []struct { + name, query string + args []any + }{ + {"missing completion", "DELETE FROM ContentOpenTable WHERE id=2", nil}, + {"missing ticket", "DELETE FROM ContentTicketTable WHERE id=902", nil}, + {"temporary ticket", "UPDATE ContentTicketTable SET ProtoBuf=? WHERE id=902", []any{friendshipTestProto(map[int][]uint64{4: {902}, 8: {2}})}}, + {"wrong ticket identity", "UPDATE ContentTicketTable SET ProtoBuf=? WHERE id=901", []any{friendshipTestProto(map[int][]uint64{4: {999}, 8: {1}})}}, + {"wrong opening identity", "UPDATE ContentOpenTable SET ProtoBuf=? WHERE id=2", []any{friendshipTestProto(map[int][]uint64{1: {23}, 2: {1}, 6: {902}})}}, + {"invalid unrelated row", "INSERT INTO ContentOpenTable VALUES(1,1,999,?)", []any{friendshipTestProto(map[int][]uint64{1: {1}, 2: {1}, 6: {999}})}}, + } { + t.Run(tc.name, func(t *testing.T) { + db := contentOpeningTestDB(t) + if _, err := db.Exec(tc.query, tc.args...); err != nil { + t.Fatal(err) + } + if _, err := loadContentOpeningDesign(db); err == nil { + t.Fatal("invalid catalog accepted") + } + }) + } +} diff --git a/go/internal/server/player/content_open.go b/go/internal/server/player/content_open.go new file mode 100644 index 0000000..29e74fc --- /dev/null +++ b/go/internal/server/player/content_open.go @@ -0,0 +1,230 @@ +package player + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "sync" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +const contentOpenPacketCode = 622 + +type contentOpenReceipt struct { + Digest string `json:"digest"` + Body []byte `json:"body"` +} + +type contentOpenState struct { + Receipts map[string]contentOpenReceipt `json:"receipts"` +} + +// ContentOpenService grants the completion ticket for the client's sole +// implemented ContentOpen type (1). The prerequisite ticket is never spent. +// Handle must run in the account request transaction: inventory and receipt +// writes commit together, and account recovery reconstructs Inventory after +// a dirty rollback. Receipts are reloaded for every request. +type ContentOpenService struct { + mu sync.Mutex + design *gamedata.ContentOpeningDesign + inventory *Inventory + store stateio.Store + squadLevel func() (uint64, error) + session string +} + +func NewContentOpenService(design *gamedata.ContentOpeningDesign, inventory *Inventory, store stateio.Store, squadLevel func() (uint64, error)) (*ContentOpenService, error) { + if design == nil || inventory == nil || store == nil || squadLevel == nil || design.Prerequisite.TicketID == 0 || design.Completion.TicketID == 0 || design.Completion.TicketID == design.Prerequisite.TicketID { + return nil, errors.New("player: incomplete ContentOpen service") + } + s := &ContentOpenService{design: design, inventory: inventory, store: store, squadLevel: squadLevel} + if _, err := s.loadState(); err != nil { + return nil, err + } + return s, nil +} + +func (s *ContentOpenService) BeginSession(id string) { + s.mu.Lock() + defer s.mu.Unlock() + s.session = id +} + +func (s *ContentOpenService) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/ContentOpen" { + return 0, nil, false, nil + } + var seq, kind uint64 + var haveSeq, haveKind bool + err := wire.Walk(request, func(field wire.Field) error { + switch field.Number { + case 1, 2: + if field.Type != 0 { + return errors.New("player: ContentOpen field must be varint") + } + value, _ := binary.Uvarint(field.Value) + if field.Number == 1 { + if haveSeq { + return errors.New("player: duplicate ContentOpen sequence") + } + seq, haveSeq = value, true + } else { + if haveKind { + return errors.New("player: duplicate ContentOpen type") + } + kind, haveKind = value, true + } + } + return nil + }) + if err != nil || !haveSeq || seq == 0 || seq > math.MaxInt32 || !haveKind || kind != 1 { + if err == nil { + err = errors.New("player: invalid ContentOpen sequence or unsupported type") + } + return 0, nil, true, err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.session == "" { + return 0, nil, true, errors.New("player: ContentOpen requires authenticated session") + } + sessionHash := sha256.Sum256([]byte(s.session)) + key := hex.EncodeToString(sessionHash[:]) + ":" + strconv.FormatUint(seq, 10) + requestHash := sha256.Sum256(request) + digest := hex.EncodeToString(requestHash[:]) + state, err := s.loadState() + if err != nil { + return 0, nil, true, err + } + if receipt, ok := state.Receipts[key]; ok { + if receipt.Digest != digest { + return 0, nil, true, errors.New("player: ContentOpen sequence reused with different request") + } + return contentOpenPacketCode, append([]byte(nil), receipt.Body...), true, nil + } + var prerequisite, complete bool + var bundle []byte + for _, item := range s.inventory.All() { + if item.Type != 19 || item.Count == 0 { + continue + } + prerequisite = prerequisite || item.ID == s.design.Prerequisite.TicketID + if item.ID == s.design.Completion.TicketID && !complete { + if item.Count != 1 || item.InvenIndex == 0 || item.InvenIndex > math.MaxInt64 { + return 0, nil, true, errors.New("player: invalid owned ContentOpen completion ticket") + } + complete = true + bundle = wire.AppendBytes(bundle, 1, ItemWire(item)) + } + } + if !complete { + if !prerequisite { + return 0, nil, true, errors.New("player: ContentOpen prerequisite ticket is not owned") + } + level, err := s.squadLevel() + if err != nil { + return 0, nil, true, fmt.Errorf("player: ContentOpen squad level: %w", err) + } + if level < max(s.design.Prerequisite.SquadLevel, s.design.Completion.SquadLevel) { + return 0, nil, true, errors.New("player: ContentOpen squad level is too low") + } + items, err := s.inventory.GrantOnce("content-open:"+strconv.FormatUint(s.design.Completion.TicketID, 10), []gamedata.BattleReward{{Type: 19, ID: s.design.Completion.TicketID, Count: 1}}) + if err != nil { + return 0, nil, true, fmt.Errorf("player: grant ContentOpen ticket: %w", err) + } + if len(items) != 1 { + return 0, nil, true, errors.New("player: ContentOpen grant marker has no completion ticket") + } + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, ItemWire(item)) + } + } + body := wire.AppendBytes(nil, 1, bundle) + state.Receipts[key] = contentOpenReceipt{Digest: digest, Body: body} + data, err := json.Marshal(state) + if err != nil { + return 0, nil, true, err + } + if err := s.store.Save("content_open", data); err != nil { + return 0, nil, true, fmt.Errorf("player: persist ContentOpen receipt: %w", err) + } + return contentOpenPacketCode, append([]byte(nil), body...), true, nil +} + +func (s *ContentOpenService) loadState() (contentOpenState, error) { + state := contentOpenState{Receipts: make(map[string]contentOpenReceipt)} + data, err := s.store.Load("content_open") + if err != nil || data == nil { + return state, err + } + if err := stateio.RequireExactJSONObject(data, "receipts"); err != nil { + return state, fmt.Errorf("player: invalid ContentOpen state: %w", err) + } + if err := json.Unmarshal(data, &state); err != nil || state.Receipts == nil { + return state, errors.New("player: invalid ContentOpen receipts") + } + validHash := func(value string) bool { + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == sha256.Size && hex.EncodeToString(decoded) == value + } + for key, receipt := range state.Receipts { + hash, sequence, found := strings.Cut(key, ":") + seq, err := strconv.ParseUint(sequence, 10, 32) + if !found || !validHash(hash) || err != nil || seq == 0 || seq > math.MaxInt32 || strconv.FormatUint(seq, 10) != sequence || !validHash(receipt.Digest) { + return state, errors.New("player: invalid ContentOpen receipt identity") + } + if err := validateContentOpenBody(receipt.Body, s.design.Completion.TicketID); err != nil { + return state, err + } + } + return state, nil +} + +func validateContentOpenBody(body []byte, ticketID uint64) error { + unwrap := func(data []byte) ([]byte, error) { + var value []byte + var found bool + err := wire.Walk(data, func(field wire.Field) error { + if field.Number != 1 || field.Type != 2 || found { + return errors.New("player: invalid ContentOpen receipt bundle") + } + value, found = field.Value, true + return nil + }) + if err != nil { + return nil, err + } + if !found { + return nil, errors.New("player: missing ContentOpen receipt bundle") + } + return value, nil + } + bundle, err := unwrap(body) + if err != nil { + return err + } + item, err := unwrap(bundle) + if err != nil { + return err + } + for field, want := range map[int]uint64{2: ticketID, 3: 19, 4: 1} { + value, present, err := wire.Varint(item, field) + if err != nil || !present || value != want { + return errors.New("player: invalid ContentOpen receipt ticket") + } + } + index, present, err := wire.Varint(item, 1) + if err != nil || !present || index == 0 || index > math.MaxInt64 { + return errors.New("player: invalid ContentOpen receipt ticket index") + } + return nil +} diff --git a/go/internal/server/player/content_open_test.go b/go/internal/server/player/content_open_test.go new file mode 100644 index 0000000..e22ad07 --- /dev/null +++ b/go/internal/server/player/content_open_test.go @@ -0,0 +1,257 @@ +package player + +import ( + "bytes" + "errors" + "path/filepath" + "testing" + + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" +) + +func contentOpenHarness(t *testing.T, store stateio.Store, prerequisite bool, level uint64) (*ContentOpenService, *Inventory) { + t.Helper() + starter := &Starter{Version: versionconfig.State()} + if prerequisite { + starter.Items = []Item{{InvenIndex: 100, Type: 19, ID: 41, Count: 1}} + } + inventory, err := OpenInventory(store, starter) + if err != nil { + t.Fatal(err) + } + design := &gamedata.ContentOpeningDesign{Prerequisite: gamedata.ContentOpenRule{TicketID: 41, SquadLevel: 3}, Completion: gamedata.ContentOpenRule{TicketID: 42, SquadLevel: 2}} + service, err := NewContentOpenService(design, inventory, store, func() (uint64, error) { return level, nil }) + if err != nil { + t.Fatal(err) + } + service.BeginSession("authenticated-session") + return service, inventory +} + +func contentOpenRequest(seq, kind uint64) []byte { + return wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, kind) +} + +func contentOpenTicketCount(inventory *Inventory, id uint64) uint64 { + var count uint64 + for _, item := range inventory.All() { + if item.Type == 19 && item.ID == id { + count += item.Count + } + } + return count +} + +func assertContentOpenResponse(t *testing.T, body []byte) { + t.Helper() + bundle, present, err := wire.Bytes(body, 1) + if err != nil || !present { + t.Fatalf("missing RewardDBInfoBundle: %x %v", body, err) + } + item, present, err := wire.Bytes(bundle, 1) + if err != nil || !present { + t.Fatalf("missing completion ItemDBInfo: %x %v", bundle, err) + } + for field, want := range map[int]uint64{2: 42, 3: 19, 4: 1} { + got, present, err := wire.Varint(item, field) + if err != nil || !present || got != want { + t.Fatalf("ItemDBInfo field %d=%d, want %d", field, got, want) + } + } +} + +func TestContentOpenTicketOnceAcrossSequencesAndSessions(t *testing.T) { + store := stateio.NewMemory() + service, inventory := contentOpenHarness(t, store, true, 3) + request := contentOpenRequest(1, 1) + code, body, handled, err := service.Handle("/ContentOpen", request) + if err != nil || !handled || code != 622 { + t.Fatalf("open: %d %v %v", code, handled, err) + } + assertContentOpenResponse(t, body) + for _, tc := range []struct { + session string + seq uint64 + }{{"authenticated-session", 1}, {"authenticated-session", 2}, {"another-session", 1}} { + service.BeginSession(tc.session) + _, next, _, err := service.Handle("/ContentOpen", contentOpenRequest(tc.seq, 1)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(next, body) { + t.Fatal("ticket repair/retry returned a different item") + } + } + if contentOpenTicketCount(inventory, 41) != 1 || contentOpenTicketCount(inventory, 42) != 1 { + t.Fatal("prerequisite consumed or completion duplicated") + } + data, err := store.Load("content_open") + if err != nil || bytes.Contains(data, []byte("authenticated-session")) || bytes.Contains(data, []byte("another-session")) { + t.Fatal("receipt contains session credentials") + } + service.BeginSession("authenticated-session") + changed := wire.AppendVarint(append([]byte(nil), request...), 3, 7) + if _, _, _, err := service.Handle("/ContentOpen", changed); err == nil { + t.Fatal("same sequence accepted different request") + } +} + +func TestContentOpenRequiresSessionAndRepairsOwnedTicket(t *testing.T) { + store := stateio.NewMemory() + service, inventory := contentOpenHarness(t, store, false, 0) + service.BeginSession("") + if _, _, _, err := service.Handle("/ContentOpen", contentOpenRequest(1, 1)); err == nil { + t.Fatal("missing authenticated session accepted") + } + if _, err := inventory.GrantOnce("existing-completion", []gamedata.BattleReward{{ID: 42, Type: 19, Count: 1}}); err != nil { + t.Fatal(err) + } + service.BeginSession("repair-session") + _, body, _, err := service.Handle("/ContentOpen", contentOpenRequest(1, 1)) + if err != nil { + t.Fatal(err) + } + assertContentOpenResponse(t, body) + if contentOpenTicketCount(inventory, 42) != 1 { + t.Fatal("repair duplicated owned completion") + } +} + +func TestContentOpenRejectsCorruptReceiptAtOpenAndAfterConstruction(t *testing.T) { + store := stateio.NewMemory() + service, inventory := contentOpenHarness(t, store, true, 3) + if err := store.Save("content_open", []byte(`{"receipts":{"credentials:1":{"digest":"bad","body":"CgA="}}}`)); err != nil { + t.Fatal(err) + } + if _, _, _, err := service.Handle("/ContentOpen", contentOpenRequest(1, 1)); err == nil { + t.Fatal("corrupt reloaded receipt accepted") + } + if contentOpenTicketCount(inventory, 42) != 0 { + t.Fatal("corrupt receipts granted completion") + } + if _, err := NewContentOpenService(service.design, inventory, store, service.squadLevel); err == nil { + t.Fatal("corrupt receipt accepted during open") + } +} + +func TestContentOpenRejectsUnqualifiedAndMalformedRequests(t *testing.T) { + for _, tc := range []struct { + name string + prerequisite bool + level uint64 + request []byte + }{ + {"missing-prerequisite", false, 3, contentOpenRequest(1, 1)}, + {"level", true, 2, contentOpenRequest(1, 1)}, + {"unknown", true, 3, contentOpenRequest(1, 2)}, + {"zero-type", true, 3, contentOpenRequest(1, 0)}, + {"group-is-not-type", true, 3, contentOpenRequest(1, 23)}, + {"zero-sequence", true, 3, contentOpenRequest(0, 1)}, + {"wrong-wire", true, 3, wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 2, []byte{1})}, + {"truncated-trailing-wire", true, 3, append(contentOpenRequest(1, 1), 0x1a, 0x02, 0x01)}, + {"duplicate-type", true, 3, wire.AppendVarint(contentOpenRequest(1, 1), 2, 1)}, + } { + t.Run(tc.name, func(t *testing.T) { + store := stateio.NewMemory() + service, inventory := contentOpenHarness(t, store, tc.prerequisite, tc.level) + _, _, handled, err := service.Handle("/ContentOpen", tc.request) + if err == nil || !handled { + t.Fatal("invalid request accepted") + } + if contentOpenTicketCount(inventory, 42) != 0 { + t.Fatal("rejected request granted ticket") + } + state, err := store.Load("content_open") + if err != nil || state != nil { + t.Fatal("rejected request persisted receipt") + } + }) + } +} + +type contentOpenFailStore struct { + stateio.AtomicEntryStore + fail bool +} + +func (s *contentOpenFailStore) Save(name string, payload []byte) error { + if name == "content_open" && s.fail { + return errors.New("injected receipt write failure") + } + return s.AtomicEntryStore.Save(name, payload) +} + +func TestContentOpenSQLiteReceiptRestartAndRollback(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "durable-exact-retry", true: "failed-receipt-rolls-back-ticket"}[fail], func(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + store := &contentOpenFailStore{AtomicEntryStore: repo, fail: fail} + service, _ := contentOpenHarness(t, store, true, 3) + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + request := contentOpenRequest(1, 1) + _, body, _, err := service.Handle("/ContentOpen", request) + if fail { + if err == nil { + t.Fatal("receipt failure ignored") + } + if err := op.Rollback(); err == nil { + t.Fatal("dirty rollback did not request account recovery") + } + } else { + if err != nil { + t.Fatal(err) + } + if err := op.Commit(); err != nil { + t.Fatal(err) + } + } + if err := repo.Close(); err != nil { + t.Fatal(err) + } + repo, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + service, inventory := contentOpenHarness(t, repo, true, 3) + if fail { + if contentOpenTicketCount(inventory, 42) != 0 { + t.Fatal("uncommitted completion survived restart") + } + data, err := repo.Load("content_open") + if err != nil || data != nil { + t.Fatal("failed receipt survived restart") + } + } + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + _, replay, _, err := service.Handle("/ContentOpen", request) + if err != nil { + t.Fatal(err) + } + if err := op.Commit(); err != nil { + t.Fatal(err) + } + if !fail && !bytes.Equal(body, replay) { + t.Fatal("exact retry changed after restart") + } + assertContentOpenResponse(t, replay) + if contentOpenTicketCount(inventory, 42) != 1 || contentOpenTicketCount(inventory, 41) != 1 { + t.Fatal("ticket ownership not preserved exactly once") + } + }) + } +}