The owner's "проверь, что документация актуальна с кодом" (2026-10-16).
Read-only reviews checked every present-tense claim against the code;
each finding was read in the source before it was fixed here.
architecture.md: held inbound mail has its row from the journal milter,
not the tailer; the antispam action is accept or tempfail, anything else
stops the start; tls-internal's certificate is 0644; the signer logs to
stderr; the log's fingerprint is the inode, not the device; a mail.log
postlogd makes is 0640 (maillog_file_permissions); an unreadable stored
place starts at end-of-file too; the test-message route; no map in page
data; design-first compares whitespace aside; the queue and sasldb2.new
are not backed up; antispam/ under /data; POSTFIX_QUEUE_DIR's desync;
SELFPOST_BACKUP_PASSWORD (the CLI's).
schema-migrations.md: inbound_spam_log's upstream columns (v12),
send_log's sender reason, row 9's client_ip.
security.md: /data/opendkim since 2.9.0 (removed, or the start refused),
sasldb2 under /data/sasl, the trigger for mandatory encryption, the
password minimum counted in characters.
development.md: Staticcheck in CI, the e2e ports 20025 and 20053/udp.
README: the client IP list refuses at MAIL FROM, overrides are per
application. product.md: the optional rspamd sidecar.
Code: validate.AdminPassword counted bytes, so six Cyrillic letters
passed for twelve characters; it counts runes, as the backup password
always did (TestAdminPasswordCountsCharacters). envdoc reads
cmd/selfpost-backup too, and a name kept in a constant. A stale comment
of cmd/panel's run ("three roles").
go vet + go test (26 packages) and make e2e on the test server for this
tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
4.5 KiB
Go
145 lines
4.5 KiB
Go
package main
|
|
|
|
import (
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The environment the image reads and the environment the documents describe
|
|
// are compared as they are, not as lists kept by hand beside them (audit
|
|
// 2026-10-12, stage 5 - the three lists this file held were compared with each
|
|
// other and with nothing else). Read: what the panel's code asks of the
|
|
// environment (cmd/panel, cmd/selfpost-backup and internal, tests aside) and what the start-up
|
|
// scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
|
|
// operator's table in guide.md § Environment variables, with the paragraph
|
|
// under it, and the internal list in architecture.md § Configuration.
|
|
|
|
var (
|
|
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
|
|
// A name kept in a constant, as the backup CLI keeps its password's.
|
|
goEnvConst = regexp.MustCompile(`\w+Env\s*=\s*"([A-Z][A-Z0-9_]+)"`)
|
|
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
|
|
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]+)`")
|
|
)
|
|
|
|
func readFile(t *testing.T, path string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// envRead is every variable the code and the scripts read.
|
|
func envRead(t *testing.T) map[string]bool {
|
|
t.Helper()
|
|
keys := map[string]bool{}
|
|
for _, root := range []string{".", filepath.Join("..", "selfpost-backup"), filepath.Join("..", "..", "internal")} {
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
|
return err
|
|
}
|
|
text := readFile(t, path)
|
|
for _, m := range goEnvRead.FindAllStringSubmatch(text, -1) {
|
|
keys[m[1]] = true
|
|
}
|
|
for _, m := range goEnvConst.FindAllStringSubmatch(text, -1) {
|
|
keys[m[1]] = true
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
scripts, err := filepath.Glob(filepath.Join("..", "..", "build", "*.sh"))
|
|
if err != nil || len(scripts) == 0 {
|
|
t.Fatalf("no build scripts: %v", err)
|
|
}
|
|
for _, path := range scripts {
|
|
for _, m := range scriptEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
|
keys[m[1]] = true
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// section is the text of doc from the first line that starts with start up
|
|
// to the next that starts with any of ends.
|
|
func section(t *testing.T, doc, start string, ends ...string) string {
|
|
t.Helper()
|
|
i := strings.Index(doc, "\n"+start)
|
|
if i < 0 {
|
|
t.Fatalf("no %q", start)
|
|
}
|
|
rest := doc[i+1+len(start):]
|
|
end := len(rest)
|
|
for _, e := range ends {
|
|
if j := strings.Index(rest, "\n"+e); j >= 0 && j < end {
|
|
end = j
|
|
}
|
|
}
|
|
return rest[:end]
|
|
}
|
|
|
|
// envDocumented is every variable the documents name, and where.
|
|
func envDocumented(t *testing.T) map[string]string {
|
|
t.Helper()
|
|
keys := map[string]string{}
|
|
guide := section(t, readFile(t, filepath.Join("..", "..", "docs", "guide.md")), "### Environment variables", "### ")
|
|
for _, m := range docEnvName.FindAllStringSubmatch(guide, -1) {
|
|
keys[m[1]] = "guide.md § Environment variables"
|
|
}
|
|
arch := section(t, readFile(t, filepath.Join("..", "..", "docs", "architecture.md")), "**Internal env vars.**", "### ", "## ", "**")
|
|
for _, m := range docEnvName.FindAllStringSubmatch(arch, -1) {
|
|
if _, ok := keys[m[1]]; !ok {
|
|
keys[m[1]] = "architecture.md § Configuration"
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func TestEnvironmentReadIsDocumented(t *testing.T) {
|
|
doc := envDocumented(t)
|
|
var missing []string
|
|
for key := range envRead(t) {
|
|
if _, ok := doc[key]; !ok {
|
|
missing = append(missing, key)
|
|
}
|
|
}
|
|
slices.Sort(missing)
|
|
for _, key := range missing {
|
|
t.Errorf("%s is read but documented nowhere: guide.md § Environment variables for an operator's setting, architecture.md § Configuration for an internal one", key)
|
|
}
|
|
}
|
|
|
|
func TestEnvironmentDocumentedIsRead(t *testing.T) {
|
|
read := envRead(t)
|
|
var stale []string
|
|
for key, where := range envDocumented(t) {
|
|
if !read[key] {
|
|
stale = append(stale, key+" ("+where+")")
|
|
}
|
|
}
|
|
slices.Sort(stale)
|
|
for _, key := range stale {
|
|
t.Errorf("%s is documented but nothing reads it", key)
|
|
}
|
|
}
|
|
|
|
// The parsers find what they are for: a key of each kind is among the read.
|
|
func TestEnvironmentParsersFindKeys(t *testing.T) {
|
|
read := envRead(t)
|
|
for _, key := range []string{"SELFPOST_HOSTNAME", "PANEL_HTTP_ADDR", "MILTER_WAIT_TIMEOUT", "POSTFIX_QUEUE_DIR"} {
|
|
if !read[key] {
|
|
t.Errorf("%s not found among the keys read", key)
|
|
}
|
|
}
|
|
}
|