512 lines
20 KiB
Go
512 lines
20 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(), time.Hour)
|
|
|
|
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
|
|
})
|
|
eventually(t, "the needs name the secrets, never their values", func() bool {
|
|
got := b.keys(t, NeedsBucket, node+".app.")
|
|
return strings.Join(got, " ") == "g14.app.config.font-size g14.app.secret.app-token g14.app.secret.db-password"
|
|
})
|
|
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(), time.Hour)
|
|
eventually(t, "app.conf is filled", func() bool { return strings.Contains(e.read(t, appConf), "token = first\n") })
|
|
assigned, err := b.kv[AssignmentsBucket].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[AssignmentsBucket].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(), time.Hour)
|
|
|
|
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")
|
|
}
|
|
if _, asked := e.counts(appDB); asked != 0 {
|
|
t.Error("a module waiting for one value had another file written: every value or none")
|
|
}
|
|
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"
|
|
})
|
|
}
|
|
|
|
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(), 100*time.Millisecond)
|
|
|
|
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")
|
|
})
|
|
time.Sleep(600 * time.Millisecond)
|
|
if n := vault.times("own/g14/app/app-token"); n != 1 {
|
|
t.Errorf("a refused secret was asked %d times in six drift passes, 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(), time.Hour)
|
|
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(), time.Hour)
|
|
|
|
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(), time.Hour)
|
|
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(), time.Hour)
|
|
|
|
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)
|
|
}
|
|
}
|