| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "testing" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | // TestSidecarWakeKeyIsAnAppSibling pins the key's shape: a SIBLING prefix |
| 13 | // under the app's own account-scoped prefix, deliberately not a key under |
| 14 | // sidecars/ — provision.ParseSidecarKey's "exactly one non-empty segment |
| 15 | // after sidecars/" rule stays exactly as strict as it reads only if nothing |
| 16 | // else ever lands under that prefix. |
| 17 | func TestSidecarWakeKeyIsAnAppSibling(t *testing.T) { |
| 18 | got, want := SidecarWakeKey("bdm", "eleven", "bot"), "apps/bdm/eleven/sidecar-wake/bot.json" |
| 19 | if got != want { |
| 20 | t.Fatalf("SidecarWakeKey = %q, want %q", got, want) |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | // TestSidecarWakeJSONShape pins the wire shape against the exact example the |
| 25 | // design settled on, so a field rename or a json-tag typo fails loudly |
| 26 | // instead of silently breaking every box's etag comparison (which never |
| 27 | // parses the body at all) while still breaking a human reading the object |
| 28 | // during an incident. |
| 29 | func TestSidecarWakeJSONShape(t *testing.T) { |
| 30 | w := SidecarWake{ |
| 31 | Schema: 1, |
| 32 | Generation: 3, |
| 33 | At: "2026-08-17T23:05:00Z", |
| 34 | By: "member:bdm", |
| 35 | Reason: "operator wake", |
| 36 | } |
| 37 | raw, err := json.Marshal(w) |
| 38 | if err != nil { |
| 39 | t.Fatalf("marshal: %v", err) |
| 40 | } |
| 41 | const want = `{"schema":1,"generation":3,"at":"2026-08-17T23:05:00Z","by":"member:bdm","reason":"operator wake"}` |
| 42 | if string(raw) != want { |
| 43 | t.Fatalf("SidecarWake JSON = %s, want %s", raw, want) |
| 44 | } |
| 45 | var back SidecarWake |
| 46 | if err := json.Unmarshal(raw, &back); err != nil { |
| 47 | t.Fatalf("unmarshal: %v", err) |
| 48 | } |
| 49 | if back != w { |
| 50 | t.Fatalf("round trip = %+v, want %+v", back, w) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | // sidecarWakeEtag reads name's wake etag, "" for absent — the exact |
| 55 | // observation a box's pass makes, so a test asserting on it asserts on the |
| 56 | // signal itself rather than on this file's internals. |
| 57 | func sidecarWakeEtag(t *testing.T, s Store, acct, app, name string) string { |
| 58 | t.Helper() |
| 59 | _, etag, err := s.Get(context.Background(), SidecarWakeKey(acct, app, name)) |
| 60 | if err != nil { |
| 61 | if errors.Is(err, os.ErrNotExist) { |
| 62 | return "" |
| 63 | } |
| 64 | t.Fatalf("read sidecar wake: %v", err) |
| 65 | } |
| 66 | return etag |
| 67 | } |
| 68 | |
| 69 | func TestTouchSidecarWakeCreatesAndMoves(t *testing.T) { |
| 70 | s := NewDirStore(t.TempDir()) |
| 71 | ctx := context.Background() |
| 72 | if err := TouchSidecarWake(ctx, s, "bdm", "eleven", "bot", "member:bdm", "operator wake"); err != nil { |
| 73 | t.Fatalf("first TouchSidecarWake: %v", err) |
| 74 | } |
| 75 | raw, first, err := s.Get(ctx, SidecarWakeKey("bdm", "eleven", "bot")) |
| 76 | if err != nil { |
| 77 | t.Fatalf("Get after first touch: %v", err) |
| 78 | } |
| 79 | var w SidecarWake |
| 80 | if err := json.Unmarshal(raw, &w); err != nil { |
| 81 | t.Fatalf("decode sidecar wake: %v", err) |
| 82 | } |
| 83 | if w.Schema != 1 { |
| 84 | t.Fatalf("Schema = %d, want 1", w.Schema) |
| 85 | } |
| 86 | if w.Generation != 1 { |
| 87 | t.Fatalf("Generation = %d, want 1 on first touch", w.Generation) |
| 88 | } |
| 89 | if w.By != "member:bdm" || w.Reason != "operator wake" { |
| 90 | t.Fatalf("By/Reason = %q/%q, want the values passed in", w.By, w.Reason) |
| 91 | } |
| 92 | if _, err := time.Parse(time.RFC3339, w.At); err != nil { |
| 93 | t.Fatalf("At %q is not RFC3339: %v", w.At, err) |
| 94 | } |
| 95 | // The etag is the whole point: a second request must be a DIFFERENT |
| 96 | // object, and Generation must climb, or the box's comparison sees |
| 97 | // nothing and nothing wakes. |
| 98 | if err := TouchSidecarWake(ctx, s, "bdm", "eleven", "bot", "member:bdm", "second wake"); err != nil { |
| 99 | t.Fatalf("second TouchSidecarWake: %v", err) |
| 100 | } |
| 101 | second := sidecarWakeEtag(t, s, "bdm", "eleven", "bot") |
| 102 | if second == first { |
| 103 | t.Fatalf("etag %q unchanged after a second request — the wake signal never fires", second) |
| 104 | } |
| 105 | raw2, _, err := s.Get(ctx, SidecarWakeKey("bdm", "eleven", "bot")) |
| 106 | if err != nil { |
| 107 | t.Fatalf("Get after second touch: %v", err) |
| 108 | } |
| 109 | var w2 SidecarWake |
| 110 | if err := json.Unmarshal(raw2, &w2); err != nil { |
| 111 | t.Fatalf("decode sidecar wake: %v", err) |
| 112 | } |
| 113 | if w2.Generation != 2 { |
| 114 | t.Fatalf("Generation after second touch = %d, want 2", w2.Generation) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // racingSidecarWakePutStore lands a different writer's value at key just |
| 119 | // before forwarding the caller's Put, which then fails CAS against the etag |
| 120 | // the caller read — two operators (or a person and a retrying CLI) asking |
| 121 | // for a wake at the same moment. Mirrors racingRestartPutStore exactly. |
| 122 | type racingSidecarWakePutStore struct { |
| 123 | Store |
| 124 | key string |
| 125 | done bool |
| 126 | } |
| 127 | |
| 128 | func (r *racingSidecarWakePutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 129 | if key == r.key && !r.done { |
| 130 | r.done = true |
| 131 | other := []byte(`{"schema":1,"generation":1,"at":"2026-01-01T00:00:00Z","by":"member:other","reason":"racing"}`) |
| 132 | if _, err := r.Store.Put(ctx, key, other, ifMatch); err != nil { |
| 133 | return "", err |
| 134 | } |
| 135 | } |
| 136 | return r.Store.Put(ctx, key, body, ifMatch) |
| 137 | } |
| 138 | |
| 139 | func TestTouchSidecarWakeCASConflictIsSuccess(t *testing.T) { |
| 140 | inner := NewDirStore(t.TempDir()) |
| 141 | ctx := context.Background() |
| 142 | racing := &racingSidecarWakePutStore{Store: inner, key: SidecarWakeKey("bdm", "eleven", "bot")} |
| 143 | if err := TouchSidecarWake(ctx, racing, "bdm", "eleven", "bot", "member:bdm", "operator wake"); err != nil { |
| 144 | t.Fatalf("TouchSidecarWake on a lost CAS race: %v, want nil (the request already fired)", err) |
| 145 | } |
| 146 | if !racing.done { |
| 147 | t.Fatal("the racing writer never ran — this test proved nothing") |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestTouchSidecarWakeRefusesInvalidNames(t *testing.T) { |
| 152 | s := NewDirStore(t.TempDir()) |
| 153 | ctx := context.Background() |
| 154 | if err := TouchSidecarWake(ctx, s, "../etc", "eleven", "bot", "member:bdm", "x"); err == nil { |
| 155 | t.Fatal("TouchSidecarWake accepted an invalid account") |
| 156 | } |
| 157 | if err := TouchSidecarWake(ctx, s, "bdm", "../etc", "bot", "member:bdm", "x"); err == nil { |
| 158 | t.Fatal("TouchSidecarWake accepted an invalid app") |
| 159 | } |
| 160 | if err := TouchSidecarWake(ctx, s, "bdm", "eleven", "../etc", "member:bdm", "x"); err == nil { |
| 161 | t.Fatal("TouchSidecarWake accepted an invalid sidecar name") |
| 162 | } |
| 163 | } |
| 164 | |