218 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|