| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | func TestFeaturesRoundTrip(t *testing.T) { |
| 11 | ctx := context.Background() |
| 12 | s := NewDirStore(t.TempDir()) |
| 13 | now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) |
| 14 | |
| 15 | // Absent is legal forever: no features, not an error. |
| 16 | got, err := GetFeatures(ctx, s, "bab", "titogo") |
| 17 | if err != nil || got != nil { |
| 18 | t.Fatalf("absent features = %v, %v; want nil, nil", got, err) |
| 19 | } |
| 20 | |
| 21 | rec, changed, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"sandbox": "on"}, nil, "alice@keymail.dev", now) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | if !changed || rec.Features["sandbox"] != "on" || rec.SetBy != "alice@keymail.dev" || !rec.SetAt.Equal(now) { |
| 26 | t.Fatalf("first set = %+v changed=%v, want sandbox=on by alice, changed", rec, changed) |
| 27 | } |
| 28 | got, err = GetFeatures(ctx, s, "bab", "titogo") |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | if got == nil || got.Features["sandbox"] != "on" || got.Schema != 1 || got.App != "titogo" { |
| 33 | t.Fatalf("features = %+v, want schema 1 titogo sandbox=on", got) |
| 34 | } |
| 35 | |
| 36 | // A second set MERGES: the existing key survives, the new one lands. |
| 37 | rec, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"beta": "1"}, nil, "bob@keymail.dev", now.Add(time.Hour)) |
| 38 | if err != nil || !changed { |
| 39 | t.Fatalf("merge set: changed=%v err=%v", changed, err) |
| 40 | } |
| 41 | if rec.Features["sandbox"] != "on" || rec.Features["beta"] != "1" || rec.SetBy != "bob@keymail.dev" { |
| 42 | t.Fatalf("after merge = %+v, want both keys, set by bob", rec) |
| 43 | } |
| 44 | |
| 45 | // Setting the same value again changes nothing — and says so, so the |
| 46 | // door can skip the audit row. |
| 47 | _, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"beta": "1"}, nil, "carol", now.Add(2*time.Hour)) |
| 48 | if err != nil || changed { |
| 49 | t.Fatalf("no-op set: changed=%v err=%v, want false, nil", changed, err) |
| 50 | } |
| 51 | got, _ = GetFeatures(ctx, s, "bab", "titogo") |
| 52 | if got.SetBy != "bob@keymail.dev" { |
| 53 | t.Fatalf("a no-op set rewrote the record: set_by %q", got.SetBy) |
| 54 | } |
| 55 | |
| 56 | // Unset removes a key; unsetting an absent key is a no-op. |
| 57 | rec, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"beta", "never"}, "dave", now.Add(3*time.Hour)) |
| 58 | if err != nil || !changed { |
| 59 | t.Fatalf("unset: changed=%v err=%v", changed, err) |
| 60 | } |
| 61 | if _, ok := rec.Features["beta"]; ok || rec.Features["sandbox"] != "on" { |
| 62 | t.Fatalf("after unset = %+v, want only sandbox", rec.Features) |
| 63 | } |
| 64 | _, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"never"}, "dave", now.Add(4*time.Hour)) |
| 65 | if err != nil || changed { |
| 66 | t.Fatalf("unset absent: changed=%v err=%v, want false, nil", changed, err) |
| 67 | } |
| 68 | |
| 69 | // Unsetting the last key leaves an EMPTY record, not an absent one: the |
| 70 | // record's set_at still tells a reader when the last change happened. |
| 71 | rec, _, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"sandbox"}, "erin", now.Add(5*time.Hour)) |
| 72 | if err != nil || len(rec.Features) != 0 { |
| 73 | t.Fatalf("after last unset = %+v err=%v", rec, err) |
| 74 | } |
| 75 | got, err = GetFeatures(ctx, s, "bab", "titogo") |
| 76 | if err != nil || got == nil || len(got.Features) != 0 || got.SetBy != "erin" { |
| 77 | t.Fatalf("empty record = %+v, %v; want present, empty, by erin", got, err) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | // Keys and values are the app's own vocabulary — but they are also bucket |
| 82 | // bytes every consumer parses, so the shape is bounded: short lowercase |
| 83 | // keys, short printable values, and a cap on how many. |
| 84 | func TestUpdateFeaturesRefusesBadShapes(t *testing.T) { |
| 85 | ctx := context.Background() |
| 86 | s := NewDirStore(t.TempDir()) |
| 87 | now := time.Now() |
| 88 | cases := []struct { |
| 89 | name string |
| 90 | set map[string]string |
| 91 | want string |
| 92 | }{ |
| 93 | {"uppercase key", map[string]string{"Sandbox": "on"}, "key"}, |
| 94 | {"empty key", map[string]string{"": "on"}, "key"}, |
| 95 | {"key with space", map[string]string{"sand box": "on"}, "key"}, |
| 96 | {"key too long", map[string]string{strings.Repeat("k", 65): "on"}, "key"}, |
| 97 | {"empty value", map[string]string{"sandbox": ""}, "value"}, |
| 98 | {"value too long", map[string]string{"sandbox": strings.Repeat("v", 257)}, "value"}, |
| 99 | {"control char in value", map[string]string{"sandbox": "on\n"}, "value"}, |
| 100 | } |
| 101 | for _, c := range cases { |
| 102 | t.Run(c.name, func(t *testing.T) { |
| 103 | _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", c.set, nil, "x", now) |
| 104 | if err == nil || !strings.Contains(err.Error(), c.want) { |
| 105 | t.Fatalf("err = %v, want one naming the %s", err, c.want) |
| 106 | } |
| 107 | }) |
| 108 | } |
| 109 | if got, err := GetFeatures(ctx, s, "bab", "titogo"); err != nil || got != nil { |
| 110 | t.Fatalf("a refused set wrote something: %+v, %v", got, err) |
| 111 | } |
| 112 | |
| 113 | // The count cap: FeaturesMax keys is fine, one more is refused — |
| 114 | // whether they arrive at once or on top of an existing record. |
| 115 | set := map[string]string{} |
| 116 | for i := 0; i < FeaturesMax; i++ { |
| 117 | set["k"+strings.Repeat("x", i%3)+string(rune('a'+i%26))+string(rune('a'+i/26))] = "v" |
| 118 | } |
| 119 | if len(set) != FeaturesMax { |
| 120 | t.Fatalf("test bug: %d keys", len(set)) |
| 121 | } |
| 122 | if _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", set, nil, "x", now); err != nil { |
| 123 | t.Fatalf("%d keys refused: %v", FeaturesMax, err) |
| 124 | } |
| 125 | _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"one-more": "v"}, nil, "x", now) |
| 126 | if err == nil || !strings.Contains(err.Error(), "at most") { |
| 127 | t.Fatalf("key %d = %v, want a cap error", FeaturesMax+1, err) |
| 128 | } |
| 129 | // Replacing an existing key at the cap is not an addition. |
| 130 | if _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"kaa": "changed"}, nil, "x", now); err != nil { |
| 131 | t.Fatalf("replacing at the cap refused: %v", err) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func TestGetFeaturesRefusesLiesAndUnknownSchema(t *testing.T) { |
| 136 | ctx := context.Background() |
| 137 | s := NewDirStore(t.TempDir()) |
| 138 | put := func(body string) { |
| 139 | s.Delete(ctx, FeaturesKey("bab", "titogo")) |
| 140 | if _, err := s.Put(ctx, FeaturesKey("bab", "titogo"), []byte(body), ""); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | } |
| 144 | put(`{"schema":1,"app":"other","features":{"a":"b"},"set_at":"2026-08-28T12:00:00Z"}`) |
| 145 | if _, err := GetFeatures(ctx, s, "bab", "titogo"); err == nil { |
| 146 | t.Fatal("a body naming a different app was accepted") |
| 147 | } |
| 148 | put(`{"schema":2,"app":"titogo","features":{"a":"b"},"set_at":"2026-08-28T12:00:00Z"}`) |
| 149 | if _, err := GetFeatures(ctx, s, "bab", "titogo"); err == nil { |
| 150 | t.Fatal("an unknown schema was accepted") |
| 151 | } |
| 152 | // A record with no features map at all reads as empty, never nil — |
| 153 | // consumers range over it without a nil check. |
| 154 | put(`{"schema":1,"app":"titogo","set_at":"2026-08-28T12:00:00Z"}`) |
| 155 | got, err := GetFeatures(ctx, s, "bab", "titogo") |
| 156 | if err != nil || got == nil || got.Features == nil { |
| 157 | t.Fatalf("missing map = %+v, %v; want a non-nil empty map", got, err) |
| 158 | } |
| 159 | } |
| 160 | |