|
5 | 5 | "os" |
6 | 6 | "path/filepath" |
7 | 7 | "testing" |
| 8 | + "time" |
8 | 9 |
|
9 | 10 | "github.com/pilot-protocol/pilotprotocol/internal/catalogtrust" |
10 | 11 | ) |
@@ -113,6 +114,82 @@ func TestProvider_RefreshPublisherAndCache(t *testing.T) { |
113 | 114 | } |
114 | 115 | } |
115 | 116 |
|
| 117 | +func TestProvider_RefreshOnMissPicksUpNewlyPinnedApp(t *testing.T) { |
| 118 | + dir := t.TempDir() |
| 119 | + cat := filepath.Join(dir, "catalogue.json") |
| 120 | + url := "file://" + cat |
| 121 | + |
| 122 | + // writeSigned writes the catalogue body + a fresh valid .sig and returns a |
| 123 | + // restore for the ephemeral key swap. |
| 124 | + writeSigned := func(body string) func() { |
| 125 | + if err := os.WriteFile(cat, []byte(body), 0o600); err != nil { |
| 126 | + t.Fatal(err) |
| 127 | + } |
| 128 | + sig, restore := catalogtrust.SignWithEphemeralKey([]byte(body)) |
| 129 | + if err := os.WriteFile(cat+".sig", []byte(base64.StdEncoding.EncodeToString(sig)), 0o600); err != nil { |
| 130 | + t.Fatal(err) |
| 131 | + } |
| 132 | + return restore |
| 133 | + } |
| 134 | + |
| 135 | + const onlyA = `{"version":2,"apps":[{"id":"io.test.a","publisher":"ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI="}]}` |
| 136 | + const aAndB = `{"version":2,"apps":[ |
| 137 | + {"id":"io.test.a","publisher":"ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI="}, |
| 138 | + {"id":"io.test.b","publisher":"ed25519:VF8fdEP/Oe2aWN3ozQ7Ar22137tHb7dkSw0hlzlk/os="}]}` |
| 139 | + |
| 140 | + restore1 := writeSigned(onlyA) |
| 141 | + |
| 142 | + p := NewProvider(url, "") |
| 143 | + p.minRefreshGap = 0 // allow a miss to refresh immediately (no cooldown in the test) |
| 144 | + if err := p.Refresh(); err != nil { |
| 145 | + t.Fatalf("initial Refresh: %v", err) |
| 146 | + } |
| 147 | + // Assert B is not pinned yet by reading the map directly — using Publisher here |
| 148 | + // would kick off a background refresh that races the re-sign below (the test's |
| 149 | + // signing key is process-global; production never swaps it at runtime). |
| 150 | + p.mu.RLock() |
| 151 | + _, hasB := p.pins["io.test.b"] |
| 152 | + p.mu.RUnlock() |
| 153 | + if hasB { |
| 154 | + t.Fatal("io.test.b must not be pinned before it is added to the catalogue") |
| 155 | + } |
| 156 | + restore1() // no refresh in flight here; safe to restore the key |
| 157 | + |
| 158 | + // Pin B in the catalogue (re-signed). A running daemon would not see it until |
| 159 | + // the next periodic refresh — but a Publisher miss should refetch. From here on |
| 160 | + // this is the only signing key live, so background refreshes don't race it. |
| 161 | + restore2 := writeSigned(aAndB) |
| 162 | + defer restore2() |
| 163 | + |
| 164 | + p.Publisher("io.test.b") // miss → triggers background refresh |
| 165 | + got := false |
| 166 | + for i := 0; i < 200; i++ { |
| 167 | + if _, ok := p.Publisher("io.test.b"); ok { |
| 168 | + got = true |
| 169 | + break |
| 170 | + } |
| 171 | + time.Sleep(5 * time.Millisecond) |
| 172 | + } |
| 173 | + if !got { |
| 174 | + t.Fatal("refresh-on-miss did not pick up the newly-pinned app within 1s") |
| 175 | + } |
| 176 | + |
| 177 | + // Drain: stop new refreshes and wait for any in-flight one to finish before the |
| 178 | + // deferred restore swaps the global signing key (else the swap races the read). |
| 179 | + p.refreshMu.Lock() |
| 180 | + p.minRefreshGap = time.Hour |
| 181 | + p.refreshMu.Unlock() |
| 182 | + for i := 0; i < 200; i++ { |
| 183 | + p.refreshMu.Lock() |
| 184 | + inFlight := p.refreshInFlight |
| 185 | + p.refreshMu.Unlock() |
| 186 | + if !inFlight { |
| 187 | + break |
| 188 | + } |
| 189 | + time.Sleep(5 * time.Millisecond) |
| 190 | + } |
| 191 | +} |
| 192 | + |
116 | 193 | func TestProvider_NilCacheAndFailClosed(t *testing.T) { |
117 | 194 | // A provider that has never loaded anything reports nothing pinned. |
118 | 195 | p := NewProvider("file:///nonexistent", "") |
|
0 commit comments