Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/secret_boundary_test.go
T

125 lines
5.3 KiB
Go

package main
// The tools never open a password file (novox/hq ADR 0436 §12, issue 1193): ReadSecret and SecretPath moved
// into cmd/mesh-mail/internal/secret, imported only by keep (imap.go) and — once issue 1195 adds it — the
// sender's own process (sender.go). Since issue 1194, Decide no longer reaches any actor that reads a secret
// at all (it records approved and queues the send for the sender); the import list this test checks still
// holds, now even more plainly. The build seat runs every test under one account, so no permission test can
// show the split (context file, "Clause 12's proof"); this is the file-level proxy the lead chose instead: an
// AST check over the tools' own files.
import (
"encoding/json"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// secretPackage is the import path the tools' bundle must never carry.
const secretPackage = "mesh-mail/cmd/mesh-mail/internal/secret"
// 1193/AC1: none of the tools' bundle files (main.go's serve path, tools.go, send.go, health.go) imports the
// password-reading package; the failure names the offending file.
func TestTheToolsBundleNeverImportsThePasswordPackage(t *testing.T) {
for _, file := range []string{"main.go", "tools.go", "send.go", "health.go"} {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("%s: %v", file, err)
}
for _, imp := range f.Imports {
path := strings.Trim(imp.Path.Value, `"`)
if path == secretPackage {
t.Errorf("%s imports %s, which only keep and the sender's actor may (novox/hq ADR 0436 §12)", file, secretPackage)
}
}
}
}
// 1193/AC2: keep (imap.go) and the sender (sender.go, issue 1195) read the password through the moved
// package; SecretGiven (stat only) stays in config.go, available to the tools, and never reads the password
// itself. (sender.go's own account file is read through secret.Path too, though its own account is not a
// password; the real per-message password read is issue 1196's.)
func TestKeepAndTheSenderReadThePasswordThroughTheMovedPackage(t *testing.T) {
for _, file := range []string{"imap.go", "sender.go"} {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("%s: %v", file, err)
}
found := false
for _, imp := range f.Imports {
if strings.Trim(imp.Path.Value, `"`) == secretPackage {
found = true
}
}
if !found {
t.Errorf("%s does not import %s", file, secretPackage)
}
}
// SecretGiven itself (config.go) never calls ReadFile, os.Open or the moved package: whether a password was
// given is read from the file's presence and size alone (issue 733, kept by config_test.go's own check too).
src, err := os.ReadFile("config.go")
if err != nil {
t.Fatal(err)
}
body := string(src)
body = body[strings.Index(body, "func SecretGiven("):]
body = body[:strings.Index(body, "\n}\n")]
for _, bad := range []string{"ReadSecret", "ReadFile", "os.Open", "secret.Read"} {
if strings.Contains(body, bad) {
t.Fatalf("SecretGiven reads the password (%s):\n%s", bad, body)
}
}
}
// 1193/AC3: with the secrets directory absent, mail_send raises its ask, the warrant of an Approve is heard,
// and mail_send_status reads it back — each succeeds, and no error mentions a secret file. Since issue 1194
// the tools never read a secret on this path at all (they queue the send for the sender), so this also proves
// the stronger property the tools' bundle never touches b.Env.Secrets.
func TestTheToolPathSucceedsWithTheSecretsDirectoryAbsent(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "never-created")
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: missing}
writeFile(t, env.Accounts, `{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be",
"incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"},
"outgoing": {"host": "h", "port": 465, "security": "tls"}}}}`)
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
router, sends, queue := newSendRouter(), newMemSends(), newMemQueue()
b := &Bundle{Env: env, Now: func() time.Time { return judgedAt }, Asks: router, Sends: sends, Queue: queue.publish}
rt := startBundle(t, b.Tools())
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
if isErr {
t.Fatalf("mail_send: %s", text)
}
var out map[string]any
_ = json.Unmarshal([]byte(text), &out)
id := out["ask"].(string)
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
router.close(w)
d, err := Decide(sends, b.queuePublish(), w, judgedAt.Add(2*time.Minute))
if err != nil {
t.Fatalf("the warrant was not heard cleanly: %v", err)
}
if d.Outcome != "queued" {
t.Fatalf("the decision: %+v; want queued (the tools never read the secrets directory)", d)
}
if strings.Contains(d.Outcome, missing) || strings.Contains(strings.ToLower(d.Outcome), "secret") {
t.Fatalf("the decision names a secret file: %q", d.Outcome)
}
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
if isErr {
t.Fatalf("mail_send_status: %s", text)
}
if strings.Contains(text, missing) || strings.Contains(strings.ToLower(text), "secret") {
t.Fatalf("mail_send_status names a secret file: %s", text)
}
}