| 1 |
|
// Package ecoretest is the test bootstrap shared by the custom services of a |
| 2 |
|
// self-hosted SourceHut instance (diff, spec, dolt, cov, bench, tokens). |
| 3 |
|
// |
| 4 |
|
// Every one of those services opens its web tests with the same two things: a |
| 5 |
|
// hand-built ini.File standing in for the instance's config.ini, and a TestMain |
| 6 |
|
// that mints a fernet network key plus an ed25519 webhook seed and hands them |
| 7 |
|
// to crypto.InitCrypto, so that sealing and opening a unified-login cookie |
| 8 |
|
// works with no meta.sr.ht and no network. Both were copied service to service |
| 9 |
|
// and drifted. The fake origins are spelled https://git.example in three |
| 10 |
|
// services and https://git.example.org in a fourth; the environment is |
| 11 |
|
// "production" in one copy and "development" in the next; only some copies |
| 12 |
|
// carry the origin-less section the service switcher has to skip, so the rule |
| 13 |
|
// that it is skipped is tested on some services and not others. This package |
| 14 |
|
// is the one copy. |
| 15 |
|
// |
| 16 |
|
// Usage — the whole bootstrap of a service's web test: |
| 17 |
|
// |
| 18 |
|
// func TestMain(m *testing.M) { |
| 19 |
|
// ecoretest.InitCrypto() |
| 20 |
|
// os.Exit(m.Run()) |
| 21 |
|
// } |
| 22 |
|
// |
| 23 |
|
// conf := ecoretest.Config("bench.sr.ht") |
| 24 |
|
// staging := ecoretest.Config("bench.sr.ht", |
| 25 |
|
// ecoretest.Set("sr.ht", "environment", "staging")) |
| 26 |
|
// noHub := ecoretest.Config("bench.sr.ht", ecoretest.Delete("hub.sr.ht")) |
| 27 |
|
// |
| 28 |
|
// Config builds a fresh ini.File with fresh section maps on every call, so a |
| 29 |
|
// test that edits or deletes a section cannot be read by the next one — the |
| 30 |
|
// shared-fixture flake this package exists to prevent. The section argument is |
| 31 |
|
// the calling service's own section: it is guaranteed to be present with an |
| 32 |
|
// origin even when this package has never heard of that service. |
| 33 |
|
// |
| 34 |
|
// The origins are one fixed set, https://<service>.example, under the reserved |
| 35 |
|
// .example TLD of RFC 2606, so a test that accidentally dials one resolves |
| 36 |
|
// nothing instead of reaching a stranger. |
| 37 |
|
// |
| 38 |
|
// Two departures from what a test helper usually looks like, both deliberate. |
| 39 |
|
// This is ordinary (non-_test.go) code so that services can import it, and it |
| 40 |
|
// therefore does not import "testing": nothing here takes a testing.TB, which |
| 41 |
|
// is also what lets InitCrypto be called from TestMain, where every donor calls |
| 42 |
|
// it and where no TB exists. And the keys are fixed constants rather than |
| 43 |
|
// freshly generated ones — they authenticate nothing outside a test process, |
| 44 |
|
// and being constant is what makes InitCrypto idempotent, so two packages of |
| 45 |
|
// one service can both call it without the second rotating the keys the first |
| 46 |
|
// sealed a cookie with. |
| 47 |
|
package ecoretest |
| 48 |
|
|
| 49 |
|
import ( |
| 50 |
|
"strings" |
| 51 |
|
"sync" |
| 52 |
|
|
| 53 |
|
"github.com/vaughan0/go-ini" |
| 54 |
|
"sourcecraft.dev/bigbes/sr-ht-core/crypto" |
| 55 |
|
) |
| 56 |
|
|
| 57 |
|
// The instance identity every service's tests render against — the [sr.ht] |
| 58 |
|
// block of the synthetic config. |
| 59 |
|
const ( |
| 60 |
|
// SiteName is [sr.ht]site-name, the brand text of the shared nav. |
| 61 |
|
SiteName = "srht.example" |
| 62 |
|
// Environment is [sr.ht]environment. It is "production" so that the |
| 63 |
|
// environment banner is off by default; a test that wants the banner asks |
| 64 |
|
// for it with Set("sr.ht", "environment", "staging"). |
| 65 |
|
Environment = "production" |
| 66 |
|
// OwnerName and OwnerEmail are [sr.ht]owner-name/owner-email, which |
| 67 |
|
// config.GetOwner panics without. |
| 68 |
|
OwnerName = "admin" |
| 69 |
|
OwnerEmail = "admin@srht.example" |
| 70 |
|
) |
| 71 |
|
|
| 72 |
|
// The two keys crypto.InitCrypto insists on. They are constants rather than |
| 73 |
|
// generated values because they secure nothing: no process outside a test |
| 74 |
|
// binary ever sees them, and a constant keyset makes InitCrypto idempotent. |
| 75 |
|
// The values are the ones core-go's own tests use. |
| 76 |
|
const ( |
| 77 |
|
// NetworkKey is [sr.ht]network-key, the fernet key that seals the |
| 78 |
|
// unified-login cookie and the Internal authorization of service-to-service |
| 79 |
|
// calls. |
| 80 |
|
NetworkKey = "tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk=" |
| 81 |
|
// WebhookKey is [webhooks]private-key, the base64 ed25519 seed webhook |
| 82 |
|
// payloads are signed with and bearer-token HMAC is derived from. |
| 83 |
|
WebhookKey = "ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc=" |
| 84 |
|
) |
| 85 |
|
|
| 86 |
|
// NoOrigin is a service section that is configured but carries no origin — the |
| 87 |
|
// shape an instance has while a service is being installed. It must never |
| 88 |
|
// appear in the service switcher, and it is in the synthetic config so that |
| 89 |
|
// every service tests that rule rather than only the ones that remembered it. |
| 90 |
|
const NoOrigin = "ghost.sr.ht" |
| 91 |
|
|
| 92 |
|
// originSuffix is the domain the fake origins live under: .example is reserved |
| 93 |
|
// by RFC 2606 and resolves nowhere. |
| 94 |
|
const originSuffix = ".example" |
| 95 |
|
|
| 96 |
|
// upstreamSections are the services a stock SourceHut ships. hub, paste and |
| 97 |
|
// pages are here precisely because the switcher excludes them: a nav test that |
| 98 |
|
// asserts an exclusion needs the excluded sections to exist. |
| 99 |
|
var upstreamSections = []string{ |
| 100 |
|
"meta.sr.ht", |
| 101 |
|
"git.sr.ht", |
| 102 |
|
"lists.sr.ht", |
| 103 |
|
"todo.sr.ht", |
| 104 |
|
"builds.sr.ht", |
| 105 |
|
"man.sr.ht", |
| 106 |
|
"hub.sr.ht", |
| 107 |
|
"paste.sr.ht", |
| 108 |
|
"pages.sr.ht", |
| 109 |
|
} |
| 110 |
|
|
| 111 |
|
// customSections are this instance's own services — the ones that share this |
| 112 |
|
// package. |
| 113 |
|
var customSections = []string{ |
| 114 |
|
"diff.sr.ht", |
| 115 |
|
"spec.sr.ht", |
| 116 |
|
"dolt.sr.ht", |
| 117 |
|
"bench.sr.ht", |
| 118 |
|
"cov.sr.ht", |
| 119 |
|
"tokens.sr.ht", |
| 120 |
|
} |
| 121 |
|
|
| 122 |
|
// Origin returns the origin this package gives a service section: |
| 123 |
|
// https://<service>.example. It returns "" for a section that is not a service |
| 124 |
|
// (anything not ending in ".sr.ht") and for NoOrigin, whose whole point is to |
| 125 |
|
// have none — so it answers "what origin does Config give this section", which |
| 126 |
|
// is what a test asserting against a rendered link wants. |
| 127 |
274 |
func Origin(section string) string { |
| 128 |
274 |
if section == NoOrigin || !strings.HasSuffix(section, ".sr.ht") { |
| 129 |
8 |
return "" |
| 130 |
8 |
} |
| 131 |
266 |
return "https://" + strings.TrimSuffix(section, ".sr.ht") + originSuffix |
| 132 |
|
} |
| 133 |
|
|
| 134 |
|
// Config builds the synthetic instance config: the [sr.ht] block, the two |
| 135 |
|
// crypto keys, the upstream services, this instance's custom services, and the |
| 136 |
|
// origin-less NoOrigin section. |
| 137 |
|
// |
| 138 |
|
// section is the calling service's own config section ("bench.sr.ht"). It is |
| 139 |
|
// added with a derived origin when this package does not already know it, so a |
| 140 |
|
// new service gets a config it appears in without editing this file; pass "" if |
| 141 |
|
// there is no such service (a test of the shared chrome, say). The overrides |
| 142 |
|
// are applied in order, after everything else — see Set, Delete and Section. |
| 143 |
|
// |
| 144 |
|
// The returned file and every section in it are freshly allocated, so callers |
| 145 |
|
// may mutate what they get without reaching the next call's fixture. |
| 146 |
15 |
func Config(section string, overrides ...func(ini.File)) ini.File { |
| 147 |
15 |
conf := ini.File{ |
| 148 |
15 |
"sr.ht": ini.Section{ |
| 149 |
15 |
"site-name": SiteName, |
| 150 |
15 |
"environment": Environment, |
| 151 |
15 |
"owner-name": OwnerName, |
| 152 |
15 |
"owner-email": OwnerEmail, |
| 153 |
15 |
"network-key": NetworkKey, |
| 154 |
15 |
}, |
| 155 |
15 |
"webhooks": ini.Section{"private-key": WebhookKey}, |
| 156 |
15 |
NoOrigin: ini.Section{}, |
| 157 |
15 |
} |
| 158 |
135 |
for _, svc := range upstreamSections { |
| 159 |
135 |
conf[svc] = ini.Section{"origin": Origin(svc)} |
| 160 |
135 |
} |
| 161 |
90 |
for _, svc := range customSections { |
| 162 |
90 |
conf[svc] = ini.Section{"origin": Origin(svc)} |
| 163 |
90 |
} |
| 164 |
15 |
if origin := Origin(section); origin != "" { |
| 165 |
11 |
if _, ok := conf[section]; !ok { |
| 166 |
1 |
conf[section] = ini.Section{"origin": origin} |
| 167 |
1 |
} |
| 168 |
|
} |
| 169 |
|
|
| 170 |
15 |
for _, override := range overrides { |
| 171 |
6 |
override(conf) |
| 172 |
6 |
} |
| 173 |
15 |
return conf |
| 174 |
|
} |
| 175 |
|
|
| 176 |
|
// Set writes one key, creating the section if the config has none. It is the |
| 177 |
|
// override for the tests that flip a single value — the environment, an origin, |
| 178 |
|
// a service's own knob. |
| 179 |
4 |
func Set(section, key, value string) func(ini.File) { |
| 180 |
4 |
return func(conf ini.File) { |
| 181 |
4 |
if conf[section] == nil { |
| 182 |
0 |
conf[section] = ini.Section{} |
| 183 |
0 |
} |
| 184 |
4 |
conf[section][key] = value |
| 185 |
|
} |
| 186 |
|
} |
| 187 |
|
|
| 188 |
|
// Delete removes whole sections. It is how a test asks for an instance that |
| 189 |
|
// runs one service fewer — Delete("hub.sr.ht") for the no-hub fallbacks of the |
| 190 |
|
// nav and the profile link. |
| 191 |
1 |
func Delete(sections ...string) func(ini.File) { |
| 192 |
1 |
return func(conf ini.File) { |
| 193 |
2 |
for _, section := range sections { |
| 194 |
2 |
delete(conf, section) |
| 195 |
2 |
} |
| 196 |
|
} |
| 197 |
|
} |
| 198 |
|
|
| 199 |
|
// Section replaces a whole section with the given keys, which are copied rather |
| 200 |
|
// than aliased, so a caller reusing one map across calls cannot make two |
| 201 |
|
// configs share a section. |
| 202 |
1 |
func Section(name string, values map[string]string) func(ini.File) { |
| 203 |
1 |
return func(conf ini.File) { |
| 204 |
1 |
section := make(ini.Section, len(values)) |
| 205 |
1 |
for k, v := range values { |
| 206 |
1 |
section[k] = v |
| 207 |
1 |
} |
| 208 |
1 |
conf[name] = section |
| 209 |
|
} |
| 210 |
|
} |
| 211 |
|
|
| 212 |
|
var cryptoOnce sync.Once |
| 213 |
|
|
| 214 |
|
// InitCrypto installs this package's keyset into core-go's process-global |
| 215 |
|
// crypto state, so that crypto.Encrypt/Decrypt (the unified-login cookie), |
| 216 |
|
// crypto.Sign/Verify (webhook payloads) and the bearer-token HMAC all work |
| 217 |
|
// offline. Call it from TestMain, before any test seals anything: |
| 218 |
|
// |
| 219 |
|
// func TestMain(m *testing.M) { |
| 220 |
|
// ecoretest.InitCrypto() |
| 221 |
|
// os.Exit(m.Run()) |
| 222 |
|
// } |
| 223 |
|
// |
| 224 |
|
// It runs the underlying installation once and is safe to call from every |
| 225 |
|
// TestMain in a service; because the keys are constants, even a caller that |
| 226 |
|
// bypasses this and hands Config to crypto.InitCrypto itself ends up with the |
| 227 |
|
// same keyset rather than invalidating what is already sealed. |
| 228 |
|
// |
| 229 |
|
// Note that crypto.InitCrypto log.Fatals rather than returning an error, so a |
| 230 |
|
// keyset it rejects kills the whole test binary. That is the other half of why |
| 231 |
|
// the keys here are constants: they cannot be malformed by accident. |
| 232 |
2 |
func InitCrypto() { |
| 233 |
2 |
cryptoOnce.Do(func() { |
| 234 |
1 |
// Config carries network-key and private-key; crypto reads nothing else. |
| 235 |
1 |
crypto.InitCrypto(Config("")) |
| 236 |
1 |
}) |
| 237 |
|
} |