package catalogue import ( "encoding/json" "reflect" "strings" "testing" ) // Issue 1591: `shares` on a requirement of s3-bucket (novox/hq ADR 0468 §8). const filesWithShares = `{"module":"files","version":"1","requires":["shell", {"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}` func minioLike() Manifest { return Manifest{Module: "minio", Version: "1", Provides: Offers("s3-bucket"), Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}} } func givenToMinio(t *testing.T, out []map[string]any) []Contribution { t.Helper() for _, r := range out { if r["path"] != "/grants/mesh.json" { continue } var parsed struct { Given []Contribution `json:"given"` } if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { t.Fatal(err) } return parsed.Given } t.Fatalf("minio was given no file: %v", out) return nil } func sharesOf(t *testing.T, values map[string]any) string { t.Helper() raw, err := json.Marshal(values["shares"]) if err != nil { t.Fatal(err) } return string(raw) } const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]` // 1591/AC1: parsed, the shares are on the manifest; stored, `requires` is plain names and the side key holds them. func TestSharesParseAndAreStoredBesideRequires(t *testing.T) { m := mustParse(t, filesWithShares) want := []BucketShare{{To: "seat:node-files", Methods: []string{"put"}}, {To: "print-and-scan", Methods: []string{"get", "put"}}} if !reflect.DeepEqual(m.RequireShares["s3-bucket"], want) { t.Fatalf("the shares read as %+v", m.RequireShares) } raw, err := json.Marshal(m) if err != nil { t.Fatal(err) } var keys map[string]json.RawMessage if err := json.Unmarshal(raw, &keys); err != nil { t.Fatal(err) } if string(keys["requires"]) != `["shell","s3-bucket"]` { t.Errorf("the stored requires is not plain names: %s", keys["requires"]) } if !strings.Contains(string(keys[RequireSharesKey]), `"seat:node-files"`) { t.Errorf("the side key does not hold the shares: %s", raw) } var again Manifest if err := json.Unmarshal(raw, &again); err != nil { t.Fatal(err) } if !reflect.DeepEqual(again, m) { t.Fatalf("not lossless:\n%+v\n%+v", again, m) } } // 1591/AC2: a malformed share is refused, naming the entry and ADR 0468 §8. func TestAMalformedShareIsRefused(t *testing.T) { cases := map[string]string{ "unknown method": `{"name":"s3-bucket","shares":[{"to":"a","methods":["delete"]}]}`, "empty to": `{"name":"s3-bucket","shares":[{"to":"","methods":["get"]}]}`, "malformed to": `{"name":"s3-bucket","shares":[{"to":"Seat:X y","methods":["get"]}]}`, "empty methods": `{"name":"s3-bucket","shares":[{"to":"a","methods":[]}]}`, "duplicate to": `{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]},{"to":"a","methods":["put"]}]}`, "duplicate verb": `{"name":"s3-bucket","shares":[{"to":"a","methods":["get","get"]}]}`, "other require": `{"name":"route","shares":[{"to":"a","methods":["get"]}]}`, } for name, entry := range cases { _, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`)) if err == nil { t.Errorf("%s: accepted", name) continue } if !strings.Contains(err.Error(), "ADR 0468 §8") || !strings.Contains(err.Error(), "requires") { t.Errorf("%s: the error does not name the entry and the ADR: %v", name, err) } } // The stored form is held to the same rules. _, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"], "requires-shares":{"s3-bucket":[{"to":"a","methods":[]}]}}`)) if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") { t.Errorf("the stored form was accepted: %v", err) } } // 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike. func TestTheGrantCarriesTheSharesSorted(t *testing.T) { m := mustParse(t, filesWithShares) remote := Resolution{Node: "laptop", Modules: []Manifest{m}} values, asks, err := remote.ContributionsFrom("s3-bucket", "files", SettingsBy{}) if err != nil || !asks { t.Fatalf("asks %v, err %v", asks, err) } if got := sharesOf(t, values); got != sortedSharesJSON { t.Fatalf("the grant values carry %s", got) } // As the provider's file shows it, through a grant. provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), World{}) if err != nil { t.Fatal(err) } out, err := provider.Declaration(Rendering{Grants: []Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}}) if err != nil { t.Fatal(err) } given := givenToMinio(t, out) if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON { t.Fatalf("the remote consumer's shares did not reach the file: %+v", given) } // Co-located: the consumer contributes to s3-bucket too, and no grant is passed. co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}}, "requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`) both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), World{}) if err != nil { t.Fatal(err) } out, err = both.Declaration(Rendering{}) if err != nil { t.Fatal(err) } given = givenToMinio(t, out) if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" { t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given) } } // 1591/AC4: only the shares change, so the module is not recreated and held for no one; the provider's file changes. func TestChangingOnlyTheSharesDoesNotRecreateTheConsumer(t *testing.T) { was := mustParse(t, filesWithShares) now := mustParse(t, `{"module":"files","version":"1","requires":["shell", {"name":"s3-bucket","shares":[{"to":"print-and-scan","methods":["get"]}]}]}`) if said := Recreates(was, now).Say("files"); said != "" { t.Errorf("a change of shares recreates the module: %s", said) } if said := Holds(was, now); said != "" { t.Errorf("a change of shares holds the module: %s", said) } before := Resolution{Node: "laptop", Modules: []Manifest{was}} after := Resolution{Node: "laptop", Modules: []Manifest{now}} a, _, _ := before.ContributionsFrom("s3-bucket", "files", SettingsBy{}) b, _, _ := after.ContributionsFrom("s3-bucket", "files", SettingsBy{}) if sharesOf(t, a) == sharesOf(t, b) { t.Fatalf("the provider's values did not change: %s", sharesOf(t, a)) } } // 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused. func TestAConsumerCannotContributeSharesItself(t *testing.T) { _, err := ParseManifest([]byte(`{"module":"files","version":"1", "contributes":{"s3-bucket":{"shares":"x"}}, "requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`)) if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") { t.Errorf("both written, accepted: %v", err) } // Without the field the grant is still refused, so the provider is never told something the manifest did not say. m := Manifest{Module: "files", Requires: []string{"s3-bucket"}, Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}} r := Resolution{Node: "laptop", Modules: []Manifest{m}} if _, _, err := r.ContributionsFrom("s3-bucket", "files", SettingsBy{}); err == nil { t.Error("a contributed `shares` reached the grant") } }