// Package ecoretest is the test bootstrap shared by the custom services of a
// self-hosted SourceHut instance (diff, spec, dolt, cov, 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{
"diff.sr.ht",
"spec.sr.ht",
"dolt.sr.ht",
"bench.sr.ht",
"cov.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(""))
})
}