coverage~bigbes/sr-ht-ecore89fa694cecoretest/ecoretest.go

Coverage
96.4% 27/28 statements
Δ
+0.0
Blob
e0596ab
Uncovered L182-L183
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 }