Files
mesh-controller/cmd/mesh-controller/vault_switch_test.go
T

414 lines
16 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/testbus"
)
// Each of the four secret commands honours the switch at its own call site (the review of issue 675): run as the
// terminal runs them, against the real store and a real bus with a stand-in vault serving the vault's seat, a
// module filled the old way takes today's path and the vault is never asked, and one filled the new way reaches
// the vault. Sending every module to the vault at any one call site — or none — fails one of these. Needs the
// store (MESH_TEST_POSTGRES), and is named in testdata/store-tests.
// vaultOnTheBus serves the vault's four intake verbs on the test's bus with a stand-in vault, and says which verbs
// were asked, in order.
type vaultOnTheBus struct {
vault *standInVault
mu sync.Mutex
asked []string
}
func (v *vaultOnTheBus) verbs() string {
v.mu.Lock()
defer v.mu.Unlock()
return strings.Join(v.asked, ",")
}
func (v *vaultOnTheBus) reset() {
v.mu.Lock()
defer v.mu.Unlock()
v.asked = nil
}
// aMeshWithAVault is aMesh with the controller's bus pointed at a test bus where a stand-in vault serves its seat,
// and the switch saying that only the modules named are filled the new way.
func aMeshWithAVault(t *testing.T) (*stores, *vaultOnTheBus, func(moved ...string)) {
t.Helper()
open := aMesh(t)
conn, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(conn.Close)
before := handActConn
handActConn = conn
t.Cleanup(func() { handActConn = before })
served := &vaultOnTheBus{vault: newStandInVault(t)}
for _, verb := range []string{"accept", "secret-ask", "issued", "rotate"} {
sub, err := conn.Subscribe(broker.CalledPattern(link.SeatToolSubject("mesh-vault", verb)), func(m *nats.Msg) {
served.mu.Lock()
served.asked = append(served.asked, verb)
served.mu.Unlock()
var args map[string]any
_ = json.Unmarshal(m.Data, &args)
result, err := served.vault.ask(verb, args)
answer := link.Answer{Result: result}
if err != nil {
answer = link.Answer{Error: err.Error()}
}
raw, _ := json.Marshal(answer)
_ = m.Respond(raw)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
if err := conn.Flush(); err != nil {
t.Fatal(err)
}
switchWas := filledTheNewWay
t.Cleanup(func() { filledTheNewWay = switchWas })
moveOnly := func(moved ...string) {
filledTheNewWay = switchOf(func(module string) bool {
for _, m := range moved {
if m == module {
return true
}
}
return false
})
}
return open, served, moveOnly
}
// aValueFile is a file holding a value, for `--from`.
func aValueFile(t *testing.T, value string) string {
t.Helper()
file := filepath.Join(t.TempDir(), "value")
if err := os.WriteFile(file, []byte(value+"\n"), 0o600); err != nil {
t.Fatal(err)
}
return file
}
// `secret accept` of a module's own secret.
func TestAcceptOfAnOwnSecretTakesThePathOfItsModulesForm(t *testing.T) {
open, vault, moveOnly := aMeshWithAVault(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "dunst", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"token": {Path: "/var/lib/mesh/dunst/token"}}})
if _, err := assign(ctx, open, "anchor", "dunst"); err != nil {
t.Fatal(err)
}
accept := []string{"accept", "anchor", "dunst", "token", "--from", aValueFile(t, "the-bots-token")}
moveOnly("some-other-module")
if err := secretCommand(ctx, accept); err != nil {
t.Fatalf("today's path: %v", err)
}
if asked := vault.verbs(); asked != "" {
t.Fatalf("the vault was asked %s for a module filled the old way", asked)
}
if origin, _, err := open.inventory.OwnSecretOrigin(ctx, "anchor", "dunst", "token"); err != nil || origin == "" {
t.Fatalf("today's store does not hold it: %q %v", origin, err)
}
moveOnly("dunst")
if err := secretCommand(ctx, accept); err != nil {
t.Fatalf("the vault's path: %v", err)
}
if asked := vault.verbs(); asked != "accept,accept" {
t.Fatalf("the vault was asked %q for a module filled the new way", asked)
}
if vault.vault.taken["accept"] != "the-bots-token" {
t.Fatalf("the vault opened %q", vault.vault.taken["accept"])
}
}
// `secret accept --provider`: the provider's module decides, not the consumer's.
func TestAcceptForAProviderTakesThePathOfTheProvidersModule(t *testing.T) {
open, vault, moveOnly := aMeshWithAVault(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "tv", Version: "1",
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh}}})
register(t, open, catalogue.Manifest{Module: "requests", Version: "1", Requires: []string{"tv-api"},
Secrets: map[string]string{"tv-api": "/var/lib/mesh/requests/tv-api.secret"}})
for _, a := range [][2]string{{"anchor", "tv"}, {"laptop", "requests"}} {
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
t.Fatal(err)
}
}
accept := []string{"accept", "laptop", "requests", "tv-api", "--provider", "anchor", "--from",
aValueFile(t, "the-pairs-key")}
// The consumer filled the new way, the provider the old: the provider's module decides, today's path.
moveOnly("requests")
if err := secretCommand(ctx, accept); err != nil {
t.Fatalf("today's path: %v", err)
}
if asked := vault.verbs(); asked != "" {
t.Fatalf("the vault was asked %s for a provider filled the old way", asked)
}
moveOnly("tv")
if err := secretCommand(ctx, accept); err != nil {
t.Fatalf("the vault's path: %v", err)
}
if asked := vault.verbs(); asked != "accept,accept" {
t.Fatalf("the vault was asked %q for a provider filled the new way", asked)
}
if said := vault.vault.said(); !strings.Contains(said, `"provider":"anchor"`) || !strings.Contains(said, `"provision":"tv-api"`) {
t.Errorf("the vault was told %s", said)
}
}
// `secret accept` of a provision's credential issued outside the mesh, given once for its provider.
func TestAcceptOfAnOutsideOfferTakesThePathOfTheProvidersModule(t *testing.T) {
open, vault, moveOnly := aMeshWithAVault(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "tv", Version: "1",
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh,
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}})
register(t, open, catalogue.Manifest{Module: "requests", Version: "1", Requires: []string{"tv-api"},
Secrets: map[string]string{"tv-api": "/var/lib/mesh/requests/tv-api.secret"}})
for _, a := range [][2]string{{"anchor", "tv"}, {"laptop", "requests"}} {
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
t.Fatal(err)
}
}
accept := []string{"accept", "anchor", "tv", "tv-api", "--from", aValueFile(t, "the-apps-key")}
moveOnly("requests")
// Today's path seals it to every consumer; whether it then sends is not this test's.
_ = secretCommand(ctx, accept)
if asked := vault.verbs(); asked != "" {
t.Fatalf("the vault was asked %s for a provider filled the old way", asked)
}
moveOnly("tv")
if err := secretCommand(ctx, accept); err != nil {
t.Fatalf("the vault's path: %v", err)
}
if asked := vault.verbs(); asked != "accept,accept" {
t.Fatalf("the vault was asked %q for a provider filled the new way", asked)
}
if vault.vault.taken["accept"] != "the-apps-key" {
t.Fatalf("the vault opened %q", vault.vault.taken["accept"])
}
}
// `secret ask` (and `secret accept --at-desk`): the desk is never answered on this bus, so today's path ends at the
// prompt; the vault's path is one ask of the vault, which asks the desk itself (issue 723), so it is done without
// the controller reaching the desk at all.
func TestAnAskAtTheDeskTakesThePathOfItsModulesForm(t *testing.T) {
open, vault, moveOnly := aMeshWithAVault(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "dunst", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"token": {Path: "/var/lib/mesh/dunst/token"}}})
if _, err := assign(ctx, open, "anchor", "dunst"); err != nil {
t.Fatal(err)
}
for _, line := range [][]string{
{"ask", "anchor", "dunst", "token", "--at", "laptop"},
{"accept", "anchor", "dunst", "token", "--at-desk", "laptop"},
} {
vault.reset()
moveOnly("some-other-module")
err := secretCommand(ctx, line)
if err == nil || !strings.Contains(err.Error(), "could not be asked") {
t.Fatalf("%v: today's path did not reach the desk: %v", line, err)
}
if asked := vault.verbs(); asked != "" {
t.Fatalf("%v: the vault was asked %s for a module filled the old way", line, asked)
}
vault.reset()
moveOnly("dunst")
if err := secretCommand(ctx, line); err != nil {
t.Fatalf("%v: the vault's path reached the desk itself, or failed: %v", line, err)
}
// One ask of the vault, which asks the desk in its own name: the controller carries nothing to it.
if asked := vault.verbs(); asked != "secret-ask" {
t.Fatalf("%v: the vault was asked %q for a module filled the new way", line, asked)
}
}
}
// `secret issued`: the provider's tool is never answered on this bus, so today's path ends at it; the vault's path
// is one ask of the vault, which asks the tool itself (issue 723).
func TestAnIssuedValueTakesThePathOfItsProvidersModule(t *testing.T) {
open, vault, moveOnly := aMeshWithAVault(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "plex", Version: "1", Tools: []string{"plex_token"},
Provides: []catalogue.Offer{{Name: "plex-api", Scope: catalogue.ScopeMesh,
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedBySoftware, Tool: "plex_token"}}}})
register(t, open, catalogue.Manifest{Module: "kometa", Version: "1", Requires: []string{"plex-api"},
Secrets: map[string]string{"plex-api": "/var/lib/mesh/kometa/plex-api.secret"}})
for _, a := range [][2]string{{"anchor", "plex"}, {"laptop", "kometa"}} {
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
t.Fatal(err)
}
}
// The consumer's send makes the pair, so the provision has a holder to take it for.
plan, settings, err := planFor(ctx, open, "laptop")
if err != nil {
t.Fatal(err)
}
if _, err := declarationFor(ctx, open, "laptop", plan, settings); err != nil {
t.Fatal(err)
}
moveOnly("kometa")
err = secretCommand(ctx, []string{"issued", "plex-api"})
if err == nil || !strings.Contains(err.Error(), "could not be asked") {
t.Fatalf("today's path did not reach the provider's tool: %v", err)
}
if asked := vault.verbs(); asked != "" {
t.Fatalf("the vault was asked %s for a provider filled the old way", asked)
}
moveOnly("plex")
if err := secretCommand(ctx, []string{"issued", "plex-api"}); err != nil {
t.Fatalf("the vault's path reached the provider's tool itself, or failed: %v", err)
}
if asked := vault.verbs(); asked != "issued" {
t.Fatalf("the vault was asked %q for a provider filled the new way", asked)
}
}
// `secret rotate` of a module's own secret.
func TestARotationTakesThePathOfItsModulesForm(t *testing.T) {
open, vault, moveOnly := aMeshWithAVault(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "dunst", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"token": {Path: "/var/lib/mesh/dunst/token"}}})
if _, err := assign(ctx, open, "anchor", "dunst"); err != nil {
t.Fatal(err)
}
moveOnly()
if err := secretCommand(ctx, []string{"accept", "anchor", "dunst", "token", "--from", aValueFile(t, "tok")}); err != nil {
t.Fatal(err)
}
rotate := []string{"rotate", "anchor", "dunst", "token", "--why", "the test rotates it"}
moveOnly("some-other-module")
_ = secretCommand(ctx, rotate) // today's path: rotated in the store, or refused there
if asked := vault.verbs(); asked != "" {
t.Fatalf("the vault was asked %s for a module filled the old way", asked)
}
moveOnly("dunst")
if err := secretCommand(ctx, []string{"rotate", "anchor", "dunst", "token"}); err == nil ||
!strings.Contains(err.Error(), "--why") || vault.verbs() != "" {
t.Fatalf("a rotation in the vault without a why: %v, the vault asked %q", err, vault.verbs())
}
if err := secretCommand(ctx, rotate); err != nil {
t.Fatalf("the vault's path: %v", err)
}
if asked := vault.verbs(); asked != "rotate" {
t.Fatalf("the vault was asked %q for a module filled the new way", asked)
}
if said := vault.vault.said(); !strings.Contains(said, `"why":"the test rotates it"`) {
t.Errorf("the vault was not told why: %s", said)
}
}
// A moved module that runs as an account of its own is announced on the operator's channels before the vault is
// asked, and with the announcement failing the vault is never called (issue 836, the N1-give rule).
func TestAcceptForAMovedTrustedModuleAnnouncesBeforeTheVaultIsAsked(t *testing.T) {
open, vault, moveOnly := aMeshWithAVault(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "telegram", Version: "1", RunsAs: "telegram", SecretsOwner: "telegram",
OwnSecrets: catalogue.OwnSecrets{"token": {Path: "/var/lib/mesh/telegram/token"}}})
register(t, open, catalogue.Manifest{Module: "dunst", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"token": {Path: "/var/lib/mesh/dunst/token"}}})
for _, m := range []string{"telegram", "dunst"} {
if _, err := assign(ctx, open, "anchor", m); err != nil {
t.Fatal(err)
}
}
moveOnly("telegram", "dunst")
was := announceTheGiven
t.Cleanup(func() { announceTheGiven = was })
var order []string
announceTheGiven = func(_ context.Context, node, module, name, how, askedBy string) error {
order = append(order, "announce:"+module+":"+vault.verbs())
return nil
}
accept := func(module string) error {
return secretCommand(ctx, []string{"accept", "anchor", module, "token", "--from", aValueFile(t, "the-bots-token")})
}
if err := accept("telegram"); err != nil {
t.Fatal(err)
}
if strings.Join(order, ",") != "announce:telegram:" || vault.verbs() != "accept,accept" {
t.Fatalf("announced %v, the vault was asked %q", order, vault.verbs())
}
vault.reset()
announceTheGiven = func(context.Context, string, string, string, string, string) error {
return errors.New("no channel took it")
}
err := accept("telegram")
if err == nil || !strings.Contains(err.Error(), "nothing was sealed") || vault.verbs() != "" {
t.Fatalf("a failed announcement: %v, the vault was asked %q", err, vault.verbs())
}
// Any other module is kept first and announced after; a failed announcement is said, not undone.
if err := accept("dunst"); err != nil || vault.verbs() != "accept,accept" {
t.Fatalf("an untrusted module: %v, asked %q", err, vault.verbs())
}
}
// issue 851 (hand-act): a moved module's terminal give is recorded in the hand-act log like acceptKept's
// (novox/hq ADR 0380 §6), naming where, and never carrying the value.
func TestAcceptForAMovedModuleRecordsAHandActAndNeverTheValue(t *testing.T) {
open, _, moveOnly := aMeshWithAVault(t)
ctx := t.Context()
api, err := jetstream.New(handActConn)
if err != nil {
t.Fatal(err)
}
if _, err := api.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: broker.HandActsBucket}); err != nil {
t.Fatal(err)
}
register(t, open, catalogue.Manifest{Module: "dunst", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"token": {Path: "/var/lib/mesh/dunst/token"}}})
if _, err := assign(ctx, open, "anchor", "dunst"); err != nil {
t.Fatal(err)
}
moveOnly("dunst")
was := announceTheGiven
t.Cleanup(func() { announceTheGiven = was })
announceTheGiven = func(context.Context, string, string, string, string, string) error { return nil }
if err := secretCommand(ctx, []string{"accept", "anchor", "dunst", "token", "--from", aValueFile(t, "the-bots-token")}); err != nil {
t.Fatal(err)
}
acts, err := link.HandActs(ctx, handActConn, time.Now().Add(-time.Minute))
if err != nil || len(acts) != 1 || acts[0].Verb != "secret accept" || acts[0].Cause != "given-at-the-terminal" ||
!strings.Contains(acts[0].Why, "at the controller's terminal") {
t.Fatalf("recorded %+v (%v)", acts, err)
}
if strings.Contains(acts[0].Why, "the-bots-token") {
t.Error("the act carries the value")
}
}