| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/objstore" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | func scanURL(fx *consoleFixture, app string) string { return storeURL(fx, app) + "/scan" } |
| 16 | |
| 17 | // grantStore drives the real operator grant so the store is provisioned — the |
| 18 | // state every scan verb but `request` needs to act on. The app's OWN store: |
| 19 | // grantStoreIn (store_env_estate_test.go) is this for any store of the app, |
| 20 | // and carries the implementation, so the two cannot drift. |
| 21 | func grantStore(t *testing.T, fx *consoleFixture, token string) { |
| 22 | t.Helper() |
| 23 | grantStoreIn(t, fx, token, "") |
| 24 | } |
| 25 | |
| 26 | // scanArmed is storeArmed plus a granted store and a working engine. |
| 27 | func scanArmed(t *testing.T, code string) (*consoleFixture, *objstore.MemScanner, string) { |
| 28 | t.Helper() |
| 29 | fx, _, token, _ := storeArmed(t, code) |
| 30 | grantStore(t, fx, token) |
| 31 | sc := objstore.NewMemScanner() |
| 32 | fx.srv.cfg.StoreScanner = sc |
| 33 | enableDeploymentScanning(t, fx) |
| 34 | return fx, sc, token |
| 35 | } |
| 36 | |
| 37 | // requestScanning is the member's half. An operator cannot enable an engine |
| 38 | // without it — the attestation it carries is what says whether these objects |
| 39 | // can be read at all. |
| 40 | func requestScanning(t *testing.T, fx *consoleFixture, token string, encrypted bool) { |
| 41 | t.Helper() |
| 42 | if s, o := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": encrypted}, token); s != http.StatusOK { |
| 43 | t.Fatalf("scan request = %d %v", s, o) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // The member gate runs FIRST: a stranger gets the same byte-identical 404 the |
| 48 | // store doors give, never a 403 that would confirm the app exists. |
| 49 | func TestScanDoorsGateOrder(t *testing.T) { |
| 50 | fx, _, token, _ := storeArmed(t, "code-scan-gate") |
| 51 | fx.srv.cfg.StoreOperators = []string{"eve@keymail.dev", "alice@keymail.dev"} |
| 52 | eve := cliToken(t, fx, "eve@keymail.dev", "code-scan-gate-eve") |
| 53 | |
| 54 | for _, door := range []string{"", "/grant", "/revoke", "/sweep", "/enforce", "/callback-key/rotate"} { |
| 55 | t.Run("scan"+door, func(t *testing.T) { |
| 56 | // (a) an operator who is a member of nothing, against a real app. |
| 57 | statusA, bodyA := postRaw(t, scanURL(fx, "titogo")+door, map[string]any{}, eve) |
| 58 | // (b) a member, against an account that does not exist. |
| 59 | statusB, bodyB := postRaw(t, fx.ts.URL+"/api/cli/apps/zzzz/titogo/store/scan"+door, map[string]any{}, token) |
| 60 | // (c) a member of the account, against an app it never claimed. |
| 61 | statusC, bodyC := postRaw(t, scanURL(fx, "ghost-app")+door, map[string]any{}, token) |
| 62 | if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound { |
| 63 | t.Fatalf("scan%s gate = %d / %d / %d, want 404 for all three", door, statusA, statusB, statusC) |
| 64 | } |
| 65 | if bodyA != bodyB || bodyB != bodyC { |
| 66 | t.Fatalf("scan%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", door, bodyA, bodyB, bodyC) |
| 67 | } |
| 68 | if bodyA != "404 page not found\n" { |
| 69 | t.Fatalf("scan%s refusal body = %q, want the stdlib http.NotFound output", door, bodyA) |
| 70 | } |
| 71 | }) |
| 72 | } |
| 73 | if status, _ := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, ""); status != http.StatusUnauthorized { |
| 74 | t.Fatalf("scan request with no token = %d, want 401", status) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // grant and sweep 503 naming CARLOS_STORE_SCANNER when no engine is built. |
| 79 | func TestScanGrantRequires503WithoutScanner(t *testing.T) { |
| 80 | fx, _, token, _ := storeArmed(t, "code-scan-noengine") |
| 81 | grantStore(t, fx, token) |
| 82 | fx.srv.cfg.StoreScanner = nil |
| 83 | |
| 84 | for _, door := range []string{"/grant", "/sweep"} { |
| 85 | status, out := postJSON(t, scanURL(fx, "titogo")+door, map[string]any{}, token) |
| 86 | if status != http.StatusServiceUnavailable { |
| 87 | t.Fatalf("scan%s with no engine = %d %v, want 503", door, status, out) |
| 88 | } |
| 89 | if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_CLAMD_ADDR") { |
| 90 | t.Fatalf("scan%s error = %q, want the wiring variable named", door, msg) |
| 91 | } |
| 92 | } |
| 93 | // enforce ON also needs one — the tag denied on comes from the engine. |
| 94 | status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token) |
| 95 | if status != http.StatusServiceUnavailable { |
| 96 | t.Fatalf("enforce on with no engine = %d %v, want 503", status, out) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | // request, revoke and enforce off work with NO engine built. Otherwise |
| 101 | // unsetting the scanner on a deployment with an enforcing record would leave |
| 102 | // the deny unclearable and the store unrepairable — the door that fixes it |
| 103 | // refusing for the very reason it needs fixing. |
| 104 | func TestScanRevokeAndEnforceOffWorkWithoutScanner(t *testing.T) { |
| 105 | fx, _, token := scanArmed(t, "code-scan-nodeadlock") |
| 106 | requestScanning(t, fx, token, false) |
| 107 | |
| 108 | // Get into the bad state honestly: enable and enforce with an engine. |
| 109 | if status, out := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK { |
| 110 | t.Fatalf("scan grant = %d %v", status, out) |
| 111 | } |
| 112 | if status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token); status != http.StatusOK { |
| 113 | t.Fatalf("enforce on = %d %v", status, out) |
| 114 | } |
| 115 | if rec := storedRecord(t, fx, "titogo"); rec.Scanning == nil || rec.Scanning.Enforce == nil { |
| 116 | t.Fatal("setup: enforcement was never recorded") |
| 117 | } |
| 118 | |
| 119 | // Now the engine goes away, exactly as unsetting the variable would do. |
| 120 | fx.srv.cfg.StoreScanner = nil |
| 121 | |
| 122 | if status, out := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, token); status != http.StatusOK { |
| 123 | t.Fatalf("scan request with no engine = %d %v, want 200", status, out) |
| 124 | } |
| 125 | if status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": false}, token); status != http.StatusOK { |
| 126 | t.Fatalf("enforce off with no engine = %d %v, want 200", status, out) |
| 127 | } |
| 128 | if rec := storedRecord(t, fx, "titogo"); rec.Scanning.Enforce != nil { |
| 129 | t.Fatal("enforce off left the deny standing on the record") |
| 130 | } |
| 131 | if status, out := postJSON(t, scanURL(fx, "titogo")+"/revoke", map[string]any{}, token); status != http.StatusOK { |
| 132 | t.Fatalf("revoke with no engine = %d %v, want 200", status, out) |
| 133 | } |
| 134 | if rec := storedRecord(t, fx, "titogo"); rec.Scanning.Enabled != nil { |
| 135 | t.Fatal("revoke left the engine recorded") |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // Non-operators cannot grant, and the check runs after the member gate. |
| 140 | func TestScanGrantRequiresOperator(t *testing.T) { |
| 141 | fx, _, token := scanArmed(t, "code-scan-operator") |
| 142 | fx.srv.cfg.StoreOperators = []string{"someone-else@keymail.dev"} |
| 143 | |
| 144 | for _, door := range []string{"/grant", "/revoke", "/sweep", "/enforce"} { |
| 145 | status, out := postJSON(t, scanURL(fx, "titogo")+door, map[string]any{"on": false}, token) |
| 146 | if status != http.StatusForbidden { |
| 147 | t.Fatalf("scan%s as a non-operator = %d %v, want 403", door, status, out) |
| 148 | } |
| 149 | if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_STORE_OPERATORS") { |
| 150 | t.Fatalf("scan%s error = %q, want the operator variable named", door, msg) |
| 151 | } |
| 152 | } |
| 153 | // The member's own verbs are unaffected: requesting and reading status are |
| 154 | // theirs, and always were. |
| 155 | if status, out := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, token); status != http.StatusOK { |
| 156 | t.Fatalf("scan request as a member = %d %v, want 200", status, out) |
| 157 | } |
| 158 | if status, out := getJSON(t, scanURL(fx, "titogo"), token); status != http.StatusOK { |
| 159 | t.Fatalf("scan status as a member = %d %v, want 200", status, out) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | // A client-encrypted store refuses enforce: nothing will ever be tagged |
| 164 | // infected, so it would be theatre — a record claiming a control that cannot |
| 165 | // possibly act. |
| 166 | func TestScanEnforceRefusedOnEncryptedStore(t *testing.T) { |
| 167 | fx, _, token := scanArmed(t, "code-scan-encrypted") |
| 168 | |
| 169 | if status, out := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": true}, token); status != http.StatusOK { |
| 170 | t.Fatalf("scan request = %d %v", status, out) |
| 171 | } |
| 172 | if status, out := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK { |
| 173 | t.Fatalf("scan grant = %d %v", status, out) |
| 174 | } |
| 175 | |
| 176 | status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token) |
| 177 | if status != http.StatusConflict { |
| 178 | t.Fatalf("enforce on an encrypted store = %d %v, want 409", status, out) |
| 179 | } |
| 180 | if msg := str(t, out, "error"); !strings.Contains(msg, "encrypted") { |
| 181 | t.Fatalf("error = %q, want it to say why", msg) |
| 182 | } |
| 183 | if rec := storedRecord(t, fx, "titogo"); rec.Scanning.Enforce != nil { |
| 184 | t.Fatal("a refused enforce still wrote the deny") |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // The attestation has no default. "Are these objects readable" is the question |
| 189 | // the whole coverage claim rests on, and neither answer is safe to assume. |
| 190 | func TestScanRequestRequiresAttestation(t *testing.T) { |
| 191 | fx, _, token := scanArmed(t, "code-scan-attest") |
| 192 | |
| 193 | status, out := postJSON(t, scanURL(fx, "titogo"), map[string]any{}, token) |
| 194 | if status != http.StatusBadRequest { |
| 195 | t.Fatalf("request with no attestation = %d %v, want 400", status, out) |
| 196 | } |
| 197 | if msg := str(t, out, "error"); !strings.Contains(msg, "--client-encrypted") { |
| 198 | t.Fatalf("error = %q, want it to name the flags", msg) |
| 199 | } |
| 200 | if rec := storedRecord(t, fx, "titogo"); rec.Scanning != nil && rec.Scanning.Requested != nil { |
| 201 | t.Fatal("a refused request still recorded an attestation") |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // Enforcement actually reaches the app's IAM policy, not just the record. A |
| 206 | // record that said enforcing while the live policy allowed reads would be |
| 207 | // worse than never having offered the switch. |
| 208 | func TestScanEnforceWritesTheDenyToThePolicy(t *testing.T) { |
| 209 | fx, fake, token, _ := storeArmed(t, "code-scan-policy") |
| 210 | grantStore(t, fx, token) |
| 211 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 212 | enableDeploymentScanning(t, fx) |
| 213 | requestScanning(t, fx, token, false) |
| 214 | |
| 215 | if status, out := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK { |
| 216 | t.Fatalf("scan grant = %d %v", status, out) |
| 217 | } |
| 218 | user := objstore.UserName(fx.sqid, "titogo") |
| 219 | |
| 220 | // The ENFORCEMENT deny specifically, which is the one keyed on the verdict |
| 221 | // tag. Every policy also carries the two anti-forgery Denys, unconditional |
| 222 | // and unrelated to this switch, so `"Effect":"Deny"` — what this test used |
| 223 | // to look for — no longer tells the two states apart. It would now report |
| 224 | // enforcement on from the moment the store was granted. |
| 225 | const enforcementDeny = "s3:ExistingObjectTag" |
| 226 | |
| 227 | if strings.Contains(fake.PolicyFor(user), enforcementDeny) { |
| 228 | t.Fatal("enabling the engine alone wrote an enforcement deny — enforcement is a separate switch") |
| 229 | } |
| 230 | |
| 231 | if status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token); status != http.StatusOK { |
| 232 | t.Fatalf("enforce on = %d %v", status, out) |
| 233 | } |
| 234 | if !strings.Contains(fake.PolicyFor(user), enforcementDeny) { |
| 235 | t.Fatalf("enforce on never reached the policy:\n%s", fake.PolicyFor(user)) |
| 236 | } |
| 237 | |
| 238 | if status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": false}, token); status != http.StatusOK { |
| 239 | t.Fatalf("enforce off = %d %v", status, out) |
| 240 | } |
| 241 | if strings.Contains(fake.PolicyFor(user), enforcementDeny) { |
| 242 | t.Fatalf("enforce off left a live enforcement deny standing:\n%s", fake.PolicyFor(user)) |
| 243 | } |
| 244 | // And turning enforcement off must not have taken the anti-forgery Denys |
| 245 | // with it: a store that has stopped denying infected reads is exactly |
| 246 | // where a forged clean tag would sit unnoticed longest. |
| 247 | if !strings.Contains(fake.PolicyFor(user), "s3:RequestObjectTagKeys") { |
| 248 | t.Fatalf("enforce off dropped the anti-forgery denys:\n%s", fake.PolicyFor(user)) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // Enforcement before an engine is enabled is refused. Otherwise a store could |
| 253 | // carry a Deny keyed on a tag nothing will ever write, which denies nothing |
| 254 | // and reads in the record as protection. |
| 255 | func TestScanEnforceRefusedBeforeGrant(t *testing.T) { |
| 256 | fx, _, token := scanArmed(t, "code-scan-enforce-early") |
| 257 | |
| 258 | status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token) |
| 259 | if status != http.StatusConflict { |
| 260 | t.Fatalf("enforce before scan grant = %d %v, want 409", status, out) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // Status reports the engine and its signature database. A clean verdict from |
| 265 | // three-month-old signatures is not the same claim as a fresh one. |
| 266 | func TestScanStatusCarriesProvenance(t *testing.T) { |
| 267 | fx, _, token := scanArmed(t, "code-scan-status") |
| 268 | |
| 269 | status, out := getJSON(t, scanURL(fx, "titogo"), token) |
| 270 | if status != http.StatusOK { |
| 271 | t.Fatalf("scan status = %d %v", status, out) |
| 272 | } |
| 273 | if got := str(t, out, "engine"); got != "clamav" { |
| 274 | t.Fatalf("engine = %q", got) |
| 275 | } |
| 276 | if str(t, out, "signature") == "" { |
| 277 | t.Fatal("status carries no signature-database version") |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | // `carlos store status` carries the scan block once it exists. Additive only: |
| 282 | // release.PurgeApp parses this same object with its own struct and freezes the |
| 283 | // grant and provisioned names, so a new key is safe and a renamed one is a |
| 284 | // purge that stops working. |
| 285 | func TestStoreStatusCarriesScanningBlock(t *testing.T) { |
| 286 | fx, _, token := scanArmed(t, "code-store-status-scan") |
| 287 | |
| 288 | status, out := getJSON(t, storeURL(fx, "titogo"), token) |
| 289 | if status != http.StatusOK { |
| 290 | t.Fatalf("store status = %d %v", status, out) |
| 291 | } |
| 292 | if _, present := out["scanning"]; !present { |
| 293 | t.Fatal("store status carries no scanning key at all") |
| 294 | } |
| 295 | if out["scanning"] != nil { |
| 296 | t.Fatalf("a store nobody requested scanning for reported %v", out["scanning"]) |
| 297 | } |
| 298 | |
| 299 | if s, o := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, token); s != http.StatusOK { |
| 300 | t.Fatalf("scan request = %d %v", s, o) |
| 301 | } |
| 302 | if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK { |
| 303 | t.Fatalf("scan grant = %d %v", s, o) |
| 304 | } |
| 305 | |
| 306 | status, out = getJSON(t, storeURL(fx, "titogo"), token) |
| 307 | if status != http.StatusOK { |
| 308 | t.Fatalf("store status = %d %v", status, out) |
| 309 | } |
| 310 | sc, ok := out["scanning"].(map[string]any) |
| 311 | if !ok { |
| 312 | t.Fatalf("scanning = %#v, want an object", out["scanning"]) |
| 313 | } |
| 314 | if _, ok := sc["enabled"]; !ok { |
| 315 | t.Fatalf("scanning block does not report the enabled engine: %v", sc) |
| 316 | } |
| 317 | // The grant and provisioned names PurgeApp parses must still be there and |
| 318 | // still be spelled the same. |
| 319 | for _, frozen := range []string{"grant", "provisioned", "declared"} { |
| 320 | if _, present := out[frozen]; !present { |
| 321 | t.Fatalf("the %q key PurgeApp parses is gone from the reply", frozen) |
| 322 | } |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | // Enabling an engine requires the MEMBER's request, and specifically their |
| 327 | // attestation. Without one, Requested stays nil — and nil reads as "not |
| 328 | // encrypted" everywhere downstream, so a client-encrypted store nobody |
| 329 | // attested would be swept and reported entirely clean. It also skips the |
| 330 | // consent point: this engine reads tenant object bytes. |
| 331 | func TestScanGrantRefusedWithoutMemberRequest(t *testing.T) { |
| 332 | fx, _, token := scanArmed(t, "code-scan-no-request") |
| 333 | |
| 334 | status, out := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token) |
| 335 | if status != http.StatusConflict { |
| 336 | t.Fatalf("scan grant with no member request = %d %v, want 409", status, out) |
| 337 | } |
| 338 | if msg := str(t, out, "error"); !strings.Contains(msg, "scan request") { |
| 339 | t.Fatalf("error = %q, want it to name the verb the member runs", msg) |
| 340 | } |
| 341 | if rec := storedRecord(t, fx, "titogo"); rec.Scanning != nil && rec.Scanning.Enabled != nil { |
| 342 | t.Fatal("a refused grant still enabled the engine") |
| 343 | } |
| 344 | |
| 345 | // With the attestation on record it goes through. |
| 346 | if s, o := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, token); s != http.StatusOK { |
| 347 | t.Fatalf("scan request = %d %v", s, o) |
| 348 | } |
| 349 | if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK { |
| 350 | t.Fatalf("scan grant after a request = %d %v, want 200", s, o) |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | // Status says so when the record names an engine this deployment no longer |
| 355 | // has. Silence there reads as health: the reply would carry the engine, the |
| 356 | // enforcement state and the coverage, and never mention that nothing runs. |
| 357 | func TestScanStatusReportsMissingEngineOnEnabledRecord(t *testing.T) { |
| 358 | fx, _, token := scanArmed(t, "code-scan-engine-gone") |
| 359 | if s, o := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, token); s != http.StatusOK { |
| 360 | t.Fatalf("scan request = %d %v", s, o) |
| 361 | } |
| 362 | if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK { |
| 363 | t.Fatalf("scan grant = %d %v", s, o) |
| 364 | } |
| 365 | fx.srv.cfg.StoreScanner = nil |
| 366 | |
| 367 | status, out := getJSON(t, scanURL(fx, "titogo"), token) |
| 368 | if status != http.StatusOK { |
| 369 | t.Fatalf("scan status = %d %v", status, out) |
| 370 | } |
| 371 | msg, _ := out["engine_error"].(string) |
| 372 | if !strings.Contains(msg, "CARLOS_CLAMD_ADDR") { |
| 373 | t.Fatalf("engine_error = %q, want it to say no engine is configured", msg) |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | // `disable` has to disable everywhere, or it disables nothing. |
| 378 | // |
| 379 | // The hourly pass going quiet is not the same as scanning being off: the sweep |
| 380 | // verb and the grant door reach the engine by their own paths. Without these |
| 381 | // checks an operator could disable scanning, watch the tick stop, and still |
| 382 | // have `carlos store scan sweep` read tenant bytes. |
| 383 | func TestDeploymentDisableStopsEveryPath(t *testing.T) { |
| 384 | fx, _, token := scanArmed(t, "code-scan-disabled") |
| 385 | requestScanning(t, fx, token, false) |
| 386 | |
| 387 | if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK { |
| 388 | t.Fatalf("setup grant = %d %v", s, o) |
| 389 | } |
| 390 | |
| 391 | // Now switch the deployment off, the way `carlos system antivirus disable` |
| 392 | // will. The engine stays wired — this is policy, not wiring. |
| 393 | disableDeploymentScanning(t, fx) |
| 394 | |
| 395 | for _, door := range []string{"/grant", "/sweep"} { |
| 396 | status, out := postJSON(t, scanURL(fx, "titogo")+door, map[string]any{}, token) |
| 397 | if status != http.StatusServiceUnavailable { |
| 398 | t.Fatalf("scan%s while disabled = %d %v, want 503", door, status, out) |
| 399 | } |
| 400 | msg := str(t, out, "error") |
| 401 | if !strings.Contains(msg, "carlos system antivirus enable") { |
| 402 | t.Fatalf("scan%s error = %q, want it to name the command that fixes it", door, msg) |
| 403 | } |
| 404 | // And NOT the wiring variable: an operator whose engine is wired and |
| 405 | // whose record is off must not be sent to edit a unit file. |
| 406 | if strings.Contains(msg, "CARLOS_CLAMD_ADDR") { |
| 407 | t.Fatalf("scan%s conflated a disabled record with missing wiring: %q", door, msg) |
| 408 | } |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | // The two 503s stay distinct. Wired-but-off and not-wired want different |
| 413 | // actions, and a single message would send someone to the wrong one. |
| 414 | func TestDisabledAndUnwiredAreDifferent503s(t *testing.T) { |
| 415 | fx, _, token := scanArmed(t, "code-scan-two-503s") |
| 416 | requestScanning(t, fx, token, false) |
| 417 | |
| 418 | disableDeploymentScanning(t, fx) |
| 419 | _, offBody := postJSON(t, scanURL(fx, "titogo")+"/sweep", map[string]any{}, token) |
| 420 | offMsg := str(t, offBody, "error") |
| 421 | |
| 422 | enableDeploymentScanning(t, fx) |
| 423 | fx.srv.cfg.StoreScanner = nil |
| 424 | _, unwiredBody := postJSON(t, scanURL(fx, "titogo")+"/sweep", map[string]any{}, token) |
| 425 | unwiredMsg := str(t, unwiredBody, "error") |
| 426 | |
| 427 | if offMsg == unwiredMsg { |
| 428 | t.Fatalf("both states answer identically: %q", offMsg) |
| 429 | } |
| 430 | if !strings.Contains(unwiredMsg, "CARLOS_CLAMD_ADDR") { |
| 431 | t.Fatalf("the unwired message does not name the wiring: %q", unwiredMsg) |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | // RunSweep itself refuses, not merely the door in front of it. The sweep verb |
| 436 | // is not the only caller, and a check that lived only in a handler would be a |
| 437 | // check the next caller forgets. |
| 438 | func TestRunSweepRefusesWhenDeploymentDisabled(t *testing.T) { |
| 439 | ctx := context.Background() |
| 440 | fx := newConsoleFixture(t) |
| 441 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 442 | enableStoreRecordOnly(t, fx, fixtureAcct, fixtureApp) |
| 443 | // No deployment record at all: absent means not enabled. |
| 444 | |
| 445 | _, err := objstore.RunSweep(ctx, fx.rel, &countingLister{keys: []string{"a.txt"}}, |
| 446 | objstore.NewMemScanner(), fixtureAcct, fixtureApp, time.Minute) |
| 447 | if !errors.Is(err, objstore.ErrScanningDisabled) { |
| 448 | t.Fatalf("RunSweep with no deployment record = %v, want ErrScanningDisabled", err) |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | // The member's own verbs keep working while scanning is disabled. Otherwise |
| 453 | // disabling would strand every enforcing record, with the door that clears it |
| 454 | // refusing for the very reason it needs using. |
| 455 | func TestMemberVerbsSurviveDeploymentDisable(t *testing.T) { |
| 456 | fx, _, token := scanArmed(t, "code-scan-disabled-member") |
| 457 | requestScanning(t, fx, token, false) |
| 458 | if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK { |
| 459 | t.Fatalf("setup grant = %d %v", s, o) |
| 460 | } |
| 461 | if s, o := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token); s != http.StatusOK { |
| 462 | t.Fatalf("setup enforce = %d %v", s, o) |
| 463 | } |
| 464 | |
| 465 | disableDeploymentScanning(t, fx) |
| 466 | |
| 467 | if s, o := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, token); s != http.StatusOK { |
| 468 | t.Fatalf("request while disabled = %d %v", s, o) |
| 469 | } |
| 470 | if s, o := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": false}, token); s != http.StatusOK { |
| 471 | t.Fatalf("enforce off while disabled = %d %v", s, o) |
| 472 | } |
| 473 | if s, o := postJSON(t, scanURL(fx, "titogo")+"/revoke", map[string]any{}, token); s != http.StatusOK { |
| 474 | t.Fatalf("revoke while disabled = %d %v", s, o) |
| 475 | } |
| 476 | if rec := storedRecord(t, fx, "titogo"); rec.Scanning.Enforce != nil { |
| 477 | t.Fatal("enforce off left a live deny while scanning was disabled") |
| 478 | } |
| 479 | } |
| 480 | |
| 481 | // disableDeploymentScanning flips the switch off, leaving the engine wired. |
| 482 | func disableDeploymentScanning(t *testing.T, fx *consoleFixture) { |
| 483 | t.Helper() |
| 484 | ctx := context.Background() |
| 485 | av, etag, err := release.GetAntivirus(ctx, fx.rel) |
| 486 | if err != nil { |
| 487 | t.Fatal(err) |
| 488 | } |
| 489 | if av == nil { |
| 490 | av = &release.AntivirusSettings{Engine: "clamav"} |
| 491 | } |
| 492 | av.Enabled = false |
| 493 | av.At = time.Now().UTC() |
| 494 | av.By = "test" |
| 495 | if _, err := release.PutAntivirus(ctx, fx.rel, av, etag); err != nil { |
| 496 | t.Fatal(err) |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | // A per-app record saying `enabled: clamav` while the deployment switch is off |
| 501 | // describes a control that is not running. `carlos store scan status` prints |
| 502 | // this reply, so omitting the deployment half is a new way for the status |
| 503 | // output to lie — the same lie the engine_error branch already refuses to tell |
| 504 | // about the wiring half. |
| 505 | func TestStoreScanStatusReportsDeploymentState(t *testing.T) { |
| 506 | ctx := context.Background() |
| 507 | fx, _, token := scanArmed(t, "code-scan-depstate") |
| 508 | requestScanning(t, fx, token, false) |
| 509 | if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK { |
| 510 | t.Fatalf("grant = %d %v", s, o) |
| 511 | } |
| 512 | |
| 513 | status, out := getJSON(t, scanURL(fx, "titogo"), token) |
| 514 | if status != http.StatusOK { |
| 515 | t.Fatalf("status = %d %v", status, out) |
| 516 | } |
| 517 | if on, _ := out["deployment_enabled"].(bool); !on { |
| 518 | t.Fatalf("deployment_enabled = %v with the switch on", out["deployment_enabled"]) |
| 519 | } |
| 520 | if _, bad := out["deployment_error"]; bad { |
| 521 | t.Fatalf("a healthy deployment reported %v", out["deployment_error"]) |
| 522 | } |
| 523 | |
| 524 | // Now turn the deployment off underneath an app whose own record still |
| 525 | // says enabled. Nothing sweeps it, and the status has to say so. |
| 526 | av, etag, err := release.GetAntivirus(ctx, fx.rel) |
| 527 | if err != nil || av == nil { |
| 528 | t.Fatalf("record = %+v, %v", av, err) |
| 529 | } |
| 530 | av.Enabled = false |
| 531 | if _, err := release.PutAntivirus(ctx, fx.rel, av, etag); err != nil { |
| 532 | t.Fatal(err) |
| 533 | } |
| 534 | |
| 535 | _, out = getJSON(t, scanURL(fx, "titogo"), token) |
| 536 | if on, _ := out["deployment_enabled"].(bool); on { |
| 537 | t.Fatal("deployment_enabled stayed true after a disable") |
| 538 | } |
| 539 | msg := str(t, out, "deployment_error") |
| 540 | if !strings.Contains(msg, "carlos system antivirus enable") { |
| 541 | t.Fatalf("deployment_error = %q, want the command that fixes it", msg) |
| 542 | } |
| 543 | // The per-app record is UNCHANGED — the two facts are reported |
| 544 | // separately, never collapsed. |
| 545 | if out["scanning"] == nil { |
| 546 | t.Fatal("the per-app record vanished from the reply") |
| 547 | } |
| 548 | } |
| 549 | |
| 550 | func TestStoreScanStatusReportsQueueDepth(t *testing.T) { |
| 551 | fx, _, token := scanArmed(t, "code-scan-queue-depth") |
| 552 | requestScanning(t, fx, token, false) |
| 553 | if _, err := objstore.Enqueue(context.Background(), fx.rel, fx.srv.scanQueue, |
| 554 | fixtureAcct, "titogo", &objstore.QueueItem{Key: "uploads/a.pdf", By: "test"}, time.Now()); err != nil { |
| 555 | t.Fatal(err) |
| 556 | } |
| 557 | |
| 558 | status, out := getJSON(t, scanURL(fx, "titogo"), token) |
| 559 | if status != http.StatusOK { |
| 560 | t.Fatalf("status = %d %v", status, out) |
| 561 | } |
| 562 | if got := int(out["queue_depth"].(float64)); got != 1 { |
| 563 | t.Fatalf("queue_depth = %d, want 1", got) |
| 564 | } |
| 565 | } |
| 566 | |