| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | func TestTagWriteGetList(t *testing.T) { |
| 14 | s := NewDirStore(t.TempDir()) |
| 15 | ctx := context.Background() |
| 16 | if _, err := GetTag(ctx, s, "acct1", "km", "0.5.0"); !errors.Is(err, os.ErrNotExist) { |
| 17 | t.Fatalf("missing tag: got %v; want os.ErrNotExist", err) |
| 18 | } |
| 19 | tags := []Tag{ |
| 20 | {Schema: 1, Version: "0.5.0", Release: "0.5.0-3", TaggedAt: time.Now().UTC(), Actor: "paul@example.com", Changelog: "## 0.5.0-1 — fixing modals\n"}, |
| 21 | {Schema: 1, Version: "0.10.0", Release: "0.10.0-1", TaggedAt: time.Now().UTC()}, |
| 22 | {Schema: 1, Version: "0.6.0", Release: "0.6.0-2", TaggedAt: time.Now().UTC()}, |
| 23 | } |
| 24 | for i := range tags { |
| 25 | if err := writeTag(ctx, s, "acct1", "km", &tags[i]); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | } |
| 29 | got, err := GetTag(ctx, s, "acct1", "km", "0.5.0") |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | if got.Release != "0.5.0-3" || got.Changelog == "" { |
| 34 | t.Fatalf("round trip lost fields: %+v", got) |
| 35 | } |
| 36 | list, err := ListTags(ctx, s, "acct1", "km") |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | // Semver-descending, which is NOT string order (0.10.0 > 0.6.0). |
| 41 | if len(list) != 3 || list[0].Version != "0.10.0" || list[1].Version != "0.6.0" || list[2].Version != "0.5.0" { |
| 42 | t.Fatalf("wrong order: %+v", list) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | func TestTagImmutable(t *testing.T) { |
| 47 | s := NewDirStore(t.TempDir()) |
| 48 | ctx := context.Background() |
| 49 | a := Tag{Schema: 1, Version: "0.5.0", Release: "0.5.0-3", TaggedAt: time.Unix(100, 0).UTC()} |
| 50 | if err := writeTag(ctx, s, "acct1", "km", &a); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | // Same bytes: idempotent. Different bytes: refused. |
| 54 | if err := writeTag(ctx, s, "acct1", "km", &a); err != nil { |
| 55 | t.Fatalf("identical rewrite refused: %v", err) |
| 56 | } |
| 57 | b := a |
| 58 | b.Release = "0.5.0-4" |
| 59 | if err := writeTag(ctx, s, "acct1", "km", &b); err == nil { |
| 60 | t.Fatal("conflicting rewrite accepted") |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func TestTagListErrors(t *testing.T) { |
| 65 | s := NewDirStore(t.TempDir()) |
| 66 | ctx := context.Background() |
| 67 | // Write a valid tag and a garbage tag (malformed JSON). |
| 68 | good := Tag{Schema: 1, Version: "0.5.0", Release: "0.5.0-1", TaggedAt: time.Now().UTC()} |
| 69 | if err := writeTag(ctx, s, "acct1", "km", &good); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | // Write garbage bytes directly to a valid tag key. |
| 73 | if _, err := s.Put(ctx, TagKey("acct1", "km", "0.7.0"), []byte("not json"), ""); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | // ListTags should return an error naming the key, not silently skip it. |
| 77 | _, err := ListTags(ctx, s, "acct1", "km") |
| 78 | if err == nil { |
| 79 | t.Fatal("ListTags accepted malformed JSON") |
| 80 | } |
| 81 | if !strings.Contains(err.Error(), "0.7.0") { |
| 82 | t.Fatalf("error does not name the key: %v", err) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func shipIteration(t *testing.T, s Store, ver, label, notes string) { |
| 87 | t.Helper() |
| 88 | m := Manifest{Schema: 1, App: "km", Version: ver, Kind: "binary", |
| 89 | Commit: "c-" + ver, Label: label, Notes: notes, |
| 90 | Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: "0000000000000000000000000000000000000000000000000000000000000000", Size: 1}}} |
| 91 | raw, err := json.Marshal(m) |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | if err := putImmutable(context.Background(), s, ManifestKey("acct1", "km", ver), raw); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestEnsureTagCompilesChangelog(t *testing.T) { |
| 101 | s := NewDirStore(t.TempDir()) |
| 102 | ctx := context.Background() |
| 103 | now := time.Unix(2000, 0).UTC() |
| 104 | shipIteration(t, s, "0.5.0-1", "fixing modals", "- focus trap fixed") |
| 105 | shipIteration(t, s, "0.5.0-2", "", "") |
| 106 | shipIteration(t, s, "0.5.0-3", "polish", "- rounded corners") |
| 107 | tag, created, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-3", "paul@example.com", now) |
| 108 | if err != nil || !created || tag == nil { |
| 109 | t.Fatalf("EnsureTag: %+v, %v, %v", tag, created, err) |
| 110 | } |
| 111 | if tag.Version != "0.5.0" || tag.Release != "0.5.0-3" || tag.Commit != "c-0.5.0-3" || tag.Actor != "paul@example.com" { |
| 112 | t.Fatalf("tag fields: %+v", tag) |
| 113 | } |
| 114 | want := "## 0.5.0-1 — fixing modals\n\n- focus trap fixed\n\n## 0.5.0-2\n\n## 0.5.0-3 — polish\n\n- rounded corners\n" |
| 115 | if tag.Changelog != want { |
| 116 | t.Fatalf("changelog:\n%q\nwant:\n%q", tag.Changelog, want) |
| 117 | } |
| 118 | // Auto-advance: the target moved to the next patch. |
| 119 | vt, _, err := GetTarget(ctx, s, "acct1", "km") |
| 120 | if err != nil || vt == nil || vt.Target != "0.5.1" { |
| 121 | t.Fatalf("target after cut: %+v, %v; want 0.5.1", vt, err) |
| 122 | } |
| 123 | // Idempotent: a second call finds the tag, creates nothing. |
| 124 | tag2, created2, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-3", "someone@else.com", now.Add(time.Hour)) |
| 125 | if err != nil || created2 || tag2.Actor != "paul@example.com" { |
| 126 | t.Fatalf("repeat: %+v, %v, %v", tag2, created2, err) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestEnsureTagSkipsNonIterations(t *testing.T) { |
| 131 | s := NewDirStore(t.TempDir()) |
| 132 | tag, created, err := EnsureTag(context.Background(), s, "acct1", "km", "de2766f8", "x", time.Unix(0, 0).UTC()) |
| 133 | if tag != nil || created || err != nil { |
| 134 | t.Fatalf("sha version: %+v, %v, %v; want nil, false, nil", tag, created, err) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestEnsureTagDoesNotLowerRaisedTarget(t *testing.T) { |
| 139 | s := NewDirStore(t.TempDir()) |
| 140 | ctx := context.Background() |
| 141 | now := time.Unix(2000, 0).UTC() |
| 142 | shipIteration(t, s, "0.5.0-1", "", "") |
| 143 | // Operator already aimed at 0.6.0; cutting 0.5.0 must not drag it back. |
| 144 | if err := SetTarget(ctx, s, "acct1", "km", "0.6.0", "paul@example.com", now); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | if _, _, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-1", "x", now); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | vt, _, _ := GetTarget(ctx, s, "acct1", "km") |
| 151 | if vt == nil || vt.Target != "0.6.0" { |
| 152 | t.Fatalf("target was moved: %+v; want 0.6.0 kept", vt) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | func TestEnsureTagBlockedByHigherTag(t *testing.T) { |
| 157 | s := NewDirStore(t.TempDir()) |
| 158 | ctx := context.Background() |
| 159 | now := time.Unix(2000, 0).UTC() |
| 160 | shipIteration(t, s, "0.5.0-1", "first", "- first iteration") |
| 161 | // Operator or a fleet already past has a 0.6.0 tag; next patch would collide. |
| 162 | higherTag := &Tag{Schema: 1, Version: "0.6.0", Release: "0.6.0-1", TaggedAt: now, Actor: "admin"} |
| 163 | if err := writeTag(ctx, s, "acct1", "km", higherTag); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | // Cutting 0.5.0 from -1 must succeed, but advance is blocked by the collision. |
| 167 | tag, created, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-1", "paul@example.com", now) |
| 168 | if err != nil || !created || tag == nil { |
| 169 | t.Fatalf("EnsureTag should succeed: %+v, %v, %v", tag, created, err) |
| 170 | } |
| 171 | if tag.Version != "0.5.0" { |
| 172 | t.Fatalf("tag version: %s; want 0.5.0", tag.Version) |
| 173 | } |
| 174 | // Target must not have been set (advance blocked by 0.6.0). |
| 175 | vt, _, _ := GetTarget(ctx, s, "acct1", "km") |
| 176 | if vt != nil { |
| 177 | t.Fatalf("target was set: %+v; want nil (blocked by higher tag 0.6.0)", vt) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | func TestEnsureTagMidRangeGap(t *testing.T) { |
| 182 | s := NewDirStore(t.TempDir()) |
| 183 | ctx := context.Background() |
| 184 | now := time.Unix(2000, 0).UTC() |
| 185 | shipIteration(t, s, "0.5.0-1", "first", "- first") |
| 186 | // Skip 0.5.0-2 intentionally. |
| 187 | shipIteration(t, s, "0.5.0-3", "third", "- third") |
| 188 | tag, created, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-3", "paul@example.com", now) |
| 189 | if err != nil || !created || tag == nil { |
| 190 | t.Fatalf("EnsureTag: %+v, %v, %v", tag, created, err) |
| 191 | } |
| 192 | // Changelog should have sections for -1 and -3 only (gap at -2 skipped). |
| 193 | want := "## 0.5.0-1 — first\n\n- first\n\n## 0.5.0-3 — third\n\n- third\n" |
| 194 | if tag.Changelog != want { |
| 195 | t.Fatalf("changelog with gap:\n%q\nwant:\n%q", tag.Changelog, want) |
| 196 | } |
| 197 | // Target should auto-advance to 0.5.1 (no collision). |
| 198 | vt, _, _ := GetTarget(ctx, s, "acct1", "km") |
| 199 | if vt == nil || vt.Target != "0.5.1" { |
| 200 | t.Fatalf("target: %+v; want 0.5.1", vt) |
| 201 | } |
| 202 | } |
| 203 | |