package link import ( "crypto/ed25519" "encoding/json" "path/filepath" "strings" "testing" "time" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asker" "git.novox.be/novox/mesh-sdk/go/asks" ) // The take act's ask (novox/hq ADR 0325 §8): a hand-over's fields and the act's own in one signed object, under a // signing context of its own. The controller's side holds the same names and words. func TestTheTakeActsAskKeepsItsFieldNamesAndContext(t *testing.T) { body, _ := json.Marshal(AccessActAsk{Act: ActTake, Access: "radarr.movies", Warrant: "w"}) if got := keysOf(t, body); got != "access act warrant" { t.Fatalf("the act's fields are %q", got) } if AskAccessActSubject("ace") != "mesh.node.ace.ask.access-act" { t.Fatalf("the subject is %q", AskAccessActSubject("ace")) } if AccessActContext != "novox-mesh access-act v2\n" { t.Fatalf("the signing context is %q", AccessActContext) } } // signedAct is an act's ask as the controller makes it: the hand-over's fields beside the act's, signed over the // act's context and its bytes. func signedAct(t *testing.T, key ed25519.PrivateKey, ask HandOverAsk, act AccessActAsk) []byte { t.Helper() raw, _ := json.Marshal(struct { HandOverAsk AccessActAsk }{ask, act}) body, _ := json.Marshal(SignedHandOver{Ask: raw, Signature: ed25519.Sign(key, append([]byte(AccessActContext), raw...))}) return body } // **Nothing is done without a warrant**, nor for any act but take, nor signed as a hand-over (ADR 0325 §8). func TestTheTakeActRunsOnlyOnAWarrant(t *testing.T) { m, key := aMember(t) now := time.Now() var given []AccessAct handle := func(a AccessAct) (string, error) { given = append(given, a); return "started", nil } check := HandOverCheck{Signer: m.Signer, Node: m.Node, Nonces: &Nonces{}} ask := func(nonce string) HandOverAsk { return HandOverAsk{Node: m.Node, Path: "/srv/media/movies", By: "the operator", Expires: now.Add(HandOverGood), Nonce: nonce} } for why, body := range map[string][]byte{ "no warrant": signedAct(t, key, ask(aNonce+"01"), AccessActAsk{Act: ActTake, Access: "radarr.movies"}), "another act": signedAct(t, key, ask(aNonce+"02"), AccessActAsk{Act: "share", Access: "radarr.movies", Warrant: "w"}), "no access": signedAct(t, key, ask(aNonce+"03"), AccessActAsk{Act: ActTake, Warrant: "w"}), "a hand-over's": signedAsk(t, key, ask(aNonce+"04")), "a slash in the warrant": signedAct(t, key, ask(aNonce+"05"), AccessActAsk{Act: ActTake, Access: "radarr.movies", Warrant: "w/1"}), "two lines as access": signedAct(t, key, ask(aNonce+"07"), AccessActAsk{Act: ActTake, Access: "radarr.\nmovies", Warrant: "w"}), } { if a := AnswerAccessAct(check, handle, body); a.Refused == "" || !strings.HasSuffix(a.Refused, "nothing was changed") { t.Errorf("%s: not refused, or refused without saying nothing was changed: %+v", why, a) } } if len(given) != 0 { t.Fatalf("a refused ask reached the act: %+v", given) } a := AnswerAccessAct(check, handle, signedAct(t, key, ask(aNonce+"06"), AccessActAsk{Act: ActTake, Access: "radarr.movies", Warrant: "w-1"})) if a.Said != "started" || len(given) != 1 || given[0].Warrant != "w-1" || given[0].Path != "/srv/media/movies" { t.Fatalf("a warranted ask did not reach the act whole: %+v %+v", a, given) } // 774 (ADR 0563 §3): a hand-over asked through the node seat is the engine's own ask, performed only on the // operator's warrant to it: never on the call, not on a warrant for another digest, once on a warrant heard twice, // and not when the disk changed since it was asked. t.Run("the node seat's hand-over acts only on the operator's warrant", func(t *testing.T) { for _, row := range []struct { name string warrant func(asked asks.Ask, w asks.Warrant) asks.Warrant diskAfter string twice bool done int state string }{ {name: "no warrant", done: 0, state: HandAsked}, {name: "the operator's yes", warrant: yes, done: 1, state: HandDone}, {name: "the yes heard twice", warrant: yes, twice: true, done: 1, state: HandDone}, {name: "a warrant for another digest", warrant: func(a asks.Ask, w asks.Warrant) asks.Warrant { w = yes(a, w) w.AskDigest = "sha256:another" return w }, done: 0, state: HandAsked}, {name: "a no", warrant: func(a asks.Ask, w asks.Warrant) asks.Warrant { w = yes(a, w) w.Option, w.Label, w.Level = "no", "No, leave it", asks.Acknowledge return w }, done: 0, state: HandRefused}, {name: "the disk changed", warrant: yes, diskAfter: "0:0 700 9:2", done: 0, state: HandRefused}, {name: "expired", warrant: func(a asks.Ask, w asks.Warrant) asks.Warrant { return asks.Warrant{Ask: a.ID, Asker: w.Asker, Outcome: asks.OutcomeExpired, At: w.At} }, done: 0, state: HandRefused}, } { t.Run(row.name, func(t *testing.T) { h, asked, done := anEngine(t) disk := "1000:1000 755 9:1" h.JudgeHandOver = func(path string) (Judged, error) { return Judged{Subject: path, Path: path, Sets: "owner jo, group jo, mode 0750", Disk: disk, Words: "A directory used as found."}, nil } if _, err := h.Call(EngineCall{Verb: ActHandOver, Caller: "shanks.node-tools", Args: map[string]any{"path": "/srv/x", "why": "a test"}}); err != nil { t.Fatal(err) } if len(*asked) != 1 || *done != 0 { t.Fatalf("the call asked %d and performed %d", len(*asked), *done) } if _, err := h.Call(EngineCall{Verb: ActHandOver, Caller: "x", Args: map[string]any{"path": "/srv/x"}}); err == nil { t.Fatal("a second ask of the same path was asked while one is open") } if row.diskAfter != "" { disk = row.diskAfter } if row.warrant != nil { w := row.warrant((*asked)[0], asks.Warrant{Asker: asks.EngineAsker("n1"), At: time.Now()}) h.Heard(w) if row.twice { h.Heard(w) } } got, _ := h.Acts(0) acts := got.(map[string]any)["acts"].([]HandAct) if *done != row.done || len(acts) != 1 || acts[0].State != row.state { t.Fatalf("performed %d (want %d), acts %+v (want %s)", *done, row.done, acts, row.state) } if row.state == HandDone && (acts[0].By != "operator" || acts[0].Via == "" || acts[0].Ask == "") { t.Fatalf("the act does not record the warrant: %+v", acts[0]) } }) } }) } // yes is the operator's yes to an ask, as the router writes it. func yes(a asks.Ask, w asks.Warrant) asks.Warrant { w.Ask, w.Outcome, w.Option, w.Label, w.Level = a.ID, asks.OutcomeChosen, "yes", "Yes", asks.Approve w.By = &asks.Person{Who: asks.Operator, Kind: "telegram", Verified: "user id verified"} w.AskDigest, w.Proofs = a.Digest(), []string{"P1"} return w } // anEngine is a node seat with its own state in a temporary folder, a router that keeps what it was asked, and a // hand-over that counts what it performed. func anEngine(t *testing.T) (*HandActs, *[]asks.Ask, *int) { t.Helper() var asked []asks.Ask done := 0 state := &FileState{Path: filepath.Join(t.TempDir(), HandActsFile)} h := &HandActs{Node: "n1", State: state, HandOver: func(path, by string) (string, error) { done++; return path + " handed over by " + by, nil }} h.Asker = &asker.Client{Module: asks.EngineAsker("n1"), State: state, Publish: func(subject string, body any, id string) error { if a, ok := body.(asks.Ask); ok && subject == asks.AskSubject(asks.EngineAsker("n1")) { asked = append(asked, a) } return nil }, Record: func(string) (*stdio.StateEntry, error) { return nil, nil }} return h, &asked, &done } // The controller names an access by its declared resource id exactly, which may hold a `/`: it reaches the act, // which matches it against what was declared. func TestATakeActNamesItsAccessByItsDeclaredId(t *testing.T) { m, key := aMember(t) var given []AccessAct handle := func(a AccessAct) (string, error) { given = append(given, a); return "started", nil } check := HandOverCheck{Signer: m.Signer, Node: m.Node, Nonces: &Nonces{}} ask := HandOverAsk{Node: m.Node, Path: "/services/media/movies", By: "the operator", Expires: time.Now().Add(HandOverGood), Nonce: aNonce + "08"} id := "radarr.access-services/media/movies" a := AnswerAccessAct(check, handle, signedAct(t, key, ask, AccessActAsk{Act: ActTake, Access: id, Warrant: "w-1"})) if a.Refused != "" || len(given) != 1 || given[0].Access != id { t.Fatalf("an access id with a slash did not reach the act as declared: %+v %+v", a, given) } }