package console // The config door's restart notice (discussion #16): a write that lands on // a process carlos does not own comes back naming it, so the CLI can say so // instead of reporting a bare success. And the empty-secret refusal from the // same report, which this door can enforce without a key because AES-GCM is // length-preserving. import ( "context" "net/http" "strings" "testing" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/release" ) // restartOf pulls the `restart` object out of a config PUT's response. // found==false means the door said nothing about it at all, which is a // distinct answer from an empty one and is asserted as such below. func restartOf(t *testing.T, out map[string]any) (map[string]any, bool) { t.Helper() r, ok := out["restart"].(map[string]any) return r, ok } // restartHosts lists the hostnames in a restart object, in order. func restartHosts(t *testing.T, r map[string]any) []string { t.Helper() raw, _ := r["hosts"].([]any) var out []string for _, h := range raw { m, _ := h.(map[string]any) out = append(out, str(t, m, "host")) } return out } // optIn turns exec_delivery on for an app's default bundle — the opt-in that // makes a TCP-addr route eligible for a delivered file in the first place. func optIn(t *testing.T, fx *consoleFixture, app string) { t.Helper() if err := appconfig.SetExecDelivery(context.Background(), fx.rel, release.OpsAccount, app, appconfig.ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } } // TestAPIConfigPutNamesRoutesAwaitingRestart is the door's half of the // report. routesFixture's connect.go.tito.io is exactly the shape that was // filed: a hand-added TCP-addr relay bound to the "relay" environment, with // no instance record anywhere and nothing that will ever bounce it. func TestAPIConfigPutNamesRoutesAwaitingRestart(t *testing.T) { fx, token := routesFixture(t, "code-restart-named") optIn(t, fx, "titogo") status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ "environment": "relay", "set": map[string]any{"LOG_LEVEL": map[string]any{"kind": "env", "value": "debug"}}, }, token) if status != http.StatusOK { t.Fatalf("PUT = %d %v", status, out) } r, ok := restartOf(t, out) if !ok { t.Fatalf("response carries no restart object: %v", out) } if r["known"] != true { t.Fatalf("known = %v, want true — this console has a registry and read it", r["known"]) } got := restartHosts(t, r) if len(got) != 1 || got[0] != "connect.go.tito.io" { t.Fatalf("hosts = %v, want just connect.go.tito.io", got) } } // TestAPIConfigPutRestartUnknownWithoutRegistry is the field that carries // the whole point. A console that cannot see a registry has not looked, and // must not answer with an empty list: "nothing needs restarting" and "I // could not tell" rendering identically IS the bug being fixed. func TestAPIConfigPutRestartUnknownWithoutRegistry(t *testing.T) { fx, token := routesFixture(t, "code-restart-noreg") optIn(t, fx, "titogo") fx.srv.cfg.Registry = "" status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ "environment": "relay", "set": map[string]any{"LOG_LEVEL": map[string]any{"kind": "env", "value": "debug"}}, }, token) if status != http.StatusOK { t.Fatalf("PUT = %d %v", status, out) } r, ok := restartOf(t, out) if !ok { t.Fatalf("response carries no restart object: %v", out) } if r["known"] != false { t.Fatalf("known = %v, want false for a console with no registry", r["known"]) } if hosts := restartHosts(t, r); len(hosts) != 0 { t.Fatalf("hosts = %v, want none alongside known:false", hosts) } } // TestAPIConfigPutRestartSilentForUnoptedApp: the notice appears only where // a file is actually delivered. An app that never opted into exec delivery // has routes, and they get nothing, so there is nothing to restart over — // and this is a genuine "none", not a "could not tell". func TestAPIConfigPutRestartSilentForUnoptedApp(t *testing.T) { fx, token := routesFixture(t, "code-restart-unopted") status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ "environment": "relay", "set": map[string]any{"LOG_LEVEL": map[string]any{"kind": "env", "value": "debug"}}, }, token) if status != http.StatusOK { t.Fatalf("PUT = %d %v", status, out) } r, ok := restartOf(t, out) if !ok { t.Fatalf("response carries no restart object: %v", out) } if r["known"] != true { t.Fatalf("known = %v, want true — the registry read fine, the answer is genuinely none", r["known"]) } if hosts := restartHosts(t, r); len(hosts) != 0 { t.Fatalf("hosts = %v, want none for an app that never opted in", hosts) } } // TestAPIConfigPutRestartAbsentWhenNothingChanged: an unset that removed // nothing moved no file, so the door does not speculate about restarts. func TestAPIConfigPutRestartAbsentWhenNothingChanged(t *testing.T) { fx, token := routesFixture(t, "code-restart-noop") optIn(t, fx, "titogo") // The bundle has to exist: unsetting from one that does not is a 409 // refusal, which is a different answer than "removed nothing". if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ "environment": "relay", "set": map[string]any{"REAL_KEY": map[string]any{"kind": "env", "value": "v"}}, }, token); status != http.StatusOK { t.Fatalf("seed PUT = %d %v", status, out) } status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ "environment": "relay", "unset": []string{"NEVER_SET"}, }, token) if status != http.StatusOK { t.Fatalf("PUT = %d %v", status, out) } if r, ok := restartOf(t, out); ok { t.Fatalf("restart = %v, want the field absent when the write removed nothing", r) } } // TestAPIConfigPutRefusesEmptySecret pins the door's half of the empty-value // refusal (the report's aside). The console holds no private key, so it // reads emptiness the only way it can: AES-GCM is length-preserving, so an // envelope at exactly the floor sealed nothing. // // The refusal must also leave the bucket untouched — a rejected write that // still stored something would be worse than the silence it replaces. func TestAPIConfigPutRefusesEmptySecret(t *testing.T) { fx, token := configFixture(t, "code-config-empty-secret") _, _, envelope := sealedFor(t, "") status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ "set": map[string]any{"CLIENT_SECRET": map[string]any{"kind": "secret", "envelope": envelope}}, }, token) if status != http.StatusBadRequest { t.Fatalf("PUT = %d %v, want 400", status, out) } msg := str(t, out, "error") for _, want := range []string{"empty value", "secrets unset", "env set"} { if !strings.Contains(msg, want) { t.Fatalf("refusal %q does not mention %q", msg, want) } } if strings.Contains(msg, envelope) { t.Fatalf("refusal echoes the envelope back: %q", msg) } if _, found := storedManifest(t, fx, "titogo"); found { t.Fatal("a refused empty secret still wrote a manifest") } } // TestAPIConfigPutAcceptsOneByteSecret is the boundary from the other side: // the shortest real secret is one byte longer than an empty one, and the // length read must never eat it. func TestAPIConfigPutAcceptsOneByteSecret(t *testing.T) { fx, token := configFixture(t, "code-config-one-byte") _, _, envelope := sealedFor(t, "x") status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{ "set": map[string]any{"CLIENT_SECRET": map[string]any{"kind": "secret", "envelope": envelope}}, }, token) if status != http.StatusOK { t.Fatalf("PUT = %d %v, want 200 for a one-byte secret", status, out) } m, found := storedManifest(t, fx, "titogo") if !found { t.Fatal("a one-byte secret was accepted but stored nothing") } if m.Vars["CLIENT_SECRET"].Envelope != envelope { t.Fatal("stored envelope is not the one that was sent") } }