NextMyDay/internal/core/recovery/flow_test.go
Claus Lohmar f0927fb0f0 feat(server): gate /setup by the direct tunnel and trust the proxy scheme
Implements D-025 (revised 2026-10-15) and D-043.

Setup gate (D-025):
- Remove the bind refusal (CheckSetupBind/ErrNonLoopbackSetup/server.IsLoopback);
  Serve binds any address, so the app can listen on 0.0.0.0:8080 behind Zoraxy.
- The gate is now per request and strict (httpx.SetupRequestAllowed): a loopback
  TCP peer AND no X-Forwarded-For — the direct SSH tunnel. Anything else is 404
  before dispatch; an unparseable peer fails closed (AC-7/AC-10). The raw XFF is
  read before ClientIP SETS it (A6).

Proxy scheme (D-043):
- httpx.RequestIsHTTPS resolves the scheme per request: direct TLS, or a trusted
  proxy's X-Forwarded-Proto. The door carries it on the request context
  (httpx.WithSecure) so the session cookie, its __Host- prefix, the pre-session
  and ceremony cookies, and HSTS all agree (AC-13).
- Over plain http (the tunnel) cookies stay bare and non-Secure, which fixes the
  bootstrap from an https:// config.example.yaml; Config.IsSecure() is removed
  as now dead.

Tests: rewrite the setup-gate tests for AC-7/AC-10, add AC-13, and unit-test
SetupRequestAllowed / RequestIsHTTPS.
2026-10-15 05:40:36 +00:00

122 lines
3.4 KiB
Go

package recovery_test
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// blockingMailer blocks every Send until release is closed, and signals when a
// send has begun.
type blockingMailer struct {
started chan struct{}
release chan struct{}
done chan struct{}
once sync.Once
}
func (m *blockingMailer) Send(_ context.Context, _ mail.Message) error {
select {
case m.started <- struct{}{}:
default:
}
<-m.release
m.once.Do(func() { close(m.done) })
return nil
}
// TestAccessNotificationIsAsynchronous covers M2s: POST /access must answer
// uniformly whether or not the email matched, so a HIT's notification is
// delivered OFF the request path. A synchronous implementation would block the
// response on the (slow) mailer and leak "is this email on record?".
func TestAccessNotificationIsAsynchronous(t *testing.T) {
ctx := context.Background()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
aud := audit.New(db.DB)
us := users.New(db.DB, aud)
if _, err := us.Create(ctx, 0, "admin@example.com", "A", users.RoleAdmin); err != nil {
t.Fatalf("create user: %v", err)
}
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x7a}, auth.KeySize))
if err != nil {
t.Fatalf("NewAEADSealer: %v", err)
}
core := auth.New(db.DB, aud, auth.WithSealer(sealer))
pre, err := httpx.NewPreSessionCSRF([]byte("flow-cookie-secret-0123456789abcdef"))
if err != nil {
t.Fatalf("NewPreSessionCSRF: %v", err)
}
m := &blockingMailer{
started: make(chan struct{}, 1),
release: make(chan struct{}),
done: make(chan struct{}),
}
// Dispatch is deliberately NOT injected: the default must be asynchronous.
a, err := recovery.New(recovery.Deps{
Requests: recovery.NewStore(db.DB, aud),
Users: us,
Identities: core.Identities(),
Mailer: m,
CSRF: pre,
Audit: aud,
BaseURL: "https://myday.example.com",
})
if err != nil {
t.Fatalf("recovery.New: %v", err)
}
form := url.Values{"email": {"admin@example.com"}}.Encode()
req := httptest.NewRequest("POST", "/access?do=auth&action=recover", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
returned := make(chan error, 1)
go func() { returned <- a.Request(rec, req) }()
select {
case err := <-returned:
if err != nil {
t.Fatalf("Request: %v", err)
}
case <-time.After(2 * time.Second):
close(m.release)
t.Fatal("POST /access blocked on the notification — it is still synchronous (M2s)")
}
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
// The notification did run (off the request path); let it finish.
close(m.release)
select {
case <-m.done:
case <-time.After(2 * time.Second):
t.Fatal("the detached notification never ran")
}
}