524 lines
18 KiB
Go
524 lines
18 KiB
Go
package session
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"bd2server/internal/server/accountstate"
|
|
"bd2server/internal/server/cryptox"
|
|
"bd2server/internal/server/protocol"
|
|
"bd2server/internal/server/stateio"
|
|
"bd2server/internal/server/transport"
|
|
"bd2server/internal/server/wire"
|
|
)
|
|
|
|
type fakeLogin struct{}
|
|
|
|
func (fakeLogin) Login(request, key []byte) ([]byte, error) {
|
|
if _, ok, err := wire.Varint(request, 1); err != nil || !ok {
|
|
return nil, errors.New("missing seq")
|
|
}
|
|
user := wire.AppendBytes(nil, 3, key)
|
|
return wire.AppendBytes(nil, 1, user), nil
|
|
}
|
|
|
|
type fakeAuthenticator struct {
|
|
accountID string
|
|
err error
|
|
calls int
|
|
request []byte
|
|
}
|
|
|
|
func (a *fakeAuthenticator) AuthenticateLogin(request []byte) (string, error) {
|
|
a.calls++
|
|
a.request = append([]byte(nil), request...)
|
|
return a.accountID, a.err
|
|
}
|
|
|
|
type fakeDomain struct{}
|
|
|
|
type fakeStateGate struct{ err error }
|
|
|
|
func (g *fakeStateGate) Check() error { return g.err }
|
|
func (g *fakeStateGate) Load(string) ([]byte, error) { return nil, nil }
|
|
func (g *fakeStateGate) Save(string, []byte) error { return nil }
|
|
func (g *fakeStateGate) Close() error { return nil }
|
|
func (g *fakeStateGate) BeginOperation() (stateio.RequestOperation, error) {
|
|
if g.err != nil {
|
|
return nil, g.err
|
|
}
|
|
return fakeOperation{}, nil
|
|
}
|
|
|
|
type fakeOperation struct{}
|
|
|
|
func (fakeOperation) Commit() error { return nil }
|
|
func (fakeOperation) Rollback() error { return nil }
|
|
|
|
func TestFormationTimingPath(t *testing.T) {
|
|
for _, path := range []string{"/PresetInfo", "/PresetSave", "/DeckSave", "/DeckCostumeSettingSave", "/FieldDeckInfo", "/EquipBatchUse"} {
|
|
if !formationTimingPath(path) {
|
|
t.Fatalf("formation path %s is not timed", path)
|
|
}
|
|
}
|
|
for _, path := range []string{"/LoginUser", "/GachaInfo", "/EquipInfo"} {
|
|
if formationTimingPath(path) {
|
|
t.Fatalf("unrelated path %s is marked as formation timing", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
type mutatingDomain struct {
|
|
store stateio.Store
|
|
fail bool
|
|
}
|
|
|
|
func (d *mutatingDomain) Handle(path string, request []byte) (int, []byte, bool, error) {
|
|
if path == "/InjectedFailure" {
|
|
return 0, nil, true, errors.New("injected domain failure")
|
|
}
|
|
if path != "/MutateTwoFiles" {
|
|
return 0, nil, false, nil
|
|
}
|
|
if err := d.store.Save("wallet", []byte("new-wallet")); err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
if err := d.store.Save("items", []byte("new-items")); err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
if d.fail {
|
|
return 0, nil, true, errors.New("injected domain failure")
|
|
}
|
|
return 77, nil, true, nil
|
|
}
|
|
|
|
func TestBatchUsesOneAccountTransaction(t *testing.T) {
|
|
root := t.TempDir()
|
|
statePath := filepath.Join(root, "state.db")
|
|
repository, err := accountstate.Open(statePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer repository.Close()
|
|
for name, content := range map[string]string{"wallet": "old-wallet", "items": "old-items"} {
|
|
if err := repository.Save(name, []byte(content)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
server, _ := NewServer(fakeLogin{}, &mutatingDomain{store: repository})
|
|
if err := server.AttachStateStore(repository); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply := login(t, server)
|
|
requests := []protocol.BatchRequest{
|
|
{Path: "/MutateTwoFiles", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))},
|
|
{Path: "/InjectedFailure", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 3))},
|
|
}
|
|
plain, _ := json.Marshal(requests)
|
|
body, _ := cryptox.EncryptBase64(plain, server.KeyForTest())
|
|
if _, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+reply.Cookie); err == nil {
|
|
t.Fatal("partially failing batch was accepted")
|
|
}
|
|
verified, err := sql.Open("sqlite", statePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer verified.Close()
|
|
for name, want := range map[string]string{"wallet": "old-wallet", "items": "old-items"} {
|
|
var got []byte
|
|
err := verified.QueryRow(`SELECT payload FROM domain_state WHERE name=?`, name).Scan(&got)
|
|
if err != nil || string(got) != want {
|
|
t.Fatalf("batch rollback %s=%q err=%v", name, got, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (fakeDomain) Handle(path string, request []byte) (int, []byte, bool, error) {
|
|
if path == "/EmptyInfo" {
|
|
return 77, nil, true, nil
|
|
}
|
|
return 0, nil, false, nil
|
|
}
|
|
|
|
func login(t *testing.T, server *Server) transport.RawReply {
|
|
t.Helper()
|
|
request := wire.AppendVarint(nil, 1, 1)
|
|
return loginRequest(t, server, request)
|
|
}
|
|
|
|
func loginRequest(t *testing.T, server *Server, request []byte) transport.RawReply {
|
|
t.Helper()
|
|
body, err := cryptox.EncryptBase64Payload(request, cryptox.Key())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply, err := server.DispatchRaw("/LoginUser", []byte(body), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var envelope protocol.Envelope
|
|
if err := json.Unmarshal(reply.Body, &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
proto, err := cryptox.DecryptBase64Payload(envelope.Data, cryptox.Key())
|
|
if err != nil || envelope.PacketCode != 3 || len(proto) == 0 || reply.Cookie == "" {
|
|
t.Fatalf("login response: %+v proto=%d err=%v", envelope, len(proto), err)
|
|
}
|
|
return reply
|
|
}
|
|
|
|
func TestLoginAuthenticatesBeforeCreatingGameSession(t *testing.T) {
|
|
server, err := NewServer(fakeLogin{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
authenticator := &fakeAuthenticator{accountID: "account-1"}
|
|
if err := server.AttachLoginAuthenticator(authenticator); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := wire.AppendVarint(nil, 1, 1)
|
|
request = wire.AppendString(request, 2, "short-lived-access-token")
|
|
reply := loginRequest(t, server, request)
|
|
if authenticator.calls != 1 {
|
|
t.Fatalf("AuthenticateLogin calls=%d, want 1", authenticator.calls)
|
|
}
|
|
accessToken, found, err := wire.Bytes(authenticator.request, 2)
|
|
if err != nil || !found || string(accessToken) != "short-lived-access-token" {
|
|
t.Fatalf("authenticated access token=%q found=%v err=%v", accessToken, found, err)
|
|
}
|
|
game := server.sessions[sessionTokenKey(reply.Cookie)]
|
|
if game == nil || game.accountID != "account-1" {
|
|
t.Fatalf("game session=%+v, want authenticated account", game)
|
|
}
|
|
}
|
|
|
|
func TestLoginAuthenticationFailureCreatesNoSession(t *testing.T) {
|
|
server, err := NewServer(fakeLogin{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
authenticator := &fakeAuthenticator{err: errors.New("invalid access token")}
|
|
if err := server.AttachLoginAuthenticator(authenticator); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := wire.AppendVarint(nil, 1, 1)
|
|
body, err := cryptox.EncryptBase64Payload(request, cryptox.Key())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := server.DispatchRaw("/LoginUser", []byte(body), ""); err == nil {
|
|
t.Fatal("invalid access token was accepted")
|
|
}
|
|
if authenticator.calls != 1 || len(server.sessions) != 0 {
|
|
t.Fatalf("auth calls=%d sessions=%d", authenticator.calls, len(server.sessions))
|
|
}
|
|
|
|
authenticator.err = nil
|
|
authenticator.accountID = ""
|
|
if _, err := server.DispatchRaw("/LoginUser", []byte(body), ""); err == nil {
|
|
t.Fatal("empty authenticated account ID was accepted")
|
|
}
|
|
if len(server.sessions) != 0 {
|
|
t.Fatalf("empty account ID created %d sessions", len(server.sessions))
|
|
}
|
|
}
|
|
|
|
func TestGameSessionsHaveIndependentKeysAndCookies(t *testing.T) {
|
|
server, err := NewServer(fakeLogin{}, fakeDomain{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := login(t, server)
|
|
firstKey := server.KeyForTest(first.Cookie)
|
|
second := login(t, server)
|
|
secondKey := server.KeyForTest(second.Cookie)
|
|
if first.Cookie == second.Cookie {
|
|
t.Fatal("two logins received the same game session cookie")
|
|
}
|
|
if string(firstKey) == string(secondKey) {
|
|
t.Fatal("two logins received the same game session key")
|
|
}
|
|
|
|
request := wire.AppendVarint(nil, 1, 2)
|
|
firstBody, err := cryptox.EncryptBase64Payload(request, firstKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
firstReply, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "other=value; s="+first.Cookie)
|
|
if err != nil {
|
|
t.Fatalf("first session stopped working after second login: %v", err)
|
|
}
|
|
var firstEnvelope protocol.Envelope
|
|
if err := json.Unmarshal(firstReply.Body, &firstEnvelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cryptox.DecryptBase64Payload(firstEnvelope.Data, firstKey); err != nil {
|
|
t.Fatalf("first response did not use first key: %v", err)
|
|
}
|
|
|
|
secondBody, err := cryptox.EncryptBase64Payload(request, secondKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := server.DispatchRaw("/EmptyInfo", []byte(secondBody), "s="+second.Cookie); err != nil {
|
|
t.Fatalf("second session request failed: %v", err)
|
|
}
|
|
if _, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "s="+second.Cookie); err == nil {
|
|
t.Fatal("request encrypted with first key was accepted under second cookie")
|
|
}
|
|
if _, err := server.DispatchRaw("/EmptyInfo", []byte(secondBody), "s="+first.Cookie); err == nil {
|
|
t.Fatal("request encrypted with second key was accepted under first cookie")
|
|
}
|
|
if _, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "s="+first.Cookie+"; s="+second.Cookie); err == nil {
|
|
t.Fatal("ambiguous duplicate session cookies were accepted")
|
|
}
|
|
}
|
|
|
|
func TestGameSessionExpiresAndClearsKey(t *testing.T) {
|
|
server, err := NewServer(fakeLogin{}, fakeDomain{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)
|
|
server.now = func() time.Time { return now }
|
|
server.sessionTTL = time.Minute
|
|
reply := login(t, server)
|
|
game := server.sessions[sessionTokenKey(reply.Cookie)]
|
|
key := append([]byte(nil), game.key...)
|
|
now = now.Add(time.Minute)
|
|
request := wire.AppendVarint(nil, 1, 2)
|
|
body, err := cryptox.EncryptBase64Payload(request, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := server.DispatchRaw("/EmptyInfo", []byte(body), "s="+reply.Cookie); err == nil {
|
|
t.Fatal("expired game session was accepted")
|
|
} else if !errors.Is(err, transport.ErrGameSessionExpired) {
|
|
t.Fatalf("expired game session error=%v, want ErrGameSessionExpired", err)
|
|
}
|
|
if len(server.sessions) != 0 {
|
|
t.Fatalf("expired game session remains in map: %d", len(server.sessions))
|
|
}
|
|
for i, value := range game.key {
|
|
if value != 0 {
|
|
t.Fatalf("expired session key byte %d was not cleared", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGameSessionLimitEvictsLeastRecentlyUsedAndClearsKey(t *testing.T) {
|
|
server, err := NewServer(fakeLogin{}, fakeDomain{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)
|
|
server.now = func() time.Time { return now }
|
|
server.maxSessions = 2
|
|
first := login(t, server)
|
|
firstKey := server.KeyForTest(first.Cookie)
|
|
now = now.Add(time.Second)
|
|
second := login(t, server)
|
|
secondGame := server.sessions[sessionTokenKey(second.Cookie)]
|
|
|
|
now = now.Add(time.Second)
|
|
request := wire.AppendVarint(nil, 1, 2)
|
|
firstBody, err := cryptox.EncryptBase64Payload(request, firstKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "s="+first.Cookie); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now = now.Add(time.Second)
|
|
third := login(t, server)
|
|
if len(server.sessions) != 2 {
|
|
t.Fatalf("game sessions=%d, want cap 2", len(server.sessions))
|
|
}
|
|
if _, ok := server.sessions[sessionTokenKey(first.Cookie)]; !ok {
|
|
t.Fatal("recently used first session was evicted")
|
|
}
|
|
if _, ok := server.sessions[sessionTokenKey(third.Cookie)]; !ok {
|
|
t.Fatal("new third session is missing")
|
|
}
|
|
if _, ok := server.sessions[sessionTokenKey(second.Cookie)]; ok {
|
|
t.Fatal("least recently used second session was not evicted")
|
|
}
|
|
for i, value := range secondGame.key {
|
|
if value != 0 {
|
|
t.Fatalf("evicted session key byte %d was not cleared", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionCookieParsingIsStrict(t *testing.T) {
|
|
valid := strings.Repeat("a", 48) + "|1"
|
|
if token, err := parseSessionCookie("other=value; s=" + valid); err != nil || token != valid {
|
|
t.Fatalf("valid cookie token=%q err=%v", token, err)
|
|
}
|
|
for name, cookie := range map[string]string{
|
|
"missing": "other=value",
|
|
"empty": "s=",
|
|
"wrong length": "s=abcd|1",
|
|
"uppercase": "s=" + strings.Repeat("A", 48) + "|1",
|
|
"wrong version": "s=" + strings.Repeat("a", 48) + "|2",
|
|
"duplicate": "s=" + valid + "; s=" + valid,
|
|
"oversized": "x=" + strings.Repeat("a", maxCookieHeaderLen),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := parseSessionCookie(cookie); err == nil {
|
|
t.Fatalf("accepted malformed cookie %q", cookie)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNativeLoginAndBatch(t *testing.T) {
|
|
server, err := NewServer(fakeLogin{}, fakeDomain{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply := login(t, server)
|
|
requests := []protocol.BatchRequest{{Path: "/EmptyInfo", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))}}
|
|
plain, _ := json.Marshal(requests)
|
|
body, _ := cryptox.EncryptBase64(plain, server.KeyForTest())
|
|
batch, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+reply.Cookie)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var items []protocol.BatchResponse
|
|
if err := json.Unmarshal(batch.Body, &items); err != nil || len(items) != 1 || items[0].Path != "/EmptyInfo" || items[0].ResponseData.PacketCode != 77 {
|
|
t.Fatalf("batch response: %+v err=%v", items, err)
|
|
}
|
|
}
|
|
|
|
func TestSessionRejectsMissingCookieAndUnknownPath(t *testing.T) {
|
|
server, _ := NewServer(fakeLogin{}, fakeDomain{})
|
|
if _, err := server.DispatchRaw("/EmptyInfo", nil, ""); !errors.Is(err, transport.ErrGameSessionExpired) {
|
|
t.Fatalf("missing cookie error=%v, want ErrGameSessionExpired", err)
|
|
}
|
|
unknown := strings.Repeat("a", 48) + "|1"
|
|
if _, err := server.DispatchRaw("/BatchRequest", nil, "s="+unknown); !errors.Is(err, transport.ErrGameSessionExpired) {
|
|
t.Fatalf("unknown cookie error=%v, want ErrGameSessionExpired", err)
|
|
}
|
|
if _, err := server.DispatchRaw("/EmptyInfo", nil, "s=malformed"); err == nil ||
|
|
errors.Is(err, transport.ErrGameSessionExpired) {
|
|
t.Fatalf("malformed cookie error=%v, want ordinary rejection", err)
|
|
}
|
|
reply := login(t, server)
|
|
request := wire.AppendVarint(nil, 1, 99)
|
|
body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest())
|
|
if _, err := server.DispatchRaw("/InventedPacket", []byte(body), "s="+reply.Cookie); !errors.Is(err, transport.ErrNotImplemented) {
|
|
t.Fatalf("unknown endpoint did not fail closed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStateGateStopsEveryRequestAfterPersistenceFailure(t *testing.T) {
|
|
server, _ := NewServer(fakeLogin{}, fakeDomain{})
|
|
gate := &fakeStateGate{}
|
|
if err := server.AttachStateStore(gate); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply := login(t, server)
|
|
gate.err = errors.New("uncertain transaction")
|
|
request := wire.AppendVarint(nil, 1, 2)
|
|
body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest())
|
|
if _, err := server.DispatchRaw("/EmptyInfo", []byte(body), "s="+reply.Cookie); err == nil {
|
|
t.Fatal("request passed a failed account state gate")
|
|
}
|
|
if _, err := server.DispatchRaw("/LoginUser", []byte(body), ""); err == nil {
|
|
t.Fatal("login passed a failed account state gate")
|
|
}
|
|
}
|
|
|
|
func TestAuthenticatedRequestTransactionCommitsOrRollsBackAllFiles(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
fail bool
|
|
want string
|
|
}{
|
|
{"commit", false, "new-"},
|
|
{"rollback", true, "old-"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
statePath := filepath.Join(root, "state.db")
|
|
repository, err := accountstate.Open(statePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer repository.Close()
|
|
for name, content := range map[string]string{"wallet": "old-wallet", "items": "old-items"} {
|
|
if err := repository.Save(name, []byte(content)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
server, _ := NewServer(fakeLogin{}, &mutatingDomain{store: repository, fail: test.fail})
|
|
if err := server.AttachStateStore(repository); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply := login(t, server)
|
|
request := wire.AppendVarint(nil, 1, 2)
|
|
body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest())
|
|
_, requestErr := server.DispatchRaw("/MutateTwoFiles", []byte(body), "s="+reply.Cookie)
|
|
if test.fail && requestErr == nil || !test.fail && requestErr != nil {
|
|
t.Fatalf("request err=%v", requestErr)
|
|
}
|
|
for _, name := range []string{"wallet", "items"} {
|
|
var got []byte
|
|
if test.fail {
|
|
reader, openErr := sql.Open("sqlite", statePath)
|
|
if openErr != nil {
|
|
t.Fatal(openErr)
|
|
}
|
|
err = reader.QueryRow(`SELECT payload FROM domain_state WHERE name=?`, name).Scan(&got)
|
|
reader.Close()
|
|
} else {
|
|
got, err = repository.Load(name)
|
|
}
|
|
if err != nil || string(got) != test.want+name {
|
|
t.Fatalf("%s=%q err=%v", name, got, err)
|
|
}
|
|
}
|
|
if test.fail {
|
|
if _, err := server.DispatchRaw("/MutateTwoFiles", []byte(body), "s="+reply.Cookie); err == nil {
|
|
t.Fatal("server continued after rolling disk back behind published domain memory")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNativePositionAndQuestDomainRouting(t *testing.T) {
|
|
server, _ := NewServer(fakeLogin{})
|
|
reply := login(t, server)
|
|
cookie := "s=" + reply.Cookie
|
|
position := wire.AppendVarint(nil, 1, 999001)
|
|
position = wire.AppendVarint(position, 2, 21)
|
|
position = wire.AppendString(position, 3, `{"MapId":211,"PlayerPosition":{"x":1,"y":2,"z":3},"ColleaguePositions":null}`)
|
|
body, _ := cryptox.EncryptBase64Payload(position, server.KeyForTest())
|
|
if _, err := server.DispatchRaw("/SaveUserPosition", []byte(body), cookie); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved, found := server.ProgressForTest().Position()
|
|
if !found || saved.PackID != 21 || saved.Position.MapID != 211 {
|
|
t.Fatalf("position not stored: %+v", saved)
|
|
}
|
|
quest := wire.AppendVarint(nil, 1, 999002)
|
|
quest = wire.AppendVarint(quest, 2, 12)
|
|
quest = wire.AppendVarint(quest, 3, 21)
|
|
quest = wire.AppendBytes(quest, 4, []byte{121})
|
|
body, _ = cryptox.EncryptBase64Payload(quest, server.KeyForTest())
|
|
if _, err := server.DispatchRaw("/QuestUpdate", []byte(body), cookie); !errors.Is(err, transport.ErrNotImplemented) {
|
|
t.Fatalf("QuestUpdate without an owning world domain must fail closed: %v", err)
|
|
}
|
|
if stored, ok := server.ProgressForTest().Quest(12); ok {
|
|
t.Fatalf("unvalidated quest bypassed the world domain: %+v", stored)
|
|
}
|
|
}
|