mesh/delivery-group group feat/774-l3-reland rejected: composed together: the checker's verdict at the members' heads (b48c90fea31f): integration: mesh-self-ch…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: integration: mesh-self-check/cmd/mesh-self-check.TestTheSelfCheckWholeRunsOnTheBus: the run answered {Run:doctor-1793351737-1 At:20…
One commit with exactly the tree of the reviewed head35b4509c0a, on main12c2574c9d, so two compiled programs added and removed on the branch leave no trace in history (issue 1577).
561 lines
22 KiB
Go
561 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/seal"
|
|
"github.com/nats-io/nats.go"
|
|
)
|
|
|
|
// A node's secrets come from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647), against a
|
|
// real bus whose accounts are the client's as mesh-controller composes them: a moved module's file is filled with
|
|
// its secret from the vault's get, asked in the client's own name and answered sealed to its key; the value never
|
|
// crosses the bus in the clear; a secret rotated in the vault reaches its file without a new declaration; a secret
|
|
// the vault does not hold yet is waited for, and a refusal fails the module with the vault's reason.
|
|
//
|
|
// The vault is a stand-in answering `mesh-vault.get` as its seat does on the bus: on the subject naming the caller,
|
|
// sealing to the key the caller sends, handing only what it allows that caller (the vault's own tests prove its
|
|
// rule), and saying `secret-changed` with no value.
|
|
|
|
// standInVault answers get on the bus, for provisioner.g14 alone and only the names it allows.
|
|
type standInVault struct {
|
|
t *testing.T
|
|
conn *nats.Conn
|
|
mu sync.Mutex
|
|
held map[string]string
|
|
gen map[string]int
|
|
allowed map[string]bool
|
|
asked map[string]int
|
|
refuse string
|
|
}
|
|
|
|
func startVault(t *testing.T, b *aBus) *standInVault {
|
|
t.Helper()
|
|
v := &standInVault{t: t, conn: b.conn, held: map[string]string{}, gen: map[string]int{}, allowed: map[string]bool{},
|
|
asked: map[string]int{}}
|
|
sub, err := b.conn.Subscribe("mesh.seat.mesh-vault.call.get.*", func(m *nats.Msg) {
|
|
caller := strings.ReplaceAll(m.Subject[strings.LastIndex(m.Subject, ".")+1:], "~", ".")
|
|
var args struct {
|
|
Name string `json:"name"`
|
|
PublicKey string `json:"public_key"`
|
|
}
|
|
_ = json.Unmarshal(m.Data, &args)
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
v.asked[args.Name]++
|
|
answer := map[string]any{}
|
|
value, held := v.held[args.Name]
|
|
switch {
|
|
case caller != "provisioner."+node || !v.allowed[args.Name]:
|
|
answer["error"] = caller + " may not ask for " + args.Name + ": not on the list"
|
|
case v.refuse != "":
|
|
answer["error"] = v.refuse
|
|
case !held:
|
|
answer["error"] = "the vault holds no secret named " + args.Name
|
|
default:
|
|
box, err := seal.Seal(value, args.PublicKey)
|
|
if err != nil {
|
|
answer["error"] = err.Error()
|
|
break
|
|
}
|
|
answer["result"] = map[string]any{"name": args.Name, "generation": v.gen[args.Name],
|
|
"changed-at": time.Now().UTC(), "sealed": box}
|
|
}
|
|
body, _ := json.Marshal(answer)
|
|
_ = m.Respond(body)
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
|
_ = b.conn.Flush()
|
|
return v
|
|
}
|
|
|
|
// keep holds value as name and allows the client to ask for it; with say, it says secret-changed as the vault does.
|
|
func (v *standInVault) keep(name, value string, say bool) {
|
|
v.mu.Lock()
|
|
v.held[name] = value
|
|
v.gen[name]++
|
|
v.allowed[name] = true
|
|
gen := v.gen[name]
|
|
v.mu.Unlock()
|
|
if say {
|
|
body, _ := json.Marshal(map[string]any{"name": name, "by": "the test", "at": time.Now().UTC(), "generation": gen})
|
|
if err := v.conn.Publish(SecretChangedSubject, body); err != nil {
|
|
v.t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (v *standInVault) times(name string) int {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
return v.asked[name]
|
|
}
|
|
|
|
// sniffer hears every message on the bus, to prove a value never crosses it in the clear.
|
|
type sniffer struct {
|
|
mu sync.Mutex
|
|
seen []string
|
|
}
|
|
|
|
func sniff(t *testing.T, b *aBus) *sniffer {
|
|
t.Helper()
|
|
s := &sniffer{}
|
|
sub, err := b.conn.Subscribe(">", func(m *nats.Msg) {
|
|
s.mu.Lock()
|
|
s.seen = append(s.seen, m.Subject+" "+string(m.Data))
|
|
s.mu.Unlock()
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
|
_ = b.conn.Flush()
|
|
return s
|
|
}
|
|
|
|
func (s *sniffer) carrying(value string) []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var out []string
|
|
for _, m := range s.seen {
|
|
if strings.Contains(m, value) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *sniffer) heard() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.seen)
|
|
}
|
|
|
|
const appConf, appDB = "/etc/app/app.conf", "/var/lib/app/db-password"
|
|
|
|
// app is a moved module with a template naming a secret and a config value, and a secret kept in a file of its own.
|
|
func app(t *testing.T) Plan {
|
|
tpl, _ := templates(t, map[string]string{"app.conf": "token = ${secret:app-token}\nfont = ${config:font-size}\n"})
|
|
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf",
|
|
OwnerAtRuntime: "owned", Takes: "restart-on"}},
|
|
Secrets: []PlannedSecret{{Name: "app-token"}, {Name: "db-password", Path: appDB}}}
|
|
}
|
|
|
|
func TestNatsAMovedModulesFileIsFilledWithItsSecretFromTheVault(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
wire := sniff(t, b)
|
|
vault := startVault(t, b)
|
|
const token, password = "tok-3f9a1c-never-on-the-bus", "pw-77b2e0-never-on-the-bus"
|
|
vault.keep("own/g14/app/app-token", token, false)
|
|
vault.keep("own/g14/app/db-password", password, false)
|
|
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
|
b.config(t, "app", "font-size", "13")
|
|
b.assign(t, "app", app(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
|
|
eventually(t, "app.conf is filled with its secret", func() bool {
|
|
return e.read(t, appConf) == "token = "+token+"\nfont = 13\n"
|
|
})
|
|
eventually(t, "the secret's own file holds it alone", func() bool { return e.read(t, appDB) == password })
|
|
eventually(t, "prov.applied says applied", func() bool {
|
|
r, _ := heard.last("app")
|
|
return r.Status == StatusApplied && len(r.Files) == 2
|
|
})
|
|
if wire.heard() == 0 {
|
|
t.Fatal("the sniffer heard nothing: it proves nothing")
|
|
}
|
|
for _, value := range []string{token, password} {
|
|
if said := wire.carrying(value); len(said) > 0 {
|
|
t.Errorf("the value crossed the bus in the clear: %q", said)
|
|
}
|
|
}
|
|
if vault.times("own/g14/app/app-token") != 1 || vault.times("own/g14/app/db-password") != 1 {
|
|
t.Errorf("the vault was asked %d and %d times, want once each", vault.times("own/g14/app/app-token"),
|
|
vault.times("own/g14/app/db-password"))
|
|
}
|
|
}
|
|
|
|
func TestNatsASecretRotatedInTheVaultReachesItsFileWithoutANewDeclaration(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
vault := startVault(t, b)
|
|
vault.keep("own/g14/app/app-token", "first", false)
|
|
vault.keep("own/g14/app/db-password", "pw", false)
|
|
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
|
b.config(t, "app", "font-size", "13")
|
|
b.assign(t, "app", app(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
eventually(t, "app.conf is filled", func() bool { return strings.Contains(e.read(t, appConf), "token = first\n") })
|
|
assigned, err := b.kv[PlansBucket].Get(context.Background(), node+".app")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
vault.keep("own/g14/app/app-token", "second", true)
|
|
eventually(t, "the rotated value reaches its file", func() bool {
|
|
return strings.Contains(e.read(t, appConf), "token = second\n")
|
|
})
|
|
eventually(t, "prov.applied says it was updated", func() bool {
|
|
r, _ := heard.last("app")
|
|
return r.Status == StatusApplied && r.Files[0].Action == "updated"
|
|
})
|
|
now, err := b.kv[PlansBucket].Get(context.Background(), node+".app")
|
|
if err != nil || now.Revision() != assigned.Revision() {
|
|
t.Errorf("the module's assignment was written again: revision %d, was %d", now.Revision(), assigned.Revision())
|
|
}
|
|
if moved, _ := e.counts(appConf); moved != 2 {
|
|
t.Errorf("app.conf moved %d times, want 2: the program restarts once for the rotation", moved)
|
|
}
|
|
if moved, _ := e.counts(appDB); moved != 1 {
|
|
t.Errorf("the other secret's file moved %d times: only what changed is written", moved)
|
|
}
|
|
// What another machine's secret, or a secret no plan here names, changes is no reason to ask.
|
|
vault.keep("own/ace/app/app-token", "elsewhere", true)
|
|
vault.keep("own/g14/other/token", "unplanned", true)
|
|
time.Sleep(300 * time.Millisecond)
|
|
if n := vault.times("own/g14/app/app-token"); n != 2 {
|
|
t.Errorf("the vault was asked for app-token %d times, want 2", n)
|
|
}
|
|
if vault.times("own/ace/app/app-token")+vault.times("own/g14/other/token") != 0 {
|
|
t.Error("a change of a secret this machine's plans do not name made the client ask")
|
|
}
|
|
}
|
|
|
|
func TestNatsASecretTheVaultDoesNotHoldYetIsWaitedFor(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
vault := startVault(t, b)
|
|
vault.keep("own/g14/app/db-password", "pw", false)
|
|
vault.allowed["own/g14/app/app-token"] = true // the plan names it; the vault does not hold it yet
|
|
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
|
b.config(t, "app", "font-size", "13")
|
|
b.assign(t, "app", app(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
|
|
eventually(t, "prov.applied says it waits for the secret", func() bool {
|
|
r, _ := heard.last("app")
|
|
return r.Status == StatusWaiting && len(r.WaitsFor) == 1 &&
|
|
strings.HasPrefix(r.WaitsFor[0], "secret:app-token (the vault holds no")
|
|
})
|
|
if _, asked := e.counts(appConf); asked != 0 {
|
|
t.Error("a file waiting for its secret was written")
|
|
}
|
|
// 1553: a file whose own values are all there is filled, though another file of the module waits.
|
|
eventually(t, "the secret's own file is filled while the template waits", func() bool { return e.read(t, appDB) == "pw" })
|
|
vault.keep("own/g14/app/app-token", "arrived", true)
|
|
eventually(t, "it is filled once the vault holds it", func() bool {
|
|
return strings.Contains(e.read(t, appConf), "token = arrived\n") && e.read(t, appDB) == "pw"
|
|
})
|
|
}
|
|
|
|
// 1129/AC4: a secret the vault refuses is asked for once and not again while nothing about it changes, even
|
|
// though several unrelated changes over the bus each reconcile the module again (never a timer).
|
|
func TestNatsARefusalFailsTheModuleWithTheVaultsReasonAndIsNotAskedEveryPass(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
vault := startVault(t, b)
|
|
vault.keep("own/g14/app/app-token", "tok", false)
|
|
vault.keep("own/g14/app/db-password", "pw", false)
|
|
vault.refuse = "provisioner.g14 may not ask for own/g14/app/app-token: app's plan on g14 names no secret app-token"
|
|
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
|
b.config(t, "app", "font-size", "13")
|
|
b.assign(t, "app", app(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
|
|
eventually(t, "prov.applied says it failed, with the vault's reason", func() bool {
|
|
r, _ := heard.last("app")
|
|
return r.Status == StatusFailed && strings.Contains(r.Reason, "names no secret app-token")
|
|
})
|
|
// Several changes reach the module, each a reason to reconcile it again.
|
|
for _, size := range []string{"14", "15", "16"} {
|
|
b.config(t, "app", "font-size", size)
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
if n := vault.times("own/g14/app/app-token"); n != 1 {
|
|
t.Errorf("a refused secret was asked %d times across several reconciles, want once", n)
|
|
}
|
|
if _, asked := e.counts(appConf); asked != 0 {
|
|
t.Error("a file whose secret was refused was written")
|
|
}
|
|
}
|
|
|
|
func TestNatsAVaultThatDoesNotAnswerIsWaitedFor(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
|
b.config(t, "app", "font-size", "13")
|
|
b.assign(t, "app", app(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
eventually(t, "prov.applied says it waits for the vault", func() bool {
|
|
r, _ := heard.last("app")
|
|
return r.Status == StatusWaiting && len(r.WaitsFor) == 2 && strings.Contains(r.WaitsFor[0], "nothing answers")
|
|
})
|
|
}
|
|
|
|
// The plan's secrets are read strictly: a name that is not a secret's, one named twice, and a file path that is not
|
|
// clean and absolute, or a template's, each refuse the plan.
|
|
func TestAPlansSecretsAreReadStrictly(t *testing.T) {
|
|
base := func() Plan {
|
|
return Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)},
|
|
Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf", OwnerAtRuntime: "owned"}}}
|
|
}
|
|
for why, secrets := range map[string][]PlannedSecret{
|
|
"a name that is not a secret's": {{Name: "App Token"}},
|
|
"a name given twice": {{Name: "t"}, {Name: "t"}},
|
|
"a relative path": {{Name: "t", Path: "etc/t"}},
|
|
"a template's path": {{Name: "t", Path: appConf}},
|
|
} {
|
|
p := base()
|
|
p.Secrets = secrets
|
|
if len(p.problems()) == 0 {
|
|
t.Errorf("a plan with %s was taken", why)
|
|
}
|
|
}
|
|
p := base()
|
|
p.Secrets = []PlannedSecret{{Name: "app-token"}, {Name: "db-password", Path: appDB}}
|
|
if wrong := p.problems(); len(wrong) > 0 {
|
|
t.Errorf("a good plan was refused: %v", wrong)
|
|
}
|
|
}
|
|
|
|
// The forge's read credential for claude-code on this machine (issue 757, issue 737): git's credential-store line
|
|
// the forge's software issued for this holder alone, kept by the vault as
|
|
// provision/<provider>/gitea-read/<node>/claude-code.
|
|
const (
|
|
forgeFile = "/var/lib/claude-code/state/gitea-read.secret"
|
|
settingsFile = "/etc/claude-code/managed-settings.json"
|
|
forgeHolder = "provision/novox/gitea-read/" + node + "/claude-code"
|
|
)
|
|
|
|
// claudeCode is claude-code's plan with the credential of what it requires, gitea-read from novox, kept in a file of
|
|
// its own and named in a template.
|
|
func claudeCode(t *testing.T) Plan {
|
|
tpl, _ := templates(t, map[string]string{"managed-settings.json": `{"forge":"${secret:gitea-read}"}`})
|
|
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "settings", Path: settingsFile,
|
|
Template: "managed-settings.json", OwnerAtRuntime: "owned"}},
|
|
Provisions: []PlannedProvision{{Name: "gitea-read", Provision: "gitea-read", Provider: "novox",
|
|
Path: forgeFile}}}
|
|
}
|
|
|
|
func TestNatsAModulesRequiredCredentialIssuedForItIsFilledFromTheVaultAndFollowsARotation(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
wire := sniff(t, b)
|
|
vault := startVault(t, b)
|
|
const first = "https://agent-g14:gta-first-never-on-the-bus@forge.example"
|
|
const second = "https://agent-g14:gta-second-never-on-the-bus@forge.example"
|
|
vault.keep(forgeHolder, first, false)
|
|
e := startEngine(t, map[string]string{forgeFile: "owned", settingsFile: "owned"})
|
|
b.assign(t, "claude-code", claudeCode(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
|
|
eventually(t, "gitea-read's file holds the value issued for claude-code here, alone", func() bool {
|
|
return e.read(t, forgeFile) == first
|
|
})
|
|
eventually(t, "the template naming it is filled with it", func() bool {
|
|
return e.read(t, settingsFile) == `{"forge":"`+first+`"}`
|
|
})
|
|
eventually(t, "prov.applied says applied", func() bool {
|
|
r, _ := heard.last("claude-code")
|
|
return r.Status == StatusApplied && len(r.Files) == 2
|
|
})
|
|
vault.keep(forgeHolder, second, true)
|
|
eventually(t, "a value the forge issued anew reaches the file without a new declaration", func() bool {
|
|
return e.read(t, forgeFile) == second
|
|
})
|
|
// Another machine's or another module's holder value changing is no reason to ask.
|
|
vault.keep("provision/novox/gitea-read/ace/claude-code", "elsewhere", true)
|
|
vault.keep("provision/novox/gitea-read/"+node+"/zsh", "unplanned", true)
|
|
time.Sleep(300 * time.Millisecond)
|
|
if n := vault.times(forgeHolder); n != 2 {
|
|
t.Errorf("the vault was asked for the holder value %d times, want 2", n)
|
|
}
|
|
if vault.times("provision/novox/gitea-read/ace/claude-code")+vault.times("provision/novox/gitea-read/"+node+"/zsh") != 0 {
|
|
t.Error("a change of a value no plan here names made the client ask")
|
|
}
|
|
if wire.heard() == 0 {
|
|
t.Fatal("the sniffer heard nothing: it proves nothing")
|
|
}
|
|
for _, value := range []string{first, second, "gta-first-never-on-the-bus", "gta-second-never-on-the-bus"} {
|
|
if said := wire.carrying(value); len(said) > 0 {
|
|
t.Errorf("the value crossed the bus in the clear: %q", said)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A required credential the vault refuses fails the module with the vault's reason, and nothing is written.
|
|
func TestNatsARequiredCredentialTheVaultRefusesFailsTheModule(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
vault := startVault(t, b)
|
|
vault.keep(forgeHolder, "https://agent-g14:tok@forge.example", false)
|
|
vault.refuse = "provisioner.g14 may not ask for " + forgeHolder + ": claude-code's plan on g14 names no credential of gitea-read from novox"
|
|
e := startEngine(t, map[string]string{forgeFile: "owned", settingsFile: "owned"})
|
|
b.assign(t, "claude-code", claudeCode(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
eventually(t, "prov.applied says it failed, with the vault's reason", func() bool {
|
|
r, _ := heard.last("claude-code")
|
|
return r.Status == StatusFailed && strings.Contains(r.Reason, "names no credential of gitea-read")
|
|
})
|
|
if _, asked := e.counts(forgeFile); asked != 0 {
|
|
t.Error("a file whose credential was refused was written")
|
|
}
|
|
}
|
|
|
|
// The plan's provisions are read strictly.
|
|
func TestAPlansProvisionsAreReadStrictly(t *testing.T) {
|
|
base := func() Plan {
|
|
return Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)},
|
|
Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf", OwnerAtRuntime: "owned"}},
|
|
Secrets: []PlannedSecret{{Name: "own"}}}
|
|
}
|
|
good := PlannedProvision{Name: "gitea-read", Provision: "gitea-read", Provider: "novox", Path: forgeFile}
|
|
for why, change := range map[string]func(*PlannedProvision){
|
|
"a name that is not a secret's": func(p *PlannedProvision) { p.Name = "Gitea Read" },
|
|
"a name an own secret has": func(p *PlannedProvision) { p.Name = "own" },
|
|
"no provision": func(p *PlannedProvision) { p.Provision = "" },
|
|
"a provider that is no machine": func(p *PlannedProvision) { p.Provider = "../ace" },
|
|
"no provider": func(p *PlannedProvision) { p.Provider = "" },
|
|
"a relative path": func(p *PlannedProvision) { p.Path = "var/x" },
|
|
"a template's path": func(p *PlannedProvision) { p.Path = appConf },
|
|
} {
|
|
p := base()
|
|
pr := good
|
|
change(&pr)
|
|
p.Provisions = []PlannedProvision{pr}
|
|
if len(p.problems()) == 0 {
|
|
t.Errorf("a plan with %s was taken", why)
|
|
}
|
|
}
|
|
p := base()
|
|
p.Provisions = []PlannedProvision{good}
|
|
if wrong := p.problems(); len(wrong) > 0 {
|
|
t.Errorf("a good plan was refused: %v", wrong)
|
|
}
|
|
p.Provisions = append(p.Provisions, good)
|
|
if len(p.problems()) == 0 {
|
|
t.Error("a plan naming one credential twice was taken")
|
|
}
|
|
}
|
|
|
|
const (
|
|
dbFile = "/var/lib/nextcloud/state/database.secret"
|
|
dbConf = "/etc/nextcloud/config.php"
|
|
dbHolder = "pair/novox/postgres-database/" + node + "/nextcloud"
|
|
)
|
|
|
|
func nextcloud(t *testing.T) Plan {
|
|
tpl, _ := templates(t, map[string]string{"config.php": `dbpass=${secret:database}`})
|
|
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "config", Path: dbConf, Template: "config.php",
|
|
OwnerAtRuntime: "owned"}},
|
|
Provisions: []PlannedProvision{{Name: "database", Provision: "postgres-database", Provider: "novox",
|
|
ProviderModule: "postgres", CredentialForm: "pair", Path: dbFile}}}
|
|
}
|
|
|
|
// A consumer's own login from a provider that gives each consumer one (issue 779) is the vault's pair row for this
|
|
// module on this machine: its secrets file holds it alone, and it is asked for again when the vault says it changed.
|
|
func TestNatsAModulesOwnLoginFromAPerLoginProviderIsFilledFromTheVaultAndFollowsARotation(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
wire := sniff(t, b)
|
|
vault := startVault(t, b)
|
|
const first, second = "login-first-never-on-the-bus", "login-second-never-on-the-bus"
|
|
vault.keep(dbHolder, first, false)
|
|
e := startEngine(t, map[string]string{dbFile: "owned", dbConf: "owned"})
|
|
b.assign(t, "nextcloud", nextcloud(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
|
|
eventually(t, "the secrets file holds the module's own login, alone", func() bool {
|
|
return e.read(t, dbFile) == first
|
|
})
|
|
eventually(t, "the template naming it is filled with it", func() bool {
|
|
return e.read(t, dbConf) == "dbpass="+first
|
|
})
|
|
eventually(t, "prov.applied says applied", func() bool {
|
|
r, _ := heard.last("nextcloud")
|
|
return r.Status == StatusApplied && len(r.Files) == 2
|
|
})
|
|
vault.keep(dbHolder, second, true)
|
|
eventually(t, "a login rotated in the vault reaches the file without a new declaration", func() bool {
|
|
return e.read(t, dbFile) == second
|
|
})
|
|
// Another consumer's login changing is no reason to ask.
|
|
other := "pair/novox/postgres-database/ace/nextcloud"
|
|
vault.keep(other, "elsewhere", true)
|
|
time.Sleep(300 * time.Millisecond)
|
|
if n := vault.times(dbHolder); n != 2 {
|
|
t.Errorf("the vault was asked for the login %d times, want 2", n)
|
|
}
|
|
if vault.times(other) != 0 {
|
|
t.Error("a change of another consumer's login made the client ask")
|
|
}
|
|
for _, value := range []string{first, second} {
|
|
if said := wire.carrying(value); len(said) > 0 {
|
|
t.Errorf("the login crossed the bus in the clear: %q", said)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAConsumersLoginIsNamedWithItsLocalName(t *testing.T) {
|
|
if got := PairName("novox", "postgres-database", "g14", "umami", "stats"); got != "pair/novox/postgres-database/g14/umami/stats" {
|
|
t.Errorf("a login under a local name: %s", got)
|
|
}
|
|
}
|
|
|
|
// 1553/AC1: a file holding one known secret is filled although a template of the module waits for a bound value;
|
|
// the template's file is not filled, and the report says waiting, names the value, and lists the file it filled.
|
|
func TestNatsAKnownSecretsFileIsFilledWhileATemplateWaits1553(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
vault := startVault(t, b)
|
|
vault.keep("own/g14/app/app-token", "tok", false)
|
|
vault.keep("own/g14/app/db-password", "pw", false)
|
|
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
|
// No config font-size: the template waits for it.
|
|
b.assign(t, "app", app(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
|
|
eventually(t, "the secret's file is filled", func() bool { return e.read(t, appDB) == "pw" })
|
|
eventually(t, "prov.applied says waiting for the value, with the file it filled", func() bool {
|
|
r, _ := heard.last("app")
|
|
return r.Status == StatusWaiting && len(r.WaitsFor) == 1 && strings.HasPrefix(r.WaitsFor[0], "config:font-size") &&
|
|
len(r.Files) == 1 && r.Files[0].Path == appDB
|
|
})
|
|
if _, asked := e.counts(appConf); asked != 0 {
|
|
t.Error("the template waiting for a value was filled")
|
|
}
|
|
}
|
|
|
|
// 1553/AC3: a template one of whose values waits keeps its file as it is: Fill is never called for it, so no
|
|
// half-filled text is written, though its other value (the secret) is known.
|
|
func TestNatsATemplateWithAWaitingValueKeepsItsFileAsItIs1553(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
vault := startVault(t, b)
|
|
vault.keep("own/g14/app/app-token", "tok", false)
|
|
vault.keep("own/g14/app/db-password", "pw", false)
|
|
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
|
b.assign(t, "app", app(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
|
|
eventually(t, "it waits", func() bool { r, _ := heard.last("app"); return r.Status == StatusWaiting })
|
|
eventually(t, "the other file is filled, so the pass is over", func() bool { return e.read(t, appDB) == "pw" })
|
|
time.Sleep(200 * time.Millisecond)
|
|
if _, asked := e.counts(appConf); asked != 0 {
|
|
t.Errorf("Fill was called %d times for a template with a waiting value", asked)
|
|
}
|
|
if got := e.read(t, appConf); got != "" {
|
|
t.Errorf("the template's file holds %q, want it untouched", got)
|
|
}
|
|
}
|