Files
goclaw/internal/tools/delegation_artifacts_test.go
Duc Nguyenandntduc bb7712a9ff fix(collaboration): harden delegated task isolation (#1486)
* feat(collaboration): isolate delegated artifacts and child runs

Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution.

* fix(collaboration): harden delegated task isolation

Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces.

* fix(collaboration): recover interrupted async tasks

* fix(collaboration): normalize persisted child-run status

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
2026-07-30 14:17:40 +07:00

752 lines
22 KiB
Go

package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
func TestDelegationArtifactExchangeTenantLayoutAndInputContract(t *testing.T) {
tenantRoot := t.TempDir()
sourcePath := t.TempDir()
if err := os.MkdirAll(filepath.Join(sourcePath, "one"), 0750); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(sourcePath, "two"), 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourcePath, "one", "report.txt"), []byte("first"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourcePath, "two", "report.txt"), []byte("second"), 0600); err != nil {
t.Fatal(err)
}
tenantID := uuid.New()
delegationID := uuid.New()
exchange := newTestDelegationExchange(t, tenantRoot, tenantID, delegationID, DelegationArtifactLimits{})
source := openTestArtifactRoot(t, sourcePath)
staged, err := exchange.StageInputs(
context.Background(),
source,
[]string{"one/report.txt", "two/report.txt"},
)
if err != nil {
t.Fatalf("StageInputs() error = %v", err)
}
if got, want := len(staged), 2; got != want {
t.Fatalf("staged count = %d, want %d", got, want)
}
if staged[0].Path != "inputs/report.txt" || staged[1].Path != "inputs/report-2.txt" {
t.Fatalf("staged paths = %#v", []string{staged[0].Path, staged[1].Path})
}
wantExchangeRoot := filepath.Join(
tenantRoot,
"collaboration",
"delegations",
delegationID.String(),
)
if got := exchange.OutputsHostPath(); got != filepath.Join(wantExchangeRoot, "outputs") {
t.Fatalf("OutputsHostPath() = %q, want %q", got, filepath.Join(wantExchangeRoot, "outputs"))
}
mount := exchange.InputsMount()
if mount.LogicalAlias != "inputs" || mount.ContainerPath != "/workspace/inputs" || !mount.ReadOnly {
t.Fatalf("InputsMount() = %#v", mount)
}
if mount.HostRoot != filepath.Join(wantExchangeRoot, "inputs") {
t.Fatalf("input host root = %q", mount.HostRoot)
}
for _, name := range []string{"report.txt", "report-2.txt"} {
info, err := os.Stat(filepath.Join(mount.HostRoot, name))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm()&0222 != 0 {
t.Fatalf("%s mode = %o, want read-only", name, info.Mode().Perm())
}
}
}
func TestDelegationArtifactStageRejectsTraversalSymlinksAndHardlinks(t *testing.T) {
testCases := []struct {
name string
prepare func(t *testing.T, source string) string
wantErr error
}{
{
name: "traversal",
prepare: func(t *testing.T, source string) string {
return "../outside.txt"
},
wantErr: ErrArtifactInvalidPath,
},
{
name: "absolute",
prepare: func(t *testing.T, source string) string {
return filepath.Join(source, "outside.txt")
},
wantErr: ErrArtifactInvalidPath,
},
{
name: "alternate data stream",
prepare: func(t *testing.T, source string) string {
return "report.txt:secret"
},
wantErr: ErrArtifactInvalidPath,
},
{
name: "reserved device",
prepare: func(t *testing.T, source string) string {
return "NUL.txt"
},
wantErr: ErrArtifactInvalidPath,
},
{
name: "control character",
prepare: func(t *testing.T, source string) string {
return "report\n.txt"
},
wantErr: ErrArtifactInvalidPath,
},
{
name: "symlink",
prepare: func(t *testing.T, source string) string {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on some Windows hosts")
}
if err := os.WriteFile(filepath.Join(source, "real.txt"), []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink("real.txt", filepath.Join(source, "link.txt")); err != nil {
t.Fatal(err)
}
return "link.txt"
},
wantErr: ErrArtifactNonRegular,
},
{
name: "hardlink",
prepare: func(t *testing.T, source string) string {
if err := os.WriteFile(filepath.Join(source, "real.txt"), []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Link(filepath.Join(source, "real.txt"), filepath.Join(source, "hard.txt")); err != nil {
t.Skipf("hardlinks unavailable: %v", err)
}
return "hard.txt"
},
wantErr: ErrArtifactHardlink,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
sourcePath := t.TempDir()
input := testCase.prepare(t, sourcePath)
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
source := openTestArtifactRoot(t, sourcePath)
_, err := exchange.StageInputs(context.Background(), source, []string{input})
if !errors.Is(err, testCase.wantErr) {
t.Fatalf("StageInputs() error = %v, want errors.Is(%v)", err, testCase.wantErr)
}
retention, retained := exchange.FailureRetention()
if !retained || retention.ReasonCode == "" || !retention.RetainUntil.After(retention.FailedAt) {
t.Fatalf("failure retention = %#v, %v", retention, retained)
}
})
}
}
func TestDelegationArtifactPublishManifestV1IsSortedAndPathSafe(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
if err := os.MkdirAll(filepath.Join(exchange.OutputsHostPath(), "nested"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "z.txt"), []byte("z"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "nested", "a.txt"), []byte("alpha"), 0600); err != nil {
t.Fatal(err)
}
destinationPath := t.TempDir()
destination := openTestArtifactRoot(t, destinationPath)
publishedAt := time.Date(2026, 7, 29, 8, 30, 0, 0, time.FixedZone("ICT", 7*60*60))
publication, err := exchange.Publish(context.Background(), destination, publishedAt)
if err != nil {
t.Fatalf("Publish() error = %v", err)
}
if publication.RootPath != filepath.ToSlash(filepath.Join(".delegations", exchange.DelegationID().String())) {
t.Fatalf("publication root = %q", publication.RootPath)
}
if publication.Manifest.SchemaVersion != 1 ||
publication.Manifest.DelegationID != exchange.DelegationID().String() ||
publication.Manifest.PublishedAt.Location() != time.UTC ||
publication.Manifest.OutputCount != 2 ||
publication.Manifest.OutputBytes != 6 {
t.Fatalf("manifest = %#v", publication.Manifest)
}
if got := []string{
publication.Manifest.Outputs[0].Path,
publication.Manifest.Outputs[1].Path,
}; got[0] != "outputs/nested/a.txt" || got[1] != "outputs/z.txt" {
t.Fatalf("manifest output order = %#v", got)
}
for _, output := range publication.Manifest.Outputs {
if err := validateManifestOutputPath(output.Path); err != nil {
t.Fatalf("manifest output path %q invalid: %v", output.Path, err)
}
if len(output.SHA256) != 64 || output.MediaType == "" {
t.Fatalf("manifest output = %#v", output)
}
}
manifestBytes, err := os.ReadFile(filepath.Join(destinationPath, filepath.FromSlash(publication.ManifestPath)))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(manifestBytes), destinationPath) ||
strings.Contains(string(manifestBytes), exchange.OutputsHostPath()) {
t.Fatalf("manifest leaked host path: %s", manifestBytes)
}
var durable DelegationArtifactManifest
if err := json.Unmarshal(manifestBytes, &durable); err != nil {
t.Fatalf("decode manifest: %v", err)
}
if durable.Outputs == nil || durable.OutputCount != len(durable.Outputs) {
t.Fatalf("durable outputs = %#v", durable.Outputs)
}
var rawManifest map[string]json.RawMessage
if err := json.Unmarshal(manifestBytes, &rawManifest); err != nil {
t.Fatal(err)
}
if len(rawManifest) != 6 {
t.Fatalf("manifest persisted unexpected fields: %v", rawManifest)
}
for _, forbidden := range []string{"tenant_id", "agent_id", "workspace", "trace_id", "status"} {
if _, exists := rawManifest[forbidden]; exists {
t.Fatalf("manifest persisted forbidden field %q", forbidden)
}
}
}
func TestDelegationArtifactPublishEmptyManifest(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
destinationPath := t.TempDir()
publication, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Unix(123, 0),
)
if err != nil {
t.Fatalf("Publish() error = %v", err)
}
if publication.Manifest.OutputCount != 0 ||
publication.Manifest.OutputBytes != 0 ||
publication.Manifest.Outputs == nil ||
len(publication.Manifest.Outputs) != 0 {
t.Fatalf("empty manifest = %#v", publication.Manifest)
}
manifestBytes, err := os.ReadFile(filepath.Join(destinationPath, filepath.FromSlash(publication.ManifestPath)))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(manifestBytes), `"outputs": []`) {
t.Fatalf("empty outputs must encode as [], manifest: %s", manifestBytes)
}
}
func TestDelegationArtifactPublishRejectsExcessEmptyDirectories(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
for index := 0; index <= DelegationArtifactMaxFiles; index++ {
name := fmt.Sprintf("empty-%03d", index)
if err := os.Mkdir(filepath.Join(exchange.OutputsHostPath(), name), 0700); err != nil {
t.Fatal(err)
}
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, t.TempDir()),
time.Now(),
)
var artifactErr *DelegationArtifactError
if !errors.Is(err, ErrArtifactLimitExceeded) ||
!errors.As(err, &artifactErr) ||
artifactErr.Code != "artifact_directory_limit" {
t.Fatalf("Publish() error = %v, want artifact_directory_limit", err)
}
}
func TestDelegationArtifactPublishRejectsDeepOutputTree(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
current := exchange.OutputsHostPath()
for depth := 0; depth <= artifactSecureMaxDepth; depth++ {
current = filepath.Join(current, "d")
if err := os.Mkdir(current, 0700); err != nil {
t.Fatal(err)
}
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, t.TempDir()),
time.Now(),
)
var artifactErr *DelegationArtifactError
if !errors.Is(err, ErrArtifactLimitExceeded) ||
!errors.As(err, &artifactErr) ||
artifactErr.Code != "artifact_depth_limit" {
t.Fatalf("Publish() error = %v, want artifact_depth_limit", err)
}
}
func TestDelegationArtifactPublishRejectsLongLogicalOutputPath(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
currentHostPath := exchange.OutputsHostPath()
logicalPath := "outputs"
for depth := 0; len(logicalPath) <= artifactOutputMaxPathBytes; depth++ {
if depth >= artifactSecureMaxDepth {
t.Fatal("test path reached depth limit before path-length limit")
}
component := fmt.Sprintf("%02d-%s", depth, strings.Repeat("x", 47))
currentHostPath = filepath.Join(currentHostPath, component)
logicalPath += "/" + component
if err := os.Mkdir(currentHostPath, 0700); err != nil {
t.Fatal(err)
}
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, t.TempDir()),
time.Now(),
)
var artifactErr *DelegationArtifactError
if !errors.Is(err, ErrArtifactLimitExceeded) ||
!errors.As(err, &artifactErr) ||
artifactErr.Code != "artifact_path_limit" {
t.Fatalf("Publish() error = %v, want artifact_path_limit", err)
}
}
func TestDelegationArtifactPublishPreservesRegularOutputFileLimit(t *testing.T) {
limits := DelegationArtifactLimits{
MaxFileBytes: 8,
MaxTotalBytes: 32,
MaxFiles: 2,
}
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
limits,
)
for _, name := range []string{"one.txt", "two.txt", "three.txt"} {
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), name), []byte(name), 0600); err != nil {
t.Fatal(err)
}
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, t.TempDir()),
time.Now(),
)
var artifactErr *DelegationArtifactError
if !errors.Is(err, ErrArtifactLimitExceeded) ||
!errors.As(err, &artifactErr) ||
artifactErr.Code != "artifact_file_limit" {
t.Fatalf("Publish() error = %v, want artifact_file_limit", err)
}
}
func TestDelegationArtifactPublishRejectsSymlinkAndHardlinkOutputs(t *testing.T) {
testCases := []struct {
name string
prepare func(t *testing.T, exchange *DelegationArtifactExchange)
wantErr error
}{
{
name: "symlink",
prepare: func(t *testing.T, exchange *DelegationArtifactExchange) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on some Windows hosts")
}
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(exchange.OutputsHostPath(), "link.txt")); err != nil {
t.Fatal(err)
}
},
wantErr: ErrArtifactNonRegular,
},
{
name: "hardlink",
prepare: func(t *testing.T, exchange *DelegationArtifactExchange) {
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Link(outside, filepath.Join(exchange.OutputsHostPath(), "hard.txt")); err != nil {
t.Skipf("hardlinks unavailable: %v", err)
}
},
wantErr: ErrArtifactHardlink,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
testCase.prepare(t, exchange)
destinationPath := t.TempDir()
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Now(),
)
if !errors.Is(err, testCase.wantErr) {
t.Fatalf("Publish() error = %v, want errors.Is(%v)", err, testCase.wantErr)
}
if _, statErr := os.Stat(filepath.Join(destinationPath, ".delegations", exchange.DelegationID().String())); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("final destination visible after rejection: %v", statErr)
}
retention, retained := exchange.FailureRetention()
if !retained || !strings.HasPrefix(
retention.PublicationTempPath,
".delegations/.tmp-"+exchange.DelegationID().String()+"-",
) {
t.Fatalf("publication temp path not retained for janitor: %#v, %v", retention, retained)
}
parentEntries, readErr := os.ReadDir(filepath.Join(destinationPath, ".delegations"))
if readErr != nil {
t.Fatal(readErr)
}
for _, entry := range parentEntries {
if !strings.HasPrefix(entry.Name(), ".tmp-") {
t.Fatalf("unexpected publication entry remains: %s", entry.Name())
}
}
})
}
}
func TestDelegationArtifactPublishNoReplaceCollision(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "new.txt"), []byte("new"), 0600); err != nil {
t.Fatal(err)
}
destinationPath := t.TempDir()
finalPath := filepath.Join(destinationPath, ".delegations", exchange.DelegationID().String())
if err := os.MkdirAll(finalPath, 0700); err != nil {
t.Fatal(err)
}
sentinel := filepath.Join(finalPath, "sentinel.txt")
if err := os.WriteFile(sentinel, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Now(),
)
if !errors.Is(err, ErrArtifactPublishConflict) {
t.Fatalf("Publish() error = %v, want conflict", err)
}
content, readErr := os.ReadFile(sentinel)
if readErr != nil || string(content) != "original" {
t.Fatalf("pre-existing destination changed: %q, %v", content, readErr)
}
}
func TestDelegationArtifactSecureRenameNeverReplaces(t *testing.T) {
root := openTestArtifactRoot(t, t.TempDir())
source, err := root.root.createSubroot("source", 0700)
if err != nil {
t.Fatal(err)
}
if err := source.close(); err != nil {
t.Fatal(err)
}
destination, err := root.root.createSubroot("destination", 0700)
if err != nil {
t.Fatal(err)
}
if err := destination.close(); err != nil {
t.Fatal(err)
}
err = root.root.renameNoReplace("source", "destination")
if !errors.Is(err, ErrArtifactPublishConflict) {
t.Fatalf("renameNoReplace() error = %v, want conflict", err)
}
for _, name := range []string{"source", "destination"} {
exists, statErr := root.root.exists(name)
if statErr != nil || !exists {
t.Fatalf("%s existence = %v, %v; rename replaced an entry", name, exists, statErr)
}
}
}
func TestDelegationArtifactPublishUsesCapturedDestinationRoot(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "result.txt"), []byte("result"), 0600); err != nil {
t.Fatal(err)
}
parent := t.TempDir()
originalPath := filepath.Join(parent, "workspace")
movedPath := filepath.Join(parent, "workspace-captured")
if err := os.Mkdir(originalPath, 0700); err != nil {
t.Fatal(err)
}
destination := openTestArtifactRoot(t, originalPath)
if err := os.Rename(originalPath, movedPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(originalPath, 0700); err != nil {
t.Fatal(err)
}
publication, err := exchange.Publish(context.Background(), destination, time.Now())
if err != nil {
t.Fatalf("Publish() error = %v", err)
}
if _, err := os.Stat(filepath.Join(movedPath, filepath.FromSlash(publication.ManifestPath))); err != nil {
t.Fatalf("artifact not published to captured root: %v", err)
}
if _, err := os.Stat(filepath.Join(originalPath, filepath.FromSlash(publication.ManifestPath))); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("replacement path received publication: %v", err)
}
}
func TestDelegationArtifactBudgetsAreSharedAcrossInputsAndOutputs(t *testing.T) {
limits := DelegationArtifactLimits{
MaxFileBytes: 5,
MaxTotalBytes: 8,
MaxFiles: 2,
}
sourcePath := t.TempDir()
if err := os.WriteFile(filepath.Join(sourcePath, "input.bin"), []byte("1234"), 0600); err != nil {
t.Fatal(err)
}
exchange := newTestDelegationExchange(t, t.TempDir(), uuid.New(), uuid.New(), limits)
if _, err := exchange.StageInputs(
context.Background(),
openTestArtifactRoot(t, sourcePath),
[]string{"input.bin"},
); err != nil {
t.Fatalf("StageInputs() error = %v", err)
}
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "output.bin"), []byte("12345"), 0600); err != nil {
t.Fatal(err)
}
destinationPath := t.TempDir()
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Now(),
)
if !errors.Is(err, ErrArtifactLimitExceeded) {
t.Fatalf("Publish() error = %v, want shared total-byte limit", err)
}
if _, statErr := os.Stat(filepath.Join(destinationPath, ".delegations", exchange.DelegationID().String())); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("final destination visible after budget failure: %v", statErr)
}
}
func TestDelegationArtifactInputFileLimitRejectsBeforeStaging(t *testing.T) {
limits := DelegationArtifactLimits{
MaxFileBytes: 8,
MaxTotalBytes: 16,
MaxFiles: 1,
}
sourcePath := t.TempDir()
for _, name := range []string{"one.txt", "two.txt"} {
if err := os.WriteFile(filepath.Join(sourcePath, name), []byte(name), 0600); err != nil {
t.Fatal(err)
}
}
exchange := newTestDelegationExchange(t, t.TempDir(), uuid.New(), uuid.New(), limits)
_, err := exchange.StageInputs(
context.Background(),
openTestArtifactRoot(t, sourcePath),
[]string{"one.txt", "two.txt"},
)
if !errors.Is(err, ErrArtifactLimitExceeded) {
t.Fatalf("StageInputs() error = %v, want file limit", err)
}
entries, readErr := os.ReadDir(exchange.InputsMount().HostRoot)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 0 {
t.Fatalf("staging began before structured input count rejection: %v", entries)
}
}
func TestDelegationArtifactPublishRejectsSymlinkPublicationParent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on some Windows hosts")
}
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
destinationPath := t.TempDir()
outsidePath := t.TempDir()
if err := os.Symlink(outsidePath, filepath.Join(destinationPath, ".delegations")); err != nil {
t.Fatal(err)
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Now(),
)
if err == nil {
t.Fatal("Publish() accepted symlink publication parent")
}
entries, readErr := os.ReadDir(outsidePath)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 0 {
t.Fatalf("publication escaped through symlink: %v", entries)
}
}
func TestDelegationArtifactFailureRetentionCleanupHook(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
failedAt := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
exchange.RetainFailure(failedAt, "validation_failed")
exchange.RetainFailure(failedAt.Add(time.Hour), "must_not_extend_ttl")
retention, ok := exchange.FailureRetention()
if !ok || retention.FailedAt != failedAt || retention.ReasonCode != "validation_failed" {
t.Fatalf("retention = %#v, %v", retention, ok)
}
if exchange.ReadyForCleanup(failedAt.Add(59 * time.Minute)) {
t.Fatal("exchange ready for cleanup before TTL")
}
if !exchange.ReadyForCleanup(failedAt.Add(60 * time.Minute)) {
t.Fatal("exchange not ready for cleanup at TTL")
}
}
func newTestDelegationExchange(
t *testing.T,
tenantRoot string,
tenantID uuid.UUID,
delegationID uuid.UUID,
limits DelegationArtifactLimits,
) *DelegationArtifactExchange {
t.Helper()
exchange, err := NewDelegationArtifactExchange(
tenantRoot,
tenantID,
delegationID,
limits,
0,
)
if err != nil {
t.Fatalf("NewDelegationArtifactExchange() error = %v", err)
}
t.Cleanup(func() {
if err := exchange.Close(); err != nil {
t.Errorf("close exchange: %v", err)
}
})
return exchange
}
func openTestArtifactRoot(t *testing.T, hostPath string) *DelegationArtifactRoot {
t.Helper()
root, err := OpenDelegationArtifactRoot(hostPath)
if err != nil {
t.Fatalf("OpenDelegationArtifactRoot() error = %v", err)
}
t.Cleanup(func() {
if err := root.Close(); err != nil {
t.Errorf("close root: %v", err)
}
})
return root
}