package host import ( "bytes" "context" "errors" "log" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/instancekey" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // Ruling 30 (Astra round 6 finding 2): the list is read again at every // call, so the procedure's invalidation and its new entries take effect at // the next replica launch, with no restart. An absent list verifies // nothing. func TestReplicaHomeKeysReadsTheListAtEveryLaunch(t *testing.T) { a, b := instancekey.Digest("home-key-a"), instancekey.Digest("home-key-b") path := filepath.Join(t.TempDir(), "replica-home-keys") keys := replicaHomeKeys(func() ([]byte, error) { return os.ReadFile(path) }) if _, ok := keys("shop-au.example.com"); ok { t.Fatal("an absent list verified a key") } write := func(body string) { t.Helper() tmp := path + ".tmp" if err := os.WriteFile(tmp, []byte(body), 0o640); err != nil { t.Fatal(err) } if err := os.Rename(tmp, path); err != nil { t.Fatal(err) } } write("shop-au.example.com=" + a + "\nshop-uk.example.com=" + a + "\n") if got, ok := keys("shop-au.example.com"); !ok || got != a { t.Fatalf("listed replica = %q, %v", got, ok) } // The procedure's invalidation: the entry is gone at the next call. write("shop-uk.example.com=" + a + "\n") if _, ok := keys("shop-au.example.com"); ok { t.Fatal("a removed entry still verified: the list was cached") } // And its replacement, after the key change and the re-run check. write("shop-au.example.com=" + b + ",shop-uk.example.com=" + a) if got, ok := keys("shop-au.example.com"); !ok || got != b { t.Fatalf("the replaced entry = %q, %v; want the new digest", got, ok) } if _, ok := keys("shop-nz.example.com"); ok { t.Fatal("an unlisted replica verified") } } // Astra round 5 finding 3: a malformed list is logged by entry position and // reason, never by its text (a bare digest, with no host before it, would // otherwise reach the log), and verifies nothing. func TestReplicaHomeKeysLogsAMalformedListWithoutItsText(t *testing.T) { sha := instancekey.Digest("home-key") var buf bytes.Buffer log.SetOutput(&buf) defer log.SetOutput(os.Stderr) keys := replicaHomeKeys(func() ([]byte, error) { return []byte("shop-au.example.com=" + sha + "\n" + sha + "\n"), nil }) if _, ok := keys("shop-au.example.com"); ok { t.Fatal("a malformed list verified a key") } line := buf.String() if !strings.Contains(line, replicaHomeKeysPath) || !strings.Contains(line, "replica home keys entry 2") || strings.Contains(line, sha[:16]) || strings.Contains(line, "shop-au") { t.Fatalf("log line %q: want the entry's position and reason, and none of its text", line) } buf.Reset() if _, ok := replicaHomeKeys(func() ([]byte, error) { return nil, errors.New("permission denied") })("shop-au.example.com"); ok { t.Fatal("an unreadable list verified a key") } if !strings.Contains(buf.String(), "could not be read") { t.Fatalf("log line %q", buf.String()) } } // Rulings 5 and 27's fresh read, as the agent wires it: the bucket's // current configured key, through the deploy store and the box's secrets // key. Which boxes have a configuration to read follows the config guard: // no secrets key still reads the bucket and renders a plain value (a // sealed one fails the render, as it fails the guard); no deployment // store is an error, unless CARLOS_NO_SEALED_CONFIG says this deployment // has no configuration at all, where nothing is on its way and no key is // rendered. func TestConfiguredKeyReadsTheBucketNow(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) priv, pubB64, err := appconfig.GenerateKey() if err != nil { t.Fatal(err) } if err := appconfig.SetEnv(ctx, s, "acct", "shop", map[string]string{instancekey.Var: "key-A"}); err != nil { t.Fatal(err) } rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", App: "shop", Account: "acct", Replica: true} opened := 0 open := func(context.Context) (release.Store, error) { opened++; return s, nil } read := configuredKey(func(k string) string { if k == "CARLOS_SECRETS_KEY" { return priv } return "" }, open) for i := 0; i < 2; i++ { if got, err := read(ctx, rt); err != nil || got != instancekey.Digest("key-A") { t.Fatalf("read %d = %q, %v", i, got, err) } } if opened != 1 { t.Fatalf("the store was opened %d times, want once", opened) } failing := configuredKey(func(string) string { return priv }, func(context.Context) (release.Store, error) { return nil, errors.New("bucket unreachable") }) if _, err := failing(ctx, rt); err == nil { t.Fatal("an unreachable bucket was not an error") } if got, err := configuredKey(func(string) string { return "" }, open)(ctx, rt); err != nil || got != instancekey.Digest("key-A") { t.Fatalf("no secrets key, a plain configured key = %q, %v; want the digest (the guard admits plain config on a keyless box)", got, err) } pub, err := appconfig.ParsePublicKey(pubB64, "test") if err != nil { t.Fatal(err) } envelope, err := appconfig.SealUnbound(pub, []byte("x")) if err != nil { t.Fatal(err) } if err := appconfig.SetSecret(ctx, s, "acct", "shop", map[string]string{"SEALED": envelope}); err != nil { t.Fatal(err) } if _, err := configuredKey(func(string) string { return "" }, open)(ctx, rt); err == nil { t.Fatal("no secrets key beside a sealed value was not an error") } noStore := func(context.Context) (release.Store, error) { return nil, errNoDeployStore } if _, err := configuredKey(func(string) string { return "" }, noStore)(ctx, rt); err == nil { t.Fatal("no deployment store was not an error") } bypass := func(k string) string { if k == "CARLOS_NO_SEALED_CONFIG" { return "1" } return "" } if got, err := configuredKey(bypass, noStore)(ctx, rt); err != nil || got != "" { t.Fatalf("no store under CARLOS_NO_SEALED_CONFIG = %q, %v; want no key and no error", got, err) } } // A failed first open is not kept: the same closure opens the store again // at the next replica launch, and reads the bucket once it opens. func TestConfiguredKeyRetriesAFailedOpen(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) priv, _, err := appconfig.GenerateKey() if err != nil { t.Fatal(err) } if err := appconfig.SetEnv(ctx, s, "acct", "shop", map[string]string{instancekey.Var: "key-A"}); err != nil { t.Fatal(err) } rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", App: "shop", Account: "acct", Replica: true} opens := 0 read := configuredKey(func(string) string { return priv }, func(context.Context) (release.Store, error) { opens++ if opens == 1 { return nil, errors.New("bucket unreachable") } return s, nil }) if _, err := read(ctx, rt); err == nil { t.Fatal("the failed first open was not an error") } for i := 0; i < 2; i++ { if got, err := read(ctx, rt); err != nil || got != instancekey.Digest("key-A") { t.Fatalf("read %d after the failure = %q, %v", i, got, err) } } if opens != 2 { t.Fatalf("the store was opened %d times, want twice: once failed, once kept", opens) } }