First Commit
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
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
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user