414 lines
16 KiB
Go
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")
|
|
}
|
|
}
|