v0.36.0 1fb2421

boundary_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package boundary_test enforces the import rules between the layers of
4// ermite. The module root holds no Go package (the binary lives in cmd/ermite),
5// so the check lives in this test-only package and walks the module from its
6// root. It parses import clauses only; it never loads packages.
7package boundary_test
8
9import (
10	"fmt"
11	"go/parser"
12	"go/token"
13	"io/fs"
14	"path/filepath"
15	"sort"
16	"strconv"
17	"strings"
18	"testing"
19)
20
21const modulePath = "bnema.dev/ermite"
22
23// apiGenPath is the generated code of the API contract (protobuf and Connect),
24// a module of its own.
25const apiGenPath = "bnema.dev/ermite-api/gen"
26
27// moduleRoot is the module root relative to this package's directory.
28const moduleRoot = "../.."
29
30type packageLayer string
31
32const (
33	layerCommand     packageLayer = "command"
34	layerApp         packageLayer = "app"
35	layerUsecase     packageLayer = "usecase"
36	layerAdapter     packageLayer = "adapter"
37	layerClientLimit packageLayer = "clientlimit"
38	layerHTTPRange   packageLayer = "httprange"
39	layerMediaPolicy packageLayer = "mediapolicy"
40	layerConfig      packageLayer = "config"
41	layerPorts       packageLayer = "ports"
42	layerDomain      packageLayer = "domain"
43	layerGenerated   packageLayer = "generated"
44	layerScript      packageLayer = "script"
45	layerBoundary    packageLayer = "boundary"
46	layerTestSupport packageLayer = "test-support"
47)
48
49// productionDependencies lists, per layer, the layers its production files
50// may import.
51//
52//   - cmd/ermite is the entry point: it only calls internal/app.
53//   - internal/app is the composition root and the CLI: it wires everything.
54//   - internal/usecase holds the use cases: domain and ports only.
55//   - adapters implement ports and never import use cases or app; the
56//     per-package rule in packageImportDenied also keeps them from importing
57//     each other, and keeps the web adapter off the generated API code: like
58//     every other adapter it reads and writes through the ports (ADR 0020).
59//   - internal/clientlimit is infrastructure shared by the HTTP adapters.
60//   - internal/httprange is infrastructure shared by the HTTP adapters that
61//     serve byte ranges; it imports nothing of the module.
62//   - internal/mediapolicy validates hostnames and shares the video origin policy;
63//     it imports nothing of the module.
64//   - internal/config parses the environment into plain values.
65var productionDependencies = map[packageLayer]map[packageLayer]bool{
66	layerCommand:     {layerApp: true},
67	layerApp:         {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerMediaPolicy: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true},
68	layerUsecase:     {layerUsecase: true, layerPorts: true, layerDomain: true},
69	layerAdapter:     {layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerMediaPolicy: true, layerPorts: true, layerDomain: true, layerGenerated: true},
70	layerClientLimit: {layerPorts: true},
71	layerHTTPRange:   {},
72	layerConfig:      {layerMediaPolicy: true},
73	layerMediaPolicy: {},
74	layerPorts:       {layerDomain: true},
75	layerDomain:      {layerDomain: true},
76	layerGenerated:   {layerGenerated: true},
77	layerScript:      {},
78	layerBoundary:    {},
79	layerTestSupport: {layerPorts: true, layerDomain: true},
80}
81
82func TestImportBoundaries(t *testing.T) {
83	violations, packages := inspectRepositoryDependencies(t)
84	if len(violations) != 0 {
85		t.Fatalf("invalid package dependencies:\n%s", strings.Join(violations, "\n"))
86	}
87	for _, want := range []string{"cmd/ermite", "internal/app", "internal/usecase", "internal/ports", "internal/domain"} {
88		if _, ok := packages[modulePath+"/"+want]; !ok {
89			t.Fatalf("package inventory misses %s: the walk is not reading the module", want)
90		}
91	}
92}
93
94// The generated API code is outside the module: the walk would skip it. The
95// web adapter may not import it in production code, whichever package of it.
96func TestWebAdapterRejectsAPIGeneratedCode(t *testing.T) {
97	tests := []struct {
98		name, source, target string
99		testFile             bool
100		want                 bool
101	}{
102		{"web messages", modulePath + "/internal/adapters/web", apiGenPath + "/ermite", false, false},
103		{"web connect", modulePath + "/internal/adapters/web", apiGenPath + "/ermite/ermiteconnect", false, false},
104		{"web subpackage", modulePath + "/internal/adapters/web/sub", apiGenPath + "/ermite", false, false},
105		{"web test file", modulePath + "/internal/adapters/web", apiGenPath + "/ermite", true, true},
106		{"rpc adapter", modulePath + "/internal/adapters/rpc", apiGenPath + "/ermite", false, true},
107		{"client adapter", modulePath + "/internal/adapters/client", apiGenPath + "/ermite/ermiteconnect", false, true},
108		{"other module of the API", modulePath + "/internal/adapters/web", "bnema.dev/ermite-api/other", false, true},
109		{"web other external package", modulePath + "/internal/adapters/web", "github.com/a-h/templ", false, true},
110		{"a package whose name only starts alike", modulePath + "/internal/adapters/web", apiGenPath + "x/y", false, true},
111	}
112	for _, tt := range tests {
113		t.Run(tt.name, func(t *testing.T) {
114			if got := !externalImportDenied(tt.source, tt.target, tt.testFile); got != tt.want {
115				t.Fatalf("externalImportDenied(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, !got, !tt.want)
116			}
117		})
118	}
119}
120
121func TestImportBoundaryFixtures(t *testing.T) {
122	p := func(s string) string { return modulePath + "/" + s }
123	tests := []struct {
124		name, source, target string
125		testFile             bool
126		want                 bool
127	}{
128		{"command accepts app", p("cmd/ermite"), p("internal/app"), false, true},
129		{"command rejects usecase", p("cmd/ermite"), p("internal/usecase"), false, false},
130		{"command rejects adapter", p("cmd/ermite"), p("internal/adapters/web"), false, false},
131		{"command rejects domain", p("cmd/ermite"), p("internal/domain"), false, false},
132		{"app accepts usecase", p("internal/app"), p("internal/usecase"), false, true},
133		{"app accepts adapter", p("internal/app"), p("internal/adapters/gitcli"), false, true},
134		{"app accepts config", p("internal/app"), p("internal/config"), false, true},
135		{"app accepts clientlimit", p("internal/app"), p("internal/clientlimit"), false, true},
136		{"app rejects command", p("internal/app"), p("cmd/ermite"), false, false},
137		{"app test may import command", p("internal/app"), p("cmd/ermite"), true, true},
138		{"usecase accepts domain", p("internal/usecase"), p("internal/domain"), false, true},
139		{"usecase accepts ports", p("internal/usecase"), p("internal/ports"), false, true},
140		{"usecase rejects adapter", p("internal/usecase"), p("internal/adapters/kvstore"), false, false},
141		{"usecase rejects app", p("internal/usecase"), p("internal/app"), false, false},
142		{"usecase rejects config", p("internal/usecase"), p("internal/config"), false, false},
143		{"usecase rejects clientlimit", p("internal/usecase"), p("internal/clientlimit"), false, false},
144		{"usecase test may import adapter", p("internal/usecase"), p("internal/adapters/kvstore"), true, true},
145		{"adapter accepts ports", p("internal/adapters/rpc"), p("internal/ports"), false, true},
146		{"adapter accepts domain", p("internal/adapters/rpc"), p("internal/domain"), false, true},
147		{"adapter accepts clientlimit", p("internal/adapters/githttp"), p("internal/clientlimit"), false, true},
148		{"adapter accepts own subpackage", p("internal/adapters/web/sub"), p("internal/adapters/web"), false, true},
149		{"adapter accepts generated", p("internal/adapters/rpc"), p("gen/ermite"), false, true},
150		{"web rejects generated", p("internal/adapters/web"), p("gen/ermite"), false, false},
151		{"web rejects generated connect", p("internal/adapters/web"), p("gen/ermite/ermiteconnect"), false, false},
152		{"web subpackage rejects generated", p("internal/adapters/web/sub"), p("gen/ermite"), false, false},
153		{"web test may import ports mocks", p("internal/adapters/web"), p("internal/ports/mocks"), true, true},
154		{"web production rejects ports mocks", p("internal/adapters/web"), p("internal/ports/mocks"), false, false},
155		{"adapter rejects usecase", p("internal/adapters/rpc"), p("internal/usecase"), false, false},
156		{"adapter rejects app", p("internal/adapters/rpc"), p("internal/app"), false, false},
157		{"adapter rejects config", p("internal/adapters/rpc"), p("internal/config"), false, false},
158		{"adapter rejects sibling adapter", p("internal/adapters/web"), p("internal/adapters/gitcli"), false, false},
159		{"adapter test may import sibling adapter", p("internal/adapters/ermitegit"), p("internal/adapters/gitcli"), true, true},
160		{"app accepts httprange", p("internal/app"), p("internal/httprange"), false, true},
161		{"usecase rejects httprange", p("internal/usecase"), p("internal/httprange"), false, false},
162		{"domain rejects httprange", p("internal/domain"), p("internal/httprange"), false, false},
163		{"ports reject httprange", p("internal/ports"), p("internal/httprange"), false, false},
164		{"adapter accepts httprange", p("internal/adapters/web"), p("internal/httprange"), false, true},
165		{"httprange rejects domain", p("internal/httprange"), p("internal/domain"), false, false},
166		{"clientlimit accepts ports", p("internal/clientlimit"), p("internal/ports"), false, true},
167		{"clientlimit rejects usecase", p("internal/clientlimit"), p("internal/usecase"), false, false},
168		{"config rejects domain", p("internal/config"), p("internal/domain"), false, false},
169		{"ports accepts domain", p("internal/ports"), p("internal/domain"), false, true},
170		{"ports rejects usecase", p("internal/ports"), p("internal/usecase"), false, false},
171		{"ports rejects adapter", p("internal/ports"), p("internal/adapters/web"), false, false},
172		{"ports mocks accept ports", p("internal/ports/mocks"), p("internal/ports"), false, true},
173		{"ports mocks reject usecase", p("internal/ports/mocks"), p("internal/usecase"), false, false},
174		{"reader conformance accepts ports", p("internal/readertest"), p("internal/ports"), false, true},
175		{"reader conformance rejects adapter", p("internal/readertest"), p("internal/adapters/gitcli"), false, false},
176		{"domain accepts domain", p("internal/domain"), p("internal/domain/x"), false, true},
177		{"domain rejects ports", p("internal/domain"), p("internal/ports"), false, false},
178		{"domain test may import anything", p("internal/domain"), p("internal/ports"), true, true},
179	}
180	for _, tt := range tests {
181		t.Run(tt.name, func(t *testing.T) {
182			got, err := dependencyAllowed(tt.source, tt.target, tt.testFile)
183			if err != nil {
184				t.Fatalf("dependencyAllowed: %v", err)
185			}
186			if got != tt.want {
187				t.Fatalf("dependencyAllowed(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, got, tt.want)
188			}
189		})
190	}
191}
192
193func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) {
194	t.Helper()
195	var violations []string
196	packages := make(map[string]struct{})
197	fset := token.NewFileSet()
198	err := filepath.WalkDir(moduleRoot, func(path string, entry fs.DirEntry, walkErr error) error {
199		if walkErr != nil {
200			return walkErr
201		}
202		if entry.IsDir() {
203			name := entry.Name()
204			if name == ".git" || name == ".worktrees" || name == "vendor" || name == "testdata" || name == "node_modules" {
205				return filepath.SkipDir
206			}
207			return nil
208		}
209		if !strings.HasSuffix(path, ".go") {
210			return nil
211		}
212		rel, err := filepath.Rel(moduleRoot, path)
213		if err != nil {
214			return err
215		}
216		source := importPathForFile(rel)
217		packages[source] = struct{}{}
218		if _, err := classifyPackage(source); err != nil {
219			violations = append(violations, rel+": "+err.Error())
220			return nil
221		}
222		file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
223		if err != nil {
224			return err
225		}
226		testFile := strings.HasSuffix(path, "_test.go")
227		for _, spec := range file.Imports {
228			target, err := strconv.Unquote(spec.Path.Value)
229			if err != nil {
230				continue
231			}
232			if externalImportDenied(source, target, testFile) {
233				violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target))
234				continue
235			}
236			if target != modulePath && !strings.HasPrefix(target, modulePath+"/") {
237				continue
238			}
239			allowed, err := dependencyAllowed(source, target, testFile)
240			if err != nil {
241				violations = append(violations, fmt.Sprintf("%s: %v", rel, err))
242				continue
243			}
244			if !allowed {
245				violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target))
246			}
247		}
248		return nil
249	})
250	if err != nil {
251		t.Fatalf("walking module: %v", err)
252	}
253	sort.Strings(violations)
254	return violations, packages
255}
256
257// dependencyAllowed reports whether a file of package source may import
258// target. Test files are exempt, so that composition tests can wire adapters
259// and use cases together; their packages must still be classified.
260func dependencyAllowed(source, target string, testFile bool) (bool, error) {
261	sourceLayer, err := classifyPackage(source)
262	if err != nil {
263		return false, err
264	}
265	targetLayer, err := classifyPackage(target)
266	if err != nil {
267		return false, err
268	}
269	if testFile {
270		return true, nil
271	}
272	if packageImportDenied(source, target) {
273		return false, nil
274	}
275	return productionDependencies[sourceLayer][targetLayer], nil
276}
277
278// externalImportDenied reports whether a file of package source may not import
279// target, a package outside the module. The only such rule: the production
280// code of the web adapter does not import the generated API code
281// (apiGenPath), so that it goes through the ports. Test files are exempt, like
282// for every rule here.
283func externalImportDenied(source, target string, testFile bool) bool {
284	if testFile || !under(target, apiGenPath) {
285		return false
286	}
287	return under(source, modulePath+"/internal/adapters/web")
288}
289
290// packageImportDenied narrows the layer rules to packages: an adapter may
291// import only packages under its own directory, never a sibling adapter; and
292// the web adapter may not import the generated API code at all (protobuf,
293// Connect): it goes through the ports, as the other adapters that serve the
294// use cases do.
295func packageImportDenied(source, target string) bool {
296	const adapters = modulePath + "/internal/adapters/"
297	if strings.HasPrefix(source, adapters) && strings.HasPrefix(target, adapters) {
298		return adapterRoot(source) != adapterRoot(target)
299	}
300	if strings.HasPrefix(source, adapters) && adapterRoot(source) == "web" {
301		return under(target, modulePath+"/gen")
302	}
303	return false
304}
305
306// adapterRoot is the first path element under internal/adapters.
307func adapterRoot(path string) string {
308	rest := strings.TrimPrefix(path, modulePath+"/internal/adapters/")
309	root, _, _ := strings.Cut(rest, "/")
310	return root
311}
312
313func under(path, root string) bool {
314	return path == root || strings.HasPrefix(path, root+"/")
315}
316
317func classifyPackage(path string) (packageLayer, error) {
318	p := func(s string) string { return modulePath + "/" + s }
319	switch {
320	case under(path, p("cmd")):
321		return layerCommand, nil
322	case under(path, p("scripts")):
323		return layerScript, nil
324	case under(path, p("internal/boundary")):
325		return layerBoundary, nil
326	case under(path, p("internal/app")):
327		return layerApp, nil
328	case under(path, p("internal/usecase")):
329		return layerUsecase, nil
330	case path == p("internal/ports/mocks"), under(path, p("internal/readertest")):
331		return layerTestSupport, nil
332	case under(path, p("internal/adapters")):
333		return layerAdapter, nil
334	case under(path, p("internal/clientlimit")):
335		return layerClientLimit, nil
336	case under(path, p("internal/httprange")):
337		return layerHTTPRange, nil
338	case under(path, p("internal/mediapolicy")):
339		return layerMediaPolicy, nil
340	case under(path, p("internal/config")):
341		return layerConfig, nil
342	case path == p("internal/ports"):
343		return layerPorts, nil
344	case under(path, p("internal/domain")):
345		return layerDomain, nil
346	case under(path, p("gen")):
347		return layerGenerated, nil
348	default:
349		return "", fmt.Errorf("unclassified package %q", path)
350	}
351}
352
353func importPathForFile(rel string) string {
354	dir := filepath.ToSlash(filepath.Dir(rel))
355	if dir == "." {
356		return modulePath
357	}
358	return modulePath + "/" + dir
359}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close