package main import ( "encoding/json" "errors" "net/url" "os" "path/filepath" "strings" "testing" "time" ) // The grant entry's shape is the mesh's Contribution (mesh-sdk go/catalogue/declaration.go: from, node, as, values, // derived), written in a `given` list by receivedFile in the same file; `shares` is written into values by // go/catalogue/bucket_shares.go withShares: [{"to": ..., "methods": [...]}]. const grantFile = `{ "contributions": 1, "requirement": "s3-bucket", "given": [ {"from": "brother", "node": "shanks", "as": "brother-key", "values": {"shares": [{"to": "seat:node-files", "methods": ["put"]}, {"to": "print-and-scan", "methods": ["get"]}]}, "derived": {"bucket": "mesh-shanks-brother"}}, {"from": "nextcloud", "node": "novox", "as": "nextcloud-key", "derived": {"bucket": "mesh-novox-nextcloud"}} ] }` // The seats answer is the controller's (mesh-controller.seats read on 2026-10-21): seats[].holders[] of {node, module}, // and outside[] of {Claim, Node, Module}. const seatsAnswer = `{"answer": {"seats": [ {"seat": "node-files", "scope": "node", "holders": [{"node": "g14", "module": "mesh-files"}, {"node": "shanks", "module": "mesh-files"}]}, {"seat": "node-backup", "scope": "node", "holders": [{"node": "g14", "module": "restic"}]} ], "outside": [{"Claim": "printer", "Scope": "mesh", "Node": "shanks", "Module": "brother", "Site": "home", "Kind": ""}]}}` type rig struct { s *server dir string now time.Time seatsCalls int seatsErr error } func newRig(t *testing.T) *rig { t.Helper() dir := t.TempDir() r := &rig{dir: dir, now: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)} write := func(name, content string) string { p := filepath.Join(dir, name) if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(content), 0o600); err != nil { t.Fatal(err) } return p } // What the mesh renders from files/object-store.json for a store on novox.internal, port 20001. cfg := write("object-store.json", `{"scheme":"http","at":"novox.internal","port":"20001","region":"eu-west"}`) write("links/mesh-shanks-brother.secret", "brother-link-secret\n") write("links/mesh-novox-nextcloud.secret", "nextcloud-link-secret\n") grants := write("grants/mesh.json", grantFile) s, err := newServer(cfg, filepath.Join(dir, "links"), grants) if err != nil { t.Fatal(err) } s.now = func() time.Time { return r.now } s.seats = func() (json.RawMessage, error) { r.seatsCalls++ if r.seatsErr != nil { return nil, r.seatsErr } return json.RawMessage(seatsAnswer), nil } r.s = s return r } func ask(bucket, method string, seconds any) map[string]any { a := map[string]any{"bucket": bucket, "key": "doc.pdf", "method": method} if seconds != nil { a["seconds"] = seconds } return a } func linkOf(t *testing.T, got any) *url.URL { t.Helper() m := got.(map[string]any) u, err := url.Parse(m["link"].(string)) if err != nil { t.Fatal(err) } return u } // 1516/AC1: brother's own ask gets a link signed with the bucket's link key, for the store's host and port, 300 s. func TestLinkIsSignedWithTheBucketsLinkKey(t *testing.T) { r := newRig(t) got, err := r.s.link("shanks.brother", ask("mesh-shanks-brother", "get", float64(300))) if err != nil { t.Fatal(err) } u := linkOf(t, got) if u.Host != "novox.internal:20001" || u.Scheme != "http" || u.Path != "/mesh-shanks-brother/doc.pdf" { t.Fatalf("link goes to %s", u) } q := u.Query() if cred := q.Get("X-Amz-Credential"); !strings.HasPrefix(cred, linkKeyID("mesh-shanks-brother")+"/20261021/eu-west/s3/") { t.Fatalf("credential %q is not the bucket's link key", cred) } if strings.Contains(u.String(), "meshroot") { t.Fatal("the root account signed a link") } if q.Get("X-Amz-Expires") != "300" || q.Get("X-Amz-Date") != "20261021T120000Z" { t.Fatalf("expiry %q at %q", q.Get("X-Amz-Expires"), q.Get("X-Amz-Date")) } } // 1516/AC1: the link key id is the one the provisioner makes (modules/minio/links.ts linkKeyId, tested there with the // same literal). func TestLinkKeyIDMatchesTheProvisioners(t *testing.T) { if got := linkKeyID("brother"); got != "link0b4bd77cec705cf5" { t.Fatalf("linkKeyID = %s", got) } } // 1516/AC2: a share to seat:node-files for put, mesh-files holding node-files on g14: put given, get refused naming the method. func TestSeatShareGivesTheHoldersTheSharedMethodOnly(t *testing.T) { r := newRig(t) if _, err := r.s.link("g14.mesh-files", ask("mesh-shanks-brother", "put", nil)); err != nil { t.Fatalf("put: %v", err) } _, err := r.s.link("g14.mesh-files", ask("mesh-shanks-brother", "get", nil)) if err == nil || !strings.Contains(err.Error(), "put, not get") { t.Fatalf("get was not refused naming the method: %v", err) } // A module share is judged by name alone. if _, err := r.s.link("novox.print-and-scan", ask("mesh-shanks-brother", "get", nil)); err != nil { t.Fatalf("print-and-scan get: %v", err) } } // 1516/AC3: each of these is refused with its reason. func TestRefusals(t *testing.T) { r := newRig(t) cases := []struct { name, caller string args map[string]any want string }{ {"a stranger", "g14.restic", ask("mesh-shanks-brother", "get", nil), "not the bucket's consumer"}, {"a seat it does not hold", "g14.restic", ask("mesh-shanks-brother", "put", nil), "not the bucket's consumer"}, {"unproven", "unproven", ask("mesh-shanks-brother", "get", nil), "did not prove"}, {"no grant", "shanks.brother", ask("no-such-bucket", "get", nil), "no grant names bucket"}, {"seconds over 600", "shanks.brother", ask("mesh-shanks-brother", "get", float64(601)), "at most ten minutes"}, {"seconds zero", "shanks.brother", ask("mesh-shanks-brother", "get", float64(0)), "outside 1 to 600"}, {"method delete", "shanks.brother", ask("mesh-shanks-brother", "delete", nil), "not get or put"}, {"another consumer's bucket", "shanks.brother", ask("mesh-novox-nextcloud", "get", nil), "not the bucket's consumer"}, } for _, c := range cases { _, err := r.s.link(c.caller, c.args) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: want a refusal containing %q, got %v", c.name, c.want, err) } } } // 1516/AC4: with the controller unreadable a seat share is refused naming why; a good read serves a second ask within // 60 s from the cache and is read again after. func TestSeatsUnreadableAndCached(t *testing.T) { r := newRig(t) r.seatsErr = errors.New("the controller did not answer") _, err := r.s.link("g14.mesh-files", ask("mesh-shanks-brother", "put", nil)) if err == nil || !strings.Contains(err.Error(), "the controller did not answer") || !strings.Contains(err.Error(), "node-files") { t.Fatalf("not refused naming why: %v", err) } r.seatsErr = nil put := func() { t.Helper() if _, err := r.s.link("g14.mesh-files", ask("mesh-shanks-brother", "put", nil)); err != nil { t.Fatal(err) } } put() // a failed read is not cached: this reads again put() // served from the cache if r.seatsCalls != 2 { t.Fatalf("the controller was asked %d times, want 2 (one failure, one good read)", r.seatsCalls) } r.now = r.now.Add(59 * time.Second) put() if r.seatsCalls != 2 { t.Fatalf("an ask within 60 s read the seats again (%d calls)", r.seatsCalls) } r.now = r.now.Add(2 * time.Second) put() if r.seatsCalls != 3 { t.Fatalf("the seats were not read again after 60 s (%d calls)", r.seatsCalls) } } // 1516/AC5: the grant file is read at each call. func TestGrantFileIsReadAtEachCall(t *testing.T) { r := newRig(t) if _, err := r.s.link("novox.print-and-scan", ask("mesh-shanks-brother", "get", nil)); err != nil { t.Fatal(err) } changed := strings.Replace(grantFile, `{"to": "print-and-scan", "methods": ["get"]}`, `{"to": "someone-else", "methods": ["get"]}`, 1) if err := os.WriteFile(r.s.receives, []byte(changed), 0o600); err != nil { t.Fatal(err) } if _, err := r.s.link("novox.print-and-scan", ask("mesh-shanks-brother", "get", nil)); err == nil { t.Fatal("the second call judged by the old shares") } if _, err := r.s.link("novox.someone-else", ask("mesh-shanks-brother", "get", nil)); err != nil { t.Fatal(err) } }