| 1 | package caps |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // TestUnknownCapabilityNameIsRefused pins the fail-closed half of spec §3b: |
| 11 | // a capability name this binary does not know must refuse the whole block, |
| 12 | // naming the capability and the fix — never silently drop it. |
| 13 | func TestUnknownCapabilityNameIsRefused(t *testing.T) { |
| 14 | var s Set |
| 15 | err := json.Unmarshal([]byte(`{"v":1,"daemon":true,"gpu":true}`), &s) |
| 16 | if err == nil || !strings.Contains(err.Error(), "gpu") { |
| 17 | t.Fatalf("err = %v, want a refusal naming gpu", err) |
| 18 | } |
| 19 | if !strings.Contains(err.Error(), "update the platform binary") { |
| 20 | t.Fatalf("refusal %q does not name the fix", err) |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | // TestUnknownFieldInsideAKnownCapabilityIsIgnoredAndNoted pins the tolerant |
| 25 | // half of spec §3b: room to grow a known capability additively without |
| 26 | // breaking an older binary that already understands its other fields. |
| 27 | func TestUnknownFieldInsideAKnownCapabilityIsIgnoredAndNoted(t *testing.T) { |
| 28 | var s Set |
| 29 | if err := json.Unmarshal([]byte(`{"v":1,"data":{"replicate":"platform","dbs":["console.db"],"future":7}}`), &s); err != nil { |
| 30 | t.Fatalf("unknown field inside a known capability must NOT fail: %v", err) |
| 31 | } |
| 32 | if s.Data == nil || s.Data.Replicate != DataReplicatePlatform || len(s.Data.DBs) != 1 { |
| 33 | t.Fatalf("known fields lost: %+v", s.Data) |
| 34 | } |
| 35 | if n := s.Notes(); len(n) != 1 || !strings.Contains(n[0], "future") { |
| 36 | t.Fatalf("notes = %v, want one line naming future", n) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | // TestVersionBumpIsRefused pins fail-closed on "v": the box keeps serving |
| 41 | // what it has rather than materialize a unit missing something new. |
| 42 | func TestVersionBumpIsRefused(t *testing.T) { |
| 43 | var s Set |
| 44 | if err := json.Unmarshal([]byte(`{"v":2,"daemon":true}`), &s); err == nil { |
| 45 | t.Fatal("v:2 must be refused — fail closed, the box keeps what it serves") |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | // TestVMissingKeyIsRefusedHonestly pins review finding I3: an absent "v" |
| 50 | // key — whether the object is entirely empty or merely misspells the key |
| 51 | // ("V" instead of "v") — is refused with a message naming the missing key, |
| 52 | // never the misleading "v 0" (which would suggest the block explicitly |
| 53 | // asked for a version number that happens to be zero). |
| 54 | func TestVMissingKeyIsRefusedHonestly(t *testing.T) { |
| 55 | for _, raw := range []string{`{}`, `{"V":1}`} { |
| 56 | var s Set |
| 57 | err := json.Unmarshal([]byte(raw), &s) |
| 58 | if err == nil { |
| 59 | t.Errorf("%s: accepted, want a refusal", raw) |
| 60 | continue |
| 61 | } |
| 62 | if !strings.Contains(err.Error(), "missing") { |
| 63 | t.Errorf("%s: err = %v, want an honest \"missing v\" message", raw, err) |
| 64 | } |
| 65 | if strings.Contains(err.Error(), "v 0") { |
| 66 | t.Errorf("%s: err = %v, must not say \"v 0\" — the key was never there to say 0", raw, err) |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // TestVRangeAcceptedUpToVersionRefusedOutsideIt pins review finding I3's |
| 72 | // range, generically against the Version constant rather than a hardcoded |
| 73 | // number: every v in 1..Version decodes, and anything outside it (0, |
| 74 | // negative, or Version+1) is refused. The day Version becomes 2, an |
| 75 | // immutable v1 manifest already in a production bucket must still decode — |
| 76 | // rollback depends on it. |
| 77 | func TestVRangeAcceptedUpToVersionRefusedOutsideIt(t *testing.T) { |
| 78 | for v := 1; v <= Version; v++ { |
| 79 | var s Set |
| 80 | if err := json.Unmarshal([]byte(fmt.Sprintf(`{"v":%d}`, v)), &s); err != nil { |
| 81 | t.Errorf("v:%d (within 1..Version=%d) refused: %v", v, Version, err) |
| 82 | } |
| 83 | } |
| 84 | for _, v := range []int{0, -1, Version + 1} { |
| 85 | var s Set |
| 86 | if err := json.Unmarshal([]byte(fmt.Sprintf(`{"v":%d}`, v)), &s); err == nil { |
| 87 | t.Errorf("v:%d (outside 1..Version=%d) accepted", v, Version) |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // TestValidCapabilitiesRefusesVOutsideRange pins review finding I6(b): the |
| 93 | // same range check inside ValidCapabilities, exercised against Set literals |
| 94 | // that never passed through UnmarshalJSON at all. |
| 95 | func TestValidCapabilitiesRefusesVOutsideRange(t *testing.T) { |
| 96 | if err := ValidCapabilities("keymail", &Set{V: 0}); err == nil { |
| 97 | t.Fatal("Set{V:0} accepted by ValidCapabilities") |
| 98 | } |
| 99 | if err := ValidCapabilities("keymail", &Set{V: 2}); err == nil { |
| 100 | t.Fatal("Set{V:2} accepted by ValidCapabilities (Version is 1)") |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // TestBareVBlockIsValidAndCommandEmptyMeansServe pins review finding I6: |
| 105 | // {"v":1} alone (every other field absent) decodes and validates cleanly, |
| 106 | // and an empty Command means the template's own "serve" — not an invalid |
| 107 | // or a refused value. |
| 108 | func TestBareVBlockIsValidAndCommandEmptyMeansServe(t *testing.T) { |
| 109 | var s Set |
| 110 | if err := json.Unmarshal([]byte(`{"v":1}`), &s); err != nil { |
| 111 | t.Fatalf(`bare {"v":1} refused: %v`, err) |
| 112 | } |
| 113 | if s.Command != "" { |
| 114 | t.Fatalf(`Command = %q, want "" (the template's own "serve")`, s.Command) |
| 115 | } |
| 116 | if err := ValidCapabilities("console", &s); err != nil { |
| 117 | t.Fatalf("bare v:1 block refused by ValidCapabilities: %v", err) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // TestKeymailSpecBlockRoundTripsAndValidates pins review finding I6(a): |
| 122 | // decode the exact keymail capabilities block from |
| 123 | // docs/superpowers/specs/2026-08-03-app-capabilities-design.md §3a, assert |
| 124 | // every field, and confirm ValidCapabilities accepts it whole. |
| 125 | func TestKeymailSpecBlockRoundTripsAndValidates(t *testing.T) { |
| 126 | const keymailBlock = `{ |
| 127 | "v": 1, |
| 128 | "command": "relay", |
| 129 | "daemon": true, |
| 130 | "children": true, |
| 131 | "runtime_dir": "keymail", |
| 132 | "ports": [ { "port": 25, "name": "mx" } ], |
| 133 | "data": { "replicate": "app" }, |
| 134 | "host_role": true |
| 135 | }` |
| 136 | var s Set |
| 137 | if err := json.Unmarshal([]byte(keymailBlock), &s); err != nil { |
| 138 | t.Fatalf("spec §3a keymail block failed to decode: %v", err) |
| 139 | } |
| 140 | if s.V != 1 { |
| 141 | t.Errorf("V = %d, want 1", s.V) |
| 142 | } |
| 143 | if s.Command != "relay" { |
| 144 | t.Errorf("Command = %q, want relay", s.Command) |
| 145 | } |
| 146 | if !s.Daemon { |
| 147 | t.Error("Daemon = false, want true") |
| 148 | } |
| 149 | if !s.Children { |
| 150 | t.Error("Children = false, want true") |
| 151 | } |
| 152 | if s.RuntimeDir != "keymail" { |
| 153 | t.Errorf("RuntimeDir = %q, want keymail", s.RuntimeDir) |
| 154 | } |
| 155 | if len(s.Ports) != 1 || s.Ports[0].Port != 25 || s.Ports[0].Name != "mx" { |
| 156 | t.Errorf("Ports = %+v, want [{25 mx}]", s.Ports) |
| 157 | } |
| 158 | if s.Data == nil || s.Data.Replicate != DataReplicateApp || len(s.Data.DBs) != 0 { |
| 159 | t.Errorf("Data = %+v, want {app []}", s.Data) |
| 160 | } |
| 161 | if !s.HostRole { |
| 162 | t.Error("HostRole = false, want true") |
| 163 | } |
| 164 | if n := s.Notes(); len(n) != 0 { |
| 165 | t.Errorf("Notes = %v, want none", n) |
| 166 | } |
| 167 | if err := ValidCapabilities("keymail", &s); err != nil { |
| 168 | t.Fatalf("ValidCapabilities(keymail, spec block) = %v, want nil", err) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // TestDocEngineDecodesAsAKnownCapability pins the wire shape of the new |
| 173 | // capability: "doc_engine" is one of the closed vocabulary's names, so it |
| 174 | // decodes to Set.DocEngine and is NOT refused as unknown and NOT recorded as an |
| 175 | // ignored field. An absent doc_engine leaves it false (not declared). |
| 176 | func TestDocEngineDecodesAsAKnownCapability(t *testing.T) { |
| 177 | var s Set |
| 178 | if err := json.Unmarshal([]byte(`{"v":1,"doc_engine":true}`), &s); err != nil { |
| 179 | t.Fatalf("doc_engine refused as unknown: %v", err) |
| 180 | } |
| 181 | if !s.DocEngine { |
| 182 | t.Error("DocEngine = false, want true") |
| 183 | } |
| 184 | if n := s.Notes(); len(n) != 0 { |
| 185 | t.Errorf("doc_engine recorded as an ignored field: %v", n) |
| 186 | } |
| 187 | if err := ValidCapabilities("gotenberg", &s); err != nil { |
| 188 | t.Fatalf("ValidCapabilities(doc_engine) = %v, want nil", err) |
| 189 | } |
| 190 | |
| 191 | var absent Set |
| 192 | if err := json.Unmarshal([]byte(`{"v":1}`), &absent); err != nil { |
| 193 | t.Fatalf("decode: %v", err) |
| 194 | } |
| 195 | if absent.DocEngine { |
| 196 | t.Error("an absent doc_engine decoded to true, want false (not declared)") |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // TestNilSetNotesDoesNotPanic pins review finding I1: every production |
| 201 | // manifest today decodes to a nil Set, and the first ranged Notes() call in |
| 202 | // Task 6's converge pass must not crash RunAdopt fleet-wide. |
| 203 | func TestNilSetNotesDoesNotPanic(t *testing.T) { |
| 204 | var s *Set |
| 205 | if n := s.Notes(); n != nil { |
| 206 | t.Fatalf("nil Set.Notes() = %v, want nil", n) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // TestPortFieldCaseInsensitiveMatchIsNotApplied pins review finding I2: a |
| 211 | // whole-object json.Unmarshal into Port would let Go's decoder case-fold an |
| 212 | // unrecognised "PORT" onto the Port field via its default case-insensitive |
| 213 | // tag matching, applying exactly the value the notes scan reports as |
| 214 | // ignored. Decoding field-by-field from the exact-key fields map must |
| 215 | // refuse to do that: "PORT" stays ignored and noted, and Port stays 0. |
| 216 | func TestPortFieldCaseInsensitiveMatchIsNotApplied(t *testing.T) { |
| 217 | var s Set |
| 218 | if err := json.Unmarshal([]byte(`{"v":1,"ports":[{"PORT":25}]}`), &s); err != nil { |
| 219 | t.Fatalf("an unrecognised-cased field must not fail decode: %v", err) |
| 220 | } |
| 221 | if len(s.Ports) != 1 || s.Ports[0].Port != 0 || s.Ports[0].Name != "" { |
| 222 | t.Fatalf(`Ports = %+v, want [{0 ""}] — "PORT" must be ignored, not case-folded onto Port`, s.Ports) |
| 223 | } |
| 224 | if n := s.Notes(); len(n) != 1 || !strings.Contains(n[0], "PORT") { |
| 225 | t.Fatalf("notes = %v, want one line naming PORT", n) |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | // TestDataFieldCaseInsensitiveMatchIsNotApplied is TestPortFieldCase...'s |
| 230 | // sibling for Data: "REPLICATE"/"DBS" must stay ignored-and-noted rather |
| 231 | // than being case-folded onto Replicate/DBs (review finding I2). |
| 232 | func TestDataFieldCaseInsensitiveMatchIsNotApplied(t *testing.T) { |
| 233 | var s Set |
| 234 | if err := json.Unmarshal([]byte(`{"v":1,"data":{"REPLICATE":"platform","DBS":["x.db"]}}`), &s); err != nil { |
| 235 | t.Fatalf("unrecognised-cased fields must not fail decode: %v", err) |
| 236 | } |
| 237 | if s.Data == nil || s.Data.Replicate != "" || len(s.Data.DBs) != 0 { |
| 238 | t.Fatalf("Data = %+v, want the zero value — REPLICATE/DBS must be ignored, not case-folded", s.Data) |
| 239 | } |
| 240 | if n := s.Notes(); len(n) != 2 { |
| 241 | t.Fatalf("notes = %v, want two lines naming REPLICATE and DBS", n) |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // TestDataExplicitNullDecodesButIsRefused pins the Data doc comment's |
| 246 | // asymmetry (review MINOR item): an explicit "data": null still allocates a |
| 247 | // non-nil *Data (the zero value), which ValidCapabilities then refuses for |
| 248 | // naming neither accepted replicate value — unlike an absent "data" key, |
| 249 | // which leaves Data nil and valid. |
| 250 | func TestDataExplicitNullDecodesButIsRefused(t *testing.T) { |
| 251 | var s Set |
| 252 | if err := json.Unmarshal([]byte(`{"v":1,"data":null}`), &s); err != nil { |
| 253 | t.Fatalf(`{"v":1,"data":null} failed to decode: %v`, err) |
| 254 | } |
| 255 | if s.Data == nil { |
| 256 | t.Fatal(`explicit "data": null must still decode to a non-nil *Data (the zero value)`) |
| 257 | } |
| 258 | if err := ValidCapabilities("console", &s); err == nil { |
| 259 | t.Fatal("a zero-value Data (from explicit \"data\": null) must be refused for naming no replicate value") |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // TestCapabilitiesEmptyObjectWithNoVIsRefused pins the reviewer's sharpest |
| 264 | // edge case: "capabilities": {} must NOT be read as "no capabilities" — an |
| 265 | // absent "v" on a PRESENT block is refused (now with an honest "missing v" |
| 266 | // message; see TestVMissingKeyIsRefusedHonestly). Only an absent |
| 267 | // capabilities KEY (a nil *Set, never constructed) means "none". |
| 268 | func TestCapabilitiesEmptyObjectWithNoVIsRefused(t *testing.T) { |
| 269 | var s Set |
| 270 | if err := json.Unmarshal([]byte(`{}`), &s); err == nil { |
| 271 | t.Fatal("{} (no v) must be refused, not silently mean no capabilities") |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | // TestRuntimeDirCharsetAndReservedNames pins the charset and the reserved |
| 276 | // list, including the names added on review (finding C1). NOTE: the plan's |
| 277 | // Step-1 listing included "" in this bad list, but that contradicts both |
| 278 | // TestNilSetIsValidAndMeansNothing and TestPortsReservedAndNonPrivilegedRefused's |
| 279 | // final assertion (a Set with only Ports set, RuntimeDir at its zero value |
| 280 | // "", must be valid) — an empty RuntimeDir means "not declared", not |
| 281 | // "declared as empty". "" is dropped here and pinned separately as valid |
| 282 | // below. |
| 283 | func TestRuntimeDirCharsetAndReservedNames(t *testing.T) { |
| 284 | bad := []string{ |
| 285 | "../etc", "carlos/apps", "/run/keymail", "Keymail", |
| 286 | "carlos", "systemd", "systemd-foo", "lock", "log", "user", |
| 287 | "dbus", "sshd", "udev", strings.Repeat("a", 33), |
| 288 | // added on review (C1): common OS/tool-owned /run subdirectories, |
| 289 | // none of which is a substitute for converge's box-wide existence |
| 290 | // check (see the note on reservedRuntimeDir and the Task 6 plan |
| 291 | // addendum) — this list can never be exhaustive. |
| 292 | "credentials", "sudo", "mount", "network", "host", "netns", "shm", |
| 293 | } |
| 294 | for _, name := range bad { |
| 295 | s := &Set{V: 1, RuntimeDir: name} |
| 296 | if err := ValidCapabilities("keymail", s); err == nil { |
| 297 | t.Errorf("runtime_dir %q was accepted", name) |
| 298 | } |
| 299 | } |
| 300 | if err := ValidCapabilities("keymail", &Set{V: 1, RuntimeDir: "keymail"}); err != nil { |
| 301 | t.Fatalf("keymail refused: %v", err) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | // TestRuntimeDirEmptyMeansNotDeclared pins the deviation explained in the |
| 306 | // comment above: the zero value is "not declared", not an invalid name. |
| 307 | func TestRuntimeDirEmptyMeansNotDeclared(t *testing.T) { |
| 308 | if err := ValidCapabilities("keymail", &Set{V: 1, RuntimeDir: ""}); err != nil { |
| 309 | t.Fatalf("empty runtime_dir (not declared) must be valid: %v", err) |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | // TestPortsReservedAndNonPrivilegedRefused pins the port range and the |
| 314 | // absolute reservation of 22/80/443 — an operator typo must not be able to |
| 315 | // hand a tenant the edge's ports or the box's SSH (spec §7). |
| 316 | func TestPortsReservedAndNonPrivilegedRefused(t *testing.T) { |
| 317 | for _, p := range []int{0, 22, 80, 443, 1024, 8080, 65535, -1} { |
| 318 | if err := ValidCapabilities("keymail", &Set{V: 1, Ports: []Port{{Port: p}}}); err == nil { |
| 319 | t.Errorf("port %d was accepted", p) |
| 320 | } |
| 321 | } |
| 322 | if err := ValidCapabilities("keymail", &Set{V: 1, Ports: []Port{{Port: 25, Name: "mx"}}}); err != nil { |
| 323 | t.Fatalf("port 25 refused: %v", err) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | // TestPort1023IsAccepted pins the upper boundary of the privileged range |
| 328 | // (review MINOR item): 1023 is the last privileged port and must be |
| 329 | // accepted, not off-by-one refused. |
| 330 | func TestPort1023IsAccepted(t *testing.T) { |
| 331 | if err := ValidCapabilities("keymail", &Set{V: 1, Ports: []Port{{Port: 1023}}}); err != nil { |
| 332 | t.Fatalf("port 1023 (upper bound) refused: %v", err) |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | // TestDuplicatePortIsRefused pins the plan's "Plus:" line item: the same |
| 337 | // port twice in one ports list is refused rather than silently deduplicated. |
| 338 | func TestDuplicatePortIsRefused(t *testing.T) { |
| 339 | if err := ValidCapabilities("keymail", &Set{V: 1, Ports: []Port{{Port: 25}, {Port: 25}}}); err == nil { |
| 340 | t.Fatal("duplicate port 25 was accepted") |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | // TestTooManyPortsIsRefused pins the len(ports) > 4 bound — a bound, not a |
| 345 | // feature. |
| 346 | func TestTooManyPortsIsRefused(t *testing.T) { |
| 347 | ports := []Port{{Port: 1}, {Port: 2}, {Port: 3}, {Port: 4}, {Port: 5}} |
| 348 | if err := ValidCapabilities("keymail", &Set{V: 1, Ports: ports}); err == nil { |
| 349 | t.Fatal("5 ports (bound is 4) was accepted") |
| 350 | } |
| 351 | if err := ValidCapabilities("keymail", &Set{V: 1, Ports: ports[:4]}); err != nil { |
| 352 | t.Fatalf("4 ports (at the bound) refused: %v", err) |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | // TestPortNameCharsetIsRefused pins ports[].name's charset — it lands in |
| 357 | // logs. |
| 358 | func TestPortNameCharsetIsRefused(t *testing.T) { |
| 359 | if err := ValidCapabilities("keymail", &Set{V: 1, Ports: []Port{{Port: 25, Name: "Mail Exchange!"}}}); err == nil { |
| 360 | t.Fatal("port name with spaces and punctuation was accepted") |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | // TestHostRoleRefusesAnAppNameTooLongForAUser pins the account-name limit, |
| 365 | // refused at ship where a human can rename. |
| 366 | func TestHostRoleRefusesAnAppNameTooLongForAUser(t *testing.T) { |
| 367 | long := strings.Repeat("a", 30) // carlos-<30> = 37 > 32 |
| 368 | if err := ValidCapabilities(long, &Set{V: 1, HostRole: true}); err == nil { |
| 369 | t.Fatal("host_role on an over-long app name must be refused at ship") |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | // TestAppUserLengthBoundary pins the exact edge (review MINOR item): |
| 374 | // carlos-<app> at exactly 32 characters is accepted, 33 is refused. |
| 375 | func TestAppUserLengthBoundary(t *testing.T) { |
| 376 | at32 := strings.Repeat("a", 25) // "carlos-" (7) + 25 = 32 |
| 377 | if err := ValidCapabilities(at32, &Set{V: 1, HostRole: true}); err != nil { |
| 378 | t.Fatalf("app user at exactly 32 chars refused: %v", err) |
| 379 | } |
| 380 | at33 := strings.Repeat("a", 26) // "carlos-" (7) + 26 = 33 |
| 381 | if err := ValidCapabilities(at33, &Set{V: 1, HostRole: true}); err == nil { |
| 382 | t.Fatal("app user at 33 chars (one over the limit) was accepted") |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | // TestHostRoleRefusesAppNameCharsetBeforeLength pins review finding I5: an |
| 387 | // app name failing the token charset is refused even when it would pass |
| 388 | // the length check — release.ValidApp's charset is checked first, as |
| 389 | // defense in depth for the User=<AppUser(app)> sink. |
| 390 | func TestHostRoleRefusesAppNameCharsetBeforeLength(t *testing.T) { |
| 391 | for _, bad := range []string{"Keymail", "1relay", "key_mail", "key mail"} { |
| 392 | if err := ValidCapabilities(bad, &Set{V: 1, HostRole: true}); err == nil { |
| 393 | t.Errorf("host_role with app name %q (fails the charset) was accepted", bad) |
| 394 | } |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | // TestNilSetIsValidAndMeansNothing pins today's every-manifest behaviour: |
| 399 | // no capabilities key parses to a nil *Set, and a nil *Set is valid. |
| 400 | func TestNilSetIsValidAndMeansNothing(t *testing.T) { |
| 401 | if err := ValidCapabilities("console", nil); err != nil { |
| 402 | t.Fatalf("nil (today's every manifest) must be valid: %v", err) |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | // TestCommandCharsetIsRefused pins the plan's "Plus:" line item: a command |
| 407 | // is one lowercase token, never flags or a capitalized subcommand. |
| 408 | func TestCommandCharsetIsRefused(t *testing.T) { |
| 409 | for _, bad := range []string{"relay --flag", "Relay", "1relay", "-relay", "a b", strings.Repeat("a", 33)} { |
| 410 | if err := ValidCapabilities("keymail", &Set{V: 1, Command: bad}); err == nil { |
| 411 | t.Errorf("command %q was accepted", bad) |
| 412 | } |
| 413 | } |
| 414 | if err := ValidCapabilities("keymail", &Set{V: 1, Command: "relay"}); err != nil { |
| 415 | t.Fatalf("command \"relay\" refused: %v", err) |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | // TestDataDBsCharsetAndSeparatorRefusals pins the "used only as |
| 420 | // filepath.Join(stateDir, name)" rail: dbs entries must be bare filenames, |
| 421 | // never ".", "..", or containing any separator. |
| 422 | func TestDataDBsCharsetAndSeparatorRefusals(t *testing.T) { |
| 423 | for _, bad := range []string{".", "..", "console.db/../../etc/passwd", "a/b", "con*", strings.Repeat("a", 65)} { |
| 424 | s := &Set{V: 1, Data: &Data{Replicate: DataReplicatePlatform, DBs: []string{bad}}} |
| 425 | if err := ValidCapabilities("console", s); err == nil { |
| 426 | t.Errorf("dbs entry %q was accepted", bad) |
| 427 | } |
| 428 | } |
| 429 | if err := ValidCapabilities("console", &Set{V: 1, Data: &Data{Replicate: DataReplicatePlatform, DBs: []string{"console.db"}}}); err != nil { |
| 430 | t.Fatalf("console.db refused: %v", err) |
| 431 | } |
| 432 | } |
| 433 | |
| 434 | // TestDataDBsDuplicateIsRefused pins review finding I4: a repeated dbs name |
| 435 | // is refused, mirroring validPorts' duplicate check — two litestream |
| 436 | // entries pointed at one WAL is the checkpoint fight §3a exists to |
| 437 | // prevent. |
| 438 | func TestDataDBsDuplicateIsRefused(t *testing.T) { |
| 439 | s := &Set{V: 1, Data: &Data{Replicate: DataReplicatePlatform, DBs: []string{"console.db", "console.db"}}} |
| 440 | if err := ValidCapabilities("console", s); err == nil { |
| 441 | t.Fatal("duplicate dbs entry \"console.db\" was accepted") |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | // TestTooManyDBsIsRefused pins the len(data.dbs) > 8 bound. |
| 446 | func TestTooManyDBsIsRefused(t *testing.T) { |
| 447 | dbs := make([]string, 9) |
| 448 | for i := range dbs { |
| 449 | dbs[i] = strings.Repeat("a", i+1) + ".db" |
| 450 | } |
| 451 | if err := ValidCapabilities("console", &Set{V: 1, Data: &Data{Replicate: DataReplicatePlatform, DBs: dbs}}); err == nil { |
| 452 | t.Fatal("9 dbs (bound is 8) was accepted") |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | // TestDataDBsWithAppReplicateIsRefused pins the two-replicators hazard: dbs |
| 457 | // is meaningless when the app streams its own WAL, so it is refused rather |
| 458 | // than silently ignored. |
| 459 | func TestDataDBsWithAppReplicateIsRefused(t *testing.T) { |
| 460 | s := &Set{V: 1, Data: &Data{Replicate: DataReplicateApp, DBs: []string{"x.db"}}} |
| 461 | if err := ValidCapabilities("keymail", s); err == nil { |
| 462 | t.Fatal("dbs alongside replicate:\"app\" must be refused") |
| 463 | } |
| 464 | } |
| 465 | |
| 466 | // TestDataReplicateUnknownValueIsRefused pins the closed pair of |
| 467 | // replicate values. |
| 468 | func TestDataReplicateUnknownValueIsRefused(t *testing.T) { |
| 469 | if err := ValidCapabilities("keymail", &Set{V: 1, Data: &Data{Replicate: "bogus"}}); err == nil { |
| 470 | t.Fatal("data.replicate \"bogus\" was accepted") |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | // TestGrantorReplicateValuesAreValidOnTheirOwn pins that replicate:"app" |
| 475 | // with no dbs, and replicate:"platform" with dbs, are both accepted — |
| 476 | // only replicate:"app" WITH dbs is refused. |
| 477 | func TestGrantorReplicateValuesAreValidOnTheirOwn(t *testing.T) { |
| 478 | if err := ValidCapabilities("keymail", &Set{V: 1, Data: &Data{Replicate: DataReplicateApp}}); err != nil { |
| 479 | t.Fatalf("replicate:\"app\" with no dbs refused: %v", err) |
| 480 | } |
| 481 | if err := ValidCapabilities("console", &Set{V: 1, Data: &Data{Replicate: DataReplicatePlatform, DBs: []string{"console.db"}}}); err != nil { |
| 482 | t.Fatalf("replicate:\"platform\" with dbs refused: %v", err) |
| 483 | } |
| 484 | } |
| 485 | |