Files
releasekit/internal/publish/record_test.go

493 lines
18 KiB
Go

package publish
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
)
func TestRecordsRoundTripNonUTF8Evidence(t *testing.T) {
repo := recordRepo(t)
raw := "path-\xff"
evidence := Evidence{RepositoryDir: raw, ModuleDir: raw, ModulePath: raw, Version: raw, Tag: raw, CandidateCommit: raw, TagObject: raw, Endpoint: raw, Remote: raw, RecordedAt: raw}
path, err := SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
if err != nil {
t.Fatal(err)
}
loaded, err := LoadEvidence(path)
if err != nil || loaded != evidence {
t.Fatalf("lossy evidence: %#v, %v", loaded, err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var envelope recordEnvelope
if err := json.Unmarshal(data, &envelope); err != nil || envelope.FormatVersion != recordByteFormatVersion {
t.Fatalf("byte record envelope: %#v, %v", envelope, err)
}
// Unknown fields remain ignorable; known byte fields retain real codec errors.
for _, test := range []struct {
field string
wantError bool
}{{"endpoint", true}, {"future_field", false}} {
envelope.EvidenceBytes = json.RawMessage(`{"` + test.field + `":"not base64!"}`)
data, err := json.Marshal(envelope)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
_, err = LoadEvidence(path)
if (err != nil) != test.wantError {
t.Fatalf("byte field %s error=%v", test.field, err)
}
}
}
func TestRecordLoadedObservationKeepsNonUTF8EndpointIdentity(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("raw pathname bytes are a POSIX behavior")
}
repo := recordRepo(t)
base := t.TempDir()
original := filepath.Join(base, "remote-\xff.git")
decoy := filepath.Join(base, "remote-\uFFFD.git")
for _, remote := range []string{original, decoy} {
recordGit(t, repo, "init", "--bare", remote)
}
recordGit(t, repo, "tag", "-a", "v1.2.3", "-m", "Release")
recordGit(t, repo, "push", decoy, "refs/tags/v1.2.3")
evidence := Evidence{RepositoryDir: repo, ModuleDir: repo, Endpoint: original, Tag: "v1.2.3", TagObject: strings.TrimSpace(recordGit(t, repo, "rev-parse", "v1.2.3")), CandidateCommit: strings.TrimSpace(recordGit(t, repo, "rev-parse", "HEAD"))}
path, err := SaveEvidence(t.Context(), repo, "", evidence)
if err != nil {
t.Fatal(err)
}
loaded, err := LoadEvidence(path)
if err != nil || loaded.Endpoint != original {
t.Fatalf("endpoint changed: %q, %v", loaded.Endpoint, err)
}
for _, selected := range []Evidence{evidence, loaded} {
observed, err := Reconcile(t.Context(), selected)
if err != nil || observed.State != NotPublished {
t.Fatalf("original endpoint observation = %#v, %v", observed, err)
}
}
loaded.Endpoint = decoy
observed, err := Reconcile(t.Context(), loaded)
if err != nil || observed.State != Published {
t.Fatalf("decoy does not distinguish identity: %#v, %v", observed, err)
}
}
func recordGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return string(out)
}
func recordRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
recordGit(t, dir, "init", "-q")
recordGit(t, dir, "config", "user.name", "Test")
recordGit(t, dir, "config", "user.email", "test@example.invalid")
if err := os.WriteFile(filepath.Join(dir, "file"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
recordGit(t, dir, "add", "file")
recordGit(t, dir, "commit", "-qm", "init")
return dir
}
func TestLoadEvidenceAcceptsPartialAndRejectsMalformedRecords(t *testing.T) {
dir := t.TempDir()
partial := filepath.Join(dir, "partial.json")
if err := os.WriteFile(partial, []byte(`{"format_version":1,"evidence":{}}`), 0600); err != nil {
t.Fatal(err)
}
got, err := LoadEvidence(partial)
if err != nil || got != (Evidence{}) {
t.Fatalf("partial record = %#v, %v", got, err)
}
for name, data := range map[string]string{"bad-json": "{", "unsupported": `{"format_version":3,"evidence":{}}`, "type": `{"format_version":1,"evidence":{"tag":false}}`, "trailing": `{"format_version":1,"evidence":{}} {}`} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatal(err)
}
_, err := LoadEvidence(path)
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "record_format" {
t.Fatalf("LoadEvidence() error=%v", err)
}
})
}
}
func TestLoadEvidenceRequiresOneDocumentAndPreservesSyntaxCause(t *testing.T) {
const valid = `{"format_version":1,"evidence":{"tag":"v1.2.3","future":{}},"future":true}`
for _, test := range []struct {
name, data string
valid bool
}{
{"single", valid, true}, {"whitespace", valid + "\n \t\r", true},
{"first incomplete", "{", false}, {"first syntax", "x", false},
{"second object", valid + ` {"unused":[1,2,3]}`, false},
{"incomplete suffix", valid + " {", false}, {"invalid suffix", valid + " x", false},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "record.json")
if err := os.WriteFile(path, []byte(test.data), 0600); err != nil {
t.Fatal(err)
}
loaded, err := LoadEvidence(path)
if test.valid {
if err != nil || loaded.Tag != "v1.2.3" {
t.Fatalf("valid record = %#v, %v", loaded, err)
}
return
}
var recovery *RecoveryError
var syntax *json.SyntaxError
if !errors.As(err, &recovery) || recovery.Kind != "record_format" || !errors.As(err, &syntax) {
t.Fatalf("lost format/parser cause: %v", err)
}
})
}
}
func TestLoadEvidenceReadFailureHasTypedError(t *testing.T) {
_, err := LoadEvidence(filepath.Join(t.TempDir(), "missing.json"))
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "record_read" || !errors.Is(err, os.ErrNotExist) {
t.Fatalf("LoadEvidence() error = %v", err)
}
}
func TestSaveEvidenceUsesPrivateUniqueRecordsAndLinkedWorktreeMetadata(t *testing.T) {
repo := recordRepo(t)
worktree := filepath.Join(t.TempDir(), "linked")
recordGit(t, repo, "worktree", "add", "--detach", worktree)
evidence := Evidence{Tag: "v1.2.3", Endpoint: "https://user:secret@example.invalid/repo.git"}
first, err := SaveEvidence(t.Context(), worktree, "", evidence)
if err != nil {
t.Fatal(err)
}
second, err := SaveEvidence(t.Context(), worktree, "", evidence)
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatalf("record paths collided: %q", first)
}
common := recordGit(t, repo, "rev-parse", "--path-format=absolute", "--git-common-dir")
wantDir := filepath.Join(strings.TrimSpace(common), "releasekit", "publication-attempts")
if filepath.Dir(first) != wantDir {
t.Fatalf("record dir=%q want %q", filepath.Dir(first), wantDir)
}
dirInfo, err := os.Stat(wantDir)
if err != nil {
t.Fatal(err)
}
if dirInfo.Mode().Perm() != 0700 {
t.Fatalf("record directory permissions=%o", dirInfo.Mode().Perm())
}
info, err := os.Stat(first)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("record permissions=%o", info.Mode().Perm())
}
loaded, err := LoadEvidence(first)
if err != nil {
t.Fatal(err)
}
if loaded.Endpoint != evidence.Endpoint || loaded.RecordedAt == "" {
t.Fatalf("loaded evidence=%#v", loaded)
}
var envelope map[string]json.RawMessage
data, err := os.ReadFile(first)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "user:secret@example.invalid") {
t.Fatal("private record did not retain the exact endpoint")
}
}
func TestSaveEvidenceResolvesRelativeAndIgnoredOverrides(t *testing.T) {
repo := recordRepo(t)
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("saved-records/\n"), 0600); err != nil {
t.Fatal(err)
}
if _, err := SaveEvidence(t.Context(), repo, "saved-records", Evidence{Tag: "v1.2.3"}); err != nil {
t.Fatalf("ignored override: %v", err)
}
if _, err := os.Stat(filepath.Join(repo, "saved-records")); err != nil {
t.Fatal(err)
}
external := filepath.Join(t.TempDir(), "external-store")
path, err := SaveEvidence(t.Context(), repo, external, Evidence{Tag: "v2.0.0"})
if err != nil || !strings.HasPrefix(path, external+string(filepath.Separator)) {
t.Fatalf("absolute external override path=%q error=%v", path, err)
}
}
func TestSaveEvidenceAllowsExplicitGitMetadataStores(t *testing.T) {
for _, layout := range []string{"ordinary", "linked", "separate"} {
t.Run(layout, func(t *testing.T) {
repo := recordRepo(t)
switch layout {
case "linked":
linked := filepath.Join(t.TempDir(), "linked")
recordGit(t, repo, "worktree", "add", "--detach", linked)
repo = linked
case "separate":
recordGit(t, repo, "init", "--separate-git-dir", filepath.Join(t.TempDir(), "metadata"))
}
defaultPath, err := SaveEvidence(t.Context(), repo, "", Evidence{})
if err != nil {
t.Fatal(err)
}
privateDir := strings.TrimSuffix(recordGit(t, repo, "rev-parse", "--absolute-git-dir"), "\n")
for _, directory := range []string{filepath.Dir(defaultPath), filepath.Join(privateDir, "other-private-records")} {
path, err := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v1.2.3"})
if err != nil || filepath.Dir(path) != directory {
t.Fatalf("explicit metadata store %q = %q, %v", directory, path, err)
}
}
if status := recordGit(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("metadata records appeared in candidate: %s", status)
}
})
}
}
func TestRecordStoragePreservesWhitespaceInRepositoryAndMetadataPaths(t *testing.T) {
for _, separate := range []bool{false, true} {
t.Run(fmt.Sprintf("separate=%t", separate), func(t *testing.T) {
original := recordRepo(t)
repo := original + " "
if err := os.Rename(original, repo); err != nil {
t.Fatal(err)
}
// recordRepo's temporary-directory cleanup owns the old spelling.
t.Cleanup(func() { _ = os.RemoveAll(repo) })
if separate {
recordGit(t, repo, "init", "--separate-git-dir", filepath.Join(t.TempDir(), "metadata "))
}
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("saved/\n"), 0600); err != nil {
t.Fatal(err)
}
recordGit(t, repo, "add", ".gitignore")
recordGit(t, repo, "commit", "-qm", "Ignore saved records")
for _, override := range []string{"", "saved"} {
path, err := SaveEvidence(t.Context(), repo, override, Evidence{RepositoryDir: repo, Tag: "v1.2.3"})
if err != nil {
t.Fatal(err)
}
listed, err := ListAttempts(t.Context(), repo, override)
if err != nil || len(listed.Attempts) != 1 || listed.Attempts[0].RecordPath != path || listed.Attempts[0].Evidence.RepositoryDir != repo {
t.Fatalf("read/write disagreement for %q: %#v, %v", override, listed, err)
}
if override != "" && filepath.Dir(path) != filepath.Join(repo, override) {
t.Fatalf("changed repository path: %q", path)
}
}
if _, err := SaveEvidence(t.Context(), repo, filepath.Join(repo, "unignored"), Evidence{}); err == nil {
t.Fatal("absolute unignored store was misclassified as external")
}
if status := recordGit(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("recording changed candidate: %s", status)
}
})
}
}
func TestSaveEvidenceRejectsUnignoredOverrideInsideWorktree(t *testing.T) {
repo := recordRepo(t)
_, err := SaveEvidence(t.Context(), repo, "records", Evidence{Tag: "v1.2.3"})
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "record_write" {
t.Fatalf("SaveEvidence() error=%v", err)
}
if _, err := os.Stat(filepath.Join(repo, "records")); err != nil {
t.Fatalf("expected parent directory to exist without record: %v", err)
}
if _, err := os.Stat(filepath.Join(repo, "records", "attempt")); !os.IsNotExist(err) {
t.Fatalf("unexpected record: %v", err)
}
}
func TestSaveEvidenceChecksEachActualFilename(t *testing.T) {
repo := recordRepo(t)
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("selective/attempt-[0-7]*.json\n"), 0600); err != nil {
t.Fatal(err)
}
recordGit(t, repo, "add", ".gitignore")
recordGit(t, repo, "commit", "-qm", "Selective ignore")
dir := filepath.Join(repo, "selective")
next := func(name string) func(string) (string, error) {
return func(directory string) (string, error) { return filepath.Join(directory, name), nil }
}
path, err := saveEvidenceNamed(t.Context(), repo, repo, dir, dir, Evidence{Tag: "v1.0.0"}, next("attempt-0.json"))
if err != nil {
t.Fatal(err)
}
recordGit(t, repo, "check-ignore", "--", path)
// A collision must validate the replacement name as well.
calls := 0
_, err = saveEvidenceNamed(t.Context(), repo, repo, dir, dir, Evidence{Tag: "v2.0.0"}, func(directory string) (string, error) {
calls++
name := "attempt-0.json"
if calls > 1 {
name = "attempt-c.json"
}
return filepath.Join(directory, name), nil
})
if err == nil || calls != 2 {
t.Fatalf("unignored replacement: calls=%d error=%v", calls, err)
}
if _, err := os.Stat(filepath.Join(dir, "attempt-c.json")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unignored file was created: %v", err)
}
if status := recordGit(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("recording dirtied candidate: %s", status)
}
loaded, err := LoadEvidence(path)
if err != nil || loaded.Tag != "v1.0.0" {
t.Fatalf("collision changed original record: %#v, %v", loaded, err)
}
}
func TestListAttemptsReturnsReadableRecordsAndDiagnosticsInPathOrder(t *testing.T) {
repo := recordRepo(t)
directory := filepath.Join(t.TempDir(), "attempts")
if err := os.Mkdir(directory, 0700); err != nil {
t.Fatal(err)
}
second, _ := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v2.0.0"})
first, _ := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v1.0.0"})
if err := os.WriteFile(filepath.Join(directory, "broken.json"), []byte("{"), 0600); err != nil {
t.Fatal(err)
}
listed, err := ListAttempts(t.Context(), repo, directory)
if err != nil || len(listed.Attempts) != 2 || len(listed.Diagnostics) != 1 {
t.Fatalf("list=%#v error=%v", listed, err)
}
wantPaths := []string{first, second}
sort.Strings(wantPaths)
if listed.Attempts[0].RecordPath != wantPaths[0] || listed.Attempts[1].RecordPath != wantPaths[1] {
t.Fatalf("attempt order=%v", listed.Attempts)
}
if listed.Diagnostics[0].Kind != "record_format" || listed.Diagnostics[0].RecordPath == "" {
t.Fatalf("diagnostic=%#v", listed.Diagnostics[0])
}
}
func TestListAttemptsFiltersMixedDirectoryAcrossBatches(t *testing.T) {
directory := t.TempDir()
for i := range 400 {
if err := os.WriteFile(filepath.Join(directory, fmt.Sprintf("unrelated-%04d.txt", i)), []byte("not a record"), 0600); err != nil {
t.Fatal(err)
}
}
if err := os.Mkdir(filepath.Join(directory, "directory.json"), 0700); err != nil {
t.Fatal(err)
}
names := []string{"z.json", "b.json", "a.json"}
for _, name := range names {
if err := os.WriteFile(filepath.Join(directory, name), []byte(`{"format_version":1,"evidence":{"tag":"v1.2.3"}}`), 0600); err != nil {
t.Fatal(err)
}
}
listed, err := ListAttempts(t.Context(), "", directory)
if err != nil || len(listed.Diagnostics) != 0 || len(listed.Attempts) != len(names) {
t.Fatalf("filtered result = %#v, %v", listed, err)
}
sort.Strings(names)
for i, name := range names {
if listed.Attempts[i].RecordPath != filepath.Join(directory, name) {
t.Fatalf("incorrect path order: %#v", listed.Attempts)
}
}
}
func TestListAttemptsRetainsEntriesWithEnumerationFailure(t *testing.T) {
directory := t.TempDir()
for name, contents := range map[string]string{
"z.json": `{"format_version":1,"evidence":{"tag":"v2.0.0"}}`,
"a.json": `{"format_version":1,"evidence":{"tag":"v1.0.0"}}`,
"broken.json": "{", "unrelated.txt": "ignored",
} {
if err := os.WriteFile(filepath.Join(directory, name), []byte(contents), 0600); err != nil {
t.Fatal(err)
}
}
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
failure := errors.New("controlled directory read failure")
for _, empty := range []bool{false, true} {
calls := 0
listed, err := listAttempts(t.Context(), "", directory, func(ctx context.Context, dir string) ([]string, error) {
return readRecordPaths(ctx, dir, func(int) ([]os.DirEntry, error) {
calls++
if empty {
return nil, failure
}
return entries, failure
})
})
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "storage_enumeration" || !errors.Is(err, failure) || calls != 1 {
t.Fatalf("enumeration failure lost: %#v, %v; reads=%d", listed, err, calls)
}
if empty {
if len(listed.Attempts) != 0 || len(listed.Diagnostics) != 0 {
t.Fatalf("empty read: %#v", listed)
}
} else if len(listed.Attempts) != 2 || len(listed.Diagnostics) != 1 || filepath.Base(listed.Attempts[0].RecordPath) != "a.json" || filepath.Base(listed.Attempts[1].RecordPath) != "z.json" || listed.Diagnostics[0].Kind != "record_format" {
t.Fatalf("partial read dropped/reordered records: %#v", listed)
}
}
}
func TestListAttemptsDistinguishesEmptyMissingAndCanceledStorage(t *testing.T) {
empty := t.TempDir()
listed, err := ListAttempts(t.Context(), "", empty)
if err != nil || len(listed.Attempts) != 0 || len(listed.Diagnostics) != 0 {
t.Fatalf("empty list=%#v error=%v", listed, err)
}
_, err = ListAttempts(t.Context(), "", filepath.Join(t.TempDir(), "missing"))
var recovery *RecoveryError
if !errors.As(err, &recovery) || recovery.Kind != "storage_enumeration" {
t.Fatalf("missing storage error=%v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = ListAttempts(ctx, "", empty)
if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) {
t.Fatalf("canceled error=%v", err)
}
}