Files
mesh-catalog/modules/minio/cmd/object-store/server_test.go
T

218 lines
8.1 KiB
Go

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)
}
}