Files
artex/server/manager_delete_files_test.go
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

281 lines
9.2 KiB
Go

package server
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
pgdb "github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/traffic"
)
func TestSeedAssociatesTargetAssetWithTask(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
defer m.Close()
task, err := m.CreateTask("seed ownership", "seed ownership", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteAssets: true})
})
host := fmt.Sprintf("seed-%s.example.test", task.ID)
s := &Server{m: m}
s.seed(task, "https://"+host)
taskID := mustTaskID(t, task.ID)
assets, err := m.Assets().QueryByTask(taskID, "", 20, 0)
if err != nil {
t.Fatal(err)
}
found := false
for _, asset := range assets {
if asset.Domain == host || asset.URL == "https://"+host {
found = true
break
}
}
if !found {
t.Fatalf("seed target was not associated with task %s: %+v", task.ID, assets)
}
}
func TestDeleteTaskFilesRemovesOnlyOwnedWorkspaceAndTranscripts(t *testing.T) {
dataDir := t.TempDir()
removed := []struct {
path string
isDir bool
}{
{path: filepath.Join("tasks", "77"), isDir: true},
{path: filepath.Join("transcripts", "exp12-main.jsonl")},
{path: filepath.Join("transcripts", "exp12-planner.jsonl")},
{path: filepath.Join("transcripts", "exp12-worker-i9.jsonl")},
{path: filepath.Join("transcripts", "exp12-main"), isDir: true},
{path: filepath.Join("transcripts", "exp12-worker-i9"), isDir: true},
}
kept := []struct {
path string
isDir bool
}{
{path: filepath.Join("tasks", "78"), isDir: true},
{path: filepath.Join("transcripts", "exp123-main.jsonl")},
{path: filepath.Join("transcripts", "exp123-main"), isDir: true},
{path: filepath.Join("transcripts", "exp1-main.jsonl")},
{path: filepath.Join("transcripts", "exp12main.jsonl")},
{path: filepath.Join("transcripts", "exp12-main.jsonl.bak")},
{path: filepath.Join("transcripts", "exp12-main.txt")},
{path: filepath.Join("transcripts", "conv-12.jsonl")},
{path: filepath.Join("transcripts", "unrelated-session"), isDir: true},
}
for _, fixture := range append(removed, kept...) {
createDeleteFixture(t, dataDir, fixture.path, fixture.isDir)
}
deleted, err := deleteTaskFiles(dataDir, "77", 12)
if err != nil {
t.Fatalf("delete task files: %v", err)
}
if !deleted {
t.Fatal("expected deletion to be reported")
}
for _, fixture := range removed {
assertPathMissing(t, filepath.Join(dataDir, fixture.path))
}
for _, fixture := range kept {
assertPathExists(t, filepath.Join(dataDir, fixture.path))
}
}
func TestDeleteTaskFilesReportsTranscriptOnlyDeletion(t *testing.T) {
dataDir := t.TempDir()
path := filepath.Join("transcripts", "exp44-main.jsonl")
createDeleteFixture(t, dataDir, path, false)
deleted, err := deleteTaskFiles(dataDir, "missing-task", 44)
if err != nil {
t.Fatalf("delete task files: %v", err)
}
if !deleted {
t.Fatal("expected transcript deletion to be reported")
}
assertPathMissing(t, filepath.Join(dataDir, path))
}
func TestDeleteTaskFilesMissingTargetsIsIdempotent(t *testing.T) {
deleted, err := deleteTaskFiles(t.TempDir(), "77", 12)
if err != nil {
t.Fatalf("delete missing task files: %v", err)
}
if deleted {
t.Fatal("missing targets must not be reported as deleted")
}
}
func TestStageTaskFilesRollbackRestoresWorkspaceAndTranscripts(t *testing.T) {
dataDir := t.TempDir()
paths := []string{
filepath.Join("tasks", "77", "notes.txt"),
filepath.Join("transcripts", "exp12-main.jsonl"),
filepath.Join("transcripts", "exp12-worker-i9", "sidechain.jsonl"),
}
for _, path := range paths {
createDeleteFixture(t, dataDir, path, false)
}
stage, err := stageTaskFiles(dataDir, "77", 12)
if err != nil {
t.Fatal(err)
}
if !stage.deleted {
t.Fatal("expected files to be staged")
}
for _, path := range paths {
assertPathMissing(t, filepath.Join(dataDir, path))
}
if err := stage.rollback(); err != nil {
t.Fatal(err)
}
for _, path := range paths {
assertPathExists(t, filepath.Join(dataDir, path))
}
}
func TestStageTaskFilesRollbackReportsRestoreFailure(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "tasks", "77")
createDeleteFixture(t, dataDir, filepath.Join("tasks", "77"), true)
stage, err := stageTaskFiles(dataDir, "77", 12)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(workspace, []byte("conflict"), 0o644); err != nil {
t.Fatal(err)
}
cause := errors.New("forced PostgreSQL delete failure")
err = rollbackTaskDelete(cause, nil, stage)
if err == nil || !errors.Is(err, cause) || !strings.Contains(err.Error(), "destination already exists") {
t.Fatalf("rollback err=%v, want original and restore errors", err)
}
if _, statErr := os.Stat(stage.stageDir); statErr != nil {
t.Fatalf("staging was removed after failed restore: %v", statErr)
}
}
func TestManagerDeleteTaskRestoresFilesAndTrafficWhenPostgresDeleteFails(t *testing.T) {
dataDir := t.TempDir()
m, err := NewManager(dataDir, "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
t.Cleanup(func() { _ = m.Close() })
task, err := m.CreateTask("delete rollback", "delete rollback", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
taskID := mustTaskID(t, task.ID)
triggerName := fmt.Sprintf("test_fail_task_delete_%d", taskID)
functionName := triggerName + "_fn"
cleanupDB := func() {
_, _ = m.pg.Exec(fmt.Sprintf(`DROP TRIGGER IF EXISTS %s ON tasks`, triggerName))
_, _ = m.pg.Exec(fmt.Sprintf(`DROP FUNCTION IF EXISTS %s()`, functionName))
_, _ = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteAssets: true, DeleteTraffic: true, DeleteFiles: true})
}
t.Cleanup(cleanupDB)
host := fmt.Sprintf("delete-rollback-%d.example.test", taskID)
if _, err := m.Assets().UpsertHTTPService(pgdb.UpsertHTTPServiceReq{URL: "https://" + host, TaskID: taskID}); err != nil {
t.Fatal(err)
}
createDeleteFixture(t, dataDir, filepath.Join("tasks", task.ID), true)
transcriptPath := filepath.Join(dataDir, "transcripts", fmt.Sprintf("exp%d-main.jsonl", task.ExpID))
createDeleteFixture(t, dataDir, filepath.Join("transcripts", filepath.Base(transcriptPath)), false)
tr, err := traffic.Open(filepath.Join(dataDir, "traffic"), "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
m.traffic = tr
trafficTree := filepath.Join(dataDir, "traffic", host, "GET", "1-0001")
if err := os.MkdirAll(trafficTree, 0o755); err != nil {
t.Fatal(err)
}
trafficMarker := filepath.Join(trafficTree, "request.http")
if err := os.WriteFile(trafficMarker, []byte("original traffic"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, "1-0001", 1, host, "GET", "/", "https://"+host+"/", 200, "text/plain", 0, 0, host+"/GET/1-0001"); err != nil {
t.Fatal(err)
}
if _, err := m.pg.Exec(fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $body$
BEGIN RAISE EXCEPTION 'forced task delete failure'; END $body$`, functionName)); err != nil {
t.Fatal(err)
}
if _, err := m.pg.Exec(fmt.Sprintf(`CREATE TRIGGER %s BEFORE DELETE ON tasks
FOR EACH ROW WHEN (OLD.id = %d) EXECUTE FUNCTION %s()`, triggerName, taskID, functionName)); err != nil {
t.Fatal(err)
}
_, err = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteTraffic: true, DeleteFiles: true})
if err == nil || !strings.Contains(err.Error(), "forced task delete failure") {
t.Fatalf("delete err=%v, want injected PostgreSQL failure", err)
}
if got, err := m.pg.GetTask(taskID); err != nil || got == nil {
t.Fatalf("task row was lost after failed delete: task=%+v err=%v", got, err)
}
assertPathExists(t, filepath.Join(dataDir, "tasks", task.ID))
assertPathExists(t, transcriptPath)
if got, err := os.ReadFile(trafficMarker); err != nil || string(got) != "original traffic" {
t.Fatalf("traffic tree was not restored: content=%q err=%v", got, err)
}
var trafficRows int
if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges WHERE host=?`, host).Scan(&trafficRows); err != nil || trafficRows != 1 {
t.Fatalf("traffic index was not restored: rows=%d err=%v", trafficRows, err)
}
}
func createDeleteFixture(t *testing.T, root, relative string, isDir bool) {
t.Helper()
path := filepath.Join(root, relative)
if isDir {
if err := os.MkdirAll(filepath.Join(path, "subagents"), 0o755); err != nil {
t.Fatalf("create fixture directory %s: %v", path, err)
}
if err := os.WriteFile(filepath.Join(path, "subagents", "agent-test.jsonl"), []byte("test"), 0o644); err != nil {
t.Fatalf("create fixture sidechain %s: %v", path, err)
}
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create fixture parent %s: %v", path, err)
}
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatalf("create fixture file %s: %v", path, err)
}
}
func assertPathMissing(t *testing.T, path string) {
t.Helper()
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("expected %s to be removed, stat err=%v", path, err)
}
}
func assertPathExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Lstat(path); err != nil {
t.Fatalf("expected %s to remain: %v", path, err)
}
}