feat(server): persist rapport prologue unlocks with ContentOpen replay safety
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user