From 12ae8522b3f9eb04a733f20010ec5a7ca099cec1 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 8 Aug 2026 22:16:01 +0300 Subject: [PATCH] ecoretest: the shared instance config and crypto bootstrap for tests --- ecoretest/ecoretest.go | 237 ++++++++++++++++++++++++++++++++++++ ecoretest/ecoretest_test.go | 176 ++++++++++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 ecoretest/ecoretest.go create mode 100644 ecoretest/ecoretest_test.go diff --git a/ecoretest/ecoretest.go b/ecoretest/ecoretest.go new file mode 100644 index 0000000000000000000000000000000000000000..b622ae461f7b4141499a9cd6e1d54b03c13ef658 --- /dev/null +++ b/ecoretest/ecoretest.go @@ -0,0 +1,237 @@ +// Package ecoretest is the test bootstrap shared by the custom services of a +// self-hosted SourceHut instance (compare, spec, dolt, cover, bench, tokens). +// +// Every one of those services opens its web tests with the same two things: a +// hand-built ini.File standing in for the instance's config.ini, and a TestMain +// that mints a fernet network key plus an ed25519 webhook seed and hands them +// to crypto.InitCrypto, so that sealing and opening a unified-login cookie +// works with no meta.sr.ht and no network. Both were copied service to service +// and drifted. The fake origins are spelled https://git.example in three +// services and https://git.example.org in a fourth; the environment is +// "production" in one copy and "development" in the next; only some copies +// carry the origin-less section the service switcher has to skip, so the rule +// that it is skipped is tested on some services and not others. This package +// is the one copy. +// +// Usage — the whole bootstrap of a service's web test: +// +// func TestMain(m *testing.M) { +// ecoretest.InitCrypto() +// os.Exit(m.Run()) +// } +// +// conf := ecoretest.Config("bench.sr.ht") +// staging := ecoretest.Config("bench.sr.ht", +// ecoretest.Set("sr.ht", "environment", "staging")) +// noHub := ecoretest.Config("bench.sr.ht", ecoretest.Delete("hub.sr.ht")) +// +// Config builds a fresh ini.File with fresh section maps on every call, so a +// test that edits or deletes a section cannot be read by the next one — the +// shared-fixture flake this package exists to prevent. The section argument is +// the calling service's own section: it is guaranteed to be present with an +// origin even when this package has never heard of that service. +// +// The origins are one fixed set, https://.example, under the reserved +// .example TLD of RFC 2606, so a test that accidentally dials one resolves +// nothing instead of reaching a stranger. +// +// Two departures from what a test helper usually looks like, both deliberate. +// This is ordinary (non-_test.go) code so that services can import it, and it +// therefore does not import "testing": nothing here takes a testing.TB, which +// is also what lets InitCrypto be called from TestMain, where every donor calls +// it and where no TB exists. And the keys are fixed constants rather than +// freshly generated ones — they authenticate nothing outside a test process, +// and being constant is what makes InitCrypto idempotent, so two packages of +// one service can both call it without the second rotating the keys the first +// sealed a cookie with. +package ecoretest + +import ( + "strings" + "sync" + + "github.com/vaughan0/go-ini" + "sourcecraft.dev/bigbes/sr-ht-core/crypto" +) + +// The instance identity every service's tests render against — the [sr.ht] +// block of the synthetic config. +const ( + // SiteName is [sr.ht]site-name, the brand text of the shared nav. + SiteName = "srht.example" + // Environment is [sr.ht]environment. It is "production" so that the + // environment banner is off by default; a test that wants the banner asks + // for it with Set("sr.ht", "environment", "staging"). + Environment = "production" + // OwnerName and OwnerEmail are [sr.ht]owner-name/owner-email, which + // config.GetOwner panics without. + OwnerName = "admin" + OwnerEmail = "admin@srht.example" +) + +// The two keys crypto.InitCrypto insists on. They are constants rather than +// generated values because they secure nothing: no process outside a test +// binary ever sees them, and a constant keyset makes InitCrypto idempotent. +// The values are the ones core-go's own tests use. +const ( + // NetworkKey is [sr.ht]network-key, the fernet key that seals the + // unified-login cookie and the Internal authorization of service-to-service + // calls. + NetworkKey = "tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk=" + // WebhookKey is [webhooks]private-key, the base64 ed25519 seed webhook + // payloads are signed with and bearer-token HMAC is derived from. + WebhookKey = "ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc=" +) + +// NoOrigin is a service section that is configured but carries no origin — the +// shape an instance has while a service is being installed. It must never +// appear in the service switcher, and it is in the synthetic config so that +// every service tests that rule rather than only the ones that remembered it. +const NoOrigin = "ghost.sr.ht" + +// originSuffix is the domain the fake origins live under: .example is reserved +// by RFC 2606 and resolves nowhere. +const originSuffix = ".example" + +// upstreamSections are the services a stock SourceHut ships. hub, paste and +// pages are here precisely because the switcher excludes them: a nav test that +// asserts an exclusion needs the excluded sections to exist. +var upstreamSections = []string{ + "meta.sr.ht", + "git.sr.ht", + "lists.sr.ht", + "todo.sr.ht", + "builds.sr.ht", + "man.sr.ht", + "hub.sr.ht", + "paste.sr.ht", + "pages.sr.ht", +} + +// customSections are this instance's own services — the ones that share this +// package. +var customSections = []string{ + "compare.sr.ht", + "spec.sr.ht", + "dolt.sr.ht", + "bench.sr.ht", + "cover.sr.ht", + "tokens.sr.ht", +} + +// Origin returns the origin this package gives a service section: +// https://.example. It returns "" for a section that is not a service +// (anything not ending in ".sr.ht") and for NoOrigin, whose whole point is to +// have none — so it answers "what origin does Config give this section", which +// is what a test asserting against a rendered link wants. +func Origin(section string) string { + if section == NoOrigin || !strings.HasSuffix(section, ".sr.ht") { + return "" + } + return "https://" + strings.TrimSuffix(section, ".sr.ht") + originSuffix +} + +// Config builds the synthetic instance config: the [sr.ht] block, the two +// crypto keys, the upstream services, this instance's custom services, and the +// origin-less NoOrigin section. +// +// section is the calling service's own config section ("bench.sr.ht"). It is +// added with a derived origin when this package does not already know it, so a +// new service gets a config it appears in without editing this file; pass "" if +// there is no such service (a test of the shared chrome, say). The overrides +// are applied in order, after everything else — see Set, Delete and Section. +// +// The returned file and every section in it are freshly allocated, so callers +// may mutate what they get without reaching the next call's fixture. +func Config(section string, overrides ...func(ini.File)) ini.File { + conf := ini.File{ + "sr.ht": ini.Section{ + "site-name": SiteName, + "environment": Environment, + "owner-name": OwnerName, + "owner-email": OwnerEmail, + "network-key": NetworkKey, + }, + "webhooks": ini.Section{"private-key": WebhookKey}, + NoOrigin: ini.Section{}, + } + for _, svc := range upstreamSections { + conf[svc] = ini.Section{"origin": Origin(svc)} + } + for _, svc := range customSections { + conf[svc] = ini.Section{"origin": Origin(svc)} + } + if origin := Origin(section); origin != "" { + if _, ok := conf[section]; !ok { + conf[section] = ini.Section{"origin": origin} + } + } + + for _, override := range overrides { + override(conf) + } + return conf +} + +// Set writes one key, creating the section if the config has none. It is the +// override for the tests that flip a single value — the environment, an origin, +// a service's own knob. +func Set(section, key, value string) func(ini.File) { + return func(conf ini.File) { + if conf[section] == nil { + conf[section] = ini.Section{} + } + conf[section][key] = value + } +} + +// Delete removes whole sections. It is how a test asks for an instance that +// runs one service fewer — Delete("hub.sr.ht") for the no-hub fallbacks of the +// nav and the profile link. +func Delete(sections ...string) func(ini.File) { + return func(conf ini.File) { + for _, section := range sections { + delete(conf, section) + } + } +} + +// Section replaces a whole section with the given keys, which are copied rather +// than aliased, so a caller reusing one map across calls cannot make two +// configs share a section. +func Section(name string, values map[string]string) func(ini.File) { + return func(conf ini.File) { + section := make(ini.Section, len(values)) + for k, v := range values { + section[k] = v + } + conf[name] = section + } +} + +var cryptoOnce sync.Once + +// InitCrypto installs this package's keyset into core-go's process-global +// crypto state, so that crypto.Encrypt/Decrypt (the unified-login cookie), +// crypto.Sign/Verify (webhook payloads) and the bearer-token HMAC all work +// offline. Call it from TestMain, before any test seals anything: +// +// func TestMain(m *testing.M) { +// ecoretest.InitCrypto() +// os.Exit(m.Run()) +// } +// +// It runs the underlying installation once and is safe to call from every +// TestMain in a service; because the keys are constants, even a caller that +// bypasses this and hands Config to crypto.InitCrypto itself ends up with the +// same keyset rather than invalidating what is already sealed. +// +// Note that crypto.InitCrypto log.Fatals rather than returning an error, so a +// keyset it rejects kills the whole test binary. That is the other half of why +// the keys here are constants: they cannot be malformed by accident. +func InitCrypto() { + cryptoOnce.Do(func() { + // Config carries network-key and private-key; crypto reads nothing else. + crypto.InitCrypto(Config("")) + }) +} diff --git a/ecoretest/ecoretest_test.go b/ecoretest/ecoretest_test.go new file mode 100644 index 0000000000000000000000000000000000000000..59719d8e577ed1c9af21321675d66c2f5fc4d80a --- /dev/null +++ b/ecoretest/ecoretest_test.go @@ -0,0 +1,176 @@ +package ecoretest + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vaughan0/go-ini" + "sourcecraft.dev/bigbes/sr-ht-core/crypto" + + "sourcecraft.dev/bigbes/sr-ht-ecore/chrome" +) + +// TestConfigHasTheSectionsTheNavRulesNeed runs the config through the very +// consumer it exists for. Every rule of the switcher — canonical order first, +// customs alphabetical after, hub/paste/pages excluded, a configured service +// without an origin skipped — needs a section in the fixture to be exercised at +// all, and the copies this package replaces were each missing a different one. +func TestConfigHasTheSectionsTheNavRulesNeed(t *testing.T) { + nav := chrome.BuildNav(Config("bench.sr.ht"), "bench.sr.ht") + + var names []string + for _, item := range nav { + names = append(names, item.Name) + } + assert.Equal(t, []string{ + "git", "lists", "todo", "builds", "man", "meta", + "bench", "compare", "cover", "dolt", "spec", "tokens", + }, names) + + for _, item := range nav { + assert.Equal(t, item.Name == "bench", item.Active, "active flag for %s", item.Name) + assert.Equal(t, Origin(item.Name+".sr.ht"), item.Origin) + } +} + +// TestConfigCarriesTheServiceItIsBuiltFor: a service this package has never +// heard of still gets a config it appears in. +func TestConfigCarriesTheServiceItIsBuiltFor(t *testing.T) { + conf := Config("newthing.sr.ht") + + origin, ok := conf.Get("newthing.sr.ht", "origin") + require.True(t, ok, "an unknown service section must be added") + assert.Equal(t, "https://newthing.example", origin) + + svc := chrome.NewService(conf, "newthing.sr.ht") + assert.Equal(t, "https://newthing.example", svc.SelfOrigin()) + assert.Equal(t, SiteName, svc.SiteName()) + assert.Equal(t, "https://meta.example", svc.MetaOrigin()) + assert.Equal(t, "https://hub.example", svc.HubOrigin()) + + // A known service is not rewritten, and "" is not a section. + assert.Equal(t, Origin("bench.sr.ht"), Config("bench.sr.ht")["bench.sr.ht"]["origin"]) + _, hasEmpty := Config("")[""] + assert.False(t, hasEmpty) +} + +// TestOriginsAgreeWithTheConfig pins the one spelling of the fake origins: what +// Origin answers is what a test asserting a rendered link can compare against. +func TestOriginsAgreeWithTheConfig(t *testing.T) { + conf := Config("") + for section, values := range conf { + if !strings.HasSuffix(section, ".sr.ht") { + continue + } + assert.Equal(t, Origin(section), values["origin"], "origin of %s", section) + } + + assert.Equal(t, "https://git.example", Origin("git.sr.ht")) + // The section that is configured without an origin, and the non-services. + assert.Empty(t, Origin(NoOrigin)) + assert.Empty(t, conf[NoOrigin]["origin"]) + assert.Empty(t, Origin("sr.ht")) + assert.Empty(t, Origin("webhooks")) +} + +func TestOverridesApply(t *testing.T) { + t.Run("set", func(t *testing.T) { + conf := Config("bench.sr.ht", Set("sr.ht", "environment", "staging")) + assert.Equal(t, "staging", conf["sr.ht"]["environment"]) + // The rest of the section survives an override of one key. + assert.Equal(t, SiteName, conf["sr.ht"]["site-name"]) + page := chrome.NewService(conf, "bench.sr.ht"). + Page(httptest.NewRequest("GET", "/", nil), "t", "") + assert.True(t, page.ShowBanner) + }) + + t.Run("set creates a missing section", func(t *testing.T) { + conf := Config("", Set("bench.sr.ht", "connection-string", "postgres://x")) + assert.Equal(t, "postgres://x", conf["bench.sr.ht"]["connection-string"]) + assert.Equal(t, Origin("bench.sr.ht"), conf["bench.sr.ht"]["origin"]) + }) + + t.Run("delete", func(t *testing.T) { + conf := Config("bench.sr.ht", Delete("hub.sr.ht", "meta.sr.ht")) + assert.NotContains(t, conf, "hub.sr.ht") + assert.NotContains(t, conf, "meta.sr.ht") + assert.Empty(t, chrome.NewService(conf, "bench.sr.ht").HubOrigin()) + }) + + t.Run("section replaces wholesale", func(t *testing.T) { + values := map[string]string{"origin": "https://elsewhere.example"} + conf := Config("bench.sr.ht", Section("git.sr.ht", values)) + assert.Equal(t, ini.Section{"origin": "https://elsewhere.example"}, conf["git.sr.ht"]) + + // The caller's map is copied, not aliased: editing either afterwards + // leaves the other alone. + values["origin"] = "https://mutated.example" + assert.Equal(t, "https://elsewhere.example", conf["git.sr.ht"]["origin"]) + }) + + t.Run("applied in order, after the base config", func(t *testing.T) { + conf := Config("bench.sr.ht", + Set("sr.ht", "site-name", "first"), + Set("sr.ht", "site-name", "second")) + assert.Equal(t, "second", conf["sr.ht"]["site-name"]) + }) +} + +// TestCallsShareNoMutableState is the whole reason Config is a function rather +// than a package-level fixture: the nav tests delete sections and the banner +// tests overwrite keys, and in a shared map the next test reads the wreckage. +func TestCallsShareNoMutableState(t *testing.T) { + first := Config("bench.sr.ht") + delete(first, "hub.sr.ht") + first["sr.ht"]["environment"] = "staging" + first["git.sr.ht"]["origin"] = "https://mutated.example" + + second := Config("bench.sr.ht") + assert.Equal(t, "https://hub.example", second["hub.sr.ht"]["origin"]) + assert.Equal(t, Environment, second["sr.ht"]["environment"]) + assert.Equal(t, "https://git.example", second["git.sr.ht"]["origin"]) + + // Not merely equal by value: the section maps are distinct allocations, so + // a mutation of one is invisible to the other in either direction. + second["sr.ht"]["site-name"] = "late edit" + assert.Equal(t, SiteName, first["sr.ht"]["site-name"]) +} + +// TestInitCryptoSealsAndOpens: the bootstrap works, end to end, offline — a +// payload sealed with the installed fernet key opens again, and a signature +// made with the installed webhook key verifies. +func TestInitCryptoSealsAndOpens(t *testing.T) { + InitCrypto() + + sealed := crypto.Encrypt([]byte("~alice")) + assert.Equal(t, []byte("~alice"), crypto.DecryptWithoutExpiration(sealed)) + + payload := []byte(`{"id":1}`) + assert.True(t, crypto.Verify(payload, crypto.Sign(payload))) + nonce, signature := crypto.SignWebhook(payload) + assert.True(t, crypto.VerifyWebhook(payload, nonce, signature)) + + // Idempotent: a second call — another package's TestMain, in a real service + // — must not rotate the keys the first one sealed with. + InitCrypto() + crypto.InitCrypto(Config("bench.sr.ht")) + assert.Equal(t, []byte("~alice"), crypto.DecryptWithoutExpiration(sealed)) +} + +// TestConfigCarriesTheKeysCryptoWants guards the two keys by name: crypto reads +// them from a config file and log.Fatals when either is missing, which takes +// the whole test binary with it rather than failing one test. +func TestConfigCarriesTheKeysCryptoWants(t *testing.T) { + conf := Config("bench.sr.ht") + + networkKey, ok := conf.Get("sr.ht", "network-key") + require.True(t, ok) + assert.Equal(t, NetworkKey, networkKey) + + webhookKey, ok := conf.Get("webhooks", "private-key") + require.True(t, ok) + assert.Equal(t, WebhookKey, webhookKey) +}