package artifacts import ( "errors" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) func TestResolvePreparedInputReturnsVerifiedIdentity(t *testing.T) { paths, m, canonicalPath, checksum := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n")) m.Inputs[0].Path = filepath.ToSlash(filepath.Join("inputs", "spell_catalog.json")) identity, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog) if err != nil { t.Fatalf("ResolvePreparedInput() error = %v", err) } wantAbsolute, err := filepath.Abs(canonicalPath) if err != nil { t.Fatalf("filepath.Abs() error = %v", err) } if identity.SourceID != artifactpolicy.SourceInputSpellCatalog || identity.ManifestKind != "spell_catalog" || identity.Path != wantAbsolute || identity.RelativePath != "inputs/spell_catalog.json" || identity.Checksum != checksum || identity.Size != int64(len("{\"spells\":[]}\n")) { t.Fatalf("ResolvePreparedInput() = %#v", identity) } } func TestResolvePreparedInputRequiresCurrentManifestRecord(t *testing.T) { paths, _, _, _ := preparedInputFixture(t, artifactpolicy.SourceInputPlayers, []byte("- Alice\n")) for _, m := range []*manifest.Manifest{nil, manifest.New("session", time.Now().UTC())} { _, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputPlayers) if !errors.Is(err, ErrPreparedInputAbsent) { t.Fatalf("ResolvePreparedInput() error = %v, want ErrPreparedInputAbsent", err) } var absent *PreparedInputAbsentError if !errors.As(err, &absent) || absent.SourceID != artifactpolicy.SourceInputPlayers { t.Fatalf("ResolvePreparedInput() error = %#v, want typed players absence", err) } } } func TestResolvePreparedInputRejectsInvalidManifestEvidence(t *testing.T) { tests := []struct { name string mutate func(*testing.T, SessionPaths, *manifest.Manifest, string) wantErr string }{ { name: "duplicate record", mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) { m.Inputs = append(m.Inputs, m.Inputs[0]) }, wantErr: "2 manifest records", }, { name: "wrong kind", mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) { m.Inputs[0].Kind = "players" }, wantErr: "recorded with manifest kind", }, { name: "wrong canonical path", mutate: func(t *testing.T, paths SessionPaths, m *manifest.Manifest, _ string) { wrong := filepath.Join(paths.InputsDir, "other.json") if err := os.WriteFile(wrong, []byte("other\n"), 0o644); err != nil { t.Fatalf("WriteFile(wrong) error = %v", err) } m.Inputs[0].Path = wrong }, wantErr: "does not match canonical path", }, { name: "traversal path", mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) { m.Inputs[0].Path = filepath.Join("..", "..", "outside.json") }, wantErr: "outside session root", }, { name: "missing checksum", mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) { m.Inputs[0].Checksum = " " }, wantErr: "manifest checksum is required", }, { name: "checksum mismatch", mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) { m.Inputs[0].Checksum = strings.Repeat("0", 64) }, wantErr: "checksum mismatch", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { paths, m, canonicalPath, _ := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n")) tt.mutate(t, paths, m, canonicalPath) _, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("ResolvePreparedInput() error = %v, want containing %q", err, tt.wantErr) } }) } } func TestResolvePreparedInputRejectsInvalidCanonicalFile(t *testing.T) { tests := []struct { name string mutate func(*testing.T, string) wantErr string }{ { name: "missing", mutate: func(t *testing.T, path string) { if err := os.Remove(path); err != nil { t.Fatalf("Remove() error = %v", err) } }, wantErr: "open prepared input source", }, { name: "symlink", mutate: func(t *testing.T, path string) { outside := filepath.Join(t.TempDir(), "outside.json") if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil { t.Fatalf("WriteFile(outside) error = %v", err) } if err := os.Remove(path); err != nil { t.Fatalf("Remove() error = %v", err) } if err := os.Symlink(outside, path); err != nil { t.Fatalf("Symlink() error = %v", err) } }, wantErr: "not a regular file", }, { name: "directory", mutate: func(t *testing.T, path string) { if err := os.Remove(path); err != nil { t.Fatalf("Remove() error = %v", err) } if err := os.Mkdir(path, 0o755); err != nil { t.Fatalf("Mkdir() error = %v", err) } }, wantErr: "not a regular file", }, { name: "empty", mutate: func(t *testing.T, path string) { if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatalf("WriteFile(empty) error = %v", err) } }, wantErr: "is empty", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { paths, m, canonicalPath, _ := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n")) tt.mutate(t, canonicalPath) _, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("ResolvePreparedInput() error = %v, want containing %q", err, tt.wantErr) } }) } } func TestResolvePreparedInputRejectsUnsupportedSource(t *testing.T) { paths := buildSessionPaths(t.TempDir(), "campaign", "session") _, err := ResolvePreparedInput(paths, nil, "narratio.input.unknown") if err == nil || errors.Is(err, ErrPreparedInputAbsent) || !strings.Contains(err.Error(), "unsupported prepared input source") { t.Fatalf("ResolvePreparedInput() error = %v", err) } } func preparedInputFixture( t *testing.T, sourceID string, payload []byte, ) (SessionPaths, *manifest.Manifest, string, string) { t.Helper() paths := buildSessionPaths(t.TempDir(), "campaign", "session") descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID) if !ok { t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID) } canonicalPath := filepath.Join(paths.InputsDir, descriptor.Filename) if err := os.MkdirAll(paths.InputsDir, 0o755); err != nil { t.Fatalf("MkdirAll(inputs) error = %v", err) } if err := os.WriteFile(canonicalPath, payload, 0o644); err != nil { t.Fatalf("WriteFile(canonical) error = %v", err) } checksum, err := SHA256File(canonicalPath) if err != nil { t.Fatalf("SHA256File() error = %v", err) } m := manifest.New("session", time.Now().UTC()) m.Inputs = []manifest.InputRecord{{ Kind: descriptor.ManifestKind, Path: canonicalPath, Checksum: checksum, Source: "campaign_config", }} return paths, m, canonicalPath, checksum }