~bigbes/sr-ht-ecore

12ae8522b3f9eb04a733f20010ec5a7ca099cec1 — Eugene Blikh 9 days ago ede9467
ecoretest: the shared instance config and crypto bootstrap for tests
2 files changed, 413 insertions(+), 0 deletions(-)

A ecoretest/ecoretest.go
A ecoretest/ecoretest_test.go
A ecoretest/ecoretest.go => ecoretest/ecoretest.go +237 -0
@@ 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(""))
	})
}

A ecoretest/ecoretest_test.go => ecoretest/ecoretest_test.go +176 -0
@@ 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)
}