~bigbes/sr-ht-ecore

ref: 353a7f19f00b0413558eaffb8e1fd658ccccb1f3 sr-ht-ecore/ecoretest/ecoretest.go -rw-r--r-- 9.2 KiB
353a7f19 — Eugene Blikh follow cover.sr.ht's rename to cov.sr.ht 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
// 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(""))
	})
}