614 lines
16 KiB
Go
614 lines
16 KiB
Go
package fs
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
gerr "galaxy/error"
|
|
"galaxy/model/client"
|
|
"galaxy/model/order"
|
|
"galaxy/model/report"
|
|
)
|
|
|
|
const testTimeout = time.Second
|
|
|
|
type callbackResult[T any] struct {
|
|
value T
|
|
err error
|
|
}
|
|
|
|
func TestStateRoundTrip(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
want := sampleState()
|
|
|
|
exists, err := s.StateExists()
|
|
if err != nil {
|
|
t.Fatalf("state exists before save: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("state file should not exist before save")
|
|
}
|
|
|
|
if err := s.SaveState(want); err != nil {
|
|
t.Fatalf("save state: %v", err)
|
|
}
|
|
|
|
exists, err = s.StateExists()
|
|
if err != nil {
|
|
t.Fatalf("state exists after save: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Fatal("state file should exist after save")
|
|
}
|
|
|
|
got, err := s.LoadState()
|
|
if err != nil {
|
|
t.Fatalf("load state: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("loaded state mismatch\nwant: %#v\ngot: %#v", want, got)
|
|
}
|
|
}
|
|
|
|
func TestStateRoundTripAsync(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
want := sampleState()
|
|
|
|
saveDone := make(chan error, 1)
|
|
s.SaveStateAsync(want, func(err error) {
|
|
saveDone <- err
|
|
})
|
|
if err := waitError(t, saveDone); err != nil {
|
|
t.Fatalf("save state: %v", err)
|
|
}
|
|
|
|
existsDone := make(chan callbackResult[bool], 1)
|
|
s.StateExistsAsync(func(ok bool, err error) {
|
|
existsDone <- callbackResult[bool]{value: ok, err: err}
|
|
})
|
|
exists := waitResult(t, existsDone)
|
|
if exists.err != nil {
|
|
t.Fatalf("state exists: %v", exists.err)
|
|
}
|
|
if !exists.value {
|
|
t.Fatal("state file should exist after save")
|
|
}
|
|
|
|
loadDone := make(chan callbackResult[client.State], 1)
|
|
s.LoadStateAsync(func(state client.State, err error) {
|
|
loadDone <- callbackResult[client.State]{value: state, err: err}
|
|
})
|
|
got := waitResult(t, loadDone)
|
|
if got.err != nil {
|
|
t.Fatalf("load state: %v", got.err)
|
|
}
|
|
if !reflect.DeepEqual(got.value, want) {
|
|
t.Fatalf("loaded state mismatch\nwant: %#v\ngot: %#v", want, got.value)
|
|
}
|
|
}
|
|
|
|
func TestReportAndOrderRoundTripAsync(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
id := client.GameID("game-1")
|
|
turn := uint(7)
|
|
initialReport := sampleReport(turn, "Terran")
|
|
updatedReport := sampleReport(turn, "Zenith")
|
|
wantOrder := sampleOrder()
|
|
|
|
saveReportDone := make(chan error, 1)
|
|
s.SaveReportAsync(id, turn, initialReport, func(err error) {
|
|
saveReportDone <- err
|
|
})
|
|
if err := waitError(t, saveReportDone); err != nil {
|
|
t.Fatalf("save report: %v", err)
|
|
}
|
|
|
|
saveOrderDone := make(chan error, 1)
|
|
s.SaveOrderAsync(id, turn, wantOrder, func(err error) {
|
|
saveOrderDone <- err
|
|
})
|
|
if err := waitError(t, saveOrderDone); err != nil {
|
|
t.Fatalf("save order: %v", err)
|
|
}
|
|
|
|
saveUpdatedReportDone := make(chan error, 1)
|
|
s.SaveReportAsync(id, turn, updatedReport, func(err error) {
|
|
saveUpdatedReportDone <- err
|
|
})
|
|
if err := waitError(t, saveUpdatedReportDone); err != nil {
|
|
t.Fatalf("save updated report: %v", err)
|
|
}
|
|
|
|
loadReportDone := make(chan callbackResult[report.Report], 1)
|
|
s.LoadReportAsync(id, turn, func(rep report.Report, err error) {
|
|
loadReportDone <- callbackResult[report.Report]{value: rep, err: err}
|
|
})
|
|
gotReport := waitResult(t, loadReportDone)
|
|
if gotReport.err != nil {
|
|
t.Fatalf("load report: %v", gotReport.err)
|
|
}
|
|
if !reflect.DeepEqual(gotReport.value, updatedReport) {
|
|
t.Fatalf("loaded report mismatch\nwant: %#v\ngot: %#v", updatedReport, gotReport.value)
|
|
}
|
|
|
|
loadOrderDone := make(chan callbackResult[order.Order], 1)
|
|
s.LoadOrderAsync(id, turn, func(got order.Order, err error) {
|
|
loadOrderDone <- callbackResult[order.Order]{value: got, err: err}
|
|
})
|
|
gotOrder := waitResult(t, loadOrderDone)
|
|
if gotOrder.err != nil {
|
|
t.Fatalf("load order: %v", gotOrder.err)
|
|
}
|
|
if !reflect.DeepEqual(gotOrder.value, wantOrder) {
|
|
t.Fatalf("loaded order mismatch\nwant: %#v\ngot: %#v", wantOrder, gotOrder.value)
|
|
}
|
|
}
|
|
|
|
func TestSaveOrderBeforeReportReturnsNotExist(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
|
|
done := make(chan error, 1)
|
|
s.SaveOrderAsync("game-2", 3, sampleOrder(), func(err error) {
|
|
done <- err
|
|
})
|
|
err := waitError(t, done)
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("save order error = %v, want os.ErrNotExist", err)
|
|
}
|
|
}
|
|
|
|
func TestRawFileCRUDAndList(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
|
|
if err := s.WriteFile("/nested/alpha.txt", []byte("alpha")); err != nil {
|
|
t.Fatalf("write alpha: %v", err)
|
|
}
|
|
if err := s.WriteFile("beta.txt", []byte("beta")); err != nil {
|
|
t.Fatalf("write beta: %v", err)
|
|
}
|
|
|
|
alphaExists, alphaPath, err := s.FileExists("nested/alpha.txt")
|
|
if err != nil {
|
|
t.Fatalf("file exists: %v", err)
|
|
}
|
|
if !alphaExists {
|
|
t.Fatal("nested/alpha.txt should exist")
|
|
}
|
|
wantAlphaPath := filepath.Join(s.storageRoot, "nested", "alpha.txt")
|
|
if alphaPath != wantAlphaPath {
|
|
t.Fatalf("file path = %q, want %q", alphaPath, wantAlphaPath)
|
|
}
|
|
|
|
missingExists, missingPath, err := s.FileExists("missing.txt")
|
|
if err != nil {
|
|
t.Fatalf("missing file exists: %v", err)
|
|
}
|
|
if missingExists {
|
|
t.Fatal("missing.txt should not exist")
|
|
}
|
|
wantMissingPath := filepath.Join(s.storageRoot, "missing.txt")
|
|
if wantMissingPath != missingPath {
|
|
t.Fatalf("missing file path = %q, want %q", missingPath, wantMissingPath)
|
|
}
|
|
|
|
alphaData, err := s.ReadFile("nested/alpha.txt")
|
|
if err != nil {
|
|
t.Fatalf("read alpha: %v", err)
|
|
}
|
|
if string(alphaData) != "alpha" {
|
|
t.Fatalf("read alpha = %q, want %q", alphaData, "alpha")
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(s.storageRoot, "skip.txt"+newFileSuffix), []byte("tmp"), 0o644); err != nil {
|
|
t.Fatalf("create stale .new file: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(s.storageRoot, "skip.txt"+oldFileSuffix), []byte("tmp"), 0o644); err != nil {
|
|
t.Fatalf("create stale .old file: %v", err)
|
|
}
|
|
|
|
files, err := s.ListFiles()
|
|
if err != nil {
|
|
t.Fatalf("list files: %v", err)
|
|
}
|
|
wantFiles := []string{
|
|
"beta.txt",
|
|
filepath.Join("nested", "alpha.txt"),
|
|
}
|
|
if !reflect.DeepEqual(files, wantFiles) {
|
|
t.Fatalf("listed files mismatch\nwant: %#v\ngot: %#v", wantFiles, files)
|
|
}
|
|
|
|
if err := s.DeleteFile("beta.txt"); err != nil {
|
|
t.Fatalf("delete beta: %v", err)
|
|
}
|
|
if err := s.DeleteFile("beta.txt"); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("delete missing beta error = %v, want os.ErrNotExist", err)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalRejected(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
|
|
for _, path := range []string{"../escape.txt", "..\\escape.txt", ""} {
|
|
t.Run(path, func(t *testing.T) {
|
|
err := s.WriteFile(path, []byte("blocked"))
|
|
if err == nil {
|
|
t.Fatalf("write %q unexpectedly succeeded", path)
|
|
}
|
|
if !gerr.IsStorage(err) {
|
|
t.Fatalf("write %q error = %v, want storage classified error", path, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeleteFileClassifiesAndPreservesNotExist(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestStorage(t)
|
|
|
|
err := s.DeleteFile("missing.txt")
|
|
if !gerr.IsStorage(err) {
|
|
t.Fatalf("DeleteFile() error = %v, want storage classified error", err)
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("DeleteFile() error = %v, want os.ErrNotExist", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadStateClassifiesDecodeErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestStorage(t)
|
|
if err := os.WriteFile(filepath.Join(s.storageRoot, stateFileName), []byte("{"), 0o644); err != nil {
|
|
t.Fatalf("seed invalid state file: %v", err)
|
|
}
|
|
|
|
_, err := s.LoadState()
|
|
if err == nil {
|
|
t.Fatal("LoadState() error = nil, want non-nil")
|
|
}
|
|
if !gerr.IsStorage(err) {
|
|
t.Fatalf("LoadState() error = %v, want storage classified error", err)
|
|
}
|
|
}
|
|
|
|
func TestAtomicWriteFirstAndOverwrite(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
target := filepath.Join("turns", "12.bin")
|
|
|
|
if err := s.WriteFile(target, []byte("first")); err != nil {
|
|
t.Fatalf("first write: %v", err)
|
|
}
|
|
assertFileContent(t, s, target, "first")
|
|
assertNoTempArtifacts(t, s, target)
|
|
|
|
if err := s.WriteFile(target, []byte("second")); err != nil {
|
|
t.Fatalf("overwrite: %v", err)
|
|
}
|
|
assertFileContent(t, s, target, "second")
|
|
assertNoTempArtifacts(t, s, target)
|
|
}
|
|
|
|
func TestAtomicWriteStaleTempCollision(t *testing.T) {
|
|
t.Run("stale new file", func(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
target := "collision-new.txt"
|
|
absTarget, err := s.resolvePath(target)
|
|
if err != nil {
|
|
t.Fatalf("resolve target: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(absTarget), os.ModePerm); err != nil {
|
|
t.Fatalf("create parent dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(absTarget+newFileSuffix, []byte("stale"), 0o644); err != nil {
|
|
t.Fatalf("write stale new file: %v", err)
|
|
}
|
|
|
|
err = s.WriteFile(target, []byte("payload"))
|
|
if err == nil || !strings.Contains(err.Error(), "new file already exists") {
|
|
t.Fatalf("write error = %v, want stale new file error", err)
|
|
}
|
|
})
|
|
|
|
t.Run("stale old file", func(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
target := "collision-old.txt"
|
|
absTarget, err := s.resolvePath(target)
|
|
if err != nil {
|
|
t.Fatalf("resolve target: %v", err)
|
|
}
|
|
if err := os.WriteFile(absTarget, []byte("current"), 0o644); err != nil {
|
|
t.Fatalf("write target: %v", err)
|
|
}
|
|
if err := os.WriteFile(absTarget+oldFileSuffix, []byte("stale"), 0o644); err != nil {
|
|
t.Fatalf("write stale old file: %v", err)
|
|
}
|
|
|
|
err = s.WriteFile(target, []byte("payload"))
|
|
if err == nil || !strings.Contains(err.Error(), "old file already exists") {
|
|
t.Fatalf("write error = %v, want stale old file error", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAtomicWriteRollbackOnRenameFailure(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
target := filepath.Join("rollback", "state.txt")
|
|
absTarget, err := s.resolvePath(target)
|
|
if err != nil {
|
|
t.Fatalf("resolve target: %v", err)
|
|
}
|
|
|
|
if err := s.WriteFile(target, []byte("original")); err != nil {
|
|
t.Fatalf("seed target file: %v", err)
|
|
}
|
|
|
|
origRename := s.renameFileFn
|
|
s.renameFileFn = func(oldPath, newPath string) error {
|
|
if oldPath == absTarget+newFileSuffix && newPath == absTarget {
|
|
return errors.New("forced rename failure")
|
|
}
|
|
return origRename(oldPath, newPath)
|
|
}
|
|
|
|
err = s.WriteFile(target, []byte("replacement"))
|
|
if err == nil || !strings.Contains(err.Error(), "forced rename failure") {
|
|
t.Fatalf("write error = %v, want forced rename failure", err)
|
|
}
|
|
|
|
assertFileContent(t, s, target, "original")
|
|
assertNoTempArtifacts(t, s, target)
|
|
}
|
|
|
|
func TestSamePathOperationsSerialize(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
target := "shared.txt"
|
|
absTarget, err := s.resolvePath(target)
|
|
if err != nil {
|
|
t.Fatalf("resolve target: %v", err)
|
|
}
|
|
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
origWrite := s.writeFileFn
|
|
var writes atomic.Int32
|
|
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
|
|
if path == absTarget+newFileSuffix && writes.Add(1) == 1 {
|
|
close(entered)
|
|
<-release
|
|
}
|
|
return origWrite(path, data, perm)
|
|
}
|
|
|
|
firstDone := make(chan error, 1)
|
|
go func() {
|
|
firstDone <- s.WriteFile(target, []byte("one"))
|
|
}()
|
|
waitSignal(t, entered, "first write entered")
|
|
|
|
secondDone := make(chan error, 1)
|
|
go func() {
|
|
secondDone <- s.WriteFile(target, []byte("two"))
|
|
}()
|
|
|
|
select {
|
|
case err := <-secondDone:
|
|
t.Fatalf("second write finished before first released: %v", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
if writes.Load() != 1 {
|
|
t.Fatalf("same-path write reached file hook %d times before release, want 1", writes.Load())
|
|
}
|
|
|
|
close(release)
|
|
if err := waitError(t, firstDone); err != nil {
|
|
t.Fatalf("first write: %v", err)
|
|
}
|
|
if err := waitError(t, secondDone); err != nil {
|
|
t.Fatalf("second write: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDifferentPathOperationsDoNotBlockEachOther(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
blockedTarget := "blocked.txt"
|
|
absTarget, err := s.resolvePath(blockedTarget)
|
|
if err != nil {
|
|
t.Fatalf("resolve blocked target: %v", err)
|
|
}
|
|
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
origWrite := s.writeFileFn
|
|
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
|
|
if path == absTarget+newFileSuffix {
|
|
close(entered)
|
|
<-release
|
|
}
|
|
return origWrite(path, data, perm)
|
|
}
|
|
|
|
blockedDone := make(chan error, 1)
|
|
go func() {
|
|
blockedDone <- s.WriteFile(blockedTarget, []byte("blocked"))
|
|
}()
|
|
waitSignal(t, entered, "blocked write entered")
|
|
|
|
freeDone := make(chan error, 1)
|
|
go func() {
|
|
freeDone <- s.WriteFile("free.txt", []byte("free"))
|
|
}()
|
|
|
|
select {
|
|
case err := <-freeDone:
|
|
if err != nil {
|
|
t.Fatalf("free write: %v", err)
|
|
}
|
|
case <-time.After(testTimeout):
|
|
t.Fatal("write for a different path should not block")
|
|
}
|
|
|
|
close(release)
|
|
if err := waitError(t, blockedDone); err != nil {
|
|
t.Fatalf("blocked write: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSaveStateIsNonBlockingAndCallbackBased(t *testing.T) {
|
|
s := newTestStorage(t)
|
|
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
origWrite := s.writeFileFn
|
|
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
|
|
close(entered)
|
|
<-release
|
|
return origWrite(path, data, perm)
|
|
}
|
|
|
|
callbacks := make(chan error, 2)
|
|
s.SaveStateAsync(sampleState(), func(err error) {
|
|
callbacks <- err
|
|
})
|
|
|
|
waitSignal(t, entered, "async save entered")
|
|
|
|
select {
|
|
case err := <-callbacks:
|
|
t.Fatalf("callback fired before storage write completed: %v", err)
|
|
default:
|
|
}
|
|
|
|
close(release)
|
|
if err := waitError(t, callbacks); err != nil {
|
|
t.Fatalf("callback error: %v", err)
|
|
}
|
|
|
|
select {
|
|
case err := <-callbacks:
|
|
t.Fatalf("callback fired more than once: %v", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func newTestStorage(t *testing.T) *fsStorage {
|
|
t.Helper()
|
|
|
|
s, err := NewFS(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new test storage: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func sampleState() client.State {
|
|
return client.State{
|
|
GameState: []client.GameState{
|
|
{ID: client.GameID("game-1"), LastTurn: 12, ActiveTurn: 11},
|
|
{ID: client.GameID("game-2"), LastTurn: 4, ActiveTurn: 4},
|
|
},
|
|
ActiveGameID: new(client.GameID("game-2")),
|
|
}
|
|
}
|
|
|
|
func sampleReport(turn uint, race string) report.Report {
|
|
return report.Report{
|
|
Turn: turn,
|
|
Width: 160,
|
|
Height: 90,
|
|
PlanetCount: 8,
|
|
Race: race,
|
|
VoteFor: "assembly",
|
|
}
|
|
}
|
|
|
|
func sampleOrder() order.Order {
|
|
return order.Order{
|
|
UpdatedAt: 1700,
|
|
Commands: []order.DecodableCommand{
|
|
&order.CommandPlanetRename{
|
|
CommandMeta: order.CommandMeta{
|
|
CmdType: order.CommandTypePlanetRename,
|
|
CmdID: "rename-planet",
|
|
},
|
|
Number: 2,
|
|
Name: "Nova Prime",
|
|
},
|
|
&order.CommandRaceVote{
|
|
CommandMeta: order.CommandMeta{
|
|
CmdType: order.CommandTypeRaceVote,
|
|
CmdID: "vote-race",
|
|
},
|
|
Acceptor: "ZENITH",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func assertFileContent(t *testing.T, s *fsStorage, path, want string) {
|
|
t.Helper()
|
|
|
|
got, err := s.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %q: %v", path, err)
|
|
}
|
|
if string(got) != want {
|
|
t.Fatalf("content for %q = %q, want %q", path, got, want)
|
|
}
|
|
}
|
|
|
|
func assertNoTempArtifacts(t *testing.T, s *fsStorage, path string) {
|
|
t.Helper()
|
|
|
|
absPath, err := s.resolvePath(path)
|
|
if err != nil {
|
|
t.Fatalf("resolve path %q: %v", path, err)
|
|
}
|
|
for _, tempPath := range []string{absPath + newFileSuffix, absPath + oldFileSuffix} {
|
|
if _, err := os.Stat(tempPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("temp artifact %q should not exist, stat err = %v", tempPath, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitSignal(t *testing.T, ch <-chan struct{}, name string) {
|
|
t.Helper()
|
|
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(testTimeout):
|
|
t.Fatalf("timeout waiting for %s", name)
|
|
}
|
|
}
|
|
|
|
func waitError(t *testing.T, ch <-chan error) error {
|
|
t.Helper()
|
|
|
|
select {
|
|
case err := <-ch:
|
|
return err
|
|
case <-time.After(testTimeout):
|
|
t.Fatal("timeout waiting for error callback")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func waitResult[T any](t *testing.T, ch <-chan callbackResult[T]) callbackResult[T] {
|
|
t.Helper()
|
|
|
|
select {
|
|
case result := <-ch:
|
|
return result
|
|
case <-time.After(testTimeout):
|
|
t.Fatal("timeout waiting for callback result")
|
|
return callbackResult[T]{}
|
|
}
|
|
}
|