package volume import ( "path/filepath" "strings" "testing" "github.com/alexei/tinyforge/internal/store" ) func TestResolveWorkloadPath(t *testing.T) { // Use real-OS absolute paths so the suite is portable Linux/Windows. allowedDir := t.TempDir() allowedJSON := `["` + filepath.ToSlash(allowedDir) + `"]` bindSource := filepath.Join(allowedDir, "db") outsideSource := filepath.Join(t.TempDir(), "passwd") const base = "/var/forge/volumes" type tc struct { name string vol store.WorkloadVolume params ResolveWorkloadParams want string wantErr string // substring match; empty = no error } cases := []tc{ { name: "absolute allowed", vol: store.WorkloadVolume{Source: bindSource, Scope: "absolute"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", AllowedVolumePaths: allowedJSON, }, want: filepath.Clean(bindSource), }, { name: "absolute outside allow-list", vol: store.WorkloadVolume{Source: outsideSource, Scope: "absolute"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", AllowedVolumePaths: allowedJSON, }, wantErr: "not under any allowed", }, { name: "absolute requires non-empty source", vol: store.WorkloadVolume{Source: "", Scope: "absolute"}, params: ResolveWorkloadParams{ BasePath: base, AllowedVolumePaths: allowedJSON, }, wantErr: "absolute scope requires a source path", }, { name: "ephemeral has no host path", vol: store.WorkloadVolume{Scope: "ephemeral"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef", WorkloadName: "api", }, wantErr: "ephemeral", }, { name: "instance uses tag suffix", vol: store.WorkloadVolume{Source: "data", Scope: "instance"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", ImageTag: "v1.2.3", }, want: filepath.Join(base, "api-01abcdef", "instance-v1.2.3", "data"), }, { name: "instance scope requires tag", vol: store.WorkloadVolume{Source: "data", Scope: "instance"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef", WorkloadName: "api", }, wantErr: "instance scope requires image tag", }, { name: "stage and project collapse to workload dir", vol: store.WorkloadVolume{Source: "shared", Scope: "stage"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", }, want: filepath.Join(base, "api-01abcdef", "shared"), }, { name: "project scope", vol: store.WorkloadVolume{Source: "shared", Scope: "project"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", }, want: filepath.Join(base, "api-01abcdef", "shared"), }, { name: "project_named requires name", vol: store.WorkloadVolume{Source: "data", Scope: "project_named"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", }, wantErr: "project_named scope requires name", }, { name: "project_named", vol: store.WorkloadVolume{Source: "data", Scope: "project_named", Name: "cache"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", }, want: filepath.Join(base, "api-01abcdef", "_named", "cache", "data"), }, { name: "named", vol: store.WorkloadVolume{Source: "data", Scope: "named", Name: "global-cache"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", }, want: filepath.Join(base, "_named", "global-cache", "data"), }, { name: "named requires name", vol: store.WorkloadVolume{Source: "data", Scope: "named"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", }, wantErr: "named scope requires name", }, { name: "empty scope rejected", vol: store.WorkloadVolume{Source: "data", Scope: ""}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", }, wantErr: "scope is required", }, { name: "unknown scope rejected", vol: store.WorkloadVolume{Source: "data", Scope: "weird"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api", }, wantErr: "unknown volume scope", }, { name: "id-only workload still resolves", vol: store.WorkloadVolume{Source: "data", Scope: "project"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", }, want: filepath.Join(base, "01abcdef", "data"), }, { name: "name-only workload still resolves", vol: store.WorkloadVolume{Source: "data", Scope: "project"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadName: "api", }, want: filepath.Join(base, "api", "data"), }, { name: "name with unsafe chars sanitized", vol: store.WorkloadVolume{Source: "data", Scope: "project"}, params: ResolveWorkloadParams{ BasePath: base, WorkloadID: "01abcdef1234", WorkloadName: "api/../etc", }, want: filepath.Join(base, "api-..-etc-01abcdef", "data"), }, { name: "no workload identity rejected", vol: store.WorkloadVolume{Source: "data", Scope: "project"}, params: ResolveWorkloadParams{ BasePath: base, }, wantErr: "workload id or name required", }, { name: "non-absolute scope requires base path", vol: store.WorkloadVolume{Source: "data", Scope: "project"}, params: ResolveWorkloadParams{ WorkloadID: "01abcdef", WorkloadName: "api", }, wantErr: "base path is required", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, err := ResolveWorkloadPath(c.vol, c.params) if c.wantErr != "" { if err == nil { t.Fatalf("want error containing %q, got nil (path=%q)", c.wantErr, got) } if !strings.Contains(err.Error(), c.wantErr) { t.Fatalf("want error containing %q, got %q", c.wantErr, err.Error()) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got != c.want { t.Fatalf("path mismatch:\n got %q\n want %q", got, c.want) } }) } } func TestSanitizePathSegment(t *testing.T) { cases := []struct { in, out string }{ {"api", "api"}, {" api ", "api"}, {"api/../etc", "api-..-etc"}, {"my app v1", "my-app-v1"}, {"---", ""}, {"", ""}, {"v1.2.3", "v1.2.3"}, } for _, c := range cases { got := sanitizePathSegment(c.in) if got != c.out { t.Errorf("sanitize(%q) = %q, want %q", c.in, got, c.out) } } }