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