服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
592 lines
18 KiB
Go
592 lines
18 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"pvfine/internal/pvf"
|
|
scriptengine "pvfine/internal/script"
|
|
)
|
|
|
|
type blockingScriptRuntime struct {
|
|
started chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (r *blockingScriptRuntime) Compile(string) scriptengine.CompileResult {
|
|
return scriptengine.CompileResult{Valid: true}
|
|
}
|
|
|
|
func (r *blockingScriptRuntime) Run(ctx context.Context, _ string, _ *scriptengine.BatchAPI) (scriptengine.RunResult, error) {
|
|
r.once.Do(func() { close(r.started) })
|
|
<-ctx.Done()
|
|
return scriptengine.RunResult{
|
|
Status: scriptengine.RunStatusCancelled,
|
|
Error: &scriptengine.Diagnostic{
|
|
Kind: scriptengine.ErrorKindCancelled,
|
|
Message: ctx.Err().Error(),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func newScriptTestService(t *testing.T) (*core, *ScriptService, int32, int32) {
|
|
t.Helper()
|
|
c := NewCore()
|
|
a := pvf.New()
|
|
first, err := a.AddFileText("equipment/first.equ", "[price]\n1\n[name]\n`first`", pvf.TypeScript)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := a.AddFileText("equipment/second.equ", "[price]\n20\n[name]\n`second`", pvf.TypeScript)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.setArchive(a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc := newScriptService(c, scriptengine.NewGojaRuntime(), t.TempDir(), newFileSetService(filepath.Join(t.TempDir(), "file-sets.json")))
|
|
t.Cleanup(c.closeArchive)
|
|
return c, svc, first, second
|
|
}
|
|
|
|
func TestScriptServiceRunPreviewAndApplySelected(t *testing.T) {
|
|
c, svc, first, second := newScriptTestService(t)
|
|
liveFirstBefore, err := c.archive.Text(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
liveSecondBefore, err := c.archive.Text(second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := ScriptRunRequest{
|
|
Name: "raise-prices",
|
|
Source: `for (const file of pvf.glob("equipment/**/*.equ")) {
|
|
const document = file.parse();
|
|
const price = document.section("price");
|
|
if (price && price.get() < 10) {
|
|
price.set(10);
|
|
file.write(document);
|
|
}
|
|
}`,
|
|
}
|
|
result, err := svc.Run(nil, request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Status != ScriptRunCompleted || result.PlanID == "" || result.ModifiedFiles != 1 {
|
|
t.Fatalf("run result = %#v", result)
|
|
}
|
|
|
|
beforeFirst, err := c.archive.Text(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if beforeFirst != liveFirstBefore {
|
|
t.Fatalf("preview changed live archive: before=%q after=%q", liveFirstBefore, beforeFirst)
|
|
}
|
|
beforeSecond, err := c.archive.Text(second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
page, err := svc.PreviewPage(result.PlanID, "", 0, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if page.ModifiedFiles != 1 || page.MatchedFiles != 1 || len(page.Rows) != 1 || page.Rows[0].FileIndex != first {
|
|
t.Fatalf("preview page = %#v", page)
|
|
}
|
|
if page.Rows[0].ChangeKey == "" || page.Rows[0].Status != ScriptFileChanged {
|
|
t.Fatalf("preview row identity = %#v", page.Rows[0])
|
|
}
|
|
if len(page.Rows[0].Diff) == 0 {
|
|
t.Fatal("preview diff is empty")
|
|
}
|
|
|
|
apply, err := svc.Apply(result.PlanID, []string{page.Rows[0].ChangeKey})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if apply.AppliedFiles != 1 || len(apply.FileIndexes) != 1 || apply.FileIndexes[0] != first {
|
|
t.Fatalf("apply result = %#v", apply)
|
|
}
|
|
afterFirst, err := c.archive.Text(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if afterFirst == beforeFirst {
|
|
t.Fatal("selected script change was not applied")
|
|
}
|
|
afterSecond, err := c.archive.Text(second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if afterSecond != beforeSecond || beforeSecond != liveSecondBefore {
|
|
t.Fatalf("unselected file changed: before=%q after=%q", beforeSecond, afterSecond)
|
|
}
|
|
if _, err := svc.PreviewPage(result.PlanID, "", 0, 10); !errors.Is(err, ErrScriptPlanStale) {
|
|
t.Fatalf("applied plan error = %v, want ErrScriptPlanStale", err)
|
|
}
|
|
}
|
|
|
|
func TestScriptServicePreviewPageFiltersByPath(t *testing.T) {
|
|
c := NewCore()
|
|
a := pvf.New()
|
|
// Three matching files and two non-matching ones, spread across paths.
|
|
for _, path := range []string{
|
|
"equipment/alpha.equ",
|
|
"equipment/beta.equ",
|
|
"equipment/nested/gamma.equ",
|
|
"text/name.str",
|
|
"text/other.str",
|
|
} {
|
|
if _, err := a.AddFileText(path, "[price]\n1\n[name]\n`x`", pvf.TypeScript); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := c.setArchive(a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(c.closeArchive)
|
|
svc := newScriptService(c, scriptengine.NewGojaRuntime(), t.TempDir(), newFileSetService(filepath.Join(t.TempDir(), "file-sets.json")))
|
|
|
|
result, err := svc.Run(nil, ScriptRunRequest{Source: `
|
|
for (const file of pvf.glob("**/*")) {
|
|
file.setText("[price]\n2\n[name]\n` + "`x`" + `");
|
|
}
|
|
`})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.ModifiedFiles != 5 {
|
|
t.Fatalf("modified files = %d, want 5", result.ModifiedFiles)
|
|
}
|
|
|
|
// An empty filter returns everything and reports the full total.
|
|
all, err := svc.PreviewPage(result.PlanID, "", 0, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if all.MatchedFiles != 5 || len(all.Rows) != 5 {
|
|
t.Fatalf("unfiltered page = %d rows, matched %d", len(all.Rows), all.MatchedFiles)
|
|
}
|
|
|
|
// A filter narrows rows and the matched total together.
|
|
filtered, err := svc.PreviewPage(result.PlanID, "equipment/", 0, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if filtered.MatchedFiles != 3 || len(filtered.Rows) != 3 {
|
|
t.Fatalf("filtered page = %d rows, matched %d, want 3", len(filtered.Rows), filtered.MatchedFiles)
|
|
}
|
|
if filtered.Filter != "equipment/" {
|
|
t.Fatalf("filter echo = %q", filtered.Filter)
|
|
}
|
|
for _, row := range filtered.Rows {
|
|
if !strings.Contains(row.Path, "equipment/") {
|
|
t.Fatalf("row %q does not match the filter", row.Path)
|
|
}
|
|
}
|
|
|
|
// Filtering is case-insensitive.
|
|
upper, err := svc.PreviewPage(result.PlanID, "EQUIPMENT/", 0, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if upper.MatchedFiles != 3 {
|
|
t.Fatalf("case-insensitive matched = %d, want 3", upper.MatchedFiles)
|
|
}
|
|
|
|
// Pagination walks only the filtered rows, so the total stays consistent.
|
|
firstPage, err := svc.PreviewPage(result.PlanID, "equipment/", 0, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(firstPage.Rows) != 2 || firstPage.NextCursor != 2 || firstPage.MatchedFiles != 3 {
|
|
t.Fatalf("first page = %d rows cursor %d matched %d", len(firstPage.Rows), firstPage.NextCursor, firstPage.MatchedFiles)
|
|
}
|
|
secondPage, err := svc.PreviewPage(result.PlanID, "equipment/", firstPage.NextCursor, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(secondPage.Rows) != 1 || secondPage.NextCursor != -1 {
|
|
t.Fatalf("second page = %d rows cursor %d", len(secondPage.Rows), secondPage.NextCursor)
|
|
}
|
|
|
|
// A filter matching nothing reports zero rather than falling back to all.
|
|
empty, err := svc.PreviewPage(result.PlanID, "no-such-path", 0, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if empty.MatchedFiles != 0 || len(empty.Rows) != 0 {
|
|
t.Fatalf("empty filter page = %d rows, matched %d", len(empty.Rows), empty.MatchedFiles)
|
|
}
|
|
}
|
|
|
|
func TestScriptServiceSelectableChangeKeysRespectsFilter(t *testing.T) {
|
|
c := NewCore()
|
|
a := pvf.New()
|
|
for _, path := range []string{"equipment/alpha.equ", "equipment/beta.equ", "text/name.str"} {
|
|
if _, err := a.AddFileText(path, "[price]\n1", pvf.TypeScript); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := c.setArchive(a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(c.closeArchive)
|
|
svc := newScriptService(c, scriptengine.NewGojaRuntime(), t.TempDir(), newFileSetService(filepath.Join(t.TempDir(), "file-sets.json")))
|
|
|
|
result, err := svc.Run(nil, ScriptRunRequest{Source: `
|
|
for (const file of pvf.glob("**/*")) file.setText("[price]\n9");
|
|
`})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The keys must cover every matching row, not just one loaded page, so the
|
|
// UI can apply a filtered set without having paged through all of it.
|
|
keys, err := svc.SelectableChangeKeys(result.PlanID, "equipment/")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(keys) != 2 {
|
|
t.Fatalf("filtered keys = %#v, want 2", keys)
|
|
}
|
|
for _, key := range keys {
|
|
if !strings.Contains(key, "equipment/") {
|
|
t.Fatalf("key %q does not match the filter", key)
|
|
}
|
|
}
|
|
|
|
all, err := svc.SelectableChangeKeys(result.PlanID, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(all) != 3 {
|
|
t.Fatalf("unfiltered keys = %#v, want 3", all)
|
|
}
|
|
}
|
|
|
|
func TestScriptServiceFailureDoesNotMutateLiveArchive(t *testing.T) {
|
|
c, svc, first, _ := newScriptTestService(t)
|
|
before, err := c.archive.Text(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := svc.Run(nil, ScriptRunRequest{Source: `
|
|
const file = pvf.find("equipment/first.equ");
|
|
file.setText("[price]\n999");
|
|
throw new Error("abort");
|
|
`})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Status != ScriptRunFailed || result.PlanID != "" || result.Error == nil {
|
|
t.Fatalf("failure result = %#v", result)
|
|
}
|
|
after, err := c.archive.Text(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after != before {
|
|
t.Fatalf("failed script mutated live archive: before=%q after=%q", before, after)
|
|
}
|
|
}
|
|
|
|
func TestScriptServicePreviewAndApplyStructuralChanges(t *testing.T) {
|
|
c, svc, _, second := newScriptTestService(t)
|
|
beforeSecond, err := c.archive.Text(second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := svc.Run(nil, ScriptRunRequest{Name: "restructure", Source: `
|
|
pvf.createFile("equipment/added.equ", pvf.types.script, "[price]\n7");
|
|
pvf.deleteFile("equipment/second.equ");
|
|
`})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Status != ScriptRunCompleted || result.PlanID == "" || result.ModifiedFiles != 2 {
|
|
t.Fatalf("run result = %#v", result)
|
|
}
|
|
if _, ok := c.archive.Find("equipment/added.equ"); ok {
|
|
t.Fatal("preview created a live entry")
|
|
}
|
|
if _, ok := c.archive.Find("equipment/second.equ"); !ok {
|
|
t.Fatal("preview deleted a live entry")
|
|
}
|
|
|
|
page, err := svc.PreviewPage(result.PlanID, "", 0, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
statusByKey := make(map[string]string, len(page.Rows))
|
|
for _, row := range page.Rows {
|
|
statusByKey[row.ChangeKey] = row.Status
|
|
}
|
|
if statusByKey["equipment/added.equ"] != ScriptFileAdded {
|
|
t.Fatalf("added row status = %q rows=%#v", statusByKey["equipment/added.equ"], page.Rows)
|
|
}
|
|
if statusByKey["equipment/second.equ"] != ScriptFileDeleted {
|
|
t.Fatalf("deleted row status = %q rows=%#v", statusByKey["equipment/second.equ"], page.Rows)
|
|
}
|
|
|
|
// Applying only the creation must leave the deletion staged, not applied.
|
|
apply, err := svc.Apply(result.PlanID, []string{"equipment/added.equ"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if apply.AppliedFiles != 1 {
|
|
t.Fatalf("apply result = %#v", apply)
|
|
}
|
|
added, ok := c.archive.Find("equipment/added.equ")
|
|
if !ok {
|
|
t.Fatal("selected creation was not applied")
|
|
}
|
|
if text, textErr := c.archive.Text(added); textErr != nil || !strings.Contains(text, "7") {
|
|
t.Fatalf("created text = %q err=%v", text, textErr)
|
|
}
|
|
if _, ok := c.archive.Find("equipment/second.equ"); !ok {
|
|
t.Fatal("unselected deletion was applied")
|
|
}
|
|
if text, textErr := c.archive.Text(second); textErr != nil || text != beforeSecond {
|
|
t.Fatalf("unselected file changed: %q err=%v", text, textErr)
|
|
}
|
|
}
|
|
|
|
func TestScriptServiceListEditFlowsThroughPreviewAndApply(t *testing.T) {
|
|
c := NewCore()
|
|
a := pvf.New()
|
|
if _, err := a.AddFileText(
|
|
"equipment/equipment.lst",
|
|
"1008 `character/a.equ`",
|
|
pvf.TypeScript,
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, path := range []string{"equipment/character/a.equ", "equipment/character/b.equ"} {
|
|
if _, err := a.AddFileText(path, "[name]\n`x`", pvf.TypeScript); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := c.setArchive(a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(c.closeArchive)
|
|
svc := newScriptService(c, scriptengine.NewGojaRuntime(), t.TempDir(), newFileSetService(filepath.Join(t.TempDir(), "file-sets.json")))
|
|
|
|
listIndex, _ := c.archive.Find("equipment/equipment.lst")
|
|
before, err := c.archive.RawBytes(listIndex)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
result, err := svc.Run(nil, ScriptRunRequest{Name: "register", Source: `
|
|
const lst = pvf.lst("equipment/equipment.lst");
|
|
lst.set("1009", "character/b.equ");
|
|
`})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Status != ScriptRunCompleted || result.PlanID == "" || result.ModifiedFiles != 1 {
|
|
t.Fatalf("run result = %#v", result)
|
|
}
|
|
// The preview must not touch the live list.
|
|
afterRun, err := c.archive.RawBytes(listIndex)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(before, afterRun) {
|
|
t.Fatal("preview mutated the live list")
|
|
}
|
|
|
|
page, err := svc.PreviewPage(result.PlanID, "", 0, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(page.Rows) != 1 || page.Rows[0].Path != "equipment/equipment.lst" {
|
|
t.Fatalf("preview rows = %#v", page.Rows)
|
|
}
|
|
// A list edit is an ordinary payload change, not a structural one.
|
|
if page.Rows[0].Status != ScriptFileChanged {
|
|
t.Fatalf("row status = %q", page.Rows[0].Status)
|
|
}
|
|
|
|
apply, err := svc.Apply(result.PlanID, []string{page.Rows[0].ChangeKey})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if apply.Structural {
|
|
t.Fatal("a list edit should not be reported as structural")
|
|
}
|
|
applied, ok := c.archive.Find("equipment/equipment.lst")
|
|
if !ok {
|
|
t.Fatal("list file disappeared")
|
|
}
|
|
pairs, err := c.archive.ListPairs(applied)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(pairs) != 2 || pairs[1].ID != "1009" || pairs[1].Path != "character/b.equ" {
|
|
t.Fatalf("applied pairs = %#v", pairs)
|
|
}
|
|
}
|
|
|
|
func TestScriptServiceApplyDeleteRemovesEntryAndRebuildsTree(t *testing.T) {
|
|
c, svc, first, second := newScriptTestService(t)
|
|
result, err := svc.Run(nil, ScriptRunRequest{Source: `
|
|
pvf.deleteFile("equipment/second.equ");
|
|
pvf.find("equipment/first.equ").setText("[price]\n42\n[name]\n` + "`first`" + `");
|
|
`})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.PlanID == "" {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
if _, err := svc.Apply(result.PlanID, []string{"equipment/second.equ", "equipment/first.equ"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := c.archive.Find("equipment/second.equ"); ok {
|
|
t.Fatal("deleted entry still resolves in the live archive")
|
|
}
|
|
// The payload edit must land on the surviving entry even though the
|
|
// deletion renumbered it.
|
|
remaining, ok := c.archive.Find("equipment/first.equ")
|
|
if !ok {
|
|
t.Fatal("surviving entry disappeared")
|
|
}
|
|
if text, textErr := c.archive.Text(remaining); textErr != nil || !strings.Contains(text, "42") {
|
|
t.Fatalf("surviving text = %q err=%v", text, textErr)
|
|
}
|
|
if c.editorText[remaining] == "" {
|
|
t.Fatal("editor text cache was not refreshed for the surviving entry")
|
|
}
|
|
// The tree index must reflect the removal.
|
|
if _, stillThere := c.dirChildren["equipment"]; stillThere {
|
|
for _, node := range c.dirChildren["equipment"] {
|
|
if node.FileIndex == second {
|
|
t.Fatal("removed entry is still present in the tree index")
|
|
}
|
|
}
|
|
}
|
|
_ = first
|
|
}
|
|
|
|
func TestScriptPlanInvalidatedByEditorEdit(t *testing.T) {
|
|
c, svc, first, _ := newScriptTestService(t)
|
|
result, err := svc.Run(nil, ScriptRunRequest{Source: `
|
|
const file = pvf.find("equipment/first.equ");
|
|
file.setText("[price]\n10");
|
|
`})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.PlanID == "" {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
if _, _, err := c.setText(first, "[price]\n2"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.PreviewPage(result.PlanID, "", 0, 10); !errors.Is(err, ErrScriptPlanStale) {
|
|
t.Fatalf("stale preview error = %v, want ErrScriptPlanStale", err)
|
|
}
|
|
}
|
|
|
|
func TestScriptServiceCancelsOnArchiveCloseAndRejectsConcurrentRun(t *testing.T) {
|
|
c := NewCore()
|
|
a := pvf.New()
|
|
if _, err := a.AddFileText("equipment/first.equ", "[price]\n1", pvf.TypeScript); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.setArchive(a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(c.closeArchive)
|
|
runtime := &blockingScriptRuntime{started: make(chan struct{})}
|
|
svc := newScriptService(c, runtime, t.TempDir(), newFileSetService(filepath.Join(t.TempDir(), "file-sets.json")))
|
|
done := make(chan ScriptRunResult, 1)
|
|
errDone := make(chan error, 1)
|
|
go func() {
|
|
result, err := svc.Run(context.Background(), ScriptRunRequest{Source: "void 0"})
|
|
done <- result
|
|
errDone <- err
|
|
}()
|
|
select {
|
|
case <-runtime.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("script runtime did not start")
|
|
}
|
|
if _, err := svc.Run(context.Background(), ScriptRunRequest{Source: "void 0"}); !errors.Is(err, ErrScriptBusy) {
|
|
t.Fatalf("concurrent run error = %v, want ErrScriptBusy", err)
|
|
}
|
|
c.closeArchive()
|
|
select {
|
|
case result := <-done:
|
|
if result.Status != ScriptRunCancelled {
|
|
t.Fatalf("cancelled result = %#v", result)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("script did not stop after archive close")
|
|
}
|
|
if err := <-errDone; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestScriptDirectoryValidationAndAtomicSave(t *testing.T) {
|
|
directory := t.TempDir()
|
|
svc := newScriptService(NewCore(), scriptengine.NewGojaRuntime(), directory, newFileSetService(filepath.Join(directory, "file-sets.json")))
|
|
if err := svc.SaveScript("z.pvf.js", "z"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := svc.SaveScript("a", "a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
external := filepath.Join(t.TempDir(), "external.pvf.js")
|
|
if err := os.WriteFile(external, []byte("external"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linkCreated := os.Symlink(external, filepath.Join(directory, "link.pvf.js")) == nil
|
|
if _, err := svc.LoadScript("../escape.pvf.js"); err == nil {
|
|
t.Fatal("path traversal was accepted")
|
|
}
|
|
if _, err := svc.LoadScript("nested/name.pvf.js"); err == nil {
|
|
t.Fatal("nested script path was accepted")
|
|
}
|
|
if linkCreated {
|
|
if _, err := svc.LoadScript("link.pvf.js"); err == nil {
|
|
t.Fatal("script symlink was accepted")
|
|
}
|
|
}
|
|
files, err := svc.ListScripts()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(files) != 2 || files[0].Name != "a.pvf.js" || files[1].Name != "z.pvf.js" {
|
|
t.Fatalf("scripts = %#v", files)
|
|
}
|
|
source, err := svc.LoadScript("a.pvf.js")
|
|
if err != nil || source != "a" {
|
|
t.Fatalf("loaded source = %q, err = %v", source, err)
|
|
}
|
|
entries, err := os.ReadDir(directory)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, entry := range entries {
|
|
if filepath.Ext(entry.Name()) == ".tmp" {
|
|
t.Fatalf("temporary save file left behind: %s", entry.Name())
|
|
}
|
|
}
|
|
}
|