@@ 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://<service>.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://<service>.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(""))
+ })
+}
@@ 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)
+}