| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "crypto/rand" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/doorkey" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // testClock is the mirror's injectable clock. |
| 17 | type testClock struct { |
| 18 | mu sync.Mutex |
| 19 | t time.Time |
| 20 | } |
| 21 | |
| 22 | func (c *testClock) now() time.Time { |
| 23 | c.mu.Lock() |
| 24 | defer c.mu.Unlock() |
| 25 | return c.t |
| 26 | } |
| 27 | |
| 28 | func (c *testClock) advance(d time.Duration) { |
| 29 | c.mu.Lock() |
| 30 | c.t = c.t.Add(d) |
| 31 | c.mu.Unlock() |
| 32 | } |
| 33 | |
| 34 | type doorKeyFixture struct { |
| 35 | pub ed25519.PublicKey |
| 36 | priv ed25519.PrivateKey |
| 37 | clk *testClock |
| 38 | } |
| 39 | |
| 40 | func newDoorKeyFixture(t *testing.T) *doorKeyFixture { |
| 41 | t.Helper() |
| 42 | pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | return &doorKeyFixture{pub: pub, priv: priv, clk: &testClock{t: time.Now().UTC()}} |
| 47 | } |
| 48 | |
| 49 | func (fx *doorKeyFixture) mirror(s release.Store) *doorKeyMirror { |
| 50 | d := newDoorKeyMirror(s, fx.pub) |
| 51 | d.now = fx.clk.now |
| 52 | return d |
| 53 | } |
| 54 | |
| 55 | // signed is a record for account/app at revision rev, signed now on the |
| 56 | // fixture's clock and good for the whole lifetime. |
| 57 | func (fx *doorKeyFixture) signed(t *testing.T, priv ed25519.PrivateKey, account, app string, rev uint64) []byte { |
| 58 | t.Helper() |
| 59 | now := fx.clk.now() |
| 60 | return fx.signedAt(t, priv, account, app, rev, now, now.Add(doorkey.Lifetime)) |
| 61 | } |
| 62 | |
| 63 | func (fx *doorKeyFixture) signedAt(t *testing.T, priv ed25519.PrivateKey, account, app string, rev uint64, signedAt, expiresAt time.Time) []byte { |
| 64 | t.Helper() |
| 65 | _, entry, err := doorkey.NewToken(account, app) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | wire, err := doorkey.Sign(priv, doorkey.Record{V: 1, Account: account, App: app, Revision: rev, |
| 70 | Keys: []doorkey.Entry{entry}, SignedAt: signedAt, ExpiresAt: expiresAt}) |
| 71 | if err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | return wire |
| 75 | } |
| 76 | |
| 77 | func refreshDoorKeys(t *testing.T, d *doorKeyMirror) { |
| 78 | t.Helper() |
| 79 | if err := d.Refresh(context.Background()); err != nil { |
| 80 | t.Fatalf("refresh: %v", err) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func wantDoorRecord(t *testing.T, d *doorKeyMirror, account, app string, want bool) doorkey.Record { |
| 85 | t.Helper() |
| 86 | rec, ok := d.Record(account, app) |
| 87 | if ok != want { |
| 88 | t.Fatalf("Record(%s, %s) ok = %v, want %v", account, app, ok, want) |
| 89 | } |
| 90 | return rec |
| 91 | } |
| 92 | |
| 93 | // A record listed under its own path, validly signed and unexpired, is |
| 94 | // admitted; one that leaves the list is dropped on the next refresh. |
| 95 | func TestDoorKeyMirrorDropsARecordAbsentFromTheList(t *testing.T) { |
| 96 | fx := newDoorKeyFixture(t) |
| 97 | s := meshTestStore(t) |
| 98 | putJSON(t, s, doorkey.RecordKey("bae", "titogo"), string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| 99 | putJSON(t, s, doorkey.RecordKey("bae", "classic"), string(fx.signed(t, fx.priv, "bae", "classic", 1))) |
| 100 | // Not a record path: ignored, not an error. |
| 101 | putJSON(t, s, doorkey.Prefix+"stray.json", "{}") |
| 102 | d := fx.mirror(s) |
| 103 | refreshDoorKeys(t, d) |
| 104 | if rec := wantDoorRecord(t, d, "bae", "titogo", true); rec.Revision != 1 || rec.App != "titogo" { |
| 105 | t.Fatalf("record = %+v", rec) |
| 106 | } |
| 107 | wantDoorRecord(t, d, "bae", "classic", true) |
| 108 | |
| 109 | if err := s.Delete(context.Background(), doorkey.RecordKey("bae", "titogo")); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | refreshDoorKeys(t, d) |
| 113 | wantDoorRecord(t, d, "bae", "titogo", false) |
| 114 | wantDoorRecord(t, d, "bae", "classic", true) |
| 115 | } |
| 116 | |
| 117 | // A failed list keeps the last map, but only for ten minutes after the last |
| 118 | // successful list: then the whole map is cleared. |
| 119 | func TestDoorKeyMirrorClearsTenMinutesAfterTheLastSuccessfulList(t *testing.T) { |
| 120 | fx := newDoorKeyFixture(t) |
| 121 | s := &listFailingStore{Store: meshTestStore(t)} |
| 122 | putJSON(t, s.Store, doorkey.RecordKey("bae", "titogo"), string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| 123 | d := fx.mirror(s) |
| 124 | refreshDoorKeys(t, d) |
| 125 | |
| 126 | s.setFail(true) |
| 127 | fx.clk.advance(9 * time.Minute) |
| 128 | if err := d.Refresh(context.Background()); err == nil { |
| 129 | t.Fatal("a failed list must be reported") |
| 130 | } |
| 131 | wantDoorRecord(t, d, "bae", "titogo", true) |
| 132 | |
| 133 | fx.clk.advance(time.Minute) |
| 134 | if err := d.Refresh(context.Background()); err == nil { |
| 135 | t.Fatal("a failed list must be reported") |
| 136 | } |
| 137 | wantDoorRecord(t, d, "bae", "titogo", false) |
| 138 | |
| 139 | // The bucket comes back: the record is admitted again. |
| 140 | s.setFail(false) |
| 141 | refreshDoorKeys(t, d) |
| 142 | wantDoorRecord(t, d, "bae", "titogo", true) |
| 143 | } |
| 144 | |
| 145 | // The bound holds at read time too: a mirror whose loop has stopped |
| 146 | // refreshing does not keep admitting a record for longer than ten minutes. |
| 147 | func TestDoorKeyMirrorRecordRefusesPastTheBoundWithoutARefresh(t *testing.T) { |
| 148 | fx := newDoorKeyFixture(t) |
| 149 | s := meshTestStore(t) |
| 150 | putJSON(t, s, doorkey.RecordKey("bae", "titogo"), string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| 151 | d := fx.mirror(s) |
| 152 | refreshDoorKeys(t, d) |
| 153 | fx.clk.advance(10 * time.Minute) |
| 154 | wantDoorRecord(t, d, "bae", "titogo", false) |
| 155 | } |
| 156 | |
| 157 | // One record's GET failing keeps that record under the same ten-minute bound, |
| 158 | // measured from its own last successful read. Its neighbours are unaffected. |
| 159 | func TestDoorKeyMirrorKeepsARecordThroughAGetFailureForTenMinutes(t *testing.T) { |
| 160 | fx := newDoorKeyFixture(t) |
| 161 | s := &oneKeyFailingStore{Store: meshTestStore(t)} |
| 162 | putJSON(t, s.Store, doorkey.RecordKey("bae", "titogo"), string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| 163 | putJSON(t, s.Store, doorkey.RecordKey("bae", "classic"), string(fx.signed(t, fx.priv, "bae", "classic", 1))) |
| 164 | d := fx.mirror(s) |
| 165 | refreshDoorKeys(t, d) |
| 166 | |
| 167 | s.failKey(doorkey.RecordKey("bae", "titogo")) |
| 168 | fx.clk.advance(5 * time.Minute) |
| 169 | if err := d.Refresh(context.Background()); err == nil { |
| 170 | t.Fatal("a failed GET must be reported") |
| 171 | } |
| 172 | wantDoorRecord(t, d, "bae", "titogo", true) |
| 173 | wantDoorRecord(t, d, "bae", "classic", true) |
| 174 | |
| 175 | fx.clk.advance(5 * time.Minute) |
| 176 | if err := d.Refresh(context.Background()); err == nil { |
| 177 | t.Fatal("a failed GET must be reported") |
| 178 | } |
| 179 | wantDoorRecord(t, d, "bae", "titogo", false) |
| 180 | wantDoorRecord(t, d, "bae", "classic", true) |
| 181 | } |
| 182 | |
| 183 | // A record that fails verification (signature, shape, expiry) or whose |
| 184 | // payload names another account or app than its path is dropped at once, |
| 185 | // not carried forward like an unreadable one. |
| 186 | func TestDoorKeyMirrorDropsAnUnverifiableRecordAtOnce(t *testing.T) { |
| 187 | fx := newDoorKeyFixture(t) |
| 188 | _, otherPriv, _ := ed25519.GenerateKey(rand.Reader) |
| 189 | now := fx.clk.now() |
| 190 | for _, c := range []struct { |
| 191 | name string |
| 192 | wire func() []byte |
| 193 | }{ |
| 194 | {"signed by another key", func() []byte { return fx.signed(t, otherPriv, "bae", "titogo", 2) }}, |
| 195 | {"not a signed record", func() []byte { return []byte(`{"payload":"e30=","sig":"AAAA"}`) }}, |
| 196 | {"expired", func() []byte { |
| 197 | return fx.signedAt(t, fx.priv, "bae", "titogo", 2, now.Add(-2*time.Hour), now.Add(-time.Hour)) |
| 198 | }}, |
| 199 | {"another app's record", func() []byte { return fx.signed(t, fx.priv, "bae", "other", 2) }}, |
| 200 | {"another account's record", func() []byte { return fx.signed(t, fx.priv, "zed", "titogo", 2) }}, |
| 201 | } { |
| 202 | t.Run(c.name, func(t *testing.T) { |
| 203 | captureLog(t) |
| 204 | s := meshTestStore(t) |
| 205 | key := doorkey.RecordKey("bae", "titogo") |
| 206 | putJSON(t, s, key, string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| 207 | d := fx.mirror(s) |
| 208 | refreshDoorKeys(t, d) |
| 209 | wantDoorRecord(t, d, "bae", "titogo", true) |
| 210 | putJSON(t, s, key, string(c.wire())) |
| 211 | refreshDoorKeys(t, d) |
| 212 | wantDoorRecord(t, d, "bae", "titogo", false) |
| 213 | if c.name == "another app's record" { |
| 214 | wantDoorRecord(t, d, "bae", "other", false) |
| 215 | } |
| 216 | }) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | // Record refuses a record whose expiry has passed at read time, between |
| 221 | // refreshes. |
| 222 | func TestDoorKeyMirrorRecordRefusesAnExpiredRecordAtReadTime(t *testing.T) { |
| 223 | fx := newDoorKeyFixture(t) |
| 224 | s := meshTestStore(t) |
| 225 | now := fx.clk.now() |
| 226 | putJSON(t, s, doorkey.RecordKey("bae", "titogo"), |
| 227 | string(fx.signedAt(t, fx.priv, "bae", "titogo", 1, now, now.Add(time.Minute)))) |
| 228 | d := fx.mirror(s) |
| 229 | refreshDoorKeys(t, d) |
| 230 | wantDoorRecord(t, d, "bae", "titogo", true) |
| 231 | fx.clk.advance(time.Minute) |
| 232 | wantDoorRecord(t, d, "bae", "titogo", false) |
| 233 | } |
| 234 | |
| 235 | // A lower revision than this process last admitted is kept out: the newer |
| 236 | // record stays while it is unexpired, the lower one is logged once, and it is |
| 237 | // never admitted for the life of the process, even after the newer expires. |
| 238 | func TestDoorKeyMirrorKeepsTheNewerRevisionAndLogsTheLowerOnce(t *testing.T) { |
| 239 | buf := captureLog(t) |
| 240 | fx := newDoorKeyFixture(t) |
| 241 | s := meshTestStore(t) |
| 242 | key := doorkey.RecordKey("bae", "titogo") |
| 243 | now := fx.clk.now() |
| 244 | putJSON(t, s, key, string(fx.signedAt(t, fx.priv, "bae", "titogo", 5, now, now.Add(time.Hour)))) |
| 245 | d := fx.mirror(s) |
| 246 | refreshDoorKeys(t, d) |
| 247 | |
| 248 | putJSON(t, s, key, string(fx.signed(t, fx.priv, "bae", "titogo", 4))) |
| 249 | for i := 0; i < 3; i++ { |
| 250 | refreshDoorKeys(t, d) |
| 251 | fx.clk.advance(30 * time.Second) |
| 252 | } |
| 253 | if rec := wantDoorRecord(t, d, "bae", "titogo", true); rec.Revision != 5 { |
| 254 | t.Fatalf("revision %d admitted, want the newer 5 kept", rec.Revision) |
| 255 | } |
| 256 | if n := strings.Count(buf.String(), "revision 4"); n != 1 { |
| 257 | t.Fatalf("the lower revision was logged %d times, want once:\n%s", n, buf.String()) |
| 258 | } |
| 259 | |
| 260 | // The kept record expires; the lower one is still refused. |
| 261 | fx.clk.advance(time.Hour) |
| 262 | refreshDoorKeys(t, d) |
| 263 | wantDoorRecord(t, d, "bae", "titogo", false) |
| 264 | |
| 265 | // An equal revision (a renewal) and a higher one are admitted. |
| 266 | putJSON(t, s, key, string(fx.signed(t, fx.priv, "bae", "titogo", 5))) |
| 267 | refreshDoorKeys(t, d) |
| 268 | wantDoorRecord(t, d, "bae", "titogo", true) |
| 269 | putJSON(t, s, key, string(fx.signed(t, fx.priv, "bae", "titogo", 6))) |
| 270 | refreshDoorKeys(t, d) |
| 271 | if rec := wantDoorRecord(t, d, "bae", "titogo", true); rec.Revision != 6 { |
| 272 | t.Fatalf("revision %d, want 6", rec.Revision) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | func TestDoorKeyMirrorNilIsInert(t *testing.T) { |
| 277 | var d *doorKeyMirror |
| 278 | if _, ok := d.Record("bae", "titogo"); ok { |
| 279 | t.Fatal("a nil mirror admitted a record") |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // run polls on its own loop until stop closes. |
| 284 | func TestDoorKeyMirrorRunPollsUntilStopped(t *testing.T) { |
| 285 | captureLog(t) |
| 286 | old := doorKeyInterval |
| 287 | doorKeyInterval = 10 * time.Millisecond |
| 288 | t.Cleanup(func() { doorKeyInterval = old }) |
| 289 | fx := newDoorKeyFixture(t) |
| 290 | s := meshTestStore(t) |
| 291 | d := fx.mirror(s) |
| 292 | stop := make(chan struct{}) |
| 293 | done := make(chan struct{}) |
| 294 | go func() { d.run(stop); close(done) }() |
| 295 | putJSON(t, s, doorkey.RecordKey("bae", "titogo"), string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| 296 | deadline := time.Now().Add(5 * time.Second) |
| 297 | for { |
| 298 | if _, ok := d.Record("bae", "titogo"); ok { |
| 299 | break |
| 300 | } |
| 301 | if time.Now().After(deadline) { |
| 302 | t.Fatal("run never admitted the record") |
| 303 | } |
| 304 | time.Sleep(5 * time.Millisecond) |
| 305 | } |
| 306 | close(stop) |
| 307 | select { |
| 308 | case <-done: |
| 309 | case <-time.After(5 * time.Second): |
| 310 | t.Fatal("run did not return after stop") |
| 311 | } |
| 312 | } |
| 313 | |