CAT-V4, CAT-C10: the vault's retire and the config events and renames verb match the seat definitions of mesh-contracts
retire takes the definition's warrant (the hand act's id, naming node, module, name and ask) and answers retired; own-retired, renamed, retiring and retired carry exactly the definition's bodies on its subjects. The whole-module tests decode what the module published strictly into the generated types. The modules require mesh-contracts, to be pinned with the group (a local replace in an uncommitted go.work until then). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -132,17 +132,17 @@ func enrolmentTools(env *Env) []toolDef {
|
||||
Description: "Retire one module's own secret on a node because no manifest reads it any more (design 32): removed " +
|
||||
"whole, and own-retired said (node, module, name, the ask, the caller; never a value). Answered to the holder of " +
|
||||
"the seat mesh-config alone, as the vault's list retire-callers names it, and only for a hand act it recorded " +
|
||||
"through mesh-hand-acts for this verb naming exactly this node, module and name (`act`), read back by its id.",
|
||||
"through mesh-hand-acts for this verb naming exactly this node, module and name and the ask of the operator's Approve " +
|
||||
"(`warrant`, the act's id), read back here.",
|
||||
Input: map[string]any{"node": node, "module": str("the module whose secret it is"), "name": str("the secret's name"),
|
||||
"act": str("the id of the hand act recorded through mesh-hand-acts that warrants it"),
|
||||
"ask": str("the id of the operator's ask whose Approve this answers, kept in what is said")},
|
||||
"warrant": str("the operator's warrant for this node, module and name: the id of the hand act recorded through mesh-hand-acts")},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.RetireOwn(caller, RetireArgs{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"),
|
||||
Act: arg(a, "act"), Ask: arg(a, "ask")})
|
||||
Warrant: arg(a, "warrant")})
|
||||
}},
|
||||
{Name: "node_logins",
|
||||
Description: "Every node's key of the vault's state node-logins: the login its node-engine dials as and, while a rotation " +
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshvault"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
@@ -300,6 +301,16 @@ func (p *player) hashOf(t *testing.T, user string) *AccountHash {
|
||||
return &a
|
||||
}
|
||||
|
||||
// strictly decodes raw into v, a field the type does not know being a fault (the definition's type is the contract).
|
||||
func strictly(t *testing.T, raw []byte, v any) {
|
||||
t.Helper()
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
t.Errorf("%s does not match the seat's definition: %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func eventually(t *testing.T, what string, ok func() bool) {
|
||||
t.Helper()
|
||||
for end := time.Now().Add(30 * time.Second); time.Now().Before(end); time.Sleep(50 * time.Millisecond) {
|
||||
@@ -385,8 +396,8 @@ func TestTheVaultWholeMintsOnTheBusWhoeverHoldsTheLease(t *testing.T) {
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
return map[string]any{"acts": []HandActSeen{
|
||||
{ID: "act-ok", By: "novox.mesh-config", At: time.Now(), Verb: "mesh-vault.retire", Args: []string{"g14 dunst token"}},
|
||||
{ID: "act-other", By: "novox.mesh-config", At: time.Now(), Verb: "mesh-vault.retire", Args: []string{"g14 dunst other"}}}}, nil
|
||||
{ID: "act-ok", By: "novox.mesh-config", At: time.Now(), Verb: "mesh-vault.retire", Args: []string{"g14 dunst token ret-1"}},
|
||||
{ID: "act-other", By: "novox.mesh-config", At: time.Now(), Verb: "mesh-vault.retire", Args: []string{"g14 dunst other ret-1"}}}}, nil
|
||||
}
|
||||
go p.serve(stdout)
|
||||
|
||||
@@ -454,21 +465,35 @@ func TestTheVaultWholeMintsOnTheBusWhoeverHoldsTheLease(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct{ caller, act string }{{"g14.claude-code", "act-ok"}, {"novox.mesh-config", "act-other"}, {"novox.mesh-config", ""}} {
|
||||
if _, err := p.call(Seat+".retire", c.caller, map[string]any{"node": "g14", "module": "dunst", "name": "token", "act": c.act}); err == nil {
|
||||
if _, err := p.call(Seat+".retire", c.caller, map[string]any{"node": "g14", "module": "dunst", "name": "token", "warrant": c.act}); err == nil {
|
||||
t.Errorf("%s retired the token with act %q", c.caller, c.act)
|
||||
}
|
||||
}
|
||||
if m, err := waiter.NextMsg(300 * time.Millisecond); err == nil {
|
||||
t.Fatalf("a refused retire said %s", m.Subject)
|
||||
}
|
||||
if _, err := p.call(Seat+".retire", "novox.mesh-config", map[string]any{"node": "g14", "module": "dunst", "name": "token", "act": "act-ok",
|
||||
"ask": "ret-1"}); err != nil {
|
||||
// The call is the definition's input, the answer its answer, and the event its body on its subject (CAT-V4).
|
||||
input, _ := json.Marshal(meshvault.RetireInput{Node: "g14", Module: "dunst", Name: "token", Warrant: "act-ok"})
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal(input, &args)
|
||||
raw, err = p.call(Seat+"."+meshvault.VerbRetire, "novox.mesh-config", args)
|
||||
if err != nil {
|
||||
t.Fatalf("retire with its warrant: %v", err)
|
||||
}
|
||||
var answered meshvault.RetireAnswer
|
||||
strictly(t, raw, &answered)
|
||||
if !answered.Retired {
|
||||
t.Errorf("retire answered %s", raw)
|
||||
}
|
||||
m, err := waiter.NextMsg(10 * time.Second)
|
||||
if err != nil || m.Subject != "mesh.mod.mesh-vault.event."+OwnRetiredEvent || !strings.Contains(string(m.Data), `"ask":"ret-1"`) {
|
||||
if err != nil || m.Subject != meshvault.HolderSubjectOwnRetired("mesh-vault") {
|
||||
t.Fatalf("retire said %v, %v", m, err)
|
||||
}
|
||||
var event meshvault.OwnRetiredHolderEvent
|
||||
strictly(t, m.Data, &event)
|
||||
if event.Ask != "ret-1" || event.Caller != "novox.mesh-config" || event.Node != "g14" || event.Module != "dunst" || event.Name != "token" {
|
||||
t.Errorf("own-retired said %s", m.Data)
|
||||
}
|
||||
for _, other := range []string{"rotated", OwnReplacedEvent} {
|
||||
if extra, err := waiter.NextMsg(300 * time.Millisecond); err == nil {
|
||||
t.Errorf("a waiter on %s heard %s", other, extra.Subject)
|
||||
|
||||
@@ -7,8 +7,9 @@ package main
|
||||
// - the caller is on the vault's list `retire-callers`, a trusted config value of the vault (ADR 0363's form, as
|
||||
// mesh-mail-reader's `callers`), which names the holder of mesh-config alone, checked against the caller the tool
|
||||
// runner proved (stdio.Callers); and
|
||||
// - the warrant: a hand act the caller recorded through mesh-hand-acts for this verb, read back here by its id, recent,
|
||||
// naming exactly this node, module and secret (ADR 0259 §6). An act for another name warrants nothing.
|
||||
// - the warrant (the verb's argument, as the seat's definition names it): the id of a hand act the caller recorded
|
||||
// through mesh-hand-acts for this verb, read back here, recent, naming exactly this node, module and secret and the
|
||||
// ask whose Approve it answers (ADR 0259 §6). An act for another name warrants nothing.
|
||||
//
|
||||
// The vault then removes the record whole and says `own-retired` (node, module, name, the ask's id, the caller it proved)
|
||||
// beside `rotated` and `own-replaced`, under its own subject: a waiter on either does not wake on it. Never a value.
|
||||
@@ -26,21 +27,24 @@ const OwnRetiredEvent = "own-retired"
|
||||
// RetireVerb is the seat's verb, and the verb of the hand act that warrants it (`mesh-vault.retire`).
|
||||
const RetireVerb = "retire"
|
||||
|
||||
// RetireArgs are `retire`'s arguments: the secret by node, module and name, the hand act that warrants it, and the ask of
|
||||
// the operator's Approve it answers (kept in what is said).
|
||||
// RetireArgs are `retire`'s arguments, as the seat's definition names them: the secret by node, module and name, and the
|
||||
// warrant, the id of the hand act that warrants it.
|
||||
type RetireArgs struct {
|
||||
Node, Module, Name, Act, Ask string
|
||||
Node, Module, Name, Warrant string
|
||||
}
|
||||
|
||||
// OwnRetired is what one `retire` did. Never a value.
|
||||
type OwnRetired struct {
|
||||
Secret string `json:"secret"`
|
||||
// OwnRetiredBody is the body of own-retired, exactly the seat definition's (node, module, name, ask, caller).
|
||||
type OwnRetiredBody struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Ask string `json:"ask"`
|
||||
Caller string `json:"caller"`
|
||||
Act string `json:"act"`
|
||||
}
|
||||
|
||||
// OwnRetired is what one `retire` answers, the seat definition's answer.
|
||||
type OwnRetired struct {
|
||||
Retired bool `json:"retired"`
|
||||
}
|
||||
|
||||
// fromRetireCallers refuses every caller the vault's list `retire-callers` does not name.
|
||||
@@ -55,38 +59,39 @@ func (v *Vault) fromRetireCallers(caller string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// warrantsRetire reads the hand act back and refuses what it does not warrant for exactly this secret.
|
||||
func (v *Vault) warrantsRetire(caller string, a RetireArgs) error {
|
||||
if strings.TrimSpace(a.Act) == "" {
|
||||
return fmt.Errorf("retire carries the hand act that warrants it (`act`): record it through mesh-hand-acts first (design 32)")
|
||||
// warrantsRetire reads the hand act back and refuses what it does not warrant for exactly this secret; it answers the
|
||||
// ask the act names.
|
||||
func (v *Vault) warrantsRetire(caller string, a RetireArgs) (ask string, err error) {
|
||||
if strings.TrimSpace(a.Warrant) == "" {
|
||||
return "", fmt.Errorf("retire carries the hand act that warrants it (`warrant`): record it through mesh-hand-acts first (design 32)")
|
||||
}
|
||||
if v.ReadAct == nil {
|
||||
return fmt.Errorf("the vault reads no hand acts, so it retires nothing")
|
||||
return "", fmt.Errorf("the vault reads no hand acts, so it retires nothing")
|
||||
}
|
||||
act, err := v.ReadAct(a.Act)
|
||||
act, err := v.ReadAct(a.Warrant)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the hand act %s could not be read back from mesh-hand-acts: %w", a.Act, err)
|
||||
return "", fmt.Errorf("the hand act %s could not be read back from mesh-hand-acts: %w", a.Warrant, err)
|
||||
}
|
||||
switch {
|
||||
case act == nil:
|
||||
return fmt.Errorf("mesh-hand-acts holds no hand act %s: record one first", a.Act)
|
||||
return "", fmt.Errorf("mesh-hand-acts holds no hand act %s: record one first", a.Warrant)
|
||||
case act.By != caller:
|
||||
return fmt.Errorf("the hand act %s was recorded by %s, not by %s who asks", a.Act, act.By, caller)
|
||||
return "", fmt.Errorf("the hand act %s was recorded by %s, not by %s who asks", a.Warrant, act.By, caller)
|
||||
case v.Now().Sub(act.At) > HandActWithin:
|
||||
return fmt.Errorf("the hand act %s is older than %s: record a new one", a.Act, HandActWithin)
|
||||
return "", fmt.Errorf("the hand act %s is older than %s: record a new one", a.Warrant, HandActWithin)
|
||||
case act.Verb != Seat+"."+RetireVerb:
|
||||
return fmt.Errorf("the hand act %s is %q, not %s.%s: it warrants no retirement", a.Act, act.Verb, Seat, RetireVerb)
|
||||
return "", fmt.Errorf("the hand act %s is %q, not %s.%s: it warrants no retirement", a.Warrant, act.Verb, Seat, RetireVerb)
|
||||
}
|
||||
var named []string
|
||||
for _, arg := range act.Args {
|
||||
named = append(named, strings.Fields(arg)...)
|
||||
}
|
||||
// The warrant is bound to the node, module and name it was asked for: all three, and no others.
|
||||
if len(named) != 3 || named[0] != a.Node || named[1] != a.Module || named[2] != a.Name {
|
||||
return fmt.Errorf("the hand act %s names %q, not %s %s %s: a warrant retires nothing else",
|
||||
a.Act, strings.Join(named, " "), a.Node, a.Module, a.Name)
|
||||
// The warrant is bound to the node, module and name it was asked for, and names the ask: those four words, no others.
|
||||
if len(named) != 4 || named[0] != a.Node || named[1] != a.Module || named[2] != a.Name {
|
||||
return "", fmt.Errorf("the hand act %s names %q, not %s %s %s and its ask: a warrant retires nothing else",
|
||||
a.Warrant, strings.Join(named, " "), a.Node, a.Module, a.Name)
|
||||
}
|
||||
return nil
|
||||
return named[3], nil
|
||||
}
|
||||
|
||||
// RetireOwn removes one module's own secret on a node on the warrant of a hand act, and says `own-retired`.
|
||||
@@ -98,7 +103,8 @@ func (v *Vault) RetireOwn(caller string, a RetireArgs) (OwnRetired, error) {
|
||||
if err != nil {
|
||||
return OwnRetired{}, err
|
||||
}
|
||||
if err := v.warrantsRetire(caller, a); err != nil {
|
||||
ask, err := v.warrantsRetire(caller, a)
|
||||
if err != nil {
|
||||
return OwnRetired{}, err
|
||||
}
|
||||
switch {
|
||||
@@ -115,10 +121,10 @@ func (v *Vault) RetireOwn(caller string, a RetireArgs) (OwnRetired, error) {
|
||||
if err := v.Store.Delete(secret); err != nil {
|
||||
return OwnRetired{}, fmt.Errorf("%s is not retired: %w", secret, err)
|
||||
}
|
||||
out := OwnRetired{Secret: secret, Node: a.Node, Module: a.Module, Name: a.Name, Ask: a.Ask, Caller: caller, Act: a.Act}
|
||||
v.log("retired %s on the warrant of the hand act %s, asked by %s (ask %s)", secret, a.Act, caller, a.Ask)
|
||||
out := OwnRetired{Retired: true}
|
||||
v.log("retired %s on the warrant of the hand act %s, asked by %s (ask %s)", secret, a.Warrant, caller, ask)
|
||||
if v.Emit != nil {
|
||||
body := map[string]any{"secret": secret, "node": a.Node, "module": a.Module, "name": a.Name, "ask": a.Ask, "caller": caller}
|
||||
body := map[string]any{"node": a.Node, "module": a.Module, "name": a.Name, "ask": ask, "caller": caller}
|
||||
if err := v.Emit(OwnRetiredEvent, body); err != nil {
|
||||
return out, fmt.Errorf("%s is retired, but own-retired was not said: %w", secret, err)
|
||||
}
|
||||
|
||||
@@ -96,13 +96,14 @@ func TestRetireRemovesAnOwnSecretOnlyOnTheWarrantOfTheListedCaller(t *testing.T)
|
||||
act := func(id, by, verb, what string, age time.Duration) {
|
||||
acts[id] = &HandActSeen{ID: id, By: by, At: v.Now().Add(-age), Verb: verb, Args: []string{what}}
|
||||
}
|
||||
act("ok", caller, "mesh-vault.retire", "g14 dunst token", time.Minute)
|
||||
act("other-name", caller, "mesh-vault.retire", "g14 dunst other", time.Minute)
|
||||
act("other-node", caller, "mesh-vault.retire", "ace dunst token", time.Minute)
|
||||
act("other-caller", "g14.claude-code", "mesh-vault.retire", "g14 dunst token", time.Minute)
|
||||
act("other-verb", caller, "mesh-vault.rotate", "g14 dunst token", time.Minute)
|
||||
act("ledgers", caller, "mesh-vault.retire", "g14 node-tools ledgers", time.Minute)
|
||||
act("old", caller, "mesh-vault.retire", "g14 dunst token", 25*time.Hour)
|
||||
act("ok", caller, "mesh-vault.retire", "g14 dunst token ret-1", time.Minute)
|
||||
act("other-name", caller, "mesh-vault.retire", "g14 dunst other ret-1", time.Minute)
|
||||
act("other-node", caller, "mesh-vault.retire", "ace dunst token ret-1", time.Minute)
|
||||
act("other-caller", "g14.claude-code", "mesh-vault.retire", "g14 dunst token ret-1", time.Minute)
|
||||
act("other-verb", caller, "mesh-vault.rotate", "g14 dunst token ret-1", time.Minute)
|
||||
act("ledgers", caller, "mesh-vault.retire", "g14 node-tools ledgers ret-1", time.Minute)
|
||||
act("no-ask", caller, "mesh-vault.retire", "g14 dunst token", time.Minute)
|
||||
act("old", caller, "mesh-vault.retire", "g14 dunst token ret-1", 25*time.Hour)
|
||||
const secret = "own/g14/dunst/token"
|
||||
for _, name := range []string{secret, "own/g14/dunst/other", "own/g14/node-tools/ledgers"} {
|
||||
if _, err := v.keep(name, "a-value-of-"+name, "the test", "g14.dunst", OriginMade); err != nil {
|
||||
@@ -115,15 +116,16 @@ func TestRetireRemovesAnOwnSecretOnlyOnTheWarrantOfTheListedCaller(t *testing.T)
|
||||
want string
|
||||
}{
|
||||
{"no act", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token"}, "carries the hand act"},
|
||||
{"an act that does not exist", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Act: "nope"}, "holds no hand act"},
|
||||
{"an act for another name", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Act: "other-name"}, "a warrant retires nothing else"},
|
||||
{"an act for another node", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Act: "other-node"}, "a warrant retires nothing else"},
|
||||
{"an act of another caller", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Act: "other-caller"}, "was recorded by g14.claude-code"},
|
||||
{"an act for another verb", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Act: "other-verb"}, "warrants no retirement"},
|
||||
{"an act older than a day", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Act: "old"}, "older than"},
|
||||
{"a caller not on the list", "g14.claude-code", RetireArgs{Node: "g14", Module: "dunst", Name: "token", Act: "other-caller"}, "retire-callers"},
|
||||
{"an unproven caller", stdio.Unproven, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Act: "ok"}, "retire-callers"},
|
||||
{"the tool runner's ledgers", caller, RetireArgs{Node: "g14", Module: "node-tools", Name: "ledgers", Act: "ledgers"}, "bus account or the tool runner's ledgers"},
|
||||
{"an act that does not exist", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "nope"}, "holds no hand act"},
|
||||
{"an act for another name", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "other-name"}, "a warrant retires nothing else"},
|
||||
{"an act for another node", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "other-node"}, "a warrant retires nothing else"},
|
||||
{"an act of another caller", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "other-caller"}, "was recorded by g14.claude-code"},
|
||||
{"an act for another verb", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "other-verb"}, "warrants no retirement"},
|
||||
{"an act that names no ask", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "no-ask"}, "a warrant retires nothing else"},
|
||||
{"an act older than a day", caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "old"}, "older than"},
|
||||
{"a caller not on the list", "g14.claude-code", RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "other-caller"}, "retire-callers"},
|
||||
{"an unproven caller", stdio.Unproven, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "ok"}, "retire-callers"},
|
||||
{"the tool runner's ledgers", caller, RetireArgs{Node: "g14", Module: "node-tools", Name: "ledgers", Warrant: "ledgers"}, "bus account or the tool runner's ledgers"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
before := len(v.bus.events)
|
||||
@@ -139,8 +141,8 @@ func TestRetireRemovesAnOwnSecretOnlyOnTheWarrantOfTheListedCaller(t *testing.T)
|
||||
})
|
||||
}
|
||||
before := len(v.bus.events)
|
||||
got, err := v.RetireOwn(caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Act: "ok", Ask: "ret-1"})
|
||||
if err != nil || got.Secret != secret || got.Caller != caller {
|
||||
got, err := v.RetireOwn(caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "ok"})
|
||||
if err != nil || !got.Retired {
|
||||
t.Fatalf("retire answered %+v, %v", got, err)
|
||||
}
|
||||
if _, held, _ := v.Store.Read(secret); held {
|
||||
@@ -152,7 +154,7 @@ func TestRetireRemovesAnOwnSecretOnlyOnTheWarrantOfTheListedCaller(t *testing.T)
|
||||
if n := eventsNamed(v, before, OwnRetiredEvent); n != 1 || len(v.bus.events)-before != 1 {
|
||||
t.Fatalf("said %d own-retired among %d events, want one and no other", n, len(v.bus.events)-before)
|
||||
}
|
||||
if body, _ := v.bus.events[before]["body"].(map[string]any); body["ask"] != "ret-1" || body["caller"] != caller || body["node"] != "g14" ||
|
||||
if body, _ := v.bus.events[before]["body"].(map[string]any); len(body) != 5 || body["ask"] != "ret-1" || body["caller"] != caller || body["node"] != "g14" ||
|
||||
body["module"] != "dunst" || body["name"] != "token" {
|
||||
t.Errorf("own-retired said %+v", body)
|
||||
}
|
||||
@@ -160,7 +162,7 @@ func TestRetireRemovesAnOwnSecretOnlyOnTheWarrantOfTheListedCaller(t *testing.T)
|
||||
t.Error("own-retired shares a name with an event a waiter hears")
|
||||
}
|
||||
// Asked again for what is gone: nothing to retire.
|
||||
if _, err := v.RetireOwn(caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Act: "ok"}); err == nil ||
|
||||
if _, err := v.RetireOwn(caller, RetireArgs{Node: "g14", Module: "dunst", Name: "token", Warrant: "ok"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "nothing to retire") {
|
||||
t.Errorf("a second retire answered %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user