feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded migration runner; each module's migrations are name-prefixed (D-012) - audit: the append-only audit log; the actor threads via context (R-1) - users: the user_id/GUID/email anchors (D-013) - config: the YAML settings file (D-035) + validation - httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security headers · redaction - middleware: correlation id · audit · rate limiting - mail: the invite/recovery mailer seam - server: the core DOOR — the resolution switch, the dispatch registry (RegisterModule), the role gates, /setup · enroll/login · recovery flows, the reserved `static` workflow, and the loopback-only bootstrap (D-025)
This commit is contained in:
parent
e114a2be61
commit
d11e14295b
45 changed files with 8051 additions and 0 deletions
151
internal/core/audit/audit.go
Normal file
151
internal/core/audit/audit.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
// Package audit is MyDay's append-only security trail (A11).
|
||||||
|
//
|
||||||
|
// Every auth-sensitive event — bootstrap, enroll, login, logout, recovery
|
||||||
|
// request/approve/deny, revoke, role change — is recorded here, together with
|
||||||
|
// the client IP and (IP-derived, D-031) location. The trail outlives the users
|
||||||
|
// it names, so it carries no foreign keys.
|
||||||
|
//
|
||||||
|
// Spec: docs/specs/spec-004-identity-sessions.md §5.
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is one audit entry. Secrets are NEVER placed in Detail or Subject
|
||||||
|
// (A9) — those columns are for identifiers and outcomes only.
|
||||||
|
type Event struct {
|
||||||
|
// ActorUserID is the authenticated principal responsible for the event;
|
||||||
|
// 0 means anonymous/system (stored as NULL).
|
||||||
|
ActorUserID int64
|
||||||
|
// Action is the event kind, e.g. "user.create", "identity.rotate".
|
||||||
|
Action string
|
||||||
|
// Subject is what the event acted on, e.g. "user:2" or "identity:5".
|
||||||
|
Subject string
|
||||||
|
// Detail is non-secret context, e.g. "role=manager".
|
||||||
|
Detail string
|
||||||
|
// ClientIP is the resolved client address (A6); optional.
|
||||||
|
ClientIP string
|
||||||
|
// Location is the approximate, IP-derived location — AUDIT ONLY (D-031).
|
||||||
|
Location string
|
||||||
|
// CorrelationID ties the event to its request (SPEC-003).
|
||||||
|
CorrelationID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit records events. The contract is deliberately fire-and-forget — the
|
||||||
|
// audit trail must never break a request.
|
||||||
|
//
|
||||||
|
// Per ruling R-1 (SPEC-004 §5) the event is recorded WITH the request context:
|
||||||
|
// the door attaches Meta before any service call, and services write their
|
||||||
|
// audit rows through it, so actor · client IP · correlation are never lost.
|
||||||
|
type Audit interface {
|
||||||
|
Record(ctx context.Context, e Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta is the per-request audit metadata the door attaches to the
|
||||||
|
// context.Context before calling any service (SPEC-004 §5, ruling R-1;
|
||||||
|
// SPEC-003 §4). It is IDENTIFICATION and CONTEXT only — never a credential.
|
||||||
|
type Meta struct {
|
||||||
|
// ActorUserID is the authenticated principal responsible for the request;
|
||||||
|
// 0 means anonymous/system.
|
||||||
|
ActorUserID int64
|
||||||
|
// ClientIP is the resolved client address (A6).
|
||||||
|
ClientIP string
|
||||||
|
// CorrelationID ties every event of one request together (SPEC-003 §4).
|
||||||
|
CorrelationID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// metaKey is the unexported context key for Meta; using a struct type prevents
|
||||||
|
// collisions with other packages' keys.
|
||||||
|
type metaKey struct{}
|
||||||
|
|
||||||
|
// WithMeta attaches m to ctx. The door calls this once, before handing off to a
|
||||||
|
// module or a service.
|
||||||
|
func WithMeta(ctx context.Context, m Meta) context.Context {
|
||||||
|
return context.WithValue(ctx, metaKey{}, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaFrom returns the Meta attached to ctx, if any.
|
||||||
|
func MetaFrom(ctx context.Context) (Meta, bool) {
|
||||||
|
m, ok := ctx.Value(metaKey{}).(Meta)
|
||||||
|
return m, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge fills an event's empty context fields from the metadata on ctx, so a
|
||||||
|
// service never has to re-thread actor · client IP · correlation by hand.
|
||||||
|
//
|
||||||
|
// Explicit event values WIN — a service that knows the actor better than the
|
||||||
|
// ambient request context (e.g. the identity owner) may set it and it is kept
|
||||||
|
// (ruling R-3).
|
||||||
|
func Merge(ctx context.Context, e Event) Event {
|
||||||
|
m, ok := MetaFrom(ctx)
|
||||||
|
if !ok {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e.ActorUserID == 0 {
|
||||||
|
e.ActorUserID = m.ActorUserID
|
||||||
|
}
|
||||||
|
if e.ClientIP == "" {
|
||||||
|
e.ClientIP = m.ClientIP
|
||||||
|
}
|
||||||
|
if e.CorrelationID == "" {
|
||||||
|
e.CorrelationID = m.CorrelationID
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log writes events to the audit_log table.
|
||||||
|
type Log struct {
|
||||||
|
db *sql.DB
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns an Audit writing to db. time.Now is injectable for tests via the
|
||||||
|
// unexported field (same package).
|
||||||
|
func New(db *sql.DB) *Log {
|
||||||
|
return &Log{db: db, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record inserts one audit row. A failure is logged, never returned: an
|
||||||
|
// un-writable audit trail must not take the application down (but it is loud).
|
||||||
|
//
|
||||||
|
// The event is merged with the Meta carried on ctx first (ruling R-1), so the
|
||||||
|
// actor · client IP · correlation attached by the door reach the row.
|
||||||
|
func (l *Log) Record(ctx context.Context, e Event) {
|
||||||
|
e = Merge(ctx, e)
|
||||||
|
|
||||||
|
var actor any
|
||||||
|
if e.ActorUserID != 0 {
|
||||||
|
actor = e.ActorUserID
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := l.db.Exec(
|
||||||
|
`INSERT INTO audit_log
|
||||||
|
(at, actor_user_id, action, subject, detail, client_ip, location, correlation_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
l.now().UTC().Truncate(time.Second).Format(time.RFC3339),
|
||||||
|
actor,
|
||||||
|
e.Action,
|
||||||
|
nullString(e.Subject),
|
||||||
|
nullString(e.Detail),
|
||||||
|
nullString(e.ClientIP),
|
||||||
|
nullString(e.Location),
|
||||||
|
nullString(e.CorrelationID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("audit: record failed", "action", e.Action, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullString maps "" to NULL so the trail distinguishes "absent" from "empty".
|
||||||
|
func nullString(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Audit = (*Log)(nil)
|
||||||
155
internal/core/audit/audit_test.go
Normal file
155
internal/core/audit/audit_test.go
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
package audit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return db.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordWritesRow(t *testing.T) {
|
||||||
|
db := newDB(t)
|
||||||
|
log := audit.New(db)
|
||||||
|
|
||||||
|
log.Record(context.Background(), audit.Event{
|
||||||
|
ActorUserID: 7,
|
||||||
|
Action: "login",
|
||||||
|
Subject: "identity:3",
|
||||||
|
Detail: "totp",
|
||||||
|
ClientIP: "203.0.113.9",
|
||||||
|
Location: "GB",
|
||||||
|
CorrelationID: "corr-1",
|
||||||
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
actor sql.NullInt64
|
||||||
|
action string
|
||||||
|
subject string
|
||||||
|
detail string
|
||||||
|
ip string
|
||||||
|
loc string
|
||||||
|
corr string
|
||||||
|
)
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT actor_user_id, action, subject, detail, client_ip, location, correlation_id FROM audit_log`,
|
||||||
|
).Scan(&actor, &action, &subject, &detail, &ip, &loc, &corr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query audit_log: %v", err)
|
||||||
|
}
|
||||||
|
if !actor.Valid || actor.Int64 != 7 {
|
||||||
|
t.Errorf("actor_user_id = %+v, want 7", actor)
|
||||||
|
}
|
||||||
|
if action != "login" || subject != "identity:3" || detail != "totp" {
|
||||||
|
t.Errorf("row = (%s,%s,%s)", action, subject, detail)
|
||||||
|
}
|
||||||
|
if ip != "203.0.113.9" || loc != "GB" || corr != "corr-1" {
|
||||||
|
t.Errorf("context = (%s,%s,%s)", ip, loc, corr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordAnonymousActorIsNull(t *testing.T) {
|
||||||
|
db := newDB(t)
|
||||||
|
log := audit.New(db)
|
||||||
|
|
||||||
|
log.Record(context.Background(), audit.Event{Action: "bootstrap"})
|
||||||
|
|
||||||
|
var actor sql.NullInt64
|
||||||
|
if err := db.QueryRow(`SELECT actor_user_id FROM audit_log`).Scan(&actor); err != nil {
|
||||||
|
t.Fatalf("query: %v", err)
|
||||||
|
}
|
||||||
|
if actor.Valid {
|
||||||
|
t.Errorf("actor_user_id = %d, want NULL for an anonymous event", actor.Int64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetaRoundTrip pins the R-1 context seam: WithMeta/MetaFrom are lossless.
|
||||||
|
func TestMetaRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, ok := audit.MetaFrom(ctx); ok {
|
||||||
|
t.Fatal("MetaFrom(background) reported a meta, want none")
|
||||||
|
}
|
||||||
|
|
||||||
|
want := audit.Meta{ActorUserID: 42, ClientIP: "203.0.113.5", CorrelationID: "corr-9"}
|
||||||
|
got, ok := audit.MetaFrom(audit.WithMeta(ctx, want))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("MetaFrom(withMeta) reported none")
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("MetaFrom = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordMergesContextMeta proves the door's Meta reaches the audit row
|
||||||
|
// without the service threading it by hand (R-1); explicit event values win.
|
||||||
|
func TestRecordMergesContextMeta(t *testing.T) {
|
||||||
|
db := newDB(t)
|
||||||
|
log := audit.New(db)
|
||||||
|
|
||||||
|
ctx := audit.WithMeta(context.Background(), audit.Meta{
|
||||||
|
ActorUserID: 55,
|
||||||
|
ClientIP: "198.51.100.7",
|
||||||
|
CorrelationID: "corr-abc",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The event carries no context fields — all come from the Meta.
|
||||||
|
log.Record(ctx, audit.Event{Action: "login"})
|
||||||
|
|
||||||
|
// This event overrides the actor explicitly — the event value must win.
|
||||||
|
log.Record(ctx, audit.Event{Action: "identity.revoke", ActorUserID: 9})
|
||||||
|
|
||||||
|
rows, err := db.Query(`SELECT action, actor_user_id, client_ip, correlation_id FROM audit_log ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query audit_log: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
action string
|
||||||
|
actor sql.NullInt64
|
||||||
|
ip sql.NullString
|
||||||
|
corr sql.NullString
|
||||||
|
}
|
||||||
|
var got []row
|
||||||
|
for rows.Next() {
|
||||||
|
var r row
|
||||||
|
if err := rows.Scan(&r.action, &r.actor, &r.ip, &r.corr); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
got = append(got, r)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("rows: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d rows, want 2", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !got[0].actor.Valid || got[0].actor.Int64 != 55 {
|
||||||
|
t.Errorf("row 0 actor = %+v, want 55 (from Meta)", got[0].actor)
|
||||||
|
}
|
||||||
|
if got[0].ip.String != "198.51.100.7" || got[0].corr.String != "corr-abc" {
|
||||||
|
t.Errorf("row 0 context = (%q,%q), want the Meta values", got[0].ip.String, got[0].corr.String)
|
||||||
|
}
|
||||||
|
if !got[1].actor.Valid || got[1].actor.Int64 != 9 {
|
||||||
|
t.Errorf("row 1 actor = %+v, want 9 (explicit event value wins)", got[1].actor)
|
||||||
|
}
|
||||||
|
if got[1].corr.String != "corr-abc" {
|
||||||
|
t.Errorf("row 1 correlation = %q, want corr-abc", got[1].corr.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
307
internal/core/config/config.go
Normal file
307
internal/core/config/config.go
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
// Package config loads MyDay's configuration from `config.yaml` into a typed
|
||||||
|
// struct and validates it fail-closed (SPEC-003 §1, D-010/D-035).
|
||||||
|
//
|
||||||
|
// Settings live in the config file, not the database (D-035): infra · secrets ·
|
||||||
|
// domain · mail · locale. The real `config.yaml` is gitignored (D-010); the
|
||||||
|
// committed `config.example.yaml` is used as a development fallback so a fresh
|
||||||
|
// checkout can still start (in setup mode, loopback-only).
|
||||||
|
//
|
||||||
|
// Fail-closed rule (SPEC-003 §1): in PRODUCTIVE mode (`setup: false`) the
|
||||||
|
// process refuses to start if a required secret is missing or weak. Setup mode
|
||||||
|
// tolerates the placeholder values in the example, because the operator has not
|
||||||
|
// provisioned the instance yet — and setup mode is loopback-only (D-025).
|
||||||
|
//
|
||||||
|
// Spec: docs/specs/spec-003-core-door.md §1, §7.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileName is the productive configuration file (gitignored — D-010).
|
||||||
|
const FileName = "config.yaml"
|
||||||
|
|
||||||
|
// ExampleFileName is the committed template, used as a development fallback.
|
||||||
|
const ExampleFileName = "config.example.yaml"
|
||||||
|
|
||||||
|
// Secret-length policy. The TOTP key must be exactly totpKeyLen bytes because
|
||||||
|
// it feeds AES-256-GCM (mirrors auth.KeySize); the other secrets are signing /
|
||||||
|
// derivation keys and must be at least minSecretLen bytes.
|
||||||
|
const (
|
||||||
|
minSecretLen = 32
|
||||||
|
totpKeyLen = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the typed shape of `config.yaml` / `config.example.yaml`.
|
||||||
|
type Config struct {
|
||||||
|
// Setup forces setup mode (D-026). Together with "no user exists" it makes
|
||||||
|
// the instance serve only /setup, loopback-only (D-025, SPEC-005 §3).
|
||||||
|
Setup bool `yaml:"setup"`
|
||||||
|
|
||||||
|
// AdminRecovery is the config approval flag for admin recovery (D-033).
|
||||||
|
// Without it nothing happens (dual control).
|
||||||
|
AdminRecovery bool `yaml:"admin_recovery"`
|
||||||
|
|
||||||
|
Server Server `yaml:"server"`
|
||||||
|
App App `yaml:"app"`
|
||||||
|
Database Database `yaml:"database"`
|
||||||
|
Secrets Secrets `yaml:"secrets"`
|
||||||
|
Mail Mail `yaml:"mail"`
|
||||||
|
Modules map[string]bool `yaml:"modules"`
|
||||||
|
// Enrollment bounds the invite window (A1). SPEC-006 refuses enrollment
|
||||||
|
// outside it.
|
||||||
|
Enrollment Enrollment `yaml:"enrollment"`
|
||||||
|
|
||||||
|
// source is the path actually read (config.yaml or the example fallback).
|
||||||
|
source string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server holds the listener, its public base URL, and the trusted proxies.
|
||||||
|
type Server struct {
|
||||||
|
Listen string `yaml:"listen"`
|
||||||
|
// BaseURL is used in invite / recovery links and decides whether cookies
|
||||||
|
// are Secure (https).
|
||||||
|
BaseURL string `yaml:"base_url"`
|
||||||
|
// TrustedProxies are the peers whose X-Forwarded-For is honoured (A6).
|
||||||
|
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// App holds locale and timezone.
|
||||||
|
type App struct {
|
||||||
|
Locale string `yaml:"locale"`
|
||||||
|
Timezone string `yaml:"timezone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database points at the SQLite file (D-012).
|
||||||
|
type Database struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secrets holds the symmetric key material (D-010). Never commit real values.
|
||||||
|
type Secrets struct {
|
||||||
|
SessionKey string `yaml:"session_key"`
|
||||||
|
CookieSecret string `yaml:"cookie_secret"`
|
||||||
|
CSRFKey string `yaml:"csrf_key"`
|
||||||
|
TOTPEncryptionKey string `yaml:"totp_encryption_key"` // A9
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mail holds the SMTP settings used for invites and recovery.
|
||||||
|
type Mail struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
From string `yaml:"from"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrollment holds the invite-window settings (A1).
|
||||||
|
type Enrollment struct {
|
||||||
|
// Window is a Go duration string, e.g. "72h". Empty defaults to 72h.
|
||||||
|
Window string `yaml:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel errors.
|
||||||
|
var (
|
||||||
|
// ErrListenRequired is returned when server.listen is empty.
|
||||||
|
ErrListenRequired = errors.New("config: server.listen is required")
|
||||||
|
// ErrDatabaseRequired is returned when database.path is empty.
|
||||||
|
ErrDatabaseRequired = errors.New("config: database.path is required")
|
||||||
|
// ErrBaseURLRequired is returned when server.base_url is empty in
|
||||||
|
// productive mode (invite/recovery links need it).
|
||||||
|
ErrBaseURLRequired = errors.New("config: server.base_url is required in productive mode")
|
||||||
|
// ErrWeakSecret is returned when a required secret is missing or weak.
|
||||||
|
ErrWeakSecret = errors.New("config: required secret is missing or weak")
|
||||||
|
// ErrInvalidEnrollWindow is returned when enrollment.window is not a valid
|
||||||
|
// positive Go duration.
|
||||||
|
ErrInvalidEnrollWindow = errors.New("config: enrollment.window is not a valid duration")
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultEnrollWindow is the code default for the bounded enrollment window
|
||||||
|
// (A1) when enrollment.window is unset.
|
||||||
|
const DefaultEnrollWindow = 72 * time.Hour
|
||||||
|
|
||||||
|
// EnrollWindow returns the configured enrollment window, defaulting to 72h.
|
||||||
|
func (c *Config) EnrollWindow() time.Duration {
|
||||||
|
d, err := time.ParseDuration(strings.TrimSpace(c.Enrollment.Window))
|
||||||
|
if err != nil || d <= 0 {
|
||||||
|
return DefaultEnrollWindow
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads and validates the configuration at path.
|
||||||
|
//
|
||||||
|
// Development fallback (SPEC-003 §1): when path is the canonical `config.yaml`
|
||||||
|
// and it does not exist, the committed `config.example.yaml` in the same
|
||||||
|
// directory is used instead. Any other missing path is an error.
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
used, data, err := read(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
dec.KnownFields(true) // fail-closed: an unknown key is a configuration error
|
||||||
|
|
||||||
|
var c Config
|
||||||
|
if err := dec.Decode(&c); err != nil {
|
||||||
|
return nil, fmt.Errorf("config: parse %s: %w", used, err)
|
||||||
|
}
|
||||||
|
c.source = used
|
||||||
|
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("config: %s: %w", used, err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source returns the path the configuration was actually loaded from.
|
||||||
|
func (c *Config) Source() string { return c.source }
|
||||||
|
|
||||||
|
// Marshal renders c as the YAML document written to `config.yaml` (D-035). The
|
||||||
|
// wizard persists through it (SPEC-005 §4). The unexported source field is not
|
||||||
|
// part of the document.
|
||||||
|
func Marshal(c *Config) ([]byte, error) {
|
||||||
|
if c == nil {
|
||||||
|
return nil, errors.New("config: cannot marshal a nil config")
|
||||||
|
}
|
||||||
|
body, err := yaml.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("config: marshal: %w", err)
|
||||||
|
}
|
||||||
|
const header = "# MyDay configuration — written by MyDay (the /setup wizard or the Modules page, D-035).\n" +
|
||||||
|
"# Settings live HERE, not in the database. This file holds SECRETS:\n" +
|
||||||
|
"# it is gitignored — never commit it (D-010).\n\n"
|
||||||
|
return append([]byte(header), body...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSecure reports whether the app is served over https (so cookies are Secure).
|
||||||
|
func (c *Config) IsSecure() bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.Server.BaseURL)), "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSecret returns n cryptographically-random bytes, base64url-encoded
|
||||||
|
// without padding (R-6): the value carries no YAML-hostile characters and its
|
||||||
|
// encoded length is 4*ceil(n/3) — 24 bytes yield the 32 characters the TOTP
|
||||||
|
// sealer requires (auth.KeySize).
|
||||||
|
func GenerateSecret(n int) (string, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
return "", errors.New("config: secret length must be positive")
|
||||||
|
}
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("config: secret entropy: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate applies the fail-closed rules (SPEC-003 §1).
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if strings.TrimSpace(c.Server.Listen) == "" {
|
||||||
|
return ErrListenRequired
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Database.Path) == "" {
|
||||||
|
return ErrDatabaseRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
// The enrollment window (A1) is parsed in both modes: a typo is a config
|
||||||
|
// error, never a silently-unbounded window.
|
||||||
|
if w := strings.TrimSpace(c.Enrollment.Window); w != "" {
|
||||||
|
d, err := time.ParseDuration(w)
|
||||||
|
if err != nil || d <= 0 {
|
||||||
|
return fmt.Errorf("%w: %q", ErrInvalidEnrollWindow, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup mode may run with placeholder secrets: the operator has not yet
|
||||||
|
// provisioned the instance, and it is loopback-only (D-025). Productive
|
||||||
|
// mode is strict.
|
||||||
|
if c.Setup {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(c.Server.BaseURL) == "" {
|
||||||
|
return ErrBaseURLRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range []struct{ name, value string }{
|
||||||
|
{"secrets.session_key", c.Secrets.SessionKey},
|
||||||
|
{"secrets.cookie_secret", c.Secrets.CookieSecret},
|
||||||
|
{"secrets.csrf_key", c.Secrets.CSRFKey},
|
||||||
|
{"secrets.totp_encryption_key", c.Secrets.TOTPEncryptionKey},
|
||||||
|
} {
|
||||||
|
if err := checkSecret(s.name, s.value, minSecretLen); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The TOTP key feeds AES-256-GCM, so it must be exactly 32 bytes.
|
||||||
|
if n := len(c.Secrets.TOTPEncryptionKey); n != totpKeyLen {
|
||||||
|
return fmt.Errorf("%w: secrets.totp_encryption_key must be exactly %d bytes, got %d",
|
||||||
|
ErrWeakSecret, totpKeyLen, n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// read loads the file, falling back to config.example.yaml for the canonical
|
||||||
|
// config.yaml only.
|
||||||
|
func read(path string) (used string, data []byte, err error) {
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
if err == nil {
|
||||||
|
return path, data, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return "", nil, fmt.Errorf("config: read %q: %w", path, err)
|
||||||
|
}
|
||||||
|
if filepath.Base(path) != FileName {
|
||||||
|
return "", nil, fmt.Errorf("config: read %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alt := filepath.Join(filepath.Dir(path), ExampleFileName)
|
||||||
|
data, altErr := os.ReadFile(alt)
|
||||||
|
if altErr != nil {
|
||||||
|
return "", nil, fmt.Errorf("config: read %q (and fallback %q): %w", path, alt, err)
|
||||||
|
}
|
||||||
|
return alt, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSecret rejects an empty, short, or placeholder secret value.
|
||||||
|
func checkSecret(name, value string, minLen int) error {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fmt.Errorf("%w: %s is empty", ErrWeakSecret, name)
|
||||||
|
}
|
||||||
|
if len(value) < minLen {
|
||||||
|
return fmt.Errorf("%w: %s is %d bytes, need at least %d", ErrWeakSecret, name, len(value), minLen)
|
||||||
|
}
|
||||||
|
if isPlaceholder(value) {
|
||||||
|
return fmt.Errorf("%w: %s still holds a placeholder value", ErrWeakSecret, name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretIsPlaceholder reports whether a secret value is empty or still holds
|
||||||
|
// one of the shipped placeholder markers (R-6). The /setup wizard replaces such
|
||||||
|
// values with strong generated ones, so a first boot from config.example.yaml
|
||||||
|
// persists a viable productive configuration.
|
||||||
|
func SecretIsPlaceholder(value string) bool {
|
||||||
|
return strings.TrimSpace(value) == "" || isPlaceholder(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlaceholder detects the well-known placeholder markers shipped in the
|
||||||
|
// example so a copy that was never edited fails closed in productive mode.
|
||||||
|
func isPlaceholder(value string) bool {
|
||||||
|
upper := strings.ToUpper(value)
|
||||||
|
return strings.Contains(upper, "CHANGE_ME") ||
|
||||||
|
strings.Contains(upper, "CHANGEME") ||
|
||||||
|
strings.Contains(upper, "PLACEHOLDER")
|
||||||
|
}
|
||||||
313
internal/core/config/config_test.go
Normal file
313
internal/core/config/config_test.go
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeConfig writes body to dir/name and returns the full path.
|
||||||
|
func writeConfig(t *testing.T, dir, name, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", p, err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupYAML = `
|
||||||
|
setup: true
|
||||||
|
server:
|
||||||
|
listen: "127.0.0.1:8080"
|
||||||
|
base_url: "http://127.0.0.1:8080"
|
||||||
|
trusted_proxies:
|
||||||
|
- "127.0.0.1"
|
||||||
|
app:
|
||||||
|
locale: "en-GB"
|
||||||
|
timezone: "Europe/London"
|
||||||
|
database:
|
||||||
|
path: "./data/myday.db"
|
||||||
|
secrets:
|
||||||
|
session_key: "CHANGE_ME_32_BYTES_RANDOM"
|
||||||
|
cookie_secret: "CHANGE_ME_32_BYTES_RANDOM"
|
||||||
|
csrf_key: "CHANGE_ME_32_BYTES_RANDOM"
|
||||||
|
totp_encryption_key: "CHANGE_ME_32_BYTES_RANDOM"
|
||||||
|
mail:
|
||||||
|
host: "smtp.example.com"
|
||||||
|
port: 587
|
||||||
|
username: ""
|
||||||
|
password: "CHANGE_ME"
|
||||||
|
from: "myday@example.com"
|
||||||
|
modules:
|
||||||
|
requests: true
|
||||||
|
expense: false
|
||||||
|
`
|
||||||
|
|
||||||
|
const productiveYAML = `
|
||||||
|
setup: false
|
||||||
|
server:
|
||||||
|
listen: "0.0.0.0:8080"
|
||||||
|
base_url: "https://myday.example.com"
|
||||||
|
database:
|
||||||
|
path: "./data/myday.db"
|
||||||
|
secrets:
|
||||||
|
session_key: "0123456789abcdef0123456789abcdef"
|
||||||
|
cookie_secret: "0123456789abcdef0123456789abcdef"
|
||||||
|
csrf_key: "0123456789abcdef0123456789abcdef"
|
||||||
|
totp_encryption_key: "0123456789abcdef0123456789abcdef"
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestLoadParsesTypedStruct(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeConfig(t, dir, config.FileName, setupYAML)
|
||||||
|
|
||||||
|
cfg, err := config.Load(filepath.Join(dir, config.FileName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Setup {
|
||||||
|
t.Error("Setup = false, want true")
|
||||||
|
}
|
||||||
|
if cfg.Server.Listen != "127.0.0.1:8080" {
|
||||||
|
t.Errorf("Listen = %q", cfg.Server.Listen)
|
||||||
|
}
|
||||||
|
if cfg.Mail.Port != 587 {
|
||||||
|
t.Errorf("Mail.Port = %d, want 587", cfg.Mail.Port)
|
||||||
|
}
|
||||||
|
if cfg.App.Timezone != "Europe/London" {
|
||||||
|
t.Errorf("Timezone = %q", cfg.App.Timezone)
|
||||||
|
}
|
||||||
|
if !cfg.Modules["requests"] || cfg.Modules["expense"] {
|
||||||
|
t.Errorf("Modules = %v", cfg.Modules)
|
||||||
|
}
|
||||||
|
if cfg.IsSecure() {
|
||||||
|
t.Error("IsSecure = true for an http base_url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadFallsBackToExample covers the dev fallback: a fresh checkout has only
|
||||||
|
// the committed example.
|
||||||
|
func TestLoadFallsBackToExample(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeConfig(t, dir, config.ExampleFileName, setupYAML)
|
||||||
|
|
||||||
|
cfg, err := config.Load(filepath.Join(dir, config.FileName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load (fallback): %v", err)
|
||||||
|
}
|
||||||
|
if filepath.Base(cfg.Source()) != config.ExampleFileName {
|
||||||
|
t.Errorf("Source = %q, want the example fallback", cfg.Source())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadNonCanonicalMissingIsError: only config.yaml gets the fallback.
|
||||||
|
func TestLoadNonCanonicalMissingIsError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeConfig(t, dir, config.ExampleFileName, setupYAML)
|
||||||
|
|
||||||
|
if _, err := config.Load(filepath.Join(dir, "other.yaml")); err == nil {
|
||||||
|
t.Error("Load(other.yaml, missing) = nil, want an error (no fallback)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetupModeToleratesPlaceholders: setup mode may use the example as-is.
|
||||||
|
func TestSetupModeToleratesPlaceholders(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeConfig(t, dir, config.FileName, setupYAML)
|
||||||
|
|
||||||
|
if _, err := config.Load(filepath.Join(dir, config.FileName)); err != nil {
|
||||||
|
t.Fatalf("setup-mode load failed on placeholder secrets: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProductiveModeRequiresStrongSecrets is the fail-closed gate (SPEC-003 §1).
|
||||||
|
func TestProductiveModeRequiresStrongSecrets(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "placeholder secret",
|
||||||
|
body: strings.Replace(productiveYAML,
|
||||||
|
"0123456789abcdef0123456789abcdef", "CHANGE_ME_32_BYTES_RANDOM", 1),
|
||||||
|
want: config.ErrWeakSecret,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "short secret",
|
||||||
|
body: strings.Replace(productiveYAML,
|
||||||
|
"0123456789abcdef0123456789abcdef", "tooshort", 1),
|
||||||
|
want: config.ErrWeakSecret,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "totp key wrong length",
|
||||||
|
body: strings.Replace(productiveYAML,
|
||||||
|
`totp_encryption_key: "0123456789abcdef0123456789abcdef"`,
|
||||||
|
`totp_encryption_key: "0123456789abcdef0123456789abcdefff"`, 1),
|
||||||
|
want: config.ErrWeakSecret,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty listen",
|
||||||
|
body: strings.Replace(productiveYAML, `listen: "0.0.0.0:8080"`, `listen: ""`, 1),
|
||||||
|
want: config.ErrListenRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing base_url",
|
||||||
|
body: strings.Replace(productiveYAML, `base_url: "https://myday.example.com"`, `base_url: ""`, 1),
|
||||||
|
want: config.ErrBaseURLRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty database path",
|
||||||
|
body: strings.Replace(productiveYAML, `path: "./data/myday.db"`, `path: ""`, 1),
|
||||||
|
want: config.ErrDatabaseRequired,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeConfig(t, dir, config.FileName, tc.body)
|
||||||
|
_, err := config.Load(filepath.Join(dir, config.FileName))
|
||||||
|
if !errors.Is(err, tc.want) {
|
||||||
|
t.Errorf("Load = %v, want %v", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecretIsPlaceholder covers the R-6 detector the wizard uses to decide
|
||||||
|
// which secrets to generate.
|
||||||
|
func TestSecretIsPlaceholder(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
value string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"", true},
|
||||||
|
{" ", true},
|
||||||
|
{"CHANGE_ME_32_BYTES_RANDOM", true},
|
||||||
|
{"change_me", true},
|
||||||
|
{"my-PLACEHOLDER-value", true},
|
||||||
|
{"0123456789abcdef0123456789abcdef", false},
|
||||||
|
{"a-strong-generated-secret-value-here", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := config.SecretIsPlaceholder(tc.value); got != tc.want {
|
||||||
|
t.Errorf("SecretIsPlaceholder(%q) = %v, want %v", tc.value, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductiveModeStrongConfigLoads(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeConfig(t, dir, config.FileName, productiveYAML)
|
||||||
|
|
||||||
|
cfg, err := config.Load(filepath.Join(dir, config.FileName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Setup {
|
||||||
|
t.Error("Setup = true, want false")
|
||||||
|
}
|
||||||
|
if !cfg.IsSecure() {
|
||||||
|
t.Error("IsSecure = false for an https base_url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownKeyFailsClosed: a typo in config.yaml must not be silently ignored.
|
||||||
|
func TestUnknownKeyFailsClosed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeConfig(t, dir, config.FileName, setupYAML+"\nnot_a_real_key: true\n")
|
||||||
|
|
||||||
|
if _, err := config.Load(filepath.Join(dir, config.FileName)); err == nil {
|
||||||
|
t.Error("Load with an unknown key = nil, want an error (fail closed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnrollWindow covers the config-driven bounded window (A1).
|
||||||
|
func TestEnrollWindow(t *testing.T) {
|
||||||
|
// Unset -> the 72h default.
|
||||||
|
if got := (&config.Config{}).EnrollWindow(); got != config.DefaultEnrollWindow {
|
||||||
|
t.Errorf("default EnrollWindow = %v, want %v", got, config.DefaultEnrollWindow)
|
||||||
|
}
|
||||||
|
// Configured.
|
||||||
|
c := &config.Config{Enrollment: config.Enrollment{Window: "12h"}}
|
||||||
|
if got := c.EnrollWindow(); got != 12*time.Hour {
|
||||||
|
t.Errorf("EnrollWindow(12h) = %v, want 12h", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed window is a configuration error in any mode.
|
||||||
|
bad := &config.Config{
|
||||||
|
Server: config.Server{Listen: "127.0.0.1:8080"},
|
||||||
|
Database: config.Database{Path: "./data/myday.db"},
|
||||||
|
Enrollment: config.Enrollment{Window: "not-a-duration"},
|
||||||
|
}
|
||||||
|
if err := bad.Validate(); !errors.Is(err, config.ErrInvalidEnrollWindow) {
|
||||||
|
t.Errorf("Validate(bad window) = %v, want ErrInvalidEnrollWindow", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadParsesEnrollmentWindow reads the window from YAML.
|
||||||
|
func TestLoadParsesEnrollmentWindow(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeConfig(t, dir, config.FileName, setupYAML+"\nenrollment:\n window: \"48h\"\n")
|
||||||
|
|
||||||
|
cfg, err := config.Load(filepath.Join(dir, config.FileName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got := cfg.EnrollWindow(); got != 48*time.Hour {
|
||||||
|
t.Errorf("EnrollWindow = %v, want 48h", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMarshalRoundTrip: the wizard's persister produces a document that loads
|
||||||
|
// back through the SAME strict loader (SPEC-005 §4, D-035). A written config
|
||||||
|
// must be exactly as valid as a hand-written one.
|
||||||
|
func TestMarshalRoundTrip(t *testing.T) {
|
||||||
|
const good = "0123456789abcdef0123456789abcdef"
|
||||||
|
src := &config.Config{
|
||||||
|
Setup: false,
|
||||||
|
Server: config.Server{
|
||||||
|
Listen: "127.0.0.1:8080",
|
||||||
|
BaseURL: "http://127.0.0.1:8080",
|
||||||
|
TrustedProxies: []string{"127.0.0.1"},
|
||||||
|
},
|
||||||
|
App: config.App{Locale: "en-GB", Timezone: "Europe/London"},
|
||||||
|
Database: config.Database{Path: "./data/myday.db"},
|
||||||
|
Secrets: config.Secrets{
|
||||||
|
SessionKey: good, CookieSecret: good, CSRFKey: good, TOTPEncryptionKey: good,
|
||||||
|
},
|
||||||
|
Mail: config.Mail{Host: "smtp.example.com", Port: 587, From: "myday@example.com"},
|
||||||
|
Modules: map[string]bool{"time": true},
|
||||||
|
Enrollment: config.Enrollment{Window: "48h"},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := config.Marshal(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := writeConfig(t, dir, config.FileName, string(data))
|
||||||
|
|
||||||
|
got, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the marshalled config does not load back: %v", err)
|
||||||
|
}
|
||||||
|
if got.Server.BaseURL != src.Server.BaseURL || got.App.Locale != src.App.Locale ||
|
||||||
|
got.App.Timezone != src.App.Timezone || got.Mail.Port != src.Mail.Port ||
|
||||||
|
got.Secrets.TOTPEncryptionKey != src.Secrets.TOTPEncryptionKey {
|
||||||
|
t.Errorf("round-trip lost fields: %+v", got)
|
||||||
|
}
|
||||||
|
if got.EnrollWindow() != 48*time.Hour {
|
||||||
|
t.Errorf("round-trip EnrollWindow = %v, want 48h", got.EnrollWindow())
|
||||||
|
}
|
||||||
|
if got.Setup {
|
||||||
|
t.Error("round-trip Setup = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
121
internal/core/httpx/clientip.go
Normal file
121
internal/core/httpx/clientip.go
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrustedProxies is the parsed set of networks whose X-Forwarded-For is
|
||||||
|
// honoured (A6). An empty set means "nothing is trusted": the peer address is
|
||||||
|
// the client.
|
||||||
|
type TrustedProxies []*net.IPNet
|
||||||
|
|
||||||
|
// ParseTrustedProxies parses the `server.trusted_proxies` entries. A bare IP
|
||||||
|
// ("127.0.0.1") becomes a single-host network (/32 or /128); a CIDR
|
||||||
|
// ("10.0.0.0/8") is used verbatim. Empty entries are skipped.
|
||||||
|
func ParseTrustedProxies(entries []string) (TrustedProxies, error) {
|
||||||
|
var tp TrustedProxies
|
||||||
|
for _, e := range entries {
|
||||||
|
e = strings.TrimSpace(e)
|
||||||
|
if e == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(e, "/") {
|
||||||
|
_, n, err := net.ParseCIDR(e)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("httpx: trusted proxy %q is not a valid CIDR: %w", e, err)
|
||||||
|
}
|
||||||
|
tp = append(tp, n)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(e)
|
||||||
|
if ip == nil {
|
||||||
|
return nil, fmt.Errorf("httpx: trusted proxy %q is not an IP or CIDR", e)
|
||||||
|
}
|
||||||
|
bits := 32
|
||||||
|
if ip.To4() == nil {
|
||||||
|
bits = 128
|
||||||
|
}
|
||||||
|
tp = append(tp, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
|
||||||
|
}
|
||||||
|
return tp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether ip is one of the trusted proxies.
|
||||||
|
func (t TrustedProxies) Contains(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, n := range t {
|
||||||
|
if n.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIP resolves the client address for r using the trusted proxies, and
|
||||||
|
// SETS the request's X-Forwarded-For to that single value — it never appends to
|
||||||
|
// a client-supplied chain (A6, SPEC-003 §4).
|
||||||
|
//
|
||||||
|
// The rules:
|
||||||
|
//
|
||||||
|
// - the immediate peer (RemoteAddr) is NOT trusted → any X-Forwarded-For is
|
||||||
|
// ignored and the peer is the client (a client cannot spoof past the gate);
|
||||||
|
// - the peer IS trusted → walk X-Forwarded-For left to right and return the
|
||||||
|
// first address that is not itself a trusted proxy (the real client); if
|
||||||
|
// every hop is trusted, the last hop is returned.
|
||||||
|
//
|
||||||
|
// The resolved value is written back with Header.Set (replace), never Add.
|
||||||
|
func ClientIP(r *http.Request, trusted TrustedProxies) string {
|
||||||
|
peer := remoteIP(r.RemoteAddr)
|
||||||
|
|
||||||
|
ip := peer
|
||||||
|
if trusted.Contains(peer) {
|
||||||
|
ip = resolveForwarded(r.Header.Get("X-Forwarded-For"), trusted, peer)
|
||||||
|
}
|
||||||
|
if ip == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
s := ip.String()
|
||||||
|
r.Header.Set("X-Forwarded-For", s) // SET — never append (A6)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveForwarded returns the leftmost non-trusted address in the header,
|
||||||
|
// falling back to the last hop, then to the peer.
|
||||||
|
func resolveForwarded(header string, trusted TrustedProxies, peer net.IP) net.IP {
|
||||||
|
if strings.TrimSpace(header) == "" {
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
var last net.IP
|
||||||
|
for _, part := range strings.Split(header, ",") {
|
||||||
|
ip := net.ParseIP(strings.TrimSpace(part))
|
||||||
|
if ip == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last = ip
|
||||||
|
if !trusted.Contains(ip) {
|
||||||
|
return ip // the real client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if last != nil {
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteIP extracts the IP from a "host:port" RemoteAddr.
|
||||||
|
func remoteIP(addr string) net.IP {
|
||||||
|
if addr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
host = addr
|
||||||
|
}
|
||||||
|
return net.ParseIP(strings.Trim(strings.TrimSpace(host), "[]"))
|
||||||
|
}
|
||||||
40
internal/core/httpx/cookie.go
Normal file
40
internal/core/httpx/cookie.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// Package httpx holds the door's HTTP helpers: the session cookie, the client
|
||||||
|
// IP resolution (trusted proxies, A6), the security headers, log redaction, and
|
||||||
|
// the CSRF token (SPEC-003 §6/§8).
|
||||||
|
//
|
||||||
|
// It imports only net/http + the standard library — the door owns HTTP; the
|
||||||
|
// pure routing model (internal/core/routing) must never import this.
|
||||||
|
package httpx
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// SessionCookieName is the name of the session cookie (SPEC-003 §6). The value
|
||||||
|
// is an opaque random id; the server-side session row is authoritative.
|
||||||
|
const SessionCookieName = "myday_session"
|
||||||
|
|
||||||
|
// SetSessionCookie writes the hardened session cookie (SPEC-003 §6):
|
||||||
|
// HttpOnly · Secure · SameSite=Lax · Path=/. The lifetime is server-side, so
|
||||||
|
// no Max-Age is set (a browser-session cookie).
|
||||||
|
func SetSessionCookie(w http.ResponseWriter, name, value string, secure bool) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSessionCookie expires the session cookie (logout, or an invalid session).
|
||||||
|
func ClearSessionCookie(w http.ResponseWriter, name string, secure bool) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
73
internal/core/httpx/csrf.go
Normal file
73
internal/core/httpx/csrf.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNoCSRFKey is returned when the CSRF key is empty — fail closed.
|
||||||
|
var ErrNoCSRFKey = errors.New("httpx: CSRF key is required")
|
||||||
|
|
||||||
|
// CSRF derives and verifies the per-session CSRF token on every action request
|
||||||
|
// (SPEC-003 §6, A8). The token is HMAC-SHA256(session_id) under the config
|
||||||
|
// `csrf_key`, so it is bound to the session, needs no extra table, and dies
|
||||||
|
// with the session.
|
||||||
|
//
|
||||||
|
// The action routing fields (`do`/`action`) are NOT a security control on their
|
||||||
|
// own (D-017); this token is. The session cookie is HttpOnly, so a page embeds
|
||||||
|
// the token in a hidden form field via Token / the request context.
|
||||||
|
type CSRF struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCSRF builds a CSRF helper from the configured key. An empty key fails
|
||||||
|
// closed.
|
||||||
|
func NewCSRF(key []byte) (*CSRF, error) {
|
||||||
|
if len(key) == 0 {
|
||||||
|
return nil, ErrNoCSRFKey
|
||||||
|
}
|
||||||
|
k := make([]byte, len(key))
|
||||||
|
copy(k, key)
|
||||||
|
return &CSRF{key: k}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token returns the CSRF token bound to sessionID.
|
||||||
|
func (c *CSRF) Token(sessionID string) string {
|
||||||
|
mac := hmac.New(sha256.New, c.key)
|
||||||
|
mac.Write([]byte("myday.csrf.v1:"))
|
||||||
|
mac.Write([]byte(sessionID))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether token is the correct CSRF token for sessionID. It
|
||||||
|
// rejects empty input and compares in constant time.
|
||||||
|
func (c *CSRF) Valid(sessionID, token string) bool {
|
||||||
|
if sessionID == "" || token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want := c.Token(sessionID)
|
||||||
|
return hmac.Equal([]byte(want), []byte(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenCtxKey is the unexported context key for the request's CSRF token.
|
||||||
|
type tokenCtxKey struct{}
|
||||||
|
|
||||||
|
// WithToken exposes the request's CSRF token on the context so a handler can
|
||||||
|
// embed it in a form without re-deriving it.
|
||||||
|
func WithToken(ctx context.Context, token string) context.Context {
|
||||||
|
return context.WithValue(ctx, tokenCtxKey{}, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenFrom returns the CSRF token attached by the door, if any.
|
||||||
|
func TokenFrom(ctx context.Context) string {
|
||||||
|
t, _ := ctx.Value(tokenCtxKey{}).(string)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenFromRequest is a convenience for handlers: the token the door attached
|
||||||
|
// to the request, ready for a hidden form field.
|
||||||
|
func TokenFromRequest(r *http.Request) string { return TokenFrom(r.Context()) }
|
||||||
28
internal/core/httpx/headers.go
Normal file
28
internal/core/httpx/headers.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package httpx
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// ContentSecurityPolicy is the strict default CSP (SPEC-003 §4). It allows only
|
||||||
|
// same-origin assets; the PWA manifest and service worker are same-origin too.
|
||||||
|
const ContentSecurityPolicy = "default-src 'self'; " +
|
||||||
|
"img-src 'self' data:; " +
|
||||||
|
"style-src 'self'; " +
|
||||||
|
"script-src 'self'; " +
|
||||||
|
"connect-src 'self'; " +
|
||||||
|
"object-src 'none'; " +
|
||||||
|
"base-uri 'none'; " +
|
||||||
|
"form-action 'self'; " +
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
|
||||||
|
// SetSecurityHeaders writes the baseline security headers on every response
|
||||||
|
// (SPEC-003 §4). HSTS is set only when the app is served over https (secure).
|
||||||
|
func SetSecurityHeaders(w http.ResponseWriter, secure bool) {
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
|
h.Set("X-Frame-Options", "DENY")
|
||||||
|
h.Set("Content-Security-Policy", ContentSecurityPolicy)
|
||||||
|
if secure {
|
||||||
|
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
}
|
||||||
|
}
|
||||||
217
internal/core/httpx/httpx_test.go
Normal file
217
internal/core/httpx/httpx_test.go
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
package httpx_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetAndClearSessionCookie(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
httpx.SetSessionCookie(rec, httpx.SessionCookieName, "abc123", true)
|
||||||
|
|
||||||
|
cookies := rec.Result().Cookies()
|
||||||
|
if len(cookies) != 1 {
|
||||||
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
||||||
|
}
|
||||||
|
c := cookies[0]
|
||||||
|
if c.Name != httpx.SessionCookieName || c.Value != "abc123" {
|
||||||
|
t.Errorf("cookie = %s=%s", c.Name, c.Value)
|
||||||
|
}
|
||||||
|
if !c.HttpOnly {
|
||||||
|
t.Error("cookie is not HttpOnly")
|
||||||
|
}
|
||||||
|
if !c.Secure {
|
||||||
|
t.Error("cookie is not Secure")
|
||||||
|
}
|
||||||
|
if c.SameSite != http.SameSiteLaxMode {
|
||||||
|
t.Errorf("SameSite = %v, want Lax", c.SameSite)
|
||||||
|
}
|
||||||
|
if c.Path != "/" {
|
||||||
|
t.Errorf("Path = %q, want /", c.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clearing expires it.
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
httpx.ClearSessionCookie(rec2, httpx.SessionCookieName, true)
|
||||||
|
if got := rec2.Result().Cookies()[0].MaxAge; got >= 0 {
|
||||||
|
t.Errorf("cleared cookie MaxAge = %d, want negative", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTrustedProxies(t *testing.T) {
|
||||||
|
tp, err := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", ""})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTrustedProxies: %v", err)
|
||||||
|
}
|
||||||
|
if !tp.Contains(parseIP(t, "127.0.0.1")) {
|
||||||
|
t.Error("127.0.0.1 not trusted")
|
||||||
|
}
|
||||||
|
if !tp.Contains(parseIP(t, "10.1.2.3")) {
|
||||||
|
t.Error("10.1.2.3 not covered by 10.0.0.0/8")
|
||||||
|
}
|
||||||
|
if tp.Contains(parseIP(t, "8.8.8.8")) {
|
||||||
|
t.Error("8.8.8.8 reported trusted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := httpx.ParseTrustedProxies([]string{"not-an-ip"}); err == nil {
|
||||||
|
t.Error("ParseTrustedProxies(bad) = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPUntrustedPeerIgnoresXFF(t *testing.T) {
|
||||||
|
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1"})
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.RemoteAddr = "203.0.113.9:5555"
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8")
|
||||||
|
|
||||||
|
got := httpx.ClientIP(r, tp)
|
||||||
|
if got != "203.0.113.9" {
|
||||||
|
t.Errorf("ClientIP = %q, want the peer (XFF from an untrusted peer must be ignored)", got)
|
||||||
|
}
|
||||||
|
// SET, never append.
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "203.0.113.9" {
|
||||||
|
t.Errorf("X-Forwarded-For = %q, want it SET to %q", xff, "203.0.113.9")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPTrustedPeerResolvesLeftmostUntrusted(t *testing.T) {
|
||||||
|
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.RemoteAddr = "127.0.0.1:5555"
|
||||||
|
r.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.5")
|
||||||
|
|
||||||
|
got := httpx.ClientIP(r, tp)
|
||||||
|
if got != "198.51.100.7" {
|
||||||
|
t.Errorf("ClientIP = %q, want the leftmost non-trusted 198.51.100.7", got)
|
||||||
|
}
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "198.51.100.7" {
|
||||||
|
t.Errorf("X-Forwarded-For = %q, want it SET (replaced), not appended", xff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPAllHopsTrusted(t *testing.T) {
|
||||||
|
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.RemoteAddr = "127.0.0.1:5555"
|
||||||
|
r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
|
||||||
|
|
||||||
|
if got := httpx.ClientIP(r, tp); got != "10.0.0.2" {
|
||||||
|
t.Errorf("ClientIP = %q, want the last hop 10.0.0.2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactPath(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
mustGone string
|
||||||
|
}{
|
||||||
|
{"/setup/token/SUPERSECRETTOKEN", "SUPERSECRETTOKEN"},
|
||||||
|
{"/access/reset/SUPERSECRETTOKEN", "SUPERSECRETTOKEN"},
|
||||||
|
{"/x/token=SUPERSECRETTOKEN", "SUPERSECRETTOKEN"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := httpx.RedactPath(tc.in)
|
||||||
|
if strings.Contains(got, tc.mustGone) {
|
||||||
|
t.Errorf("RedactPath(%q) = %q, still contains the credential", tc.in, got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, httpx.Redacted) {
|
||||||
|
t.Errorf("RedactPath(%q) = %q, want a %s marker", tc.in, got, httpx.Redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The GUID is public and must survive verbatim (D-001).
|
||||||
|
const guid = "AbCdEf0123456789"
|
||||||
|
if got := httpx.RedactPath("/" + guid + "/home"); got != "/"+guid+"/home" {
|
||||||
|
t.Errorf("RedactPath preserved a GUID: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactQuery(t *testing.T) {
|
||||||
|
got := httpx.RedactQuery("token=SECRET&view=open")
|
||||||
|
if strings.Contains(got, "SECRET") {
|
||||||
|
t.Errorf("RedactQuery = %q, still contains the token", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "view=open") {
|
||||||
|
t.Errorf("RedactQuery = %q, want the non-sensitive param kept", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSecurityHeaders(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
httpx.SetSecurityHeaders(rec, false)
|
||||||
|
h := rec.Header()
|
||||||
|
if h.Get("X-Content-Type-Options") != "nosniff" {
|
||||||
|
t.Error("missing X-Content-Type-Options: nosniff")
|
||||||
|
}
|
||||||
|
if h.Get("Referrer-Policy") != "no-referrer" {
|
||||||
|
t.Error("missing Referrer-Policy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(h.Get("Content-Security-Policy"), "default-src 'self'") {
|
||||||
|
t.Errorf("CSP = %q", h.Get("Content-Security-Policy"))
|
||||||
|
}
|
||||||
|
if h.Get("Strict-Transport-Security") != "" {
|
||||||
|
t.Error("HSTS set for an insecure request")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
httpx.SetSecurityHeaders(rec2, true)
|
||||||
|
if rec2.Header().Get("Strict-Transport-Security") == "" {
|
||||||
|
t.Error("HSTS missing for a secure request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRF(t *testing.T) {
|
||||||
|
csrf, err := httpx.NewCSRF([]byte("a-test-csrf-key-0123456789abcdef"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCSRF: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tok := csrf.Token("session-1")
|
||||||
|
if tok == "" {
|
||||||
|
t.Fatal("Token returned empty")
|
||||||
|
}
|
||||||
|
if !csrf.Valid("session-1", tok) {
|
||||||
|
t.Error("Valid rejected the correct token")
|
||||||
|
}
|
||||||
|
if csrf.Valid("session-2", tok) {
|
||||||
|
t.Error("Valid accepted a token bound to another session")
|
||||||
|
}
|
||||||
|
if csrf.Valid("session-1", "") {
|
||||||
|
t.Error("Valid accepted an empty token")
|
||||||
|
}
|
||||||
|
if csrf.Valid("", tok) {
|
||||||
|
t.Error("Valid accepted an empty session id")
|
||||||
|
}
|
||||||
|
if csrf.Valid("session-1", tok+"x") {
|
||||||
|
t.Error("Valid accepted a tampered token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := httpx.NewCSRF(nil); err == nil {
|
||||||
|
t.Error("NewCSRF(nil) = nil, want an error (fail closed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenContext(t *testing.T) {
|
||||||
|
ctx := httpx.WithToken(context.Background(), "tok-9")
|
||||||
|
if got := httpx.TokenFrom(ctx); got != "tok-9" {
|
||||||
|
t.Errorf("TokenFrom = %q, want tok-9", got)
|
||||||
|
}
|
||||||
|
if got := httpx.TokenFrom(context.Background()); got != "" {
|
||||||
|
t.Errorf("TokenFrom(background) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIP(t *testing.T, s string) (ip net.IP) {
|
||||||
|
t.Helper()
|
||||||
|
ip = net.ParseIP(s)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("ParseIP(%q) = nil", s)
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
30
internal/core/httpx/loopback.go
Normal file
30
internal/core/httpx/loopback.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsLoopbackHost reports whether a request Host ("host" or "host:port") names a
|
||||||
|
// loopback origin.
|
||||||
|
//
|
||||||
|
// It gates the passkey nudge (SPEC-006 §8): a passkey registered at
|
||||||
|
// `127.0.0.1` binds the Relying Party ID `127.0.0.1` and is UNUSABLE at the
|
||||||
|
// public domain (O-13/D-041), so the nudge is suppressed on loopback. An
|
||||||
|
// empty/unknown host is treated as loopback — fail closed, so an origin we
|
||||||
|
// cannot classify never triggers the nudge.
|
||||||
|
func IsLoopbackHost(hostport string) bool {
|
||||||
|
host := strings.TrimSpace(hostport)
|
||||||
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
host = strings.Trim(strings.TrimSpace(host), "[]")
|
||||||
|
if host == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(host, "localhost") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
return ip != nil && ip.IsLoopback()
|
||||||
|
}
|
||||||
140
internal/core/httpx/presession.go
Normal file
140
internal/core/httpx/presession.go
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PreSessionCookieName is the cookie carrying the anonymous double-submit CSRF
|
||||||
|
// token (A15). It is issued on the enroll/login screens and verified on the
|
||||||
|
// anonymous actions — before any session exists (SPEC-006 §5).
|
||||||
|
const PreSessionCookieName = "myday_csrf"
|
||||||
|
|
||||||
|
// ErrNoSigningKey is returned when the signing secret is empty — fail closed.
|
||||||
|
var ErrNoSigningKey = errors.New("httpx: signing key is required")
|
||||||
|
|
||||||
|
// Signer signs and verifies opaque cookie values with HMAC-SHA256. It is the
|
||||||
|
// primitive behind the pre-session CSRF token and the WebAuthn ceremony state:
|
||||||
|
// both are tamper-evident without a server-side table. The key comes from
|
||||||
|
// `secrets.cookie_secret`.
|
||||||
|
type Signer struct{ key []byte }
|
||||||
|
|
||||||
|
// NewSigner builds a Signer from the configured key. An empty key fails closed.
|
||||||
|
func NewSigner(key []byte) (*Signer, error) {
|
||||||
|
if len(key) == 0 {
|
||||||
|
return nil, ErrNoSigningKey
|
||||||
|
}
|
||||||
|
k := make([]byte, len(key))
|
||||||
|
copy(k, key)
|
||||||
|
return &Signer{key: k}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign returns `base64url(value).base64url(HMAC)`. Encoding the value keeps the
|
||||||
|
// "." delimiter unambiguous even when value contains one.
|
||||||
|
func (s *Signer) Sign(value string) string {
|
||||||
|
payload := base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||||
|
return payload + "." + s.mac(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsign verifies a token and returns the original value. A tampered or
|
||||||
|
// malformed token reports ok=false.
|
||||||
|
func (s *Signer) Unsign(token string) (value string, ok bool) {
|
||||||
|
payload, sig, found := strings.Cut(token, ".")
|
||||||
|
if !found || payload == "" || sig == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if !hmac.Equal([]byte(sig), []byte(s.mac(payload))) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(raw), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mac is the HMAC over the domain-separated payload.
|
||||||
|
func (s *Signer) mac(payload string) string {
|
||||||
|
m := hmac.New(sha256.New, s.key)
|
||||||
|
m.Write([]byte("myday.sign.v1:"))
|
||||||
|
m.Write([]byte(payload))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreSessionCSRF is the signed double-submit token for the anonymous
|
||||||
|
// enroll/login forms (A15). SPEC-003 §6's session-bound CSRF does not apply
|
||||||
|
// before a session exists, so this fills the gap: the server issues a signed
|
||||||
|
// random token in an HttpOnly cookie AND embeds the same token in the form; on
|
||||||
|
// submit both must match and the signature must verify.
|
||||||
|
type PreSessionCSRF struct {
|
||||||
|
signer *Signer
|
||||||
|
secure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPreSessionCSRF builds the helper from `secrets.cookie_secret`.
|
||||||
|
func NewPreSessionCSRF(secret []byte, secure bool) (*PreSessionCSRF, error) {
|
||||||
|
s, err := NewSigner(secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PreSessionCSRF{signer: s, secure: secure}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signer exposes the underlying signer so the flows can sign their own
|
||||||
|
// short-lived ceremony state with the same key.
|
||||||
|
func (p *PreSessionCSRF) Signer() *Signer { return p.signer }
|
||||||
|
|
||||||
|
// Token mints a fresh signed token.
|
||||||
|
func (p *PreSessionCSRF) Token() (string, error) {
|
||||||
|
var b [32]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", fmt.Errorf("httpx: csrf entropy: %w", err)
|
||||||
|
}
|
||||||
|
return p.signer.Sign(base64.RawURLEncoding.EncodeToString(b[:])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure returns a valid token for the request, reusing the cookie when it is
|
||||||
|
// present and valid, else issuing a fresh one.
|
||||||
|
func (p *PreSessionCSRF) Ensure(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||||
|
if c, err := r.Cookie(PreSessionCookieName); err == nil && c.Value != "" {
|
||||||
|
if v, ok := p.signer.Unsign(c.Value); ok && v != "" {
|
||||||
|
return c.Value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tok, err := p.Token()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: PreSessionCookieName,
|
||||||
|
Value: tok,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: p.secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid verifies the double-submit: the submitted token must equal the cookie's
|
||||||
|
// value AND carry a valid signature. The comparison is constant-time.
|
||||||
|
func (p *PreSessionCSRF) Valid(r *http.Request, submitted string) bool {
|
||||||
|
if submitted == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(PreSessionCookieName)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !hmac.Equal([]byte(c.Value), []byte(submitted)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, ok := p.signer.Unsign(submitted)
|
||||||
|
return ok && v != ""
|
||||||
|
}
|
||||||
87
internal/core/httpx/presession_test.go
Normal file
87
internal/core/httpx/presession_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package httpx_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignerRoundTripAndTamper(t *testing.T) {
|
||||||
|
s, err := httpx.NewSigner([]byte("a-signing-key-0123456789abcdef"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSigner: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := httpx.NewSigner(nil); err == nil {
|
||||||
|
t.Error("NewSigner(nil) = nil, want an error (fail closed)")
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = "hello.world/with/slashes"
|
||||||
|
tok := s.Sign(value)
|
||||||
|
got, ok := s.Unsign(tok)
|
||||||
|
if !ok || got != value {
|
||||||
|
t.Fatalf("Unsign(Sign(%q)) = %q,%v", value, got, ok)
|
||||||
|
}
|
||||||
|
if _, ok := s.Unsign(tok + "x"); ok {
|
||||||
|
t.Error("Unsign accepted a tampered token")
|
||||||
|
}
|
||||||
|
if _, ok := s.Unsign("not-a-token"); ok {
|
||||||
|
t.Error("Unsign accepted a malformed token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different key must not validate the token.
|
||||||
|
other, _ := httpx.NewSigner([]byte("another-key-0123456789abcdefgh"))
|
||||||
|
if _, ok := other.Unsign(tok); ok {
|
||||||
|
t.Error("a token validated under a different key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSessionCSRFEnsureAndValid(t *testing.T) {
|
||||||
|
p, err := httpx.NewPreSessionCSRF([]byte("a-cookie-secret-0123456789abcdef"), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPreSessionCSRF: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/someguid", nil)
|
||||||
|
tok, err := p.Ensure(rec, r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ensure: %v", err)
|
||||||
|
}
|
||||||
|
cookies := rec.Result().Cookies()
|
||||||
|
if len(cookies) != 1 || cookies[0].Name != httpx.PreSessionCookieName {
|
||||||
|
t.Fatalf("Ensure cookies = %v, want one %s", cookies, httpx.PreSessionCookieName)
|
||||||
|
}
|
||||||
|
if !cookies[0].HttpOnly {
|
||||||
|
t.Error("the pre-session CSRF cookie is not HttpOnly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-submit: cookie value must equal the submitted token.
|
||||||
|
r2 := httptest.NewRequest("POST", "/someguid?do=auth&action=enroll.begin", nil)
|
||||||
|
r2.AddCookie(cookies[0])
|
||||||
|
if !p.Valid(r2, tok) {
|
||||||
|
t.Error("Valid rejected a matching double-submit token")
|
||||||
|
}
|
||||||
|
if p.Valid(r2, tok+"x") {
|
||||||
|
t.Error("Valid accepted a mismatched token")
|
||||||
|
}
|
||||||
|
if p.Valid(httptest.NewRequest("POST", "/", nil), tok) {
|
||||||
|
t.Error("Valid accepted a token with no cookie")
|
||||||
|
}
|
||||||
|
if p.Valid(r2, "") {
|
||||||
|
t.Error("Valid accepted an empty token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure reuses a valid cookie rather than rotating it every call.
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
tok2, err := p.Ensure(rec2, r2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ensure(reuse): %v", err)
|
||||||
|
}
|
||||||
|
if tok2 != tok {
|
||||||
|
t.Error("Ensure rotated an already-valid token")
|
||||||
|
}
|
||||||
|
if len(rec2.Result().Cookies()) != 0 {
|
||||||
|
t.Error("Ensure re-issued a cookie for a valid token")
|
||||||
|
}
|
||||||
|
}
|
||||||
76
internal/core/httpx/redact.go
Normal file
76
internal/core/httpx/redact.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Redacted is the placeholder that replaces a credential in logs.
|
||||||
|
const Redacted = "<redacted>"
|
||||||
|
|
||||||
|
// sensitiveNames are the segment / parameter names whose VALUE is treated as a
|
||||||
|
// credential (SPEC-003 §8, A8/A11). The GUID is a public username (D-001) and
|
||||||
|
// is deliberately NOT in this set — it is never redacted.
|
||||||
|
var sensitiveNames = map[string]bool{
|
||||||
|
"token": true,
|
||||||
|
"secret": true,
|
||||||
|
"key": true,
|
||||||
|
"code": true,
|
||||||
|
"password": true,
|
||||||
|
"passwd": true,
|
||||||
|
"invite": true,
|
||||||
|
"reset": true,
|
||||||
|
"session": true,
|
||||||
|
"csrf": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactPath masks credential-bearing path segments so the access log can
|
||||||
|
// record the raw path shape without leaking a credential (SPEC-003 §8).
|
||||||
|
//
|
||||||
|
// A segment that follows a sensitive name (e.g. /setup/token/<cred>)
|
||||||
|
// A `name=value` segment whose name is sensitive (e.g. /x/token=abc)
|
||||||
|
//
|
||||||
|
// The GUID is public and is preserved verbatim. Segment count is preserved so
|
||||||
|
// the logged path stays structurally comparable.
|
||||||
|
func RedactPath(p string) string {
|
||||||
|
if p == "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
segs := strings.Split(p, "/")
|
||||||
|
redactNext := false
|
||||||
|
for i, seg := range segs {
|
||||||
|
if redactNext {
|
||||||
|
segs[i] = Redacted
|
||||||
|
redactNext = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name, _, ok := strings.Cut(seg, "="); ok {
|
||||||
|
if sensitiveNames[strings.ToLower(name)] {
|
||||||
|
segs[i] = name + "=" + Redacted
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sensitiveNames[strings.ToLower(seg)] {
|
||||||
|
redactNext = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(segs, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactQuery masks the values of sensitive query parameters. An unparseable
|
||||||
|
// query is redacted wholesale rather than risking a leak.
|
||||||
|
func RedactQuery(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
vals, err := url.ParseQuery(raw)
|
||||||
|
if err != nil {
|
||||||
|
return Redacted
|
||||||
|
}
|
||||||
|
for name := range vals {
|
||||||
|
if sensitiveNames[strings.ToLower(name)] {
|
||||||
|
vals.Set(name, Redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vals.Encode()
|
||||||
|
}
|
||||||
146
internal/core/mail/mail.go
Normal file
146
internal/core/mail/mail.go
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
// Package mail is MyDay's outbound-mail seam (SPEC-007 §4/§5): recovery
|
||||||
|
// notifications and the enrolment link emailed after an identity rotation.
|
||||||
|
//
|
||||||
|
// Email is NEVER an authentication factor (D-005/D-009) — it only *initiates*
|
||||||
|
// recovery; a human in the chain authorises it. The seam is therefore tiny: an
|
||||||
|
// interface, an in-memory Fake for tests, and minimal SMTP wiring for
|
||||||
|
// production.
|
||||||
|
//
|
||||||
|
// Spec: docs/specs/spec-007-roles-chain-recovery.md.
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is one outbound email.
|
||||||
|
type Message struct {
|
||||||
|
To string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mailer sends an email. Delivery failures are the caller's to log — the
|
||||||
|
// contract is deliberately fire-and-forget, like the audit trail.
|
||||||
|
type Mailer interface {
|
||||||
|
Send(ctx context.Context, m Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake records every message in memory. It is the test seam.
|
||||||
|
type Fake struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sent []Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFake returns an empty recording mailer.
|
||||||
|
func NewFake() *Fake { return &Fake{} }
|
||||||
|
|
||||||
|
// Send implements Mailer.
|
||||||
|
func (f *Fake) Send(_ context.Context, m Message) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.sent = append(f.sent, m)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent returns a copy of every recorded message.
|
||||||
|
func (f *Fake) Sent() []Message {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := make([]Message, len(f.sent))
|
||||||
|
copy(out, f.sent)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// To returns every recorded message addressed to addr (case-insensitive).
|
||||||
|
func (f *Fake) To(addr string) []Message {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []Message
|
||||||
|
for _, m := range f.sent {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(m.To), strings.TrimSpace(addr)) {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nop discards messages. It is the fallback when SMTP is not configured.
|
||||||
|
type Nop struct{}
|
||||||
|
|
||||||
|
// Send implements Mailer.
|
||||||
|
func (Nop) Send(context.Context, Message) error { return nil }
|
||||||
|
|
||||||
|
// SMTP sends through a real SMTP server. It is kept deliberately minimal: the
|
||||||
|
// /setup wizard collects the settings; delivery is best-effort and never
|
||||||
|
// blocks a request's outcome.
|
||||||
|
type SMTP struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
from string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSMTP builds an SMTP mailer.
|
||||||
|
func NewSMTP(host string, port int, username, password, from string) *SMTP {
|
||||||
|
return &SMTP{
|
||||||
|
host: strings.TrimSpace(host),
|
||||||
|
port: port,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
from: strings.TrimSpace(from),
|
||||||
|
logger: slog.Default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send implements Mailer via net/smtp (STARTTLS/plain auth when a username is
|
||||||
|
// set). An unconfigured host is a hard error — the caller decides whether that
|
||||||
|
// matters.
|
||||||
|
func (s *SMTP) Send(_ context.Context, m Message) error {
|
||||||
|
if s.host == "" {
|
||||||
|
return errors.New("mail: SMTP host is not configured")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(m.To) == "" {
|
||||||
|
return errors.New("mail: recipient is required")
|
||||||
|
}
|
||||||
|
addr := fmt.Sprintf("%s:%d", s.host, s.port)
|
||||||
|
var auth smtp.Auth
|
||||||
|
if s.username != "" {
|
||||||
|
auth = smtp.PlainAuth("", s.username, s.password, s.host)
|
||||||
|
}
|
||||||
|
if err := smtp.SendMail(addr, auth, s.from, []string{m.To}, encode(s.from, m)); err != nil {
|
||||||
|
return fmt.Errorf("mail: send to %s: %w", m.To, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encode renders the RFC 5322 message. The body is plain text; every header
|
||||||
|
// value is stripped of CR/LF first, so a crafted address or subject cannot
|
||||||
|
// inject extra headers (header-injection guard).
|
||||||
|
func encode(from string, m Message) []byte {
|
||||||
|
sanitize := strings.NewReplacer("\r", " ", "\n", " ").Replace
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "From: %s\r\n", sanitize(from))
|
||||||
|
fmt.Fprintf(&b, "To: %s\r\n", sanitize(m.To))
|
||||||
|
fmt.Fprintf(&b, "Subject: %s\r\n", sanitize(m.Subject))
|
||||||
|
b.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString(m.Body)
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ Mailer = (*Fake)(nil)
|
||||||
|
_ Mailer = Nop{}
|
||||||
|
_ Mailer = (*SMTP)(nil)
|
||||||
|
)
|
||||||
48
internal/core/mail/mail_test.go
Normal file
48
internal/core/mail/mail_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package mail_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFakeRecordsAndFilters: the test seam records every message and can filter
|
||||||
|
// by recipient (case-insensitive).
|
||||||
|
func TestFakeRecordsAndFilters(t *testing.T) {
|
||||||
|
f := mail.NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := f.Send(ctx, mail.Message{To: "a@example.com", Subject: "one", Body: "hi"}); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Send(ctx, mail.Message{To: "B@Example.com", Subject: "two"}); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := len(f.Sent()); got != 2 {
|
||||||
|
t.Fatalf("Sent len = %d, want 2", got)
|
||||||
|
}
|
||||||
|
toB := f.To("b@example.com")
|
||||||
|
if len(toB) != 1 || toB[0].Subject != "two" {
|
||||||
|
t.Errorf("To(b@example.com) = %+v, want the second message", toB)
|
||||||
|
}
|
||||||
|
if len(f.To("nobody@example.com")) != 0 {
|
||||||
|
t.Error("To returned a message for an unknown recipient")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNopSend: the unconfigured fallback discards silently.
|
||||||
|
func TestNopSend(t *testing.T) {
|
||||||
|
if err := (mail.Nop{}).Send(context.Background(), mail.Message{To: "x@example.com"}); err != nil {
|
||||||
|
t.Errorf("Nop.Send = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSMTPRequiresHost: the SMTP mailer fails closed without a host.
|
||||||
|
func TestSMTPRequiresHost(t *testing.T) {
|
||||||
|
m := mail.NewSMTP("", 25, "", "", "myday@example.com")
|
||||||
|
if err := m.Send(context.Background(), mail.Message{To: "x@example.com"}); err == nil {
|
||||||
|
t.Error("SMTP.Send with no host = nil, want an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/core/middleware/audit.go
Normal file
18
internal/core/middleware/audit.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AttachAudit attaches the request's audit metadata to ctx (SPEC-003 §4, ruling
|
||||||
|
// R-1). The door calls it before dispatching to a module; every service then
|
||||||
|
// writes its audit rows with the right actor · client IP · correlation.
|
||||||
|
func AttachAudit(ctx context.Context, actorUserID int64, clientIP, correlationID string) context.Context {
|
||||||
|
return audit.WithMeta(ctx, audit.Meta{
|
||||||
|
ActorUserID: actorUserID,
|
||||||
|
ClientIP: clientIP,
|
||||||
|
CorrelationID: correlationID,
|
||||||
|
})
|
||||||
|
}
|
||||||
57
internal/core/middleware/correlation.go
Normal file
57
internal/core/middleware/correlation.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// Package middleware holds the door's cross-cutting HTTP concerns — the
|
||||||
|
// correlation id, the rate limiter, and the audit-metadata attachment
|
||||||
|
// (SPEC-003 §3/§4).
|
||||||
|
//
|
||||||
|
// These are deliberately small and transport-level: the door
|
||||||
|
// (internal/core/server) orchestrates them in one visible pipeline.
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CorrelationHeader is the response header carrying the correlation id, echoed
|
||||||
|
// in every log line and audit row (SPEC-003 §4/§8, AC-8).
|
||||||
|
const CorrelationHeader = "X-Correlation-ID"
|
||||||
|
|
||||||
|
// correlationKey is the unexported context key for the correlation id.
|
||||||
|
type correlationKey struct{}
|
||||||
|
|
||||||
|
// NewCorrelationID returns a fresh UUID v4 string. It is generated per request:
|
||||||
|
// an inbound header is never trusted, so a caller cannot forge a trail.
|
||||||
|
func NewCorrelationID() string {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
// crypto/rand failing is a platform fault; fall back to a
|
||||||
|
// time-derived id rather than emitting an empty correlation.
|
||||||
|
return fmt.Sprintf("corr-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCorrelation attaches id to ctx.
|
||||||
|
func WithCorrelation(ctx context.Context, id string) context.Context {
|
||||||
|
return context.WithValue(ctx, correlationKey{}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CorrelationFrom returns the correlation id attached to ctx, or "".
|
||||||
|
func CorrelationFrom(ctx context.Context) string {
|
||||||
|
id, _ := ctx.Value(correlationKey{}).(string)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// CorrelationID is the middleware form: it generates a correlation id, puts it
|
||||||
|
// on the response header and the request context, then calls next.
|
||||||
|
func CorrelationID(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := NewCorrelationID()
|
||||||
|
w.Header().Set(CorrelationHeader, id)
|
||||||
|
next.ServeHTTP(w, r.WithContext(WithCorrelation(r.Context(), id)))
|
||||||
|
})
|
||||||
|
}
|
||||||
123
internal/core/middleware/middleware_test.go
Normal file
123
internal/core/middleware/middleware_test.go
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewCorrelationID(t *testing.T) {
|
||||||
|
a := middleware.NewCorrelationID()
|
||||||
|
b := middleware.NewCorrelationID()
|
||||||
|
if a == "" || a == b {
|
||||||
|
t.Fatalf("correlation ids are empty or identical: %q, %q", a, b)
|
||||||
|
}
|
||||||
|
if len(a) != 36 || a[8] != '-' || a[13] != '-' || a[18] != '-' || a[23] != '-' {
|
||||||
|
t.Errorf("correlation id %q is not UUID-shaped", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorrelationIDMiddleware(t *testing.T) {
|
||||||
|
var seen string
|
||||||
|
h := middleware.CorrelationID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seen = middleware.CorrelationFrom(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "/home", nil))
|
||||||
|
|
||||||
|
header := rec.Header().Get(middleware.CorrelationHeader)
|
||||||
|
if header == "" {
|
||||||
|
t.Fatal("response is missing the correlation header")
|
||||||
|
}
|
||||||
|
if seen != header {
|
||||||
|
t.Errorf("context correlation = %q, header = %q; want equal", seen, header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorrelationRoundTrip(t *testing.T) {
|
||||||
|
if got := middleware.CorrelationFrom(context.Background()); got != "" {
|
||||||
|
t.Errorf("CorrelationFrom(background) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
ctx := middleware.WithCorrelation(context.Background(), "corr-1")
|
||||||
|
if got := middleware.CorrelationFrom(ctx); got != "corr-1" {
|
||||||
|
t.Errorf("CorrelationFrom = %q, want corr-1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiterAllow(t *testing.T) {
|
||||||
|
l := middleware.NewRateLimiter(2, time.Minute)
|
||||||
|
if !l.Allow("k") || !l.Allow("k") {
|
||||||
|
t.Fatal("the first two requests should be allowed")
|
||||||
|
}
|
||||||
|
if l.Allow("k") {
|
||||||
|
t.Error("the third request should be denied")
|
||||||
|
}
|
||||||
|
if !l.Allow("other") {
|
||||||
|
t.Error("a different key must have its own bucket")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiterWindowResets(t *testing.T) {
|
||||||
|
l := middleware.NewRateLimiter(1, time.Minute)
|
||||||
|
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
l.SetClock(func() time.Time { return now })
|
||||||
|
|
||||||
|
if !l.Allow("k") || l.Allow("k") {
|
||||||
|
t.Fatal("limit of 1 not enforced")
|
||||||
|
}
|
||||||
|
now = now.Add(2 * time.Minute)
|
||||||
|
if !l.Allow("k") {
|
||||||
|
t.Error("the window did not reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiterMiddleware429(t *testing.T) {
|
||||||
|
l := middleware.NewRateLimiter(1, time.Minute)
|
||||||
|
h := l.Middleware(func(*http.Request) string { return "k" },
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||||
|
|
||||||
|
rec1 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec1, httptest.NewRequest("GET", "/access", nil))
|
||||||
|
if rec1.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first request = %d, want 200", rec1.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, httptest.NewRequest("GET", "/access", nil))
|
||||||
|
if rec2.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("second request = %d, want 429", rec2.Code)
|
||||||
|
}
|
||||||
|
if rec2.Header().Get("Retry-After") == "" {
|
||||||
|
t.Error("429 is missing a Retry-After header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassAndAuthSensitive(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/", ""},
|
||||||
|
{"/home", ""},
|
||||||
|
{"/api/v1", ""},
|
||||||
|
{"/static/x.css", ""},
|
||||||
|
{"/access", "access"},
|
||||||
|
{"/setup", "setup"},
|
||||||
|
{"/AbCdEf0123456789", "guid"},
|
||||||
|
{"/AbCdEf0123456789/home", "guid"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := middleware.Class(tc.path); got != tc.want {
|
||||||
|
t.Errorf("Class(%q) = %q, want %q", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
if got := middleware.AuthSensitive(tc.path); got != (tc.want != "") {
|
||||||
|
t.Errorf("AuthSensitive(%q) = %v, want %v", tc.path, got, tc.want != "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
internal/core/middleware/ratelimit.go
Normal file
129
internal/core/middleware/ratelimit.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimiter is a per-key fixed-window limiter (A3, SPEC-003 §4/AC-9). It
|
||||||
|
// guards the auth-sensitive routes: /{GUID}, /access, /setup. State is
|
||||||
|
// in-memory and single-process, which matches the single-instance model
|
||||||
|
// (D-012).
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
state map[string]*window
|
||||||
|
}
|
||||||
|
|
||||||
|
type window struct {
|
||||||
|
count int
|
||||||
|
reset time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRateLimit / DefaultRateWindow are the code defaults for the
|
||||||
|
// auth-sensitive routes.
|
||||||
|
const (
|
||||||
|
DefaultRateLimit = 30
|
||||||
|
DefaultRateWindow = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRateLimiter builds a limiter allowing limit requests per window per key.
|
||||||
|
// Non-positive values fall back to the defaults.
|
||||||
|
func NewRateLimiter(limit int, win time.Duration) *RateLimiter {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = DefaultRateLimit
|
||||||
|
}
|
||||||
|
if win <= 0 {
|
||||||
|
win = DefaultRateWindow
|
||||||
|
}
|
||||||
|
return &RateLimiter{
|
||||||
|
limit: limit,
|
||||||
|
window: win,
|
||||||
|
now: time.Now,
|
||||||
|
state: make(map[string]*window),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow records a request for key and reports whether it is within the limit.
|
||||||
|
func (l *RateLimiter) Allow(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
now := l.now()
|
||||||
|
w, ok := l.state[key]
|
||||||
|
if !ok || !now.Before(w.reset) {
|
||||||
|
l.state[key] = &window{count: 1, reset: now.Add(l.window)}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w.count++
|
||||||
|
return w.count <= l.limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClock injects a clock (tests).
|
||||||
|
func (l *RateLimiter) SetClock(fn func() time.Time) {
|
||||||
|
if fn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.now = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryAfter returns the whole seconds until the window resets for key (for the
|
||||||
|
// Retry-After header). It is best-effort.
|
||||||
|
func (l *RateLimiter) RetryAfter(key string) int {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
w, ok := l.state[key]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
d := time.Until(w.reset)
|
||||||
|
if d < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(d.Seconds()) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class classifies a path into an auth-sensitive bucket, or "" when the path is
|
||||||
|
// not rate-limited. /access and /setup are explicit; any non-reserved first
|
||||||
|
// segment is a GUID lookup.
|
||||||
|
func Class(path string) string {
|
||||||
|
p := routing.Parse(path)
|
||||||
|
if len(p) == 0 || p[0] == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch p[0] {
|
||||||
|
case "access", "setup":
|
||||||
|
return p[0]
|
||||||
|
}
|
||||||
|
if routing.IsReservedWorkflow(p[0]) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "guid"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthSensitive reports whether path is one of the rate-limited routes (A3).
|
||||||
|
func AuthSensitive(path string) bool { return Class(path) != "" }
|
||||||
|
|
||||||
|
// Middleware wraps next with a limiter keyed by keyFn. It answers 429 with a
|
||||||
|
// Retry-After header once the limit is exceeded.
|
||||||
|
func (l *RateLimiter) Middleware(keyFn func(*http.Request) string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key := keyFn(r)
|
||||||
|
if !l.Allow(key) {
|
||||||
|
if secs := l.RetryAfter(key); secs > 0 {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||||
|
}
|
||||||
|
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
49
internal/core/server/authflow.go
Normal file
49
internal/core/server/authflow.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authModule is the `do` module name carrying the SPEC-006 enroll/login
|
||||||
|
// actions. The GUID still rides the path — `do`/`action` never carry identity
|
||||||
|
// (D-032/D-039).
|
||||||
|
const authModule = "auth"
|
||||||
|
|
||||||
|
// RegisterAuthFlow registers the SPEC-006 core screens (enroll · login) and the
|
||||||
|
// `auth` action module into reg. It is the wiring the door needs to make
|
||||||
|
// `POST /{GUID}?do=auth&action=…` resolve and dispatch.
|
||||||
|
func RegisterAuthFlow(reg *MapRegistry, a *flow.Actions) {
|
||||||
|
reg.RegisterCore("enroll", adaptFlow(a.EnrollScreen))
|
||||||
|
reg.RegisterCore("login", adaptFlow(a.LoginScreen))
|
||||||
|
|
||||||
|
reg.RegisterModule(NewModule(authModule).
|
||||||
|
// SPEC-006 §8 (D-041): the session-gated passkey-nudge screen. It is a
|
||||||
|
// ROUTE on the auth module (GET /{GUID}/auth/passkey) — the redirect
|
||||||
|
// target of a nudged TOTP login.
|
||||||
|
AddRoute("passkey", "passkey.prompt", adaptFlow(a.PasskeyPrompt)).
|
||||||
|
AddAction("enroll.begin", "enroll.begin", adaptFlow(a.EnrollBegin)).
|
||||||
|
AddAction("enroll.finish", "enroll.finish", adaptFlow(a.EnrollFinish)).
|
||||||
|
AddAction("totp.begin", "totp.begin", adaptFlow(a.TOTPBegin)).
|
||||||
|
AddAction("totp.verify", "totp.verify", adaptFlow(a.TOTPVerify)).
|
||||||
|
AddAction("login.begin", "login.begin", adaptFlow(a.LoginBegin)).
|
||||||
|
AddAction("login.finish", "login.finish", adaptFlow(a.LoginFinish)).
|
||||||
|
AddAction("logout", "logout", adaptFlow(a.Logout)).
|
||||||
|
// The nudge actions carry a session, so the door session-gates them
|
||||||
|
// (serveAuthAction forwards the `passkey.` prefix to serveGUID).
|
||||||
|
AddAction("passkey.add", "passkey.add", adaptFlow(a.PasskeyAdd)).
|
||||||
|
AddAction("passkey.later", "passkey.later", adaptFlow(a.PasskeyLater)).
|
||||||
|
AddAction("passkey.never", "passkey.never", adaptFlow(a.PasskeyNever)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// flowFunc is a flow handler: the GUID comes from the RequestContext (the door
|
||||||
|
// resolves it from the path; the flow never parses a raw path).
|
||||||
|
type flowFunc func(w http.ResponseWriter, r *http.Request, guid string) error
|
||||||
|
|
||||||
|
// adaptFlow adapts a flow handler to the door's Handler signature.
|
||||||
|
func adaptFlow(f flowFunc) Handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error {
|
||||||
|
return f(w, r, rc.Workflow)
|
||||||
|
}
|
||||||
|
}
|
||||||
488
internal/core/server/enroll_login_test.go
Normal file
488
internal/core/server/enroll_login_test.go
Normal file
|
|
@ -0,0 +1,488 @@
|
||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pqotp "github.com/pquerna/otp/totp"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── the WebAuthn fake (the ceremony is browser-bound) ────────────────────────
|
||||||
|
|
||||||
|
type fakeWebAuthn struct {
|
||||||
|
beginReg, finishReg int
|
||||||
|
beginLogin, finishLogin int
|
||||||
|
beginErr, finishErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWebAuthn) BeginRegistration(context.Context, webauthn.User, []webauthn.Credential) (*webauthn.Begin, error) {
|
||||||
|
f.beginReg++
|
||||||
|
if f.beginErr != nil {
|
||||||
|
return nil, f.beginErr
|
||||||
|
}
|
||||||
|
return fakeBegin("reg-challenge"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWebAuthn) FinishRegistration(context.Context, webauthn.User, *webauthn.Begin, *http.Request) (webauthn.Credential, error) {
|
||||||
|
f.finishReg++
|
||||||
|
if f.finishErr != nil {
|
||||||
|
return webauthn.Credential{}, f.finishErr
|
||||||
|
}
|
||||||
|
return webauthn.Credential{
|
||||||
|
ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"),
|
||||||
|
SignCount: 1, Transports: []string{"internal"}, AAGUID: []byte{1, 2, 3, 4},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWebAuthn) BeginLogin(context.Context, webauthn.User, []webauthn.Credential) (*webauthn.Begin, error) {
|
||||||
|
f.beginLogin++
|
||||||
|
if f.beginErr != nil {
|
||||||
|
return nil, f.beginErr
|
||||||
|
}
|
||||||
|
return fakeBegin("login-challenge"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWebAuthn) FinishLogin(context.Context, webauthn.User, *webauthn.Begin, *http.Request, []webauthn.Credential) (webauthn.Credential, error) {
|
||||||
|
f.finishLogin++
|
||||||
|
if f.finishErr != nil {
|
||||||
|
return webauthn.Credential{}, f.finishErr
|
||||||
|
}
|
||||||
|
return webauthn.Credential{
|
||||||
|
ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"),
|
||||||
|
SignCount: 2, Transports: []string{"internal"}, AAGUID: []byte{1, 2, 3, 4},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeBegin(challenge string) *webauthn.Begin {
|
||||||
|
opts, _ := json.Marshal(map[string]any{
|
||||||
|
"publicKey": map[string]any{"challenge": challenge},
|
||||||
|
})
|
||||||
|
sd, _ := json.Marshal(map[string]any{
|
||||||
|
"challenge": challenge,
|
||||||
|
"expires": time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return &webauthn.Begin{Options: opts, SessionData: sd}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── harness helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// pendingUser creates a team user under the admin and mints a pending identity.
|
||||||
|
func (h *harness) pendingUser(t *testing.T, email string) (userID, identityID int64, guid string) {
|
||||||
|
t.Helper()
|
||||||
|
userID, err := h.users.Create(h.ctx, h.adminID, email, "Pending", users.RoleTeam)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
identityID, guid, err = h.ids.Mint(h.ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mint: %v", err)
|
||||||
|
}
|
||||||
|
return userID, identityID, guid
|
||||||
|
}
|
||||||
|
|
||||||
|
// action issues an anonymous flow action with a fresh pre-session CSRF token.
|
||||||
|
func (h *harness) action(action, guid string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
h.t.Helper()
|
||||||
|
cookie, token := h.preCSRF()
|
||||||
|
if form == nil {
|
||||||
|
form = url.Values{}
|
||||||
|
}
|
||||||
|
form.Set("csrf", token)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action="+url.QueryEscape(action),
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
for _, c := range cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.srv.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionAction issues a session-gated action (logout) with the session-bound
|
||||||
|
// CSRF token.
|
||||||
|
func (h *harness) sessionAction(action, guid, sessionID string, extra ...*http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
h.t.Helper()
|
||||||
|
form := url.Values{"csrf": {h.csrf.Token(sessionID)}}
|
||||||
|
req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action="+url.QueryEscape(action),
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{Name: httpx.SessionCookieName, Value: sessionID})
|
||||||
|
for _, c := range extra {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.srv.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ceremonyCookie extracts the signed WebAuthn ceremony cookie from a response.
|
||||||
|
func ceremonyCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == flow.CeremonyCookieName && c.Value != "" {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("response carried no %s cookie", flow.CeremonyCookieName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionCookie extracts the session cookie value from a response.
|
||||||
|
func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == httpx.SessionCookieName && c.Value != "" {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("response carried no session cookie (status %d)", rec.Code)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrollFully runs the whole two-factor enrollment and returns the session id.
|
||||||
|
func (h *harness) enrollFully(t *testing.T, identityID int64, guid string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Factor 1 — passkey.
|
||||||
|
begin := h.action("enroll.begin", guid, nil)
|
||||||
|
if begin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("enroll.begin = %d, want 200", begin.Code)
|
||||||
|
}
|
||||||
|
finish := h.action("enroll.finish", guid, nil, ceremonyCookie(t, begin))
|
||||||
|
if finish.Code != http.StatusOK {
|
||||||
|
t.Fatalf("enroll.finish = %d, want 200", finish.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 2 — TOTP.
|
||||||
|
if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("totp.begin = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
secret, err := h.ids.TOTPSecret(h.ctx, identityID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TOTPSecret: %v", err)
|
||||||
|
}
|
||||||
|
code, err := pqotp.GenerateCode(string(secret), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateCode: %v", err)
|
||||||
|
}
|
||||||
|
done := h.action("totp.verify", guid, url.Values{"code": {code}})
|
||||||
|
if done.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("totp.verify = %d, want 303 (enrollment complete)", done.Code)
|
||||||
|
}
|
||||||
|
return sessionCookie(t, done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-1 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC1_PendingRoutesToEnrollNotLogin(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
_, _, guid := h.pendingUser(t, "ac1@example.com")
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/"+guid)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /{pending-guid} = %d, want 200 (enroll)", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "Enrollment") {
|
||||||
|
t.Error("a pending GUID did not render the enrollment screen (AC-1)")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "Sign in") {
|
||||||
|
t.Error("a pending GUID rendered the login screen (AC-1)")
|
||||||
|
}
|
||||||
|
// The anonymous screen issues the pre-session CSRF cookie (A15).
|
||||||
|
if !hasCookie(rec, httpx.PreSessionCookieName) {
|
||||||
|
t.Error("the enrollment screen issued no pre-session CSRF cookie (A15)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-2 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC2_EnrollmentRequiresBothFactors(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
_, id, guid := h.pendingUser(t, "ac2@example.com")
|
||||||
|
|
||||||
|
// TOTP first, no passkey yet -> refused; identity stays pending.
|
||||||
|
if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("totp.begin = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
secret, err := h.ids.TOTPSecret(h.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TOTPSecret: %v", err)
|
||||||
|
}
|
||||||
|
code, err := pqotp.GenerateCode(string(secret), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateCode: %v", err)
|
||||||
|
}
|
||||||
|
rec := h.action("totp.verify", guid, url.Values{"code": {code}})
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("totp.verify without a passkey = %d, want 400 (AC-2)", rec.Code)
|
||||||
|
}
|
||||||
|
if hasCookie(rec, httpx.SessionCookieName) {
|
||||||
|
t.Error("a session was minted without both factors (AC-2)")
|
||||||
|
}
|
||||||
|
if idn, err := h.ids.ByGUID(h.ctx, guid); err != nil || idn.Status != auth.StatusPendingEnrollment {
|
||||||
|
t.Errorf("identity after one factor = %+v, want pending_enrollment (AC-2)", idn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passkey, no verified TOTP -> still pending, still no session.
|
||||||
|
begin := h.action("enroll.begin", guid, nil)
|
||||||
|
if begin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("enroll.begin = %d, want 200", begin.Code)
|
||||||
|
}
|
||||||
|
finish := h.action("enroll.finish", guid, nil, ceremonyCookie(t, begin))
|
||||||
|
if finish.Code != http.StatusOK {
|
||||||
|
t.Fatalf("enroll.finish = %d, want 200", finish.Code)
|
||||||
|
}
|
||||||
|
if hasCookie(finish, httpx.SessionCookieName) {
|
||||||
|
t.Error("enroll.finish minted a session before TOTP (AC-2)")
|
||||||
|
}
|
||||||
|
if idn, err := h.ids.ByGUID(h.ctx, guid); err != nil || idn.Status != auth.StatusPendingEnrollment {
|
||||||
|
t.Errorf("identity after passkey only = %+v, want pending_enrollment (AC-2)", idn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now both -> active + session.
|
||||||
|
code, err = pqotp.GenerateCode(string(secret), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateCode: %v", err)
|
||||||
|
}
|
||||||
|
rec = h.action("totp.verify", guid, url.Values{"code": {code}})
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("totp.verify with both factors = %d, want 303 (AC-2)", rec.Code)
|
||||||
|
}
|
||||||
|
if !hasCookie(rec, httpx.SessionCookieName) {
|
||||||
|
t.Error("no session minted after both factors (AC-2)")
|
||||||
|
}
|
||||||
|
idn, err := h.ids.ByGUID(h.ctx, guid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ByGUID: %v", err)
|
||||||
|
}
|
||||||
|
if idn.Status != auth.StatusActive {
|
||||||
|
t.Errorf("identity after both factors = %s, want active (AC-2)", idn.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-3 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC3_ActiveRoutesToLoginThenApp(t *testing.T) {
|
||||||
|
h := newHarness(t) // the admin is active
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/"+h.guid)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /{active-guid} without a session = %d, want 200 (login screen, F-1)", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Sign in") {
|
||||||
|
t.Error("an active GUID without a session did not render the login screen (AC-3)")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = request(h.srv, "GET", "/"+h.guid, h.cookie())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /{active-guid} with a session = %d, want 200 (the app)", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "MyDay") {
|
||||||
|
t.Error("a valid session did not reach the app (AC-3)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-4 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC4_WindowExpiryBlocksEnrollment(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
_, id, guid := h.pendingUser(t, "ac4@example.com")
|
||||||
|
|
||||||
|
// Age the identity beyond the bounded window (A1).
|
||||||
|
past := store.FormatTime(time.Now().Add(-100 * time.Hour))
|
||||||
|
if _, err := h.db.Exec(`UPDATE identities SET created_at = ? WHERE identity_id = ?`, past, id); err != nil {
|
||||||
|
t.Fatalf("age identity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := request(h.srv, "GET", "/"+guid); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET /{expired-guid} = %d, want 404 (A1)", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := h.action("enroll.begin", guid, nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("enroll.begin past the window = %d, want 404 (A1)", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("totp.begin past the window = %d, want 404 (A1)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-5 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC5_LoginRotatesAndLogoutRevokes(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
_, id, guid := h.pendingUser(t, "ac5@example.com")
|
||||||
|
s1 := h.enrollFully(t, id, guid)
|
||||||
|
|
||||||
|
if _, err := h.sessions.Validate(h.ctx, s1); err != nil {
|
||||||
|
t.Fatalf("the enrollment session is invalid: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A passkey login while S1 is live rotates it (fixation defence).
|
||||||
|
begin := h.action("login.begin", guid, nil, &http.Cookie{Name: httpx.SessionCookieName, Value: s1})
|
||||||
|
if begin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login.begin = %d, want 200", begin.Code)
|
||||||
|
}
|
||||||
|
finish := h.action("login.finish", guid, nil,
|
||||||
|
&http.Cookie{Name: httpx.SessionCookieName, Value: s1}, ceremonyCookie(t, begin))
|
||||||
|
if finish.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login.finish = %d, want 303", finish.Code)
|
||||||
|
}
|
||||||
|
s2 := sessionCookie(t, finish)
|
||||||
|
if s2 == s1 {
|
||||||
|
t.Error("login did not rotate the session id (AC-5)")
|
||||||
|
}
|
||||||
|
if _, err := h.sessions.Validate(h.ctx, s1); err == nil {
|
||||||
|
t.Error("the pre-login session survived rotation (AC-5)")
|
||||||
|
}
|
||||||
|
if _, err := h.sessions.Validate(h.ctx, s2); err != nil {
|
||||||
|
t.Errorf("the rotated session is invalid: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout revokes S2.
|
||||||
|
lo := h.sessionAction("logout", guid, s2)
|
||||||
|
if lo.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("logout = %d, want 303", lo.Code)
|
||||||
|
}
|
||||||
|
if _, err := h.sessions.Validate(h.ctx, s2); err == nil {
|
||||||
|
t.Error("logout did not revoke the session (AC-5)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-6 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC6_TOTPSecretEncryptedAndNeverLogged(t *testing.T) {
|
||||||
|
var logs bytes.Buffer
|
||||||
|
h := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.Logger = slog.New(slog.NewTextHandler(&logs, nil))
|
||||||
|
})
|
||||||
|
_, id, guid := h.pendingUser(t, "ac6@example.com")
|
||||||
|
|
||||||
|
rec := h.action("totp.begin", guid, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("totp.begin = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := h.ids.TOTPSecret(h.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TOTPSecret: %v", err)
|
||||||
|
}
|
||||||
|
if len(secret) == 0 {
|
||||||
|
t.Fatal("the issued secret is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) The stored blob is ciphertext, not the secret (A9).
|
||||||
|
var raw []byte
|
||||||
|
if err := h.db.QueryRow(`SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, id).Scan(&raw); err != nil {
|
||||||
|
t.Fatalf("read secret_encrypted: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Contains(raw, secret) {
|
||||||
|
t.Error("AC-6: secret_encrypted holds the plaintext secret (A9)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) The otpauth URL is shown to the user (the one display) — but the
|
||||||
|
// secret never reached the log (A9).
|
||||||
|
if !strings.Contains(rec.Body.String(), "otpauth://") {
|
||||||
|
t.Error("totp.begin did not return an otpauth URL")
|
||||||
|
}
|
||||||
|
if strings.Contains(logs.String(), string(secret)) {
|
||||||
|
t.Error("AC-6: the TOTP secret reached the log (A9)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-7 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC7_RateLimitAndGenericErrors(t *testing.T) {
|
||||||
|
// Rate limit (A3): the enroll/login routes return 429 past the limit.
|
||||||
|
h := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.RateLimit = 1
|
||||||
|
d.RateWindow = time.Minute
|
||||||
|
})
|
||||||
|
_, _, guid := h.pendingUser(t, "ac7@example.com")
|
||||||
|
if rec := request(h.srv, "GET", "/"+guid); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first request = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
rec := request(h.srv, "GET", "/"+guid)
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("second request = %d, want 429 (A3/AC-7)", rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Retry-After") == "" {
|
||||||
|
t.Error("429 is missing a Retry-After header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic error text: failures never reveal which factor or why.
|
||||||
|
h2 := newHarness(t)
|
||||||
|
_, id2, guid2 := h2.pendingUser(t, "ac7b@example.com")
|
||||||
|
h2.enrollFully(t, id2, guid2)
|
||||||
|
|
||||||
|
rec = h2.action("totp.verify", guid2, url.Values{"code": {"000000"}})
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("bad TOTP code = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(rec.Body.String()); got != "unauthorized" {
|
||||||
|
t.Errorf("failed login body = %q, want a generic %q", got, "unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing/forged pre-session token is rejected generically.
|
||||||
|
req := httptest.NewRequest("POST", "/"+guid2+"?do=auth&action=enroll.begin", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: httpx.PreSessionCookieName, Value: "forged"})
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h2.srv.ServeHTTP(rec2, req)
|
||||||
|
if rec2.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("forged pre-session token = %d, want 403 (A15)", rec2.Code)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(rec2.Body.String()); got != "forbidden" {
|
||||||
|
t.Errorf("CSRF failure body = %q, want a generic %q", got, "forbidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestF2_NoStoreOnEnrollAndTOTPResponses: the enrollment screen and the TOTP
|
||||||
|
// issue response (which embeds the otpauth URL — the secret) are never cached.
|
||||||
|
func TestF2_NoStoreOnEnrollAndTOTPResponses(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
_, _, guid := h.pendingUser(t, "f2@example.com")
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/"+guid)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("enrollment screen = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Errorf("enrollment screen Cache-Control = %q, want no-store (F-2)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = h.action("totp.begin", guid, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("totp.begin = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Errorf("totp.begin Cache-Control = %q, want no-store (F-2)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasCookie reports whether the response set a non-empty cookie of that name.
|
||||||
|
func hasCookie(rec *httptest.ResponseRecorder, name string) bool {
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == name && c.Value != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
66
internal/core/server/handlers.go
Normal file
66
internal/core/server/handlers.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The core handlers below are the door's minimal landing surfaces. SPEC-003 §4
|
||||||
|
// says the door renders no dynamic HTML — it resolves, gates, and hands off;
|
||||||
|
// these are the hand-off targets for the routes SPEC-003 must classify, and the
|
||||||
|
// real screens arrive with SPEC-005 (setup), SPEC-006 (enroll/login) and
|
||||||
|
// SPEC-007 (access). SPEC-009 §4 renders them inside the shared shell.
|
||||||
|
|
||||||
|
// Home is the neutral landing page. It is PUBLIC: it takes no session and
|
||||||
|
// touches no database (SPEC-003 §9 AC-2). It is registered both as the core
|
||||||
|
// `home` workflow (level 1, /home) and as the `home` module's dashboard (level
|
||||||
|
// 2, /{GUID}).
|
||||||
|
func Home(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error {
|
||||||
|
return web.Render(w, http.StatusOK, web.Page{
|
||||||
|
Title: "MyDay",
|
||||||
|
Guest: true,
|
||||||
|
Content: web.Markup(`<h1>MyDay</h1>
|
||||||
|
<p>Welcome. Sign in with your personal link.</p>`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginPlaceholder stands in for the login screen (SPEC-006). It answers 200:
|
||||||
|
// a rendered page is a successful response — only a failed credential attempt
|
||||||
|
// is 401/400 (F-1).
|
||||||
|
func LoginPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, err := w.Write([]byte(`<!doctype html><html><body><h1>Sign in</h1>` +
|
||||||
|
`<p>This link requires a valid session.</p></body></html>`))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrollPlaceholder stands in for the enrollment screen (SPEC-006). The GUID is
|
||||||
|
// found but not yet enrolled, so no session is required to see it.
|
||||||
|
func EnrollPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, err := w.Write([]byte(`<!doctype html><html><body><h1>Enrollment</h1>` +
|
||||||
|
`<p>Passkey and TOTP enrollment.</p></body></html>`))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupPlaceholder stands in for the /setup wizard (SPEC-005). The door only
|
||||||
|
// reaches it in setup mode.
|
||||||
|
func SetupPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, err := w.Write([]byte(`<!doctype html><html><body><h1>Setup</h1>` +
|
||||||
|
`<p>Bootstrap wizard.</p></body></html>`))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessPlaceholder stands in for the recovery entry (SPEC-007).
|
||||||
|
func AccessPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, err := w.Write([]byte(`<!doctype html><html><body><h1>Account access</h1>` +
|
||||||
|
`<p>Recovery.</p></body></html>`))
|
||||||
|
return err
|
||||||
|
}
|
||||||
52
internal/core/server/recoveryflow.go
Normal file
52
internal/core/server/recoveryflow.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recoveryModule is the `do` module carrying the SPEC-007 approve/deny actions.
|
||||||
|
// It rides a GUID path, so the door session-gates it (unlike the anonymous
|
||||||
|
// `auth` recover action).
|
||||||
|
const recoveryModule = "recovery"
|
||||||
|
|
||||||
|
// RegisterRecoveryFlow registers SPEC-007's surface:
|
||||||
|
//
|
||||||
|
// - the core `access` workflow (GET /access — the anonymous recovery entry),
|
||||||
|
// - the `auth` module's `recover` action (POST /access?do=auth&action=recover,
|
||||||
|
// anonymous, A15), and
|
||||||
|
// - the session-gated `recovery` approve/deny actions
|
||||||
|
// (POST /{GUID}?do=recovery&action=approve|deny).
|
||||||
|
func RegisterRecoveryFlow(reg *MapRegistry, a *recovery.Actions) {
|
||||||
|
reg.RegisterCore("access", adaptRecovery(a.Page))
|
||||||
|
|
||||||
|
// The recovery request rides the `auth` action module, exactly as SPEC-007
|
||||||
|
// §4 spells it: POST /access?do=auth&action=recover. The module may already
|
||||||
|
// exist (SPEC-006's enroll/login); extend it rather than replace it.
|
||||||
|
auth := reg.module(authModule)
|
||||||
|
if auth == nil {
|
||||||
|
auth = NewModule(authModule)
|
||||||
|
reg.RegisterModule(auth)
|
||||||
|
}
|
||||||
|
auth.AddAction("recover", "recover.request", adaptRecovery(a.Request))
|
||||||
|
|
||||||
|
reg.RegisterModule(NewModule(recoveryModule).
|
||||||
|
AddAction("approve", "approve", adaptRecoveryActor(a.Approve)).
|
||||||
|
AddAction("deny", "deny", adaptRecoveryActor(a.Deny)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// adaptRecovery adapts an anonymous recovery handler to the door's Handler.
|
||||||
|
func adaptRecovery(f func(http.ResponseWriter, *http.Request) error) Handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, _ *RequestContext) error {
|
||||||
|
return f(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adaptRecoveryActor adapts a session-gated decision handler: the door has
|
||||||
|
// authenticated the actor and put its id on the RequestContext.
|
||||||
|
func adaptRecoveryActor(f func(http.ResponseWriter, *http.Request, int64) error) Handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error {
|
||||||
|
return f(w, r, rc.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
162
internal/core/server/registry.go
Normal file
162
internal/core/server/registry.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler is a dispatched module function. It receives the door's resolved
|
||||||
|
// RequestContext — identification and routing, never a raw path (rule of the
|
||||||
|
// door). A non-nil error is logged and answered as 500.
|
||||||
|
type Handler func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error
|
||||||
|
|
||||||
|
// Registry is the door's dispatch surface: the pure routing.Registry (so
|
||||||
|
// routing.Dispatch can resolve a Resolution) plus the function lookup that
|
||||||
|
// turns a resolved (module, function) into a Handler.
|
||||||
|
//
|
||||||
|
// SPEC-008 owns the full, discovered registry; this is the minimal in-memory
|
||||||
|
// implementation that makes the door exercisable.
|
||||||
|
type Registry interface {
|
||||||
|
// routing.Registry is the whitelist the pure model consults.
|
||||||
|
routing.Registry
|
||||||
|
|
||||||
|
// Handler returns the function registered for (module, function).
|
||||||
|
Handler(module, function string) (Handler, bool)
|
||||||
|
|
||||||
|
// CoreHandler returns the handler for a core-owned workflow (home, access,
|
||||||
|
// setup, login, enroll, …) — the reserved level-1 routes.
|
||||||
|
CoreHandler(workflow string) (Handler, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapModule is one in-memory module: its routing.Module whitelist plus its
|
||||||
|
// function handlers.
|
||||||
|
type MapModule struct {
|
||||||
|
name string
|
||||||
|
deflt string
|
||||||
|
routes map[string]string
|
||||||
|
actions map[string]string
|
||||||
|
handlers map[string]Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModule starts a module definition.
|
||||||
|
func NewModule(name string) *MapModule {
|
||||||
|
return &MapModule{
|
||||||
|
name: name,
|
||||||
|
routes: make(map[string]string),
|
||||||
|
actions: make(map[string]string),
|
||||||
|
handlers: make(map[string]Handler),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name implements routing.Module.
|
||||||
|
func (m *MapModule) Name() string { return m.name }
|
||||||
|
|
||||||
|
// SetDefault declares the function (and handler) for an absent/trailing PARAM3.
|
||||||
|
func (m *MapModule) SetDefault(fn string, h Handler) *MapModule {
|
||||||
|
m.deflt = fn
|
||||||
|
if h != nil {
|
||||||
|
m.handlers[fn] = h
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRoute registers a whitelisted path-mode function.
|
||||||
|
func (m *MapModule) AddRoute(name, fn string, h Handler) *MapModule {
|
||||||
|
m.routes[name] = fn
|
||||||
|
if h != nil {
|
||||||
|
m.handlers[fn] = h
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAction registers a whitelisted action-mode function.
|
||||||
|
func (m *MapModule) AddAction(name, fn string, h Handler) *MapModule {
|
||||||
|
m.actions[name] = fn
|
||||||
|
if h != nil {
|
||||||
|
m.handlers[fn] = h
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route implements routing.Module.
|
||||||
|
func (m *MapModule) Route(function string, tail routing.Params) (string, routing.Params, bool) {
|
||||||
|
if function == "" {
|
||||||
|
if m.deflt == "" {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
return m.deflt, tail, true
|
||||||
|
}
|
||||||
|
if fn, ok := m.routes[function]; ok {
|
||||||
|
return fn, tail, true
|
||||||
|
}
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action implements routing.Module.
|
||||||
|
func (m *MapModule) Action(action string) (string, bool) {
|
||||||
|
fn, ok := m.actions[action]
|
||||||
|
return fn, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapRegistry is the minimal in-memory Registry.
|
||||||
|
type MapRegistry struct {
|
||||||
|
modules map[string]*MapModule
|
||||||
|
core map[string]Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMapRegistry returns an empty registry.
|
||||||
|
func NewMapRegistry() *MapRegistry {
|
||||||
|
return &MapRegistry{
|
||||||
|
modules: make(map[string]*MapModule),
|
||||||
|
core: make(map[string]Handler),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterModule adds a module under PARAM2.
|
||||||
|
func (r *MapRegistry) RegisterModule(m *MapModule) *MapRegistry {
|
||||||
|
r.modules[m.name] = m
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterCore adds a core-owned workflow handler (home, access, setup, login,
|
||||||
|
// enroll).
|
||||||
|
func (r *MapRegistry) RegisterCore(workflow string, h Handler) *MapRegistry {
|
||||||
|
r.core[workflow] = h
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// module returns the registered MapModule (nil when absent). It is the
|
||||||
|
// package-internal handle a registration helper uses to EXTEND an existing
|
||||||
|
// module — e.g. the SPEC-006 `auth` module gaining SPEC-007's `recover` action.
|
||||||
|
func (r *MapRegistry) module(name string) *MapModule { return r.modules[name] }
|
||||||
|
|
||||||
|
// Module implements routing.Registry. An unknown module is "not found" → 404.
|
||||||
|
func (r *MapRegistry) Module(name string) (routing.Module, bool) {
|
||||||
|
m, ok := r.modules[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the function handler for (module, function).
|
||||||
|
func (r *MapRegistry) Handler(module, function string) (Handler, bool) {
|
||||||
|
m, ok := r.modules[module]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
h, ok := m.handlers[function]
|
||||||
|
return h, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoreHandler returns the core handler for a reserved workflow.
|
||||||
|
func (r *MapRegistry) CoreHandler(workflow string) (Handler, bool) {
|
||||||
|
h, ok := r.core[workflow]
|
||||||
|
return h, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ Registry = (*MapRegistry)(nil)
|
||||||
|
_ routing.Module = (*MapModule)(nil)
|
||||||
|
)
|
||||||
70
internal/core/server/requestcontext.go
Normal file
70
internal/core/server/requestcontext.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestContext is the in-process hand-over from the door to a module function
|
||||||
|
// (SPEC-003 §5, D-003). It carries IDENTIFICATION, never a credential — the
|
||||||
|
// Principal and the session cookie are separate concerns (D-001).
|
||||||
|
//
|
||||||
|
// Field names match the GMS `X-*-*` headers so a future process split is
|
||||||
|
// mechanical (D-003).
|
||||||
|
type RequestContext struct {
|
||||||
|
// Workflow is the PARAM1 classification: a reserved workflow ("home",
|
||||||
|
// "access", …) or the resolved GUID.
|
||||||
|
Workflow string
|
||||||
|
// UserID is the resolved user (0 when anonymous).
|
||||||
|
UserID int64
|
||||||
|
// Principal is "user:<id>" — IDENTIFICATION only, never a credential.
|
||||||
|
Principal string
|
||||||
|
// Function is the resolved module function / action.
|
||||||
|
Function string
|
||||||
|
// Page is the joined path remainder handed to the function.
|
||||||
|
Page string
|
||||||
|
// Correlation is the request's correlation id.
|
||||||
|
Correlation string
|
||||||
|
// ClientIP is the resolved client IP.
|
||||||
|
ClientIP string
|
||||||
|
// Location is IP-derived and AUDIT ONLY (D-031) — no tracking.
|
||||||
|
Location string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRequestContext builds the hand-over from a resolution.
|
||||||
|
func newRequestContext(res routing.Resolution, userID int64, correlation, clientIP string) *RequestContext {
|
||||||
|
principal := "anonymous"
|
||||||
|
if userID != 0 {
|
||||||
|
principal = "user:" + strconv.FormatInt(userID, 10)
|
||||||
|
}
|
||||||
|
return &RequestContext{
|
||||||
|
Workflow: res.Workflow,
|
||||||
|
UserID: userID,
|
||||||
|
Principal: principal,
|
||||||
|
Function: res.Function,
|
||||||
|
Page: strings.Join(res.Params, "/"),
|
||||||
|
Correlation: correlation,
|
||||||
|
ClientIP: clientIP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleGate decides whether an authenticated user's role may call a given
|
||||||
|
// (module, function) (SPEC-003 §4 step 5d).
|
||||||
|
//
|
||||||
|
// SPEC-003 does not fix the policy — that is SPEC-007/SPEC-008. The door
|
||||||
|
// enforces the gate; the default implementation admits any authenticated user
|
||||||
|
// (the tier model ripples down, D-034).
|
||||||
|
type RoleGate interface {
|
||||||
|
Allowed(role users.Role, module, function string) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowAuthenticated is the default RoleGate: every authenticated user may call
|
||||||
|
// every registered function. The one-admin and role-above rules are enforced by
|
||||||
|
// the users service; per-module authorisation arrives with SPEC-007/008.
|
||||||
|
type AllowAuthenticated struct{}
|
||||||
|
|
||||||
|
// Allowed implements RoleGate.
|
||||||
|
func (AllowAuthenticated) Allowed(users.Role, string, string) bool { return true }
|
||||||
50
internal/core/server/roles.go
Normal file
50
internal/core/server/roles.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||||
|
|
||||||
|
// Tiers is the D-034 RoleGate: simple tiers, ONE role per user, and the
|
||||||
|
// assigned role RIPPLES DOWN (admin > director > manager > team) — a director
|
||||||
|
// may do everything a manager and a team member may. There are no tick-boxes
|
||||||
|
// and no ACL.
|
||||||
|
//
|
||||||
|
// A function carries a MINIMUM required role. With no explicit requirement the
|
||||||
|
// floor is team, so every authenticated user is admitted — the door still
|
||||||
|
// enforces session + identity before this gate runs (SPEC-003 §4 step 5d).
|
||||||
|
//
|
||||||
|
// The role-ABOVE rules that need the *other* party (creation authority,
|
||||||
|
// recovery approval) do not live here: they cannot be decided from
|
||||||
|
// (role, module, function) alone, so the users service enforces them
|
||||||
|
// (SPEC-007 AC-1/AC-2).
|
||||||
|
type Tiers struct {
|
||||||
|
required map[string]users.Role
|
||||||
|
floor users.Role
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTiers returns the tiered gate with the default floor (team).
|
||||||
|
func NewTiers() *Tiers {
|
||||||
|
return &Tiers{required: make(map[string]users.Role), floor: users.RoleTeam}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require records the minimum role a (module, function) needs and returns the
|
||||||
|
// receiver for chaining. An unknown (module, function) keeps the floor.
|
||||||
|
func (t *Tiers) Require(module, function string, min users.Role) *Tiers {
|
||||||
|
if t.required == nil {
|
||||||
|
t.required = make(map[string]users.Role)
|
||||||
|
}
|
||||||
|
t.required[gateKey(module, function)] = min
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed implements RoleGate. The ripple-down rule is simply rank >= minimum;
|
||||||
|
// an invalid role (rank 0) never passes — fail closed.
|
||||||
|
func (t *Tiers) Allowed(role users.Role, module, function string) bool {
|
||||||
|
min, ok := t.required[gateKey(module, function)]
|
||||||
|
if !ok {
|
||||||
|
min = t.floor
|
||||||
|
}
|
||||||
|
return role.Rank() >= min.Rank()
|
||||||
|
}
|
||||||
|
|
||||||
|
func gateKey(module, function string) string { return module + "/" + function }
|
||||||
|
|
||||||
|
var _ RoleGate = (*Tiers)(nil)
|
||||||
49
internal/core/server/roles_test.go
Normal file
49
internal/core/server/roles_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTiersRippleDown proves the D-034 role gate: simple tiers, one role per
|
||||||
|
// user, and a role implies every role below it.
|
||||||
|
func TestTiersRippleDown(t *testing.T) {
|
||||||
|
g := NewTiers()
|
||||||
|
|
||||||
|
// No explicit requirement -> the floor (team): every authenticated role is
|
||||||
|
// admitted.
|
||||||
|
for _, r := range []users.Role{users.RoleTeam, users.RoleManager, users.RoleDirector, users.RoleAdmin} {
|
||||||
|
if !g.Allowed(r, "home", "dashboard") {
|
||||||
|
t.Errorf("%s was denied an unlisted function (floor = team)", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An invalid/empty role fails closed.
|
||||||
|
if g.Allowed(users.Role("boss"), "home", "dashboard") {
|
||||||
|
t.Error("an invalid role was admitted by the tier gate")
|
||||||
|
}
|
||||||
|
if g.Allowed(users.Role(""), "home", "dashboard") {
|
||||||
|
t.Error("an empty role was admitted by the tier gate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A function requiring manager: team is denied, manager and above pass
|
||||||
|
// (ripple-down).
|
||||||
|
g.Require("users", "create", users.RoleManager)
|
||||||
|
if g.Allowed(users.RoleTeam, "users", "create") {
|
||||||
|
t.Error("team was admitted to a manager function")
|
||||||
|
}
|
||||||
|
for _, r := range []users.Role{users.RoleManager, users.RoleDirector, users.RoleAdmin} {
|
||||||
|
if !g.Allowed(r, "users", "create") {
|
||||||
|
t.Errorf("%s was denied a manager function (ripple-down)", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A director function: only director and admin pass.
|
||||||
|
g.Require("users", "promote", users.RoleDirector)
|
||||||
|
if g.Allowed(users.RoleManager, "users", "promote") {
|
||||||
|
t.Error("manager was admitted to a director function")
|
||||||
|
}
|
||||||
|
if !g.Allowed(users.RoleAdmin, "users", "promote") {
|
||||||
|
t.Error("admin was denied a director function")
|
||||||
|
}
|
||||||
|
}
|
||||||
591
internal/core/server/server.go
Normal file
591
internal/core/server/server.go
Normal file
|
|
@ -0,0 +1,591 @@
|
||||||
|
// Package server is MyDay's ONE door (SPEC-003 §1/§4): it turns an inbound
|
||||||
|
// request into a dispatched module function, applying identity · session · role
|
||||||
|
// and every cross-cutting concern. The door RESOLVES and GATES; a module never
|
||||||
|
// sees a request it should not (D-002, D-039).
|
||||||
|
//
|
||||||
|
// The pipeline (SPEC-003 §4):
|
||||||
|
//
|
||||||
|
// 1. correlation id -> context + response header
|
||||||
|
// 2. client IP -> from server.trusted_proxies (SET XFF, A6)
|
||||||
|
// 3. security headers
|
||||||
|
// 4. rate limit -> /{GUID}, /access, /setup (A3)
|
||||||
|
// 5. routing.Dispatch -> the pure model (SPEC-002)
|
||||||
|
// 6. the Kind switch -> Redirect · NotFound · Setup · Enroll · Route/Action
|
||||||
|
// Route/Action under a GUID -> the identity/session/role gate:
|
||||||
|
// session valid? (non-empty, not expired/revoked — A2)
|
||||||
|
// session.user_id == the GUID's user?
|
||||||
|
// role permits the function?
|
||||||
|
// attach audit.Meta -> dispatch
|
||||||
|
//
|
||||||
|
// Spec: docs/specs/spec-003-core-door.md.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"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/config"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNonLoopbackSetup is returned when a non-loopback listen address is
|
||||||
|
// requested while in setup mode (D-025) — the process must refuse to start.
|
||||||
|
var ErrNonLoopbackSetup = errors.New("server: refusing to bind a non-loopback address in setup mode")
|
||||||
|
|
||||||
|
// Deps assembles the door's dependencies.
|
||||||
|
type Deps struct {
|
||||||
|
// Config is the loaded configuration.
|
||||||
|
Config *config.Config
|
||||||
|
// Resolver answers the pure model's GUID question (SPEC-002 §3.1).
|
||||||
|
Resolver routing.Resolver
|
||||||
|
// Registry is the module + core-handler dispatch surface.
|
||||||
|
Registry Registry
|
||||||
|
// Sessions validates the session cookie.
|
||||||
|
Sessions auth.Sessions
|
||||||
|
// Identities resolves a GUID to a user (the door's own lookup, distinct
|
||||||
|
// from the pure Resolver surface).
|
||||||
|
Identities auth.Identities
|
||||||
|
// Users loads the authenticated user's role for the role gate.
|
||||||
|
Users users.Users
|
||||||
|
// Audit receives the door's and the services' audit rows.
|
||||||
|
Audit audit.Audit
|
||||||
|
// CSRF verifies action requests (A8).
|
||||||
|
CSRF *httpx.CSRF
|
||||||
|
// PreSession verifies the anonymous enroll/login double-submit token
|
||||||
|
// (A15). When nil the `auth` action module is unavailable (404).
|
||||||
|
PreSession *httpx.PreSessionCSRF
|
||||||
|
// TrustedProxies overrides the set parsed from Config.Server.TrustedProxies.
|
||||||
|
TrustedProxies httpx.TrustedProxies
|
||||||
|
// SetupMode reports whether the instance is in setup mode (D-025). When nil
|
||||||
|
// it defaults to Config.Setup; the entrypoint widens it to "or no user
|
||||||
|
// exists" (SPEC-005 §3).
|
||||||
|
SetupMode func() bool
|
||||||
|
// RoleGate enforces per-function authorisation. Defaults to
|
||||||
|
// AllowAuthenticated.
|
||||||
|
RoleGate RoleGate
|
||||||
|
// Logger receives structured logs. Defaults to slog.Default().
|
||||||
|
Logger *slog.Logger
|
||||||
|
// Now is the clock (tests). Defaults to time.Now.
|
||||||
|
Now func() time.Time
|
||||||
|
// RateLimit / RateWindow tune the auth-sensitive limiter (A3).
|
||||||
|
RateLimit int
|
||||||
|
RateWindow time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the door.
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
disp *routing.Dispatcher
|
||||||
|
reg Registry
|
||||||
|
sessions auth.Sessions
|
||||||
|
ids auth.Identities
|
||||||
|
users users.Users
|
||||||
|
audit audit.Audit
|
||||||
|
csrf *httpx.CSRF
|
||||||
|
precsrf *httpx.PreSessionCSRF
|
||||||
|
trusted httpx.TrustedProxies
|
||||||
|
secure bool
|
||||||
|
setup func() bool
|
||||||
|
roles RoleGate
|
||||||
|
log *slog.Logger
|
||||||
|
now func() time.Time
|
||||||
|
limiter *middleware.RateLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// New validates the dependencies and builds the door.
|
||||||
|
func New(d Deps) (*Server, error) {
|
||||||
|
if d.Config == nil {
|
||||||
|
return nil, errors.New("server: Config is required")
|
||||||
|
}
|
||||||
|
if d.Resolver == nil {
|
||||||
|
return nil, errors.New("server: Resolver is required")
|
||||||
|
}
|
||||||
|
if d.Registry == nil {
|
||||||
|
return nil, errors.New("server: Registry is required")
|
||||||
|
}
|
||||||
|
if d.Sessions == nil {
|
||||||
|
return nil, errors.New("server: Sessions is required")
|
||||||
|
}
|
||||||
|
if d.Identities == nil {
|
||||||
|
return nil, errors.New("server: Identities is required")
|
||||||
|
}
|
||||||
|
if d.Users == nil {
|
||||||
|
return nil, errors.New("server: Users is required")
|
||||||
|
}
|
||||||
|
if d.CSRF == nil {
|
||||||
|
return nil, errors.New("server: CSRF is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
trusted := d.TrustedProxies
|
||||||
|
if trusted == nil {
|
||||||
|
var err error
|
||||||
|
trusted, err = httpx.ParseTrustedProxies(d.Config.Server.TrustedProxies)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setup := d.SetupMode
|
||||||
|
if setup == nil {
|
||||||
|
cfg := d.Config
|
||||||
|
setup = func() bool { return cfg.Setup }
|
||||||
|
}
|
||||||
|
logger := d.Logger
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
clock := d.Now
|
||||||
|
if clock == nil {
|
||||||
|
clock = time.Now
|
||||||
|
}
|
||||||
|
roles := d.RoleGate
|
||||||
|
if roles == nil {
|
||||||
|
roles = AllowAuthenticated{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
cfg: d.Config,
|
||||||
|
disp: routing.New(d.Resolver, d.Registry),
|
||||||
|
reg: d.Registry,
|
||||||
|
sessions: d.Sessions,
|
||||||
|
ids: d.Identities,
|
||||||
|
users: d.Users,
|
||||||
|
audit: d.Audit,
|
||||||
|
csrf: d.CSRF,
|
||||||
|
precsrf: d.PreSession,
|
||||||
|
trusted: trusted,
|
||||||
|
secure: d.Config.IsSecure(),
|
||||||
|
setup: setup,
|
||||||
|
roles: roles,
|
||||||
|
log: logger,
|
||||||
|
now: clock,
|
||||||
|
limiter: middleware.NewRateLimiter(d.RateLimit, d.RateWindow),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler wraps the door with the correlation-id middleware (SPEC-003 §4).
|
||||||
|
func (s *Server) Handler() http.Handler { return middleware.CorrelationID(s) }
|
||||||
|
|
||||||
|
// Serve binds listen and serves the door until it fails. In setup mode a
|
||||||
|
// non-loopback listen is refused outright (D-025, AC-7).
|
||||||
|
func (s *Server) Serve(listen string) error {
|
||||||
|
if err := CheckSetupBind(s.setup(), listen); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.log.Info("myday: listening", "addr", listen, "setup_mode", s.setup())
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: listen,
|
||||||
|
Handler: s.Handler(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
return srv.ListenAndServe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP is the door pipeline (SPEC-003 §4).
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := s.now()
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
cid := middleware.CorrelationFrom(ctx)
|
||||||
|
if cid == "" {
|
||||||
|
cid = middleware.NewCorrelationID()
|
||||||
|
ctx = middleware.WithCorrelation(ctx, cid)
|
||||||
|
}
|
||||||
|
w.Header().Set(middleware.CorrelationHeader, cid)
|
||||||
|
|
||||||
|
// 2. client IP (SET XFF, never append — A6).
|
||||||
|
ip := httpx.ClientIP(r, s.trusted)
|
||||||
|
|
||||||
|
// 3. security headers.
|
||||||
|
httpx.SetSecurityHeaders(w, s.secure)
|
||||||
|
|
||||||
|
// The door attaches audit.Meta before any service call (R-1). The actor is
|
||||||
|
// filled in once the identity gate resolves it.
|
||||||
|
ctx = middleware.AttachAudit(ctx, 0, ip, cid)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
var res routing.Resolution
|
||||||
|
defer func() {
|
||||||
|
s.log.Info("request",
|
||||||
|
"correlation_id", cid,
|
||||||
|
"method", r.Method,
|
||||||
|
"path", httpx.RedactPath(r.URL.Path),
|
||||||
|
"query", httpx.RedactQuery(r.URL.RawQuery),
|
||||||
|
"client_ip", ip,
|
||||||
|
"status", sw.status,
|
||||||
|
"workflow", res.Workflow,
|
||||||
|
"module", res.Module,
|
||||||
|
"function", res.Function,
|
||||||
|
"duration_ms", s.now().Sub(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 4. rate limit the auth-sensitive routes (A3, AC-9).
|
||||||
|
if class := middleware.Class(r.URL.Path); class != "" {
|
||||||
|
key := ip + "|" + class
|
||||||
|
if !s.limiter.Allow(key) {
|
||||||
|
if secs := s.limiter.RetryAfter(key); secs > 0 {
|
||||||
|
sw.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||||
|
}
|
||||||
|
s.writeError(sw, http.StatusTooManyRequests, "too many requests")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. the pure model.
|
||||||
|
res = s.disp.Dispatch(r.URL.Path, r.FormValue("do"), r.FormValue("action"))
|
||||||
|
|
||||||
|
// 6. the Kind switch.
|
||||||
|
s.dispatch(sw, r, res, cid, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch is the Kind switch (SPEC-003 §4 step 5).
|
||||||
|
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
|
||||||
|
// SPEC-005 §3: in setup mode the app serves ONLY /setup (+ static assets);
|
||||||
|
// every other route is 404 (delete, don't redirect). Reserved names like
|
||||||
|
// /home and /access are 404'd too — the bootstrap has no app yet.
|
||||||
|
if s.setup() && !setupAllowed(res) {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch res.Kind {
|
||||||
|
case routing.KindRedirect:
|
||||||
|
// The ONE canonical redirect ("/" -> "/home").
|
||||||
|
http.Redirect(w, r, res.Redirect, http.StatusFound)
|
||||||
|
|
||||||
|
case routing.KindNotFound:
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
|
||||||
|
case routing.KindSetup:
|
||||||
|
// /setup is served ONLY in setup mode; otherwise it 404s (delete,
|
||||||
|
// don't redirect — SPEC-005 §5).
|
||||||
|
if !s.setup() {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.serveCore(w, r, "setup", res, cid, ip)
|
||||||
|
|
||||||
|
case routing.KindEnroll:
|
||||||
|
// GUID found but not enrolled -> the enrollment screen (SPEC-006).
|
||||||
|
s.serveCore(w, r, "enroll", res, cid, ip)
|
||||||
|
|
||||||
|
case routing.KindLogin:
|
||||||
|
s.serveCore(w, r, "login", res, cid, ip)
|
||||||
|
|
||||||
|
case routing.KindRoute, routing.KindAction:
|
||||||
|
// /setup is setup-mode-only through BOTH doors — a path request
|
||||||
|
// classifies as KindSetup, but `do`/`action` under /setup classifies as
|
||||||
|
// KindAction with Workflow "setup". Productive mode 404s either way
|
||||||
|
// (delete, don't redirect — SPEC-005 §5).
|
||||||
|
if res.Workflow == "setup" && !s.setup() {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SPEC-005: the wizard's steps are actions under the reserved `setup`
|
||||||
|
// workflow. They run anonymously (pre-session CSRF, A15).
|
||||||
|
if res.Kind == routing.KindAction && res.Workflow == "setup" {
|
||||||
|
s.serveSetupAction(w, r, res, cid, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SPEC-006: the `auth` module's actions are the enroll/login flows.
|
||||||
|
// They run BEFORE the session gate (the whole point is to obtain a
|
||||||
|
// session); logout is the exception and stays session-gated.
|
||||||
|
if res.Kind == routing.KindAction && res.Module == authModule &&
|
||||||
|
!routing.IsReservedWorkflow(res.Workflow) {
|
||||||
|
s.serveAuthAction(w, r, res, cid, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SPEC-007: the anonymous /access recovery action — the recovery
|
||||||
|
// request form (`do=auth`). It runs before any session (pre-session
|
||||||
|
// CSRF, A15) and is always a generic response (A4). Only the `auth`
|
||||||
|
// module is served here: the session-gated approve/deny actions must
|
||||||
|
// ride a GUID path and are deliberately NOT reachable at /access.
|
||||||
|
if res.Kind == routing.KindAction && res.Workflow == "access" && res.Module == authModule {
|
||||||
|
s.serveAccessAction(w, r, res, cid, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A reserved level-1 workflow (home · access · api · static · …) is
|
||||||
|
// core-owned and needs no identity gate.
|
||||||
|
if routing.IsReservedWorkflow(res.Workflow) {
|
||||||
|
s.serveCore(w, r, res.Workflow, res, cid, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Otherwise it is a GUID branch: the identity/session/role gate.
|
||||||
|
s.serveGUID(w, r, res, cid, ip)
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupAllowed reports whether a Resolution is one of the only routes served
|
||||||
|
// while in setup mode: the wizard itself (`/setup`, path or action) and static
|
||||||
|
// assets (SPEC-005 §3). Everything else — including the canonical redirect and
|
||||||
|
// every GUID branch — is 404.
|
||||||
|
func setupAllowed(res routing.Resolution) bool {
|
||||||
|
switch res.Kind {
|
||||||
|
case routing.KindSetup:
|
||||||
|
return true
|
||||||
|
case routing.KindAction:
|
||||||
|
return res.Workflow == "setup"
|
||||||
|
case routing.KindRoute:
|
||||||
|
return res.Workflow == "static"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveCore hands off to a core-owned workflow handler.
|
||||||
|
func (s *Server) serveCore(w http.ResponseWriter, r *http.Request, workflow string, res routing.Resolution, cid, ip string) {
|
||||||
|
h, ok := s.reg.CoreHandler(workflow)
|
||||||
|
if !ok {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveAuthAction runs the anonymous enroll/login actions (SPEC-006 §4/§5).
|
||||||
|
// It runs BEFORE the identity/session gate — the whole point is to obtain a
|
||||||
|
// session — so it verifies the signed pre-session double-submit token (A15)
|
||||||
|
// instead of the session-bound one. `logout` is session-gated (serveGUID).
|
||||||
|
func (s *Server) serveAuthAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
|
||||||
|
if s.precsrf == nil {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// `logout` revokes an existing session, and the SPEC-006 §8 passkey-nudge
|
||||||
|
// actions (passkey.add / passkey.later / passkey.never) require one — they
|
||||||
|
// are session-gated (SPEC-003 §6 CSRF), NOT anonymous like the enroll/login
|
||||||
|
// actions above.
|
||||||
|
if res.Function == "logout" || strings.HasPrefix(res.Function, "passkey.") {
|
||||||
|
s.serveGUID(w, r, res, cid, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.precsrf.Valid(r, r.FormValue("csrf")) {
|
||||||
|
s.writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h, ok := s.reg.Handler(res.Module, res.Function)
|
||||||
|
if !ok {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveSetupAction runs a SPEC-005 wizard step. The wizard is anonymous — no
|
||||||
|
// session exists before the admin is created — so it verifies the signed
|
||||||
|
// pre-session double-submit token (A15) rather than the session-bound CSRF. The
|
||||||
|
// door has already gated this to setup mode.
|
||||||
|
func (s *Server) serveSetupAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
|
||||||
|
if !s.setup() {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.precsrf == nil {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.precsrf.Valid(r, r.FormValue("csrf")) {
|
||||||
|
s.writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h, ok := s.reg.Handler(res.Module, res.Function)
|
||||||
|
if !ok {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveAccessAction runs a SPEC-007 /access action. The recovery entry is
|
||||||
|
// anonymous — no session exists yet — so it verifies the signed pre-session
|
||||||
|
// double-submit token (A15) rather than the session-bound CSRF (§6). The
|
||||||
|
// response is generic whatever the email matches (AC-4).
|
||||||
|
func (s *Server) serveAccessAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
|
||||||
|
if s.precsrf == nil {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.precsrf.Valid(r, r.FormValue("csrf")) {
|
||||||
|
s.writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h, ok := s.reg.Handler(res.Module, res.Function)
|
||||||
|
if !ok {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveGUID runs the identity/session/role gate, then dispatches to the module
|
||||||
|
// function (SPEC-003 §4 step 5).
|
||||||
|
func (s *Server) serveGUID(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// (a) load the session cookie; an absent or empty id is rejected (A2).
|
||||||
|
cookie, err := r.Cookie(httpx.SessionCookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
s.loginRequired(w, r, res, cid, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate: exists · not expired · not revoked · non-empty (A2/AC-5).
|
||||||
|
sess, err := s.sessions.Validate(ctx, cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
httpx.ClearSessionCookie(w, httpx.SessionCookieName, s.secure)
|
||||||
|
s.loginRequired(w, r, res, cid, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the GUID's user and require an ACTIVE identity.
|
||||||
|
idn, err := s.ids.ByGUID(ctx, res.Workflow)
|
||||||
|
if err != nil || idn.Status != auth.StatusActive {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) the identity match: the session must belong to the GUID's user
|
||||||
|
// (AC-4). The rejection is generic — no oracle about the session's owner.
|
||||||
|
if sess.UserID != idn.UserID {
|
||||||
|
s.loginRequired(w, r, res, cid, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// (d) the role gate.
|
||||||
|
u, err := s.users.Get(ctx, sess.UserID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.roles.Allowed(u.Role, res.Module, res.Function) {
|
||||||
|
s.writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach the actor now that the identity is known (R-1).
|
||||||
|
ctx = middleware.AttachAudit(ctx, sess.UserID, ip, cid)
|
||||||
|
|
||||||
|
// CSRF on every action (A8/AC-6). The routing fields are not a control.
|
||||||
|
if res.Kind == routing.KindAction {
|
||||||
|
if !s.csrf.Valid(sess.ID, r.FormValue("csrf")) {
|
||||||
|
s.writeError(w, http.StatusForbidden, "invalid csrf token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (e) build the RequestContext and dispatch.
|
||||||
|
h, ok := s.reg.Handler(res.Module, res.Function)
|
||||||
|
if !ok {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx = httpx.WithToken(ctx, s.csrf.Token(sess.ID))
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
s.invoke(w, r, h, newRequestContext(res, sess.UserID, cid, ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginRequired hands off to the login screen without revealing why the session
|
||||||
|
// was rejected (missing, empty, invalid, or belonging to another user). The
|
||||||
|
// screen is a RENDERED PAGE and answers 200; only a failed credential attempt is
|
||||||
|
// 401/400 (F-1). The 401 below is a degenerate fallback for a registry that
|
||||||
|
// carries no login screen at all.
|
||||||
|
func (s *Server) loginRequired(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
|
||||||
|
h, ok := s.reg.CoreHandler("login")
|
||||||
|
if !ok {
|
||||||
|
s.writeError(w, http.StatusUnauthorized, "login required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
// invoke calls a handler and maps a returned error to a 500 (logged).
|
||||||
|
func (s *Server) invoke(w http.ResponseWriter, r *http.Request, h Handler, rc *RequestContext) {
|
||||||
|
if err := h(w, r, rc); err != nil {
|
||||||
|
s.log.Error("handler error",
|
||||||
|
"correlation_id", rc.Correlation,
|
||||||
|
"workflow", rc.Workflow,
|
||||||
|
"function", rc.Function,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
s.writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeError writes a terse plain-text error response.
|
||||||
|
func (s *Server) writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(msg + "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSetupBind enforces D-025: while in setup mode the process MUST NOT bind
|
||||||
|
// a non-loopback address. It is fail-closed — an unparseable or wildcard listen
|
||||||
|
// is refused.
|
||||||
|
func CheckSetupBind(setup bool, listen string) error {
|
||||||
|
if !setup {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !IsLoopback(listen) {
|
||||||
|
return fmt.Errorf("%w: listen %q", ErrNonLoopbackSetup, listen)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLoopback reports whether a listen address ("host:port") is loopback-only.
|
||||||
|
func IsLoopback(listen string) bool {
|
||||||
|
host := strings.TrimSpace(listen)
|
||||||
|
if h, _, err := net.SplitHostPort(listen); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
host = strings.Trim(strings.TrimSpace(host), "[]")
|
||||||
|
if host == "" {
|
||||||
|
return false // ":8080" / "0.0.0.0" bind every interface — never loopback
|
||||||
|
}
|
||||||
|
if strings.EqualFold(host, "localhost") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
return ip != nil && ip.IsLoopback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusWriter captures the response status for the access log.
|
||||||
|
type statusWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
wrote bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusWriter) WriteHeader(code int) {
|
||||||
|
if w.wrote {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.status = code
|
||||||
|
w.wrote = true
|
||||||
|
w.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||||
|
if !w.wrote {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.Handler = (*Server)(nil)
|
||||||
631
internal/core/server/server_test.go
Normal file
631
internal/core/server/server_test.go
Normal file
|
|
@ -0,0 +1,631 @@
|
||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"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/auth/flow"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── fakes / wrappers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The counting wrappers embed the real interface and increment a counter before
|
||||||
|
// delegating, so a test can prove "the door never touched this" (AC-2).
|
||||||
|
type countingResolver struct {
|
||||||
|
routing.Resolver
|
||||||
|
calls *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingResolver) GUID(g string) (bool, bool) { *c.calls++; return c.Resolver.GUID(g) }
|
||||||
|
|
||||||
|
type countingSessions struct {
|
||||||
|
auth.Sessions
|
||||||
|
calls *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingSessions) Validate(ctx context.Context, id string) (auth.Session, error) {
|
||||||
|
*c.calls++
|
||||||
|
return c.Sessions.Validate(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingIdentities struct {
|
||||||
|
auth.Identities
|
||||||
|
calls *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingIdentities) ByGUID(ctx context.Context, g string) (auth.Identity, error) {
|
||||||
|
*c.calls++
|
||||||
|
return c.Identities.ByGUID(ctx, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingUsers struct {
|
||||||
|
users.Users
|
||||||
|
calls *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countingUsers) Get(ctx context.Context, id int64) (users.User, error) {
|
||||||
|
*c.calls++
|
||||||
|
return c.Users.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeCapture records what the door handed to a module function.
|
||||||
|
type probeCapture struct {
|
||||||
|
rc *server.RequestContext
|
||||||
|
meta audit.Meta
|
||||||
|
metaOK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── registry ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func testRegistry(cap *probeCapture) *server.MapRegistry {
|
||||||
|
reg := server.NewMapRegistry()
|
||||||
|
|
||||||
|
reg.RegisterCore("home", server.Home)
|
||||||
|
reg.RegisterCore("login", server.LoginPlaceholder)
|
||||||
|
reg.RegisterCore("enroll", server.EnrollPlaceholder)
|
||||||
|
reg.RegisterCore("setup", server.SetupPlaceholder)
|
||||||
|
reg.RegisterCore("access", server.AccessPlaceholder)
|
||||||
|
|
||||||
|
reg.RegisterModule(server.NewModule("home").
|
||||||
|
SetDefault("dashboard", server.Home).
|
||||||
|
AddRoute("home", "home", server.Home))
|
||||||
|
|
||||||
|
reg.RegisterModule(server.NewModule("hr").
|
||||||
|
AddAction("save-data", "save-data", func(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, err := w.Write([]byte("saved"))
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
|
||||||
|
reg.RegisterModule(server.NewModule("probe").
|
||||||
|
AddRoute("show", "show", func(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error {
|
||||||
|
if cap != nil {
|
||||||
|
cap.rc = rc
|
||||||
|
cap.meta, cap.metaOK = audit.MetaFrom(r.Context())
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, err := w.Write([]byte("probe"))
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── harness ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type harness struct {
|
||||||
|
t *testing.T
|
||||||
|
ctx context.Context
|
||||||
|
db *store.DB
|
||||||
|
users *users.Store
|
||||||
|
ids *auth.IdentitiesService
|
||||||
|
sessions *auth.SessionsService
|
||||||
|
csrf *httpx.CSRF
|
||||||
|
pre *httpx.PreSessionCSRF
|
||||||
|
wa *fakeWebAuthn
|
||||||
|
totp *totp.Generator
|
||||||
|
srv *server.Server
|
||||||
|
|
||||||
|
adminID int64
|
||||||
|
guid string
|
||||||
|
session auth.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness {
|
||||||
|
t.Helper()
|
||||||
|
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)
|
||||||
|
|
||||||
|
adminID, err := us.Create(ctx, 0, "admin@example.com", "Admin", users.RoleAdmin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bootstrap admin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x42}, auth.KeySize))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAEADSealer: %v", err)
|
||||||
|
}
|
||||||
|
core := auth.New(db.DB, aud, auth.WithSealer(sealer))
|
||||||
|
ids := core.Identities()
|
||||||
|
sessions := core.Sessions()
|
||||||
|
|
||||||
|
idID, guid, err := ids.Mint(ctx, adminID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mint: %v", err)
|
||||||
|
}
|
||||||
|
if err := ids.Activate(ctx, idID); err != nil {
|
||||||
|
t.Fatalf("Activate: %v", err)
|
||||||
|
}
|
||||||
|
sess, err := sessions.Mint(ctx, idID, "127.0.0.1", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session Mint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
csrfKey = "test-csrf-key-0123456789abcdef"
|
||||||
|
cookieSecret = "test-cookie-secret-0123456789abcdef"
|
||||||
|
)
|
||||||
|
csrf, err := httpx.NewCSRF([]byte(csrfKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCSRF: %v", err)
|
||||||
|
}
|
||||||
|
pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPreSessionCSRF: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Setup: false,
|
||||||
|
Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "http://127.0.0.1:8080"},
|
||||||
|
Database: config.Database{Path: db.Path()},
|
||||||
|
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
|
||||||
|
}
|
||||||
|
|
||||||
|
wa := &fakeWebAuthn{}
|
||||||
|
totpSvc := totp.New("MyDay", ids)
|
||||||
|
|
||||||
|
deps := server.Deps{
|
||||||
|
Config: cfg,
|
||||||
|
Resolver: ids,
|
||||||
|
Sessions: sessions,
|
||||||
|
Identities: ids,
|
||||||
|
Users: us,
|
||||||
|
Audit: aud,
|
||||||
|
CSRF: csrf,
|
||||||
|
PreSession: pre,
|
||||||
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(&deps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the SPEC-006 flow with the (possibly overridden) logger + CSRF, and
|
||||||
|
// register it unless the test supplied its own registry.
|
||||||
|
authFlow, err := flow.New(flow.Deps{
|
||||||
|
Identities: ids,
|
||||||
|
Credentials: ids,
|
||||||
|
Sessions: sessions,
|
||||||
|
Users: us,
|
||||||
|
WebAuthn: wa,
|
||||||
|
TOTP: totpSvc,
|
||||||
|
CSRF: deps.PreSession,
|
||||||
|
Audit: aud,
|
||||||
|
Logger: deps.Logger,
|
||||||
|
// The passkey nudge (SPEC-006 §8) is suppressed in setup mode; the
|
||||||
|
// door's SetupMode is the same predicate (nil = nudge enabled).
|
||||||
|
SuppressNudge: deps.SetupMode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("flow.New: %v", err)
|
||||||
|
}
|
||||||
|
if deps.Registry == nil {
|
||||||
|
reg := testRegistry(nil)
|
||||||
|
server.RegisterAuthFlow(reg, authFlow)
|
||||||
|
deps.Registry = reg
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := server.New(deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &harness{
|
||||||
|
t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions,
|
||||||
|
csrf: csrf, pre: pre, wa: wa, totp: totpSvc, srv: srv,
|
||||||
|
adminID: adminID, guid: guid, session: sess,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// preCSRF mints a signed pre-session token plus its cookie, as the enroll/login
|
||||||
|
// screens would (A15).
|
||||||
|
func (h *harness) preCSRF() (*http.Cookie, string) {
|
||||||
|
h.t.Helper()
|
||||||
|
tok, err := h.pre.Token()
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("pre-session Token: %v", err)
|
||||||
|
}
|
||||||
|
return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *harness) cookie() *http.Cookie {
|
||||||
|
return &http.Cookie{Name: httpx.SessionCookieName, Value: h.session.ID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// request issues a request directly against the door (no network).
|
||||||
|
func request(h http.Handler, method, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, target, nil)
|
||||||
|
for _, c := range cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-1 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAC1_RedirectAndNotFound(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/")
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("GET / = %d, want 302", rec.Code)
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/home" {
|
||||||
|
t.Errorf("Location = %q, want /home", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = request(h.srv, "GET", "/holy-shit")
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET /holy-shit = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-2 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAC2_HomeNoSessionNoDB(t *testing.T) {
|
||||||
|
var resolverCalls, validateCalls, byGUIDCalls, getCalls int
|
||||||
|
h := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.Resolver = countingResolver{Resolver: d.Resolver, calls: &resolverCalls}
|
||||||
|
d.Sessions = countingSessions{Sessions: d.Sessions, calls: &validateCalls}
|
||||||
|
d.Identities = countingIdentities{Identities: d.Identities, calls: &byGUIDCalls}
|
||||||
|
d.Users = countingUsers{Users: d.Users, calls: &getCalls}
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/home")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /home = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "MyDay") {
|
||||||
|
t.Error("GET /home did not render the landing page")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolverCalls != 0 || validateCalls != 0 || byGUIDCalls != 0 || getCalls != 0 {
|
||||||
|
t.Errorf("/home touched storage: resolver=%d sessions=%d identities=%d users=%d (AC-2)",
|
||||||
|
resolverCalls, validateCalls, byGUIDCalls, getCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-3 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAC3_GUIDWithAndWithoutSession(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
// No session -> the login screen (a rendered page: 200, F-1).
|
||||||
|
rec := request(h.srv, "GET", "/"+h.guid)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /{guid} without a session = %d, want 200 (login screen, F-1)", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Sign in") {
|
||||||
|
t.Error("GET /{guid} without a session did not render the login screen (AC-3)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid session -> the module function (the dashboard).
|
||||||
|
rec = request(h.srv, "GET", "/"+h.guid, h.cookie())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /{guid} with a session = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "MyDay") {
|
||||||
|
t.Error("the module function did not render the dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
// /{GUID}/home is the same user landing.
|
||||||
|
rec = request(h.srv, "GET", "/"+h.guid+"/home", h.cookie())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /{guid}/home with a session = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-4 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAC4_SessionUserMismatchRejected(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
// A second user with an active identity.
|
||||||
|
otherID, err := h.users.Create(h.ctx, h.adminID, "b@example.com", "B", users.RoleTeam)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create other user: %v", err)
|
||||||
|
}
|
||||||
|
idOther, guidOther, err := h.ids.Mint(h.ctx, otherID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mint other: %v", err)
|
||||||
|
}
|
||||||
|
if err := h.ids.Activate(h.ctx, idOther); err != nil {
|
||||||
|
t.Fatalf("Activate other: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin's session must NOT open the other user's GUID: the door hands
|
||||||
|
// off to the login screen (200, a rendered page — F-1), never the module.
|
||||||
|
rec := request(h.srv, "GET", "/"+guidOther, h.cookie())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /{other-guid} with a foreign session = %d, want 200 (login screen, AC-4)", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Sign in") {
|
||||||
|
t.Error("a foreign session did not land on the login screen (AC-4)")
|
||||||
|
}
|
||||||
|
// The dashboard marker proves the module function was never reached (the
|
||||||
|
// shell's own chrome carries "MyDay" on every page, login included).
|
||||||
|
if strings.Contains(rec.Body.String(), "Your day, at a glance.") {
|
||||||
|
t.Error("a foreign session reached the module function (AC-4)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-5 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAC5_EmptyOrMissingSessionRejected(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
empty := &http.Cookie{Name: httpx.SessionCookieName, Value: ""}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
target string
|
||||||
|
cookie *http.Cookie
|
||||||
|
}{
|
||||||
|
{"missing id on /{guid}", "/" + h.guid, nil},
|
||||||
|
{"empty id on /{guid}", "/" + h.guid, empty},
|
||||||
|
{"missing id on /{guid}/home", "/" + h.guid + "/home", nil},
|
||||||
|
{"empty id on /{guid}/home", "/" + h.guid + "/home", empty},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var rec *httptest.ResponseRecorder
|
||||||
|
if tc.cookie == nil {
|
||||||
|
rec = request(h.srv, "GET", tc.target)
|
||||||
|
} else {
|
||||||
|
rec = request(h.srv, "GET", tc.target, tc.cookie)
|
||||||
|
}
|
||||||
|
// The door rejects the empty/missing session and renders the login
|
||||||
|
// screen (200, F-1) — it must never reach the module.
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("%s = %d, want 200 (login screen, A2/F-1)", tc.target, rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Sign in") {
|
||||||
|
t.Errorf("%s did not render the login screen (A2)", tc.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-6 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAC6_ActionRequiresCSRF(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
base := "/" + h.guid + "?do=hr&action=save-data"
|
||||||
|
|
||||||
|
// No CSRF token -> rejected.
|
||||||
|
rec := request(h.srv, "POST", base, h.cookie())
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("action without a CSRF token = %d, want 403 (AC-6)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wrong token -> rejected.
|
||||||
|
rec = request(h.srv, "POST", base+"&csrf=wrong", h.cookie())
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("action with a bad CSRF token = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The correct, session-bound token -> dispatched.
|
||||||
|
token := url.QueryEscape(h.csrf.Token(h.session.ID))
|
||||||
|
rec = request(h.srv, "POST", base+"&csrf="+token, h.cookie())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("action with a valid CSRF token = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "saved") {
|
||||||
|
t.Error("the action handler did not run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-7 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAC7_LoopbackEnforcement(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
setup bool
|
||||||
|
listen string
|
||||||
|
want bool // want an error?
|
||||||
|
}{
|
||||||
|
{true, "0.0.0.0:8080", true},
|
||||||
|
{true, ":8080", true},
|
||||||
|
{true, "192.168.1.5:8080", true},
|
||||||
|
{true, "127.0.0.1:8080", false},
|
||||||
|
{true, "localhost:8080", false},
|
||||||
|
{true, "[::1]:8080", false},
|
||||||
|
{false, "0.0.0.0:8080", false}, // productive: a public bind is allowed
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
err := server.CheckSetupBind(tc.setup, tc.listen)
|
||||||
|
if tc.want && !errors.Is(err, server.ErrNonLoopbackSetup) {
|
||||||
|
t.Errorf("CheckSetupBind(%v, %q) = %v, want ErrNonLoopbackSetup", tc.setup, tc.listen, err)
|
||||||
|
}
|
||||||
|
if !tc.want && err != nil {
|
||||||
|
t.Errorf("CheckSetupBind(%v, %q) = %v, want nil", tc.setup, tc.listen, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full Serve path refuses to bind too (no listener is opened).
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
|
||||||
|
if err := h.srv.Serve("0.0.0.0:0"); !errors.Is(err, server.ErrNonLoopbackSetup) {
|
||||||
|
t.Errorf("Serve with a public listen in setup mode = %v, want ErrNonLoopbackSetup (AC-7)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-8 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAC8_CorrelationAndRedaction(t *testing.T) {
|
||||||
|
var logs bytes.Buffer
|
||||||
|
h := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.Logger = slog.New(slog.NewTextHandler(&logs, nil))
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/home")
|
||||||
|
cid := rec.Header().Get(middleware.CorrelationHeader)
|
||||||
|
if cid == "" {
|
||||||
|
t.Fatal("the response carries no correlation id (AC-8)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs.String(), cid) {
|
||||||
|
t.Errorf("the log does not echo the correlation id %q (AC-8)", cid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A credential riding the path is redacted in the log.
|
||||||
|
logs.Reset()
|
||||||
|
request(h.srv, "GET", "/setup/token/SUPERSECRETTOKEN")
|
||||||
|
out := logs.String()
|
||||||
|
if strings.Contains(out, "SUPERSECRETTOKEN") {
|
||||||
|
t.Error("the access log leaked a path credential (A8/AC-8)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, httpx.Redacted) {
|
||||||
|
t.Errorf("the access log did not mark the redaction (%q)", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-9 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAC9_RateLimiterOnAuthRoutes(t *testing.T) {
|
||||||
|
h := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.RateLimit = 2
|
||||||
|
d.RateWindow = time.Minute
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if rec := request(h.srv, "GET", "/access"); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("request %d = %d, want 200", i+1, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec := request(h.srv, "GET", "/access")
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("third request = %d, want 429 (AC-9)", rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Retry-After") == "" {
|
||||||
|
t.Error("429 is missing a Retry-After header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── supporting behaviour ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestRequestContextHandover proves §5: the module function receives the
|
||||||
|
// resolved identification, and the door attached audit.Meta (R-1).
|
||||||
|
func TestRequestContextHandover(t *testing.T) {
|
||||||
|
var cap probeCapture
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.Registry = testRegistry(&cap) })
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/"+h.guid+"/probe/show", h.cookie())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("probe/show = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if cap.rc == nil {
|
||||||
|
t.Fatal("the module function was never invoked")
|
||||||
|
}
|
||||||
|
if cap.rc.UserID != h.adminID {
|
||||||
|
t.Errorf("rc.UserID = %d, want %d", cap.rc.UserID, h.adminID)
|
||||||
|
}
|
||||||
|
if want := "user:" + strconv.FormatInt(h.adminID, 10); cap.rc.Principal != want {
|
||||||
|
t.Errorf("rc.Principal = %q, want %q", cap.rc.Principal, want)
|
||||||
|
}
|
||||||
|
if cap.rc.Workflow != h.guid {
|
||||||
|
t.Errorf("rc.Workflow = %q, want the GUID %q", cap.rc.Workflow, h.guid)
|
||||||
|
}
|
||||||
|
if cap.rc.Function != "show" {
|
||||||
|
t.Errorf("rc.Function = %q, want show", cap.rc.Function)
|
||||||
|
}
|
||||||
|
if cap.rc.Correlation == "" || cap.rc.ClientIP == "" {
|
||||||
|
t.Errorf("rc correlation/client IP not set: %+v", cap.rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cap.metaOK {
|
||||||
|
t.Fatal("audit.Meta was not attached to the context (R-1)")
|
||||||
|
}
|
||||||
|
if cap.meta.ActorUserID != h.adminID {
|
||||||
|
t.Errorf("meta.ActorUserID = %d, want %d", cap.meta.ActorUserID, h.adminID)
|
||||||
|
}
|
||||||
|
if cap.meta.CorrelationID != cap.rc.Correlation {
|
||||||
|
t.Errorf("meta.CorrelationID = %q, want %q", cap.meta.CorrelationID, cap.rc.Correlation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetupModeRouting: /setup is served only in setup mode (SPEC-003 §4).
|
||||||
|
func TestSetupModeRouting(t *testing.T) {
|
||||||
|
productive := newHarness(t)
|
||||||
|
if rec := request(productive.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET /setup in productive mode = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
// The action door must also 404 /setup in productive mode.
|
||||||
|
if rec := request(productive.srv, "POST", "/setup?do=setup&action=begin"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("POST /setup?do=setup in productive mode = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
inSetup := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
|
||||||
|
if rec := request(inSetup.srv, "GET", "/setup"); rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /setup in setup mode = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnrollRouting: a pending identity routes to the enrollment screen.
|
||||||
|
func TestEnrollRouting(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
pendingUser, err := h.users.Create(h.ctx, h.adminID, "pending@example.com", "P", users.RoleTeam)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create pending user: %v", err)
|
||||||
|
}
|
||||||
|
_, guidPending, err := h.ids.Mint(h.ctx, pendingUser) // pending_enrollment
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mint pending: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/"+guidPending)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /{pending-guid} = %d, want 200 (enroll)", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Enrollment") {
|
||||||
|
t.Error("a pending GUID did not route to the enrollment screen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrustedProxyClientIPAtTheDoor: only a trusted peer's XFF is honoured.
|
||||||
|
func TestTrustedProxyClientIPAtTheDoor(t *testing.T) {
|
||||||
|
h := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.Config.Server.TrustedProxies = []string{"127.0.0.1"}
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/home", nil)
|
||||||
|
req.RemoteAddr = "127.0.0.1:9999"
|
||||||
|
req.Header.Set("X-Forwarded-For", "203.0.113.77")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Forwarded-For"); got != "203.0.113.77" {
|
||||||
|
t.Errorf("X-Forwarded-For = %q, want the client value SET (never appended)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
592
internal/core/server/setup_wizard_test.go
Normal file
592
internal/core/server/setup_wizard_test.go
Normal file
|
|
@ -0,0 +1,592 @@
|
||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pqotp "github.com/pquerna/otp/totp"
|
||||||
|
|
||||||
|
"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/auth/flow"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── harness: a FRESH instance (no user) wired to the real SPEC-005 wizard ─────
|
||||||
|
|
||||||
|
type setupHarness struct {
|
||||||
|
t *testing.T
|
||||||
|
ctx context.Context
|
||||||
|
db *store.DB
|
||||||
|
users *users.Store
|
||||||
|
ids *auth.IdentitiesService
|
||||||
|
sessions *auth.SessionsService
|
||||||
|
csrf *httpx.CSRF
|
||||||
|
pre *httpx.PreSessionCSRF
|
||||||
|
wa *fakeWebAuthn
|
||||||
|
totpSvc *totp.Generator
|
||||||
|
|
||||||
|
cfg *config.Config
|
||||||
|
cfgPath string
|
||||||
|
prints *bytes.Buffer
|
||||||
|
// writeFile overrides the persister (a failing writer exercises the
|
||||||
|
// "print the values" fallback, SPEC-005 §4).
|
||||||
|
writeFile func(string, []byte) error
|
||||||
|
|
||||||
|
setupFlow *setup.Actions
|
||||||
|
srv *server.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSetupHarness(t *testing.T, opts ...func(*setupHarness)) *setupHarness {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
db, err := store.Open(filepath.Join(dir, "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) // NO user created — a fresh instance.
|
||||||
|
|
||||||
|
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x24}, auth.KeySize))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAEADSealer: %v", err)
|
||||||
|
}
|
||||||
|
core := auth.New(db.DB, aud, auth.WithSealer(sealer))
|
||||||
|
ids := core.Identities()
|
||||||
|
sessions := core.Sessions()
|
||||||
|
|
||||||
|
const (
|
||||||
|
csrfKey = "setup-csrf-key-0123456789abcdef"
|
||||||
|
cookieSecret = "setup-cookie-secret-0123456789abcdef"
|
||||||
|
)
|
||||||
|
csrf, err := httpx.NewCSRF([]byte(csrfKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCSRF: %v", err)
|
||||||
|
}
|
||||||
|
pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPreSessionCSRF: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Setup: true,
|
||||||
|
Server: config.Server{Listen: "127.0.0.1:8080"},
|
||||||
|
Database: config.Database{Path: db.Path()},
|
||||||
|
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &setupHarness{
|
||||||
|
t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions,
|
||||||
|
csrf: csrf, pre: pre, wa: &fakeWebAuthn{}, totpSvc: totp.New("MyDay", ids),
|
||||||
|
cfg: cfg, cfgPath: filepath.Join(dir, config.FileName), prints: &bytes.Buffer{},
|
||||||
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup mode (SPEC-005 §3), latched: config.setup OR not yet bootstrapped.
|
||||||
|
// Latched at startup so the wizard can finish in-process; cleared by Done().
|
||||||
|
bootstrapped := func() bool {
|
||||||
|
n, err := us.Count(context.Background())
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
idn, err := ids.ByUserID(context.Background(), 1)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return idn.Status == auth.StatusActive
|
||||||
|
}
|
||||||
|
inSetup := cfg.Setup || !bootstrapped()
|
||||||
|
setupMode := func() bool {
|
||||||
|
if h.setupFlow != nil && h.setupFlow.Done() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return inSetup
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SPEC-006 flow, reused by the wizard. TOTPOnlyEnroll is the wizard's
|
||||||
|
// D-041 mode (setup enrolls TOTP only); SuccessPath keeps the operator on
|
||||||
|
// the wizard until it is finished.
|
||||||
|
authFlow, err := flow.New(flow.Deps{
|
||||||
|
Identities: ids, Credentials: ids, Sessions: sessions, Users: us,
|
||||||
|
WebAuthn: h.wa, TOTP: h.totpSvc, CSRF: pre, Audit: aud,
|
||||||
|
TOTPOnlyEnroll: true,
|
||||||
|
SuppressNudge: setupMode,
|
||||||
|
SuccessPath: func(string) string {
|
||||||
|
if setupMode() {
|
||||||
|
return "/setup"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("flow.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFlow, err := setup.New(setup.Deps{
|
||||||
|
Config: cfg, ConfigPath: h.cfgPath, Users: us, Identities: ids,
|
||||||
|
Enroll: authFlow, Audit: aud, CSRF: pre,
|
||||||
|
Printout: h.prints, WriteFile: h.writeFile,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup.New: %v", err)
|
||||||
|
}
|
||||||
|
h.setupFlow = setupFlow
|
||||||
|
|
||||||
|
reg := server.NewMapRegistry()
|
||||||
|
reg.RegisterCore("home", server.Home)
|
||||||
|
reg.RegisterCore("access", server.AccessPlaceholder)
|
||||||
|
reg.RegisterModule(server.NewModule("home").
|
||||||
|
SetDefault("dashboard", server.Home).
|
||||||
|
AddRoute("home", "home", server.Home))
|
||||||
|
server.RegisterSetupFlow(reg, setupFlow)
|
||||||
|
|
||||||
|
srv, err := server.New(server.Deps{
|
||||||
|
Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions,
|
||||||
|
Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre,
|
||||||
|
SetupMode: setupMode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server.New: %v", err)
|
||||||
|
}
|
||||||
|
h.srv = srv
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// preCSRF mints a signed pre-session token plus its cookie (A15).
|
||||||
|
func (h *setupHarness) preCSRF() (*http.Cookie, string) {
|
||||||
|
h.t.Helper()
|
||||||
|
tok, err := h.pre.Token()
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("pre-session Token: %v", err)
|
||||||
|
}
|
||||||
|
return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok
|
||||||
|
}
|
||||||
|
|
||||||
|
// post issues a wizard step with a fresh pre-session CSRF token.
|
||||||
|
func (h *setupHarness) post(action string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
h.t.Helper()
|
||||||
|
cookie, token := h.preCSRF()
|
||||||
|
if form == nil {
|
||||||
|
form = url.Values{}
|
||||||
|
}
|
||||||
|
form.Set("csrf", token)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/setup?do=setup&action="+url.QueryEscape(action),
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
for _, c := range cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.srv.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// beginAdmin runs steps 1 and 2a: the domain, then the admin's details.
|
||||||
|
func (h *setupHarness) beginAdmin(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if rec := h.post("domain.save", url.Values{"base_url": {"http://127.0.0.1:8080"}}); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("domain.save = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := h.post("admin.create", url.Values{
|
||||||
|
"display_name": {"Claus"}, "email": {"admin@example.com"},
|
||||||
|
}); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("admin.create = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrollAdmin runs step 2b — the TOTP-only ceremony (D-041) — and returns the
|
||||||
|
// session id the flow minted on completion (SPEC-006 reused).
|
||||||
|
func (h *setupHarness) enrollAdmin(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if rec := h.post("admin.totp.begin", nil); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin.totp.begin = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := h.ids.TOTPSecret(h.ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TOTPSecret: %v", err)
|
||||||
|
}
|
||||||
|
code, err := pqotp.GenerateCode(string(secret), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateCode: %v", err)
|
||||||
|
}
|
||||||
|
done := h.post("admin.totp.verify", url.Values{"code": {code}})
|
||||||
|
if done.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("admin.totp.verify = %d, want 303 (enrolled)", done.Code)
|
||||||
|
}
|
||||||
|
// The wizard reuses the flow's SuccessPath so the admin stays on /setup.
|
||||||
|
if loc := done.Header().Get("Location"); loc != "/setup" {
|
||||||
|
t.Errorf("post-enroll Location = %q, want /setup", loc)
|
||||||
|
}
|
||||||
|
return sessionCookie(t, done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-1 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec005AC1_SetupModeServesOnlySetup(t *testing.T) {
|
||||||
|
h := newSetupHarness(t)
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/setup")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /setup on a fresh instance = %d, want 200 (AC-1)", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "MyDay setup") {
|
||||||
|
t.Error("GET /setup did not render the wizard (AC-1)")
|
||||||
|
}
|
||||||
|
// The wizard issues the pre-session CSRF cookie (A15).
|
||||||
|
if !hasCookie(rec, httpx.PreSessionCookieName) {
|
||||||
|
t.Error("the wizard issued no pre-session CSRF cookie (A15)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EVERY other route is 404 — including the app the productive instance
|
||||||
|
// would serve (/home, /access, the canonical redirect, GUID branches).
|
||||||
|
for _, p := range []string{"/home", "/access", "/", "/holy-shit", "/some-guid", "/home/extra"} {
|
||||||
|
if rec := request(h.srv, "GET", p); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET %s in setup mode = %d, want 404 (AC-1)", p, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── R-6 · generated secrets ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestR6_WizardGeneratesStrongSecrets: a first boot from config.example.yaml
|
||||||
|
// (all secrets are placeholders) must persist a VIABLE productive config — the
|
||||||
|
// wizard generates strong replacements (R-6), and the result reloads through
|
||||||
|
// the strict loader.
|
||||||
|
func TestR6_WizardGeneratesStrongSecrets(t *testing.T) {
|
||||||
|
h := newSetupHarness(t, func(h *setupHarness) {
|
||||||
|
h.cfg.Secrets = config.Secrets{
|
||||||
|
SessionKey: "CHANGE_ME_32_BYTES_RANDOM",
|
||||||
|
CookieSecret: "CHANGE_ME_32_BYTES_RANDOM",
|
||||||
|
CSRFKey: "CHANGE_ME_32_BYTES_RANDOM",
|
||||||
|
TOTPEncryptionKey: "CHANGE_ME_32_BYTES_RANDOM",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
h.beginAdmin(t)
|
||||||
|
h.enrollAdmin(t)
|
||||||
|
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("finish = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(h.cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config was not persisted: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "CHANGE_ME") {
|
||||||
|
t.Error("the persisted config still holds a placeholder secret (R-6)")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(h.cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the generated config does not load (R-6): %v", err)
|
||||||
|
}
|
||||||
|
for name, v := range map[string]string{
|
||||||
|
"session_key": cfg.Secrets.SessionKey,
|
||||||
|
"cookie_secret": cfg.Secrets.CookieSecret,
|
||||||
|
"csrf_key": cfg.Secrets.CSRFKey,
|
||||||
|
"totp_encryption_key": cfg.Secrets.TOTPEncryptionKey,
|
||||||
|
} {
|
||||||
|
if config.SecretIsPlaceholder(v) {
|
||||||
|
t.Errorf("%s is still a placeholder after setup (R-6)", name)
|
||||||
|
}
|
||||||
|
if len(v) < 32 {
|
||||||
|
t.Errorf("%s is only %d bytes, want >= 32 (R-6)", name, len(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The TOTP key feeds AES-256-GCM: exactly 32 bytes.
|
||||||
|
if n := len(cfg.Secrets.TOTPEncryptionKey); n != 32 {
|
||||||
|
t.Errorf("totp_encryption_key = %d bytes, want exactly 32 (R-6)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-2 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec005AC2_NonLoopbackRefusedInSetupMode(t *testing.T) {
|
||||||
|
h := newSetupHarness(t)
|
||||||
|
|
||||||
|
// The process refuses to START on a non-loopback listen (D-025).
|
||||||
|
if err := h.srv.Serve("0.0.0.0:8080"); !errors.Is(err, server.ErrNonLoopbackSetup) {
|
||||||
|
t.Errorf("Serve(0.0.0.0:8080) in setup mode = %v, want ErrNonLoopbackSetup (AC-2)", err)
|
||||||
|
}
|
||||||
|
if err := h.srv.Serve("192.168.1.5:8080"); !errors.Is(err, server.ErrNonLoopbackSetup) {
|
||||||
|
t.Errorf("Serve(192.168.1.5:8080) in setup mode = %v, want ErrNonLoopbackSetup (AC-2)", err)
|
||||||
|
}
|
||||||
|
// Loopback is allowed (no error, and no listener is opened for :0).
|
||||||
|
if err := server.CheckSetupBind(true, "127.0.0.1:8080"); err != nil {
|
||||||
|
t.Errorf("CheckSetupBind(loopback) = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-3 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec005AC3_CompletingTheWizardCreatesTheTopAdmin(t *testing.T) {
|
||||||
|
h := newSetupHarness(t)
|
||||||
|
h.beginAdmin(t)
|
||||||
|
|
||||||
|
// Step 2b renders the TOTP-only enrollment ceremony (D-041) — the passkey
|
||||||
|
// is deliberately NOT offered here.
|
||||||
|
page := request(h.srv, "GET", "/setup")
|
||||||
|
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "admin.totp.verify") {
|
||||||
|
t.Errorf("the enrollment step did not render the TOTP form (AC-3): %d", page.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(page.Body.String(), "admin.enroll.begin") {
|
||||||
|
t.Error("the wizard still offers passkey enrollment (D-041, AC-3)")
|
||||||
|
}
|
||||||
|
h.enrollAdmin(t)
|
||||||
|
|
||||||
|
u, err := h.users.Get(h.ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("user 1 was not created: %v", err)
|
||||||
|
}
|
||||||
|
if u.Role != users.RoleAdmin {
|
||||||
|
t.Errorf("user 1 role = %q, want admin (AC-3)", u.Role)
|
||||||
|
}
|
||||||
|
if u.Manager.Set {
|
||||||
|
t.Error("the top admin must be the root of the chain (no manager, AC-3)")
|
||||||
|
}
|
||||||
|
|
||||||
|
idn, err := h.ids.ByUserID(h.ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ByUserID(1): %v", err)
|
||||||
|
}
|
||||||
|
if idn.Status != auth.StatusActive {
|
||||||
|
t.Errorf("the admin's identity = %s, want active (AC-3)", idn.Status)
|
||||||
|
}
|
||||||
|
// D-041/AC-3: TOTP is enrolled, and NO passkey is — the admin adds one
|
||||||
|
// later at first sign-in on the real origin, via the nudge (SPEC-006 §8).
|
||||||
|
if hasPasskey, err := h.ids.HasWebAuthnCredential(h.ctx, idn.ID); err != nil || hasPasskey {
|
||||||
|
t.Errorf("the wizard enrolled a passkey (D-041, AC-3): %v", err)
|
||||||
|
}
|
||||||
|
secret, err := h.ids.TOTPSecret(h.ctx, idn.ID)
|
||||||
|
if err != nil || len(secret) == 0 {
|
||||||
|
t.Errorf("the admin has no TOTP secret (AC-3): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-4 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec005AC4_CompletionPersistsAndSetupDisappears(t *testing.T) {
|
||||||
|
h := newSetupHarness(t)
|
||||||
|
h.beginAdmin(t)
|
||||||
|
h.enrollAdmin(t)
|
||||||
|
|
||||||
|
rec := h.post("finish", url.Values{
|
||||||
|
"mail.host": {"smtp.example.com"}, "mail.port": {"587"},
|
||||||
|
"mail.username": {"myday"}, "mail.from": {"myday@example.com"},
|
||||||
|
"app.locale": {"en-GB"}, "app.timezone": {"Europe/London"},
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("finish = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.setupFlow.Done() {
|
||||||
|
t.Error("the wizard did not signal completion (AC-4)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The settings were persisted to config.yaml (D-035).
|
||||||
|
data, err := os.ReadFile(h.cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config was not persisted to %s: %v", h.cfgPath, err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
for _, want := range []string{
|
||||||
|
"setup: false",
|
||||||
|
"base_url: http://127.0.0.1:8080",
|
||||||
|
"locale: en-GB",
|
||||||
|
"timezone: Europe/London",
|
||||||
|
"smtp.example.com",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("persisted config is missing %q (AC-4)", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /setup now 404s — through BOTH doors (delete, don't redirect).
|
||||||
|
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET /setup after completion = %d, want 404 (AC-4)", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := h.post("finish", nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("POST /setup action after completion = %d, want 404 (AC-4)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec005AC4_UnwritableConfigPrintsTheValues: when config.yaml is not
|
||||||
|
// writable the wizard prints the settings for the operator and still completes.
|
||||||
|
func TestSpec005AC4_UnwritableConfigPrintsTheValues(t *testing.T) {
|
||||||
|
h := newSetupHarness(t, func(h *setupHarness) {
|
||||||
|
h.writeFile = func(string, []byte) error { return errors.New("read-only file system") }
|
||||||
|
})
|
||||||
|
h.beginAdmin(t)
|
||||||
|
h.enrollAdmin(t)
|
||||||
|
|
||||||
|
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("finish = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if !h.setupFlow.Done() {
|
||||||
|
t.Error("setup was not marked complete despite the printed fallback (AC-4)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(h.prints.String(), "setup: false") {
|
||||||
|
t.Error("the values were not printed for the operator (AC-4)")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(h.cfgPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Error("a config file was written even though the writer failed (AC-4)")
|
||||||
|
}
|
||||||
|
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET /setup after the printed completion = %d, want 404 (AC-4)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-5 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec005AC5_OnlyOneAdmin(t *testing.T) {
|
||||||
|
h := newSetupHarness(t)
|
||||||
|
h.beginAdmin(t) // creates user 1
|
||||||
|
|
||||||
|
rec := h.post("admin.create", url.Values{
|
||||||
|
"display_name": {"Second"}, "email": {"second@example.com"},
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Errorf("a second admin.create = %d, want 409 (AC-5)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service itself refuses a second admin, whoever asks (SPEC-004 AC-6).
|
||||||
|
if _, err := h.users.Create(h.ctx, 1, "third@example.com", "Third", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
|
||||||
|
t.Errorf("Create(role=admin) with user 1 present = %v, want ErrOneAdmin (AC-5)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := h.users.Count(h.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Count: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("user count = %d, want exactly 1 (AC-5)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-6 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec005AC6_TheBootstrapIsAudited(t *testing.T) {
|
||||||
|
h := newSetupHarness(t)
|
||||||
|
h.beginAdmin(t)
|
||||||
|
h.enrollAdmin(t)
|
||||||
|
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("finish = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.db.Query(`SELECT action FROM audit_log`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read audit_log: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
got := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var a string
|
||||||
|
if err := rows.Scan(&a); err != nil {
|
||||||
|
t.Fatalf("scan audit action: %v", err)
|
||||||
|
}
|
||||||
|
got[a] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("iterate audit_log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole bootstrap: the admin's creation, its identity, the TOTP
|
||||||
|
// enrollment (D-041: no passkey), and the completion of setup (AC-6).
|
||||||
|
for _, want := range []string{
|
||||||
|
"user.create", "identity.mint",
|
||||||
|
"enroll.totp.begin", "enroll.complete",
|
||||||
|
"setup.admin", "setup.complete",
|
||||||
|
} {
|
||||||
|
if !got[want] {
|
||||||
|
t.Errorf("audit log is missing %q (AC-6); got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec005AdminCreateRepairsMissingIdentity: a crash between Create and Mint
|
||||||
|
// must not wedge the wizard — the next admin.create mints the missing identity.
|
||||||
|
func TestSpec005AdminCreateRepairsMissingIdentity(t *testing.T) {
|
||||||
|
h := newSetupHarness(t)
|
||||||
|
|
||||||
|
// The user exists (user 1) but has NO identity.
|
||||||
|
if _, err := h.users.Create(h.ctx, 0, "admin@example.com", "Claus", users.RoleAdmin); err != nil {
|
||||||
|
t.Fatalf("create admin: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := h.ids.ByUserID(h.ctx, 1); !errors.Is(err, auth.ErrIdentityNotFound) {
|
||||||
|
t.Fatalf("precondition: identity exists already (%v)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := h.post("admin.create", url.Values{"display_name": {"Claus"}, "email": {"admin@example.com"}})
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("admin.create (repair) = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
idn, err := h.ids.ByUserID(h.ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the admin identity was not repaired: %v", err)
|
||||||
|
}
|
||||||
|
if idn.Status != auth.StatusPendingEnrollment {
|
||||||
|
t.Errorf("repaired identity = %s, want pending_enrollment", idn.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec005FailSafeSetupMode: with `config.setup: false` and an empty
|
||||||
|
// database the instance is STILL in setup mode (fail-safe) — and it stays there
|
||||||
|
// until the wizard finishes, even though creating the admin makes a live
|
||||||
|
// "no user exists" check false.
|
||||||
|
func TestSpec005FailSafeSetupMode(t *testing.T) {
|
||||||
|
h := newSetupHarness(t, func(h *setupHarness) { h.cfg.Setup = false })
|
||||||
|
|
||||||
|
// Fresh: setup mode via the fail-safe clause.
|
||||||
|
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /setup (fail-safe) = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("GET /home (fail-safe) = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the admin (now a user exists) and enroll — setup mode must HOLD.
|
||||||
|
h.beginAdmin(t)
|
||||||
|
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /setup after admin.create = %d, want 200 (setup must hold mid-wizard)", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("GET /home after admin.create = %d, want 404 (setup must hold mid-wizard)", rec.Code)
|
||||||
|
}
|
||||||
|
h.enrollAdmin(t)
|
||||||
|
|
||||||
|
// Finish ends setup mode.
|
||||||
|
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("finish = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET /setup after finish = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
internal/core/server/setupflow.go
Normal file
38
internal/core/server/setupflow.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupModule is the `do` module name for the SPEC-005 wizard steps. The
|
||||||
|
// wizard is a reserved level-1 workflow (`/setup`) whose steps are actions.
|
||||||
|
const setupModule = "setup"
|
||||||
|
|
||||||
|
// RegisterSetupFlow registers the SPEC-005 `/setup` wizard: the page (the core
|
||||||
|
// `setup` workflow) and the action module (domain.save · admin.create ·
|
||||||
|
// admin.enroll.* · admin.totp.* · finish). The door gates them to setup mode
|
||||||
|
// and verifies the pre-session CSRF token (A15) before dispatch.
|
||||||
|
func RegisterSetupFlow(reg *MapRegistry, a *setup.Actions) {
|
||||||
|
reg.RegisterCore("setup", adaptSetup(a.Page))
|
||||||
|
|
||||||
|
reg.RegisterModule(NewModule(setupModule).
|
||||||
|
AddAction("domain.save", "domain.save", adaptSetup(a.DomainSave)).
|
||||||
|
AddAction("admin.create", "admin.create", adaptSetup(a.AdminCreate)).
|
||||||
|
// Step 2 is TOTP only (D-041) — there is no admin.enroll.* action.
|
||||||
|
AddAction("admin.totp.begin", "admin.totp.begin", adaptSetup(a.TOTPBegin)).
|
||||||
|
AddAction("admin.totp.verify", "admin.totp.verify", adaptSetup(a.TOTPVerify)).
|
||||||
|
AddAction("finish", "finish", adaptSetup(a.Finish)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupFunc is a wizard handler. The door resolves the workflow; the wizard
|
||||||
|
// never parses a raw path.
|
||||||
|
type setupFunc func(w http.ResponseWriter, r *http.Request) error
|
||||||
|
|
||||||
|
// adaptSetup adapts a wizard handler to the door's Handler signature.
|
||||||
|
func adaptSetup(f setupFunc) Handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, _ *RequestContext) error {
|
||||||
|
return f(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
263
internal/core/server/spec006_passkey_nudge_test.go
Normal file
263
internal/core/server/spec006_passkey_nudge_test.go
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pqotp "github.com/pquerna/otp/totp"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── SPEC-006 §8 · the passkey nudge (D-041) · AC-8…AC-11 ─────────────────────
|
||||||
|
|
||||||
|
// issueTOTP issues a TOTP secret for an identity and returns a valid code.
|
||||||
|
func (h *harness) issueTOTP(t *testing.T, identityID int64) string {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := h.totp.Begin(h.ctx, identityID, h.guid); err != nil {
|
||||||
|
t.Fatalf("totp.Begin: %v", err)
|
||||||
|
}
|
||||||
|
secret, err := h.ids.TOTPSecret(h.ctx, identityID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TOTPSecret: %v", err)
|
||||||
|
}
|
||||||
|
code, err := pqotp.GenerateCode(string(secret), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateCode: %v", err)
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
// totpLogin performs an anonymous TOTP login for guid. host overrides the
|
||||||
|
// request Host (the browser origin); "" keeps the httptest default.
|
||||||
|
func (h *harness) totpLogin(t *testing.T, guid, code, host string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
cookie, token := h.preCSRF()
|
||||||
|
form := url.Values{"csrf": {token}, "code": {code}}
|
||||||
|
req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action=totp.verify",
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if host != "" {
|
||||||
|
req.Host = host
|
||||||
|
}
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.srv.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// putPasskey stores a passkey credential for an identity (the WebAuthn fake).
|
||||||
|
func (h *harness) putPasskey(t *testing.T, identityID int64) {
|
||||||
|
t.Helper()
|
||||||
|
err := h.ids.PutWebAuthnCredential(h.ctx, identityID, webauthn.Credential{
|
||||||
|
ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"), SignCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutWebAuthnCredential: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countAudit returns how many audit rows carry the given action.
|
||||||
|
func (h *harness) countAudit(t *testing.T, action string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := h.db.QueryRow(`SELECT count(*) FROM audit_log WHERE action = ?`, action).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count audit %q: %v", action, err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *harness) sessionCookie(id string) *http.Cookie {
|
||||||
|
return &http.Cookie{Name: httpx.SessionCookieName, Value: id}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-8 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC8_TOTPLoginWithoutPasskeyIsNudged(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
code := h.issueTOTP(t, h.session.IdentityID)
|
||||||
|
|
||||||
|
rec := h.totpLogin(t, h.guid, code, "") // httptest Host = example.com (public)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("TOTP login = %d, want 303 (AC-8)", rec.Code)
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" {
|
||||||
|
t.Errorf("TOTP login without a passkey landed on %q, want the nudge (AC-8)", loc)
|
||||||
|
}
|
||||||
|
if s := sessionCookie(t, rec); s == "" {
|
||||||
|
t.Error("the nudge redirect carried no session cookie (AC-8)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A TOTP login WITH a passkey present is NEVER nudged (AC-8, second half).
|
||||||
|
h.putPasskey(t, h.session.IdentityID)
|
||||||
|
code = h.issueTOTP(t, h.session.IdentityID)
|
||||||
|
rec = h.totpLogin(t, h.guid, code, "")
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/home" {
|
||||||
|
t.Errorf("TOTP login with a passkey landed on %q, want the app (AC-8)", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-9 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC9_DontAskAgainPersistsAndSilences(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
code := h.issueTOTP(t, h.session.IdentityID)
|
||||||
|
|
||||||
|
// First TOTP login -> nudge.
|
||||||
|
rec := h.totpLogin(t, h.guid, code, "")
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" {
|
||||||
|
t.Fatalf("first TOTP login = %q, want the nudge (AC-9)", loc)
|
||||||
|
}
|
||||||
|
sid := sessionCookie(t, rec)
|
||||||
|
|
||||||
|
// "Don't ask again" -> passkey_opt_out = true (audited).
|
||||||
|
never := h.sessionAction("passkey.never", h.guid, sid)
|
||||||
|
if never.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("passkey.never = %d, want 303 (AC-9)", never.Code)
|
||||||
|
}
|
||||||
|
if loc := never.Header().Get("Location"); loc != "/"+h.guid+"/home" {
|
||||||
|
t.Errorf("passkey.never landed on %q, want the app (AC-9)", loc)
|
||||||
|
}
|
||||||
|
optOut, err := h.users.PasskeyOptOut(h.ctx, h.adminID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PasskeyOptOut: %v", err)
|
||||||
|
}
|
||||||
|
if !optOut {
|
||||||
|
t.Error("passkey.never did not persist the opt-out (AC-9)")
|
||||||
|
}
|
||||||
|
if n := h.countAudit(t, "user.passkey_opt_out"); n != 1 {
|
||||||
|
t.Errorf("opt-out audit rows = %d, want 1 (A11/AC-9)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Later TOTP logins are never nudged again.
|
||||||
|
code = h.issueTOTP(t, h.session.IdentityID)
|
||||||
|
rec = h.totpLogin(t, h.guid, code, "")
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/home" {
|
||||||
|
t.Errorf("a login after opting out landed on %q, want the app (AC-9)", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-10 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC10_NoNudgeOnLoopback(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
code := h.issueTOTP(t, h.session.IdentityID)
|
||||||
|
|
||||||
|
rec := h.totpLogin(t, h.guid, code, "127.0.0.1") // the loopback origin
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("loopback TOTP login = %d, want 303 (AC-10)", rec.Code)
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/home" {
|
||||||
|
t.Errorf("loopback TOTP login landed on %q, want the app — no nudge (AC-10)", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpec006AC10_NoNudgeInSetupMode(t *testing.T) {
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
|
||||||
|
|
||||||
|
// In setup mode the app serves ONLY /setup: the TOTP login action and the
|
||||||
|
// passkey screen are both unreachable, so the nudge can never appear.
|
||||||
|
if rec := h.totpLogin(t, h.guid, "123456", ""); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("TOTP login in setup mode = %d, want 404 (AC-10)", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := request(h.srv, "GET", "/"+h.guid+"/auth/passkey", h.sessionCookie(h.session.ID)); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("the passkey screen in setup mode = %d, want 404 (AC-10)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-11 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSpec006AC11_PasskeyAddRegistersAndAudits(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
code := h.issueTOTP(t, h.session.IdentityID)
|
||||||
|
|
||||||
|
// TOTP login -> the nudge screen.
|
||||||
|
rec := h.totpLogin(t, h.guid, code, "")
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" {
|
||||||
|
t.Fatalf("TOTP login = %q, want the nudge (AC-11)", loc)
|
||||||
|
}
|
||||||
|
sid := sessionCookie(t, rec)
|
||||||
|
|
||||||
|
// GET the nudge screen with the session: it begins the registration and
|
||||||
|
// carries the ceremony state.
|
||||||
|
page := request(h.srv, "GET", "/"+h.guid+"/auth/passkey", h.sessionCookie(sid))
|
||||||
|
if page.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET the passkey screen = %d, want 200 (AC-11)", page.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(page.Body.String(), "passkey.add") {
|
||||||
|
t.Error("the nudge screen does not offer passkey.add (AC-11)")
|
||||||
|
}
|
||||||
|
if h.wa.beginReg != 1 {
|
||||||
|
t.Errorf("BeginRegistration calls = %d, want 1 (AC-11)", h.wa.beginReg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkey.add completes the registration: the credential is stored and the
|
||||||
|
// event is audited.
|
||||||
|
add := h.sessionAction("passkey.add", h.guid, sid, ceremonyCookie(t, page))
|
||||||
|
if add.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("passkey.add = %d, want 303 (AC-11)", add.Code)
|
||||||
|
}
|
||||||
|
if loc := add.Header().Get("Location"); loc != "/"+h.guid+"/home" {
|
||||||
|
t.Errorf("passkey.add landed on %q, want the app (AC-11)", loc)
|
||||||
|
}
|
||||||
|
has, err := h.ids.HasWebAuthnCredential(h.ctx, h.session.IdentityID)
|
||||||
|
if err != nil || !has {
|
||||||
|
t.Errorf("passkey.add stored no credential (AC-11): %v", err)
|
||||||
|
}
|
||||||
|
if n := h.countAudit(t, "passkey.add"); n != 1 {
|
||||||
|
t.Errorf("passkey.add audit rows = %d, want 1 (AC-11)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec006_NudgeLaterPersistsNothing: "Not now" skips this time — nothing is
|
||||||
|
// written, and the user is nudged again at the next TOTP login (SPEC-006 §8).
|
||||||
|
func TestSpec006_NudgeLaterPersistsNothing(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
code := h.issueTOTP(t, h.session.IdentityID)
|
||||||
|
|
||||||
|
rec := h.totpLogin(t, h.guid, code, "")
|
||||||
|
sid := sessionCookie(t, rec)
|
||||||
|
|
||||||
|
later := h.sessionAction("passkey.later", h.guid, sid)
|
||||||
|
if later.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("passkey.later = %d, want 303", later.Code)
|
||||||
|
}
|
||||||
|
if optOut, err := h.users.PasskeyOptOut(h.ctx, h.adminID); err != nil || optOut {
|
||||||
|
t.Errorf("passkey.later persisted an opt-out (SPEC-006 §8): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still nudged next time.
|
||||||
|
code = h.issueTOTP(t, h.session.IdentityID)
|
||||||
|
rec = h.totpLogin(t, h.guid, code, "")
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" {
|
||||||
|
t.Errorf("after 'not now' the next login landed on %q, want the nudge", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec006_PasskeyLoginNeverNudged: a passkey login never nudges, even with
|
||||||
|
// a passkey present and a public origin (SPEC-006 §8).
|
||||||
|
func TestSpec006_PasskeyLoginNeverNudged(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
h.putPasskey(t, h.session.IdentityID)
|
||||||
|
|
||||||
|
begin := h.action("login.begin", h.guid, nil)
|
||||||
|
if begin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login.begin = %d, want 200", begin.Code)
|
||||||
|
}
|
||||||
|
finish := h.action("login.finish", h.guid, nil, ceremonyCookie(t, begin))
|
||||||
|
if finish.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login.finish = %d, want 303", finish.Code)
|
||||||
|
}
|
||||||
|
if loc := finish.Header().Get("Location"); loc != "/"+h.guid+"/home" {
|
||||||
|
t.Errorf("a passkey login landed on %q, want the app — never nudged", loc)
|
||||||
|
}
|
||||||
|
if h.wa.finishLogin != 1 {
|
||||||
|
t.Errorf("FinishLogin calls = %d, want 1", h.wa.finishLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
524
internal/core/server/spec007_recovery_test.go
Normal file
524
internal/core/server/spec007_recovery_test.go
Normal file
|
|
@ -0,0 +1,524 @@
|
||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"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/config"
|
||||||
|
"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/server"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// member is one enrolled user with a live session (the chain's four tiers).
|
||||||
|
type member struct {
|
||||||
|
userID int64
|
||||||
|
identityID int64
|
||||||
|
guid string
|
||||||
|
sessionID string
|
||||||
|
email string
|
||||||
|
role users.Role
|
||||||
|
}
|
||||||
|
|
||||||
|
type recoveryHarness struct {
|
||||||
|
t *testing.T
|
||||||
|
ctx context.Context
|
||||||
|
db *store.DB
|
||||||
|
users *users.Store
|
||||||
|
ids *auth.IdentitiesService
|
||||||
|
sessions *auth.SessionsService
|
||||||
|
requests *recovery.Store
|
||||||
|
mailer *mail.Fake
|
||||||
|
csrf *httpx.CSRF
|
||||||
|
pre *httpx.PreSessionCSRF
|
||||||
|
srv *server.Server
|
||||||
|
|
||||||
|
admin member
|
||||||
|
director member
|
||||||
|
manager member
|
||||||
|
team member
|
||||||
|
peer member // a second team member (same role as team)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRecoveryHarness(t *testing.T) *recoveryHarness {
|
||||||
|
t.Helper()
|
||||||
|
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)
|
||||||
|
|
||||||
|
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x51}, auth.KeySize))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAEADSealer: %v", err)
|
||||||
|
}
|
||||||
|
core := auth.New(db.DB, aud, auth.WithSealer(sealer))
|
||||||
|
ids := core.Identities()
|
||||||
|
sessions := core.Sessions()
|
||||||
|
|
||||||
|
const (
|
||||||
|
csrfKey = "recovery-csrf-key-0123456789abcdef"
|
||||||
|
cookieSecret = "recovery-cookie-secret-0123456789abcdef"
|
||||||
|
)
|
||||||
|
csrf, err := httpx.NewCSRF([]byte(csrfKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCSRF: %v", err)
|
||||||
|
}
|
||||||
|
pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPreSessionCSRF: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &recoveryHarness{
|
||||||
|
t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions,
|
||||||
|
mailer: mail.NewFake(), csrf: csrf, pre: pre,
|
||||||
|
}
|
||||||
|
h.requests = recovery.NewStore(db.DB, aud)
|
||||||
|
|
||||||
|
// The chain, built top-down (D-019).
|
||||||
|
h.admin = h.enroll(ctx, 0, "admin@example.com", "Admin", users.RoleAdmin)
|
||||||
|
h.director = h.enroll(ctx, h.admin.userID, "director@example.com", "Director", users.RoleDirector)
|
||||||
|
h.manager = h.enroll(ctx, h.director.userID, "manager@example.com", "Manager", users.RoleManager)
|
||||||
|
h.team = h.enroll(ctx, h.manager.userID, "team@example.com", "Team", users.RoleTeam)
|
||||||
|
h.peer = h.enroll(ctx, h.manager.userID, "peer@example.com", "Peer", users.RoleTeam)
|
||||||
|
|
||||||
|
rec, err := recovery.New(recovery.Deps{
|
||||||
|
Requests: h.requests,
|
||||||
|
Users: us,
|
||||||
|
Identities: ids,
|
||||||
|
Mailer: h.mailer,
|
||||||
|
CSRF: pre,
|
||||||
|
Audit: aud,
|
||||||
|
BaseURL: "https://myday.example.com",
|
||||||
|
Logger: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recovery.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := server.NewMapRegistry()
|
||||||
|
reg.RegisterCore("home", server.Home)
|
||||||
|
reg.RegisterModule(server.NewModule("home").
|
||||||
|
SetDefault("dashboard", server.Home).
|
||||||
|
AddRoute("home", "home", server.Home))
|
||||||
|
server.RegisterRecoveryFlow(reg, rec)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "https://myday.example.com"},
|
||||||
|
Database: config.Database{Path: db.Path()},
|
||||||
|
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
|
||||||
|
}
|
||||||
|
srv, err := server.New(server.Deps{
|
||||||
|
Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions,
|
||||||
|
Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre,
|
||||||
|
RoleGate: server.NewTiers(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server.New: %v", err)
|
||||||
|
}
|
||||||
|
h.srv = srv
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// enroll creates a user on the chain, activates an identity and mints a session.
|
||||||
|
func (h *recoveryHarness) enroll(ctx context.Context, creator int64, email, name string, role users.Role) member {
|
||||||
|
h.t.Helper()
|
||||||
|
uid, err := h.users.Create(ctx, creator, email, name, role)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("create %s: %v", role, err)
|
||||||
|
}
|
||||||
|
id, guid, err := h.ids.Mint(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("Mint %s: %v", role, err)
|
||||||
|
}
|
||||||
|
if err := h.ids.Activate(ctx, id); err != nil {
|
||||||
|
h.t.Fatalf("Activate %s: %v", role, err)
|
||||||
|
}
|
||||||
|
sess, err := h.sessions.Mint(ctx, id, "127.0.0.1", "test")
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("session Mint %s: %v", role, err)
|
||||||
|
}
|
||||||
|
return member{userID: uid, identityID: id, guid: guid, sessionID: sess.ID, email: email, role: role}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *recoveryHarness) preCSRF() (*http.Cookie, string) {
|
||||||
|
h.t.Helper()
|
||||||
|
tok, err := h.pre.Token()
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("pre-session Token: %v", err)
|
||||||
|
}
|
||||||
|
return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok
|
||||||
|
}
|
||||||
|
|
||||||
|
// access posts the recovery request form (anonymous, A15).
|
||||||
|
func (h *recoveryHarness) access(email string) *httptest.ResponseRecorder {
|
||||||
|
h.t.Helper()
|
||||||
|
cookie, token := h.preCSRF()
|
||||||
|
form := url.Values{"email": {email}, "csrf": {token}}
|
||||||
|
req := httptest.NewRequest("POST", "/access?do=auth&action=recover", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.srv.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// decide issues a session-gated approve/deny action as actor.
|
||||||
|
func (h *recoveryHarness) decide(action string, actor member, requestID int64) *httptest.ResponseRecorder {
|
||||||
|
h.t.Helper()
|
||||||
|
form := url.Values{
|
||||||
|
"request": {strconv.FormatInt(requestID, 10)},
|
||||||
|
"csrf": {h.csrf.Token(actor.sessionID)},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/"+actor.guid+"?do=recovery&action="+action, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{Name: httpx.SessionCookieName, Value: actor.sessionID})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.srv.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// openRequest submits team's recovery request and returns its id.
|
||||||
|
func (h *recoveryHarness) openRequest(m member) recovery.Request {
|
||||||
|
h.t.Helper()
|
||||||
|
if rec := h.access(m.email); rec.Code != http.StatusOK {
|
||||||
|
h.t.Fatalf("POST /access = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
req, err := h.requests.Pending(h.ctx, m.userID)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("Pending(%d): %v", m.userID, err)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditActions returns the set of audit actions recorded.
|
||||||
|
func (h *recoveryHarness) auditActions(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := h.db.Query(`SELECT action FROM audit_log`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read audit_log: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
got := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var a string
|
||||||
|
if err := rows.Scan(&a); err != nil {
|
||||||
|
t.Fatalf("scan audit action: %v", err)
|
||||||
|
}
|
||||||
|
got[a] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("iterate audit_log: %v", err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-2 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestSpec007AC2_NoSelfApprovalOrLowerRole: the requester can never approve
|
||||||
|
// their own request, and a peer (same role) cannot either (D-019, AC-2).
|
||||||
|
func TestSpec007AC2_NoSelfApprovalOrLowerRole(t *testing.T) {
|
||||||
|
h := newRecoveryHarness(t)
|
||||||
|
req := h.openRequest(h.team)
|
||||||
|
|
||||||
|
// The requester themselves -> forbidden. The body must be EXACTLY the
|
||||||
|
// client error — a returned error would make the door append a 500 on top
|
||||||
|
// of an already-written response.
|
||||||
|
if rec := h.decide("approve", h.team, req.ID); rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("self-approval = %d, want 403 (AC-2)", rec.Code)
|
||||||
|
} else if got := rec.Body.String(); strings.TrimSpace(got) != "forbidden" {
|
||||||
|
t.Errorf("self-approval body = %q, want exactly %q", got, "forbidden")
|
||||||
|
}
|
||||||
|
// A peer (same role) -> forbidden.
|
||||||
|
if rec := h.decide("approve", h.peer, req.ID); rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("peer approval = %d, want 403 (AC-2)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing changed: the identity is still active, the request still pending.
|
||||||
|
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
|
||||||
|
if err != nil || idn.Status != auth.StatusActive {
|
||||||
|
t.Errorf("identity after a refused approval = %+v, want still active (AC-2)", idn)
|
||||||
|
}
|
||||||
|
still, err := h.requests.Get(h.ctx, req.ID)
|
||||||
|
if err != nil || still.Status != recovery.StatusPending {
|
||||||
|
t.Errorf("request after a refused approval = %+v, want pending (AC-2)", still)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec007AC2_R7_ChainAuthority: the recovery authority is the CHAIN (R-7).
|
||||||
|
// A director in a DIFFERENT subtree is a role above a team member but not on
|
||||||
|
// their manager line, so they may NOT approve; the admin (the root) may.
|
||||||
|
func TestSpec007AC2_R7_ChainAuthority(t *testing.T) {
|
||||||
|
h := newRecoveryHarness(t)
|
||||||
|
|
||||||
|
// A second director, created by the admin: role-above team, but NOT an
|
||||||
|
// ancestor of team (team -> manager -> director -> admin).
|
||||||
|
directorB := h.enroll(h.ctx, h.admin.userID, "director2@example.com", "Director2", users.RoleDirector)
|
||||||
|
|
||||||
|
req := h.openRequest(h.team)
|
||||||
|
|
||||||
|
// Role-above but off the chain -> forbidden (R-7's correction).
|
||||||
|
if rec := h.decide("approve", directorB, req.ID); rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("non-ancestor director approval = %d, want 403 (R-7)", rec.Code)
|
||||||
|
}
|
||||||
|
// The request is untouched by the refusal.
|
||||||
|
if still, err := h.requests.Get(h.ctx, req.ID); err != nil || still.Status != recovery.StatusPending {
|
||||||
|
t.Errorf("request after a refused off-chain approval = %+v, want pending (R-7)", still)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin is the root of the chain and may approve.
|
||||||
|
if rec := h.decide("approve", h.admin, req.ID); rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("admin approval = %d, want 200 (R-7)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-3 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestSpec007AC3_ApprovalRotatesIdentity: an approver on the requester's chain
|
||||||
|
// revokes the old identity and all its sessions and mints a NEW GUID, then
|
||||||
|
// emails the enrolment link (D-007, AC-3).
|
||||||
|
func TestSpec007AC3_ApprovalRotatesIdentity(t *testing.T) {
|
||||||
|
h := newRecoveryHarness(t)
|
||||||
|
req := h.openRequest(h.team)
|
||||||
|
|
||||||
|
rec := h.decide("approve", h.manager, req.ID) // manager is above team
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("approve = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The OLD identity is revoked and its GUID tombstoned.
|
||||||
|
old, err := h.ids.ByGUID(h.ctx, h.team.guid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ByGUID(old): %v", err)
|
||||||
|
}
|
||||||
|
if old.Status != auth.StatusRevoked {
|
||||||
|
t.Errorf("old identity status = %s, want revoked (AC-3)", old.Status)
|
||||||
|
}
|
||||||
|
// Every session of the old identity is dead.
|
||||||
|
if _, err := h.sessions.Validate(h.ctx, h.team.sessionID); err == nil {
|
||||||
|
t.Error("a pre-approval session survived the rotation (AC-3)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A NEW GUID is minted, pending enrollment, under the same user.
|
||||||
|
fresh, err := h.ids.ByUserID(h.ctx, h.team.userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ByUserID: %v", err)
|
||||||
|
}
|
||||||
|
if fresh.GUID == h.team.guid {
|
||||||
|
t.Error("approval reissued the old GUID (AC-3)")
|
||||||
|
}
|
||||||
|
if fresh.Status != auth.StatusPendingEnrollment {
|
||||||
|
t.Errorf("new identity status = %s, want pending_enrollment (AC-3)", fresh.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The enrolment link reached the address on record.
|
||||||
|
found := false
|
||||||
|
for _, m := range h.mailer.To(h.team.email) {
|
||||||
|
if strings.Contains(m.Body, fresh.GUID) {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("no enrolment email carrying the new GUID reached %s (AC-3)", h.team.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The request is approved.
|
||||||
|
done, err := h.requests.Get(h.ctx, req.ID)
|
||||||
|
if err != nil || done.Status != recovery.StatusApproved {
|
||||||
|
t.Errorf("request = %+v, want approved (AC-3)", done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-4 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestSpec007AC4_AccessIsGeneric: /access never reveals whether the email
|
||||||
|
// matched — the same status and the same body either way (A3, AC-4).
|
||||||
|
func TestSpec007AC4_AccessIsGeneric(t *testing.T) {
|
||||||
|
h := newRecoveryHarness(t)
|
||||||
|
|
||||||
|
hit := h.access(h.team.email)
|
||||||
|
miss := h.access("nobody@example.com")
|
||||||
|
|
||||||
|
if hit.Code != http.StatusOK || miss.Code != http.StatusOK {
|
||||||
|
t.Fatalf("handle codes = %d/%d, want 200/200", hit.Code, miss.Code)
|
||||||
|
}
|
||||||
|
if hit.Body.String() != miss.Body.String() {
|
||||||
|
t.Errorf("the /access response differs on a hit vs a miss:\nhit: %q\nmiss: %q",
|
||||||
|
hit.Body.String(), miss.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(hit.Body.String(), "If a record exists") {
|
||||||
|
t.Errorf("the /access notice is not generic: %q", hit.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-6 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestSpec007AC6_RecoveryIsAudited: request, approve, deny and the identity
|
||||||
|
// rotation are all recorded (A11, AC-6).
|
||||||
|
func TestSpec007AC6_RecoveryIsAudited(t *testing.T) {
|
||||||
|
h := newRecoveryHarness(t)
|
||||||
|
|
||||||
|
// approve
|
||||||
|
req := h.openRequest(h.team)
|
||||||
|
if rec := h.decide("approve", h.manager, req.ID); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("approve = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
// deny
|
||||||
|
req2 := h.openRequest(h.manager)
|
||||||
|
if rec := h.decide("deny", h.director, req2.ID); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("deny = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := h.auditActions(t)
|
||||||
|
for _, want := range []string{
|
||||||
|
"recovery.request",
|
||||||
|
"recovery.approve",
|
||||||
|
"recovery.deny",
|
||||||
|
"identity.rotate",
|
||||||
|
} {
|
||||||
|
if !got[want] {
|
||||||
|
t.Errorf("audit log is missing %q (AC-6); got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── supporting behaviour ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestSpec007AccessIssuesPreSessionCSRF: the anonymous /access form carries the
|
||||||
|
// signed pre-session token (A15) and the action without it is refused.
|
||||||
|
func TestSpec007AccessIssuesPreSessionCSRF(t *testing.T) {
|
||||||
|
h := newRecoveryHarness(t)
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/access")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /access = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if !hasCookie(rec, httpx.PreSessionCookieName) {
|
||||||
|
t.Error("GET /access issued no pre-session CSRF cookie (A15)")
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Errorf("GET /access Cache-Control = %q, want no-store", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the double-submit token the action is refused (A15).
|
||||||
|
req := httptest.NewRequest("POST", "/access?do=auth&action=recover",
|
||||||
|
strings.NewReader(url.Values{"email": {h.team.email}}.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.srv.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("recovery request without CSRF = %d, want 403 (A15)", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec007_RepeatRequestDoesNotRenotify: a second POST for the same user
|
||||||
|
// reuses the pending request and does NOT email the chain again (no spam).
|
||||||
|
func TestSpec007_RepeatRequestDoesNotRenotify(t *testing.T) {
|
||||||
|
h := newRecoveryHarness(t)
|
||||||
|
|
||||||
|
if rec := h.access(h.team.email); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first POST /access = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
first := len(h.mailer.Sent())
|
||||||
|
if first == 0 {
|
||||||
|
t.Fatal("the first request sent no notification")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := h.access(h.team.email); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second POST /access = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := len(h.mailer.Sent()); got != first {
|
||||||
|
t.Errorf("a repeated request re-notified the chain: %d messages, want %d", got, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec007_SecondDecisionRefused: the request is claimed atomically — a
|
||||||
|
// second approve/deny is refused and does not rotate a second time.
|
||||||
|
func TestSpec007_SecondDecisionRefused(t *testing.T) {
|
||||||
|
h := newRecoveryHarness(t)
|
||||||
|
req := h.openRequest(h.team)
|
||||||
|
|
||||||
|
if rec := h.decide("approve", h.manager, req.ID); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first approve = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := h.decide("approve", h.director, req.ID); rec.Code != http.StatusConflict {
|
||||||
|
t.Errorf("second approve = %d, want 409 (already decided)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user has exactly one live (pending) identity — no double rotation.
|
||||||
|
var n int
|
||||||
|
if err := h.db.QueryRow(
|
||||||
|
`SELECT count(*) FROM identities WHERE user_id = ? AND status <> 'revoked'`, h.team.userID,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count live identities: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("live identities = %d, want exactly 1 (no double rotation)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec007_ExpiredRequestCannotBeApproved: a request past the ~72h window
|
||||||
|
// is expired and refused (SPEC-007 §4).
|
||||||
|
func TestSpec007_ExpiredRequestCannotBeApproved(t *testing.T) {
|
||||||
|
h := newRecoveryHarness(t)
|
||||||
|
req := h.openRequest(h.team)
|
||||||
|
|
||||||
|
// Age the request well past the TTL.
|
||||||
|
past := store.FormatTime(time.Now().Add(-100 * time.Hour))
|
||||||
|
if _, err := h.db.Exec(`UPDATE recovery_requests SET requested_at = ? WHERE request_id = ?`, past, req.ID); err != nil {
|
||||||
|
t.Fatalf("age request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := h.decide("approve", h.manager, req.ID)
|
||||||
|
if rec.Code != http.StatusGone {
|
||||||
|
t.Errorf("approve an expired request = %d, want 410", rec.Code)
|
||||||
|
}
|
||||||
|
// The identity was NOT rotated.
|
||||||
|
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
|
||||||
|
if err != nil || idn.Status != auth.StatusActive {
|
||||||
|
t.Errorf("identity after an expired approval = %+v, want still active", idn)
|
||||||
|
}
|
||||||
|
stored, err := h.requests.Get(h.ctx, req.ID)
|
||||||
|
if err != nil || stored.Status != recovery.StatusExpired {
|
||||||
|
t.Errorf("request = %+v, want expired", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec007DenyDoesNotRotate: a denial leaves the requester's identity intact.
|
||||||
|
func TestSpec007DenyDoesNotRotate(t *testing.T) {
|
||||||
|
h := newRecoveryHarness(t)
|
||||||
|
req := h.openRequest(h.team)
|
||||||
|
|
||||||
|
if rec := h.decide("deny", h.director, req.ID); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("deny = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
|
||||||
|
if err != nil || idn.Status != auth.StatusActive {
|
||||||
|
t.Errorf("identity after a denial = %+v, want still active", idn)
|
||||||
|
}
|
||||||
|
done, err := h.requests.Get(h.ctx, req.ID)
|
||||||
|
if err != nil || done.Status != recovery.StatusDenied {
|
||||||
|
t.Errorf("request = %+v, want denied", done)
|
||||||
|
}
|
||||||
|
}
|
||||||
235
internal/core/server/spec008_modules_test.go
Normal file
235
internal/core/server/spec008_modules_test.go
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||||
|
|
||||||
|
// Discovery (D-028): blank-importing the aggregator runs the module
|
||||||
|
// directories' init(), so module.Load sees the real, discovered set.
|
||||||
|
_ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/modules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// doorAudit records audit action names in memory (AC-6).
|
||||||
|
type doorAudit struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
actions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *doorAudit) Record(_ context.Context, e audit.Event) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.actions = append(a.actions, e.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *doorAudit) has(action string) bool {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
for _, x := range a.actions {
|
||||||
|
if x == action {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// testModule is a minimal OPTIONAL module (a stand-in for a future feature such
|
||||||
|
// as `expense`) used to exercise enable/disable through the door.
|
||||||
|
type testModule struct{ name string }
|
||||||
|
|
||||||
|
func (m *testModule) Name() string { return m.name }
|
||||||
|
func (m *testModule) Required() bool { return false }
|
||||||
|
func (m *testModule) Kind() string { return "domain" }
|
||||||
|
func (m *testModule) Migrations() []module.Migration {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *testModule) Routes() []module.Route {
|
||||||
|
return []module.Route{{Function: "index", Handler: func(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return nil
|
||||||
|
}, Default: true}}
|
||||||
|
}
|
||||||
|
func (m *testModule) Actions() []module.Action { return nil }
|
||||||
|
func (m *testModule) Register(r *module.Registry) error { return r.Add(m) }
|
||||||
|
func (m *testModule) Start(context.Context) error { return nil }
|
||||||
|
|
||||||
|
// consoleGate is the admin-only role gate the entrypoint installs (SPEC-008 §7).
|
||||||
|
func consoleGate() *server.Tiers {
|
||||||
|
return server.NewTiers().
|
||||||
|
Require(module.ConsoleModule, module.FunctionModules, users.RoleAdmin).
|
||||||
|
Require(module.ConsoleModule, module.FunctionToggle, users.RoleAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildConsole mounts a Modules page (with a writable temp config) onto reg.
|
||||||
|
func buildConsole(t *testing.T, mods *module.Registry, reg *server.MapRegistry, aud audit.Audit) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Server: config.Server{Listen: "127.0.0.1:8080"},
|
||||||
|
Database: config.Database{Path: "test.db"},
|
||||||
|
Modules: map[string]bool{},
|
||||||
|
}
|
||||||
|
page, err := module.NewPage(module.PageDeps{
|
||||||
|
Registry: mods,
|
||||||
|
Config: cfg,
|
||||||
|
ConfigPath: filepath.Join(t.TempDir(), config.FileName),
|
||||||
|
Audit: aud,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("module.NewPage: %v", err)
|
||||||
|
}
|
||||||
|
page.Register(reg)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveForm issues a urlencoded POST against the door.
|
||||||
|
func serveForm(h http.Handler, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest("POST", target, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
for _, c := range cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec008_DoorDispatchesToDiscoveredHome is the definition-of-done proof:
|
||||||
|
// the discovered `home` module mounts into the door and the door dispatches
|
||||||
|
// /{GUID} to its dashboard.
|
||||||
|
func TestSpec008_DoorDispatchesToDiscoveredHome(t *testing.T) {
|
||||||
|
mods, err := module.Load(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("module.Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := server.NewMapRegistry()
|
||||||
|
mods.Mount(reg)
|
||||||
|
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.Registry = reg })
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/"+h.guid, h.cookie())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /{guid} = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Your day, at a glance.") {
|
||||||
|
t.Errorf("the door did not dispatch to the discovered home module:\n%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// /{GUID}/home reaches the same module default (SPEC-002 §4.4).
|
||||||
|
rec = request(h.srv, "GET", "/"+h.guid+"/home", h.cookie())
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Your day, at a glance.") {
|
||||||
|
t.Errorf("GET /{guid}/home did not reach the home module: %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec008_ModulesPageIsAdminOnly: the core Modules page and its toggle are
|
||||||
|
// gated to the admin by the door's role gate (SPEC-008 §7, D-038/AC-4).
|
||||||
|
func TestSpec008_ModulesPageIsAdminOnly(t *testing.T) {
|
||||||
|
mods, err := module.Load(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("module.Load: %v", err)
|
||||||
|
}
|
||||||
|
reg := server.NewMapRegistry()
|
||||||
|
mods.Mount(reg)
|
||||||
|
pageCfg := buildConsole(t, mods, reg, nil)
|
||||||
|
|
||||||
|
h := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.Registry = reg
|
||||||
|
d.RoleGate = consoleGate()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The admin may open it.
|
||||||
|
rec := request(h.srv, "GET", "/"+h.guid+"/core/modules", h.cookie())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin GET /{guid}/core/modules = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "home") {
|
||||||
|
t.Errorf("the Modules page does not list the discovered modules:\n%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A team member may not (403).
|
||||||
|
teamID, err := h.users.Create(h.ctx, h.adminID, "worker@example.com", "Worker", users.RoleTeam)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create team: %v", err)
|
||||||
|
}
|
||||||
|
idTeam, guidTeam, err := h.ids.Mint(h.ctx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mint team: %v", err)
|
||||||
|
}
|
||||||
|
if err := h.ids.Activate(h.ctx, idTeam); err != nil {
|
||||||
|
t.Fatalf("Activate team: %v", err)
|
||||||
|
}
|
||||||
|
sess, err := h.sessions.Mint(h.ctx, idTeam, "127.0.0.1", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session Mint team: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = request(h.srv, "GET", "/"+guidTeam+"/core/modules",
|
||||||
|
&http.Cookie{Name: httpx.SessionCookieName, Value: sess.ID})
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("team GET /{guid}/core/modules = %d, want 403 (admin only)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A required module cannot be disabled, even by the admin (AC-4).
|
||||||
|
form := url.Values{
|
||||||
|
"module": {"home"},
|
||||||
|
"enabled": {"false"},
|
||||||
|
"csrf": {h.csrf.Token(h.session.ID)},
|
||||||
|
}
|
||||||
|
if rec := serveForm(h.srv, "/"+h.guid+"?do=core&action=modules.toggle", form, h.cookie()); rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("admin disabling a required module = %d, want 403 (AC-4)", rec.Code)
|
||||||
|
}
|
||||||
|
if pageCfg.Modules["home"] {
|
||||||
|
t.Error("a required module was written as disabled (AC-4)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec008_ModulesPageToggleAudited: an admin toggling an optional module
|
||||||
|
// writes the config and is audited (D-035, AC-6).
|
||||||
|
func TestSpec008_ModulesPageToggleAudited(t *testing.T) {
|
||||||
|
mods, err := module.Load(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("module.Load: %v", err)
|
||||||
|
}
|
||||||
|
if err := mods.Add(&testModule{name: "expense"}); err != nil {
|
||||||
|
t.Fatalf("add expense: %v", err)
|
||||||
|
}
|
||||||
|
reg := server.NewMapRegistry()
|
||||||
|
mods.Mount(reg)
|
||||||
|
|
||||||
|
rec := &doorAudit{}
|
||||||
|
pageCfg := buildConsole(t, mods, reg, rec)
|
||||||
|
|
||||||
|
h := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.Registry = reg
|
||||||
|
d.RoleGate = consoleGate()
|
||||||
|
})
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"module": {"expense"},
|
||||||
|
"enabled": {"false"},
|
||||||
|
"csrf": {h.csrf.Token(h.session.ID)},
|
||||||
|
}
|
||||||
|
res := serveForm(h.srv, "/"+h.guid+"?do=core&action=modules.toggle", form, h.cookie())
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("toggle = %d, want 200 (body %q)", res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
if pageCfg.Modules["expense"] {
|
||||||
|
t.Error("the config was not updated for the optional module")
|
||||||
|
}
|
||||||
|
if !rec.has("module.disable") {
|
||||||
|
t.Errorf("the toggle was not audited (AC-6): %v", rec.actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
475
internal/core/server/spec009_web_pwa_test.go
Normal file
475
internal/core/server/spec009_web_pwa_test.go
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
|
||||||
|
|
||||||
|
_ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/modules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── SPEC-009 · the web layer & PWA ───────────────────────────────────────────
|
||||||
|
|
||||||
|
// staticRegistry is the door registry as the entrypoint wires it for the
|
||||||
|
// reserved workflows (SPEC-009 §3): `home` plus the embedded `static` assets.
|
||||||
|
func staticRegistry() *server.MapRegistry {
|
||||||
|
reg := server.NewMapRegistry()
|
||||||
|
reg.RegisterCore("home", server.Home)
|
||||||
|
reg.RegisterCore(server.StaticWorkflow, server.StaticHandler())
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-3: no inline scripts anywhere ─────────────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
htmlScriptTag = regexp.MustCompile(`(?is)<script\b[^>]*>`)
|
||||||
|
htmlScriptSrc = regexp.MustCompile(`(?is)<script\b[^>]*\bsrc\s*=\s*["'][^"']*["']`)
|
||||||
|
htmlInlineHandler = regexp.MustCompile(`(?is)\son(click|change|input|submit|load|error)\s*=`)
|
||||||
|
htmlJavascriptURL = regexp.MustCompile(`(?is)javascript:`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// assertNoInlineScripts fails when a rendered page would force the CSP
|
||||||
|
// `script-src 'self'` (SPEC-003 §4) to be weakened: an inline <script>, an
|
||||||
|
// inline event handler, or a javascript: URL (AC-3).
|
||||||
|
func assertNoInlineScripts(t *testing.T, screen, body string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tag := range htmlScriptTag.FindAllString(body, -1) {
|
||||||
|
if !strings.Contains(strings.ToLower(tag), "src=") {
|
||||||
|
t.Errorf("%s emitted an inline <script> (AC-3): %q", screen, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tag := range htmlScriptTag.FindAllString(body, -1) {
|
||||||
|
if !htmlScriptSrc.MatchString(tag) {
|
||||||
|
t.Errorf("%s emitted a <script> without a same-origin src (AC-3): %q", screen, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m := htmlInlineHandler.FindString(body); m != "" {
|
||||||
|
t.Errorf("%s emitted an inline event handler %q (AC-3)", screen, m)
|
||||||
|
}
|
||||||
|
if htmlJavascriptURL.MatchString(body) {
|
||||||
|
t.Errorf("%s emitted a javascript: URL (AC-3)", screen)
|
||||||
|
}
|
||||||
|
// Every script reference is SAME-ORIGIN (the assets live under /static/).
|
||||||
|
for _, tag := range htmlScriptTag.FindAllString(body, -1) {
|
||||||
|
if !strings.Contains(tag, `src="/`) {
|
||||||
|
t.Errorf("%s references a script that is not same-origin (AC-3): %q", screen, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAC3_NoInlineScriptsOnEveryScreen renders every human-facing screen
|
||||||
|
// through the door and asserts the CSP can stay strict (AC-3).
|
||||||
|
func TestAC3_NoInlineScriptsOnEveryScreen(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
_, _, pendingGUID := h.pendingUser(t, "ac3-web@example.com")
|
||||||
|
|
||||||
|
// A door wired as main wires it: the reserved `static` workflow plus the
|
||||||
|
// discovered modules (home) and the core Modules page.
|
||||||
|
mods, err := module.Load(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("module.Load: %v", err)
|
||||||
|
}
|
||||||
|
reg := staticRegistry()
|
||||||
|
mods.Mount(reg)
|
||||||
|
buildConsole(t, mods, reg, nil)
|
||||||
|
web.SetNav(mods)
|
||||||
|
t.Cleanup(func() { web.SetNav(nil) })
|
||||||
|
|
||||||
|
dh := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.Registry = reg
|
||||||
|
d.RoleGate = consoleGate()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The bootstrap wizard, in setup mode.
|
||||||
|
sh := newSetupHarness(t)
|
||||||
|
|
||||||
|
screens := []struct {
|
||||||
|
name string
|
||||||
|
srv http.Handler
|
||||||
|
target string
|
||||||
|
cookie *http.Cookie
|
||||||
|
}{
|
||||||
|
{"login", h.srv, "/" + h.guid, nil},
|
||||||
|
{"enrollment", h.srv, "/" + pendingGUID, nil},
|
||||||
|
{"the passkey nudge", h.srv, "/" + h.guid + "/auth/passkey", h.cookie()},
|
||||||
|
{"the /home landing", h.srv, "/home", nil},
|
||||||
|
{"the recovery entry", h.srv, "/access", nil},
|
||||||
|
{"the dashboard", dh.srv, "/" + dh.guid, dh.cookie()},
|
||||||
|
{"the Modules page", dh.srv, "/" + dh.guid + "/core/modules", dh.cookie()},
|
||||||
|
{"the /setup wizard", sh.srv, "/setup", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, screen := range screens {
|
||||||
|
var rec *httptest.ResponseRecorder
|
||||||
|
if screen.cookie == nil {
|
||||||
|
rec = request(screen.srv, "GET", screen.target)
|
||||||
|
} else {
|
||||||
|
rec = request(screen.srv, "GET", screen.target, screen.cookie)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("%s = %d, want 200", screen.name, rec.Code)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assertNoInlineScripts(t, screen.name, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ceremonies must publish their data-* contract, or no human can
|
||||||
|
// complete one in a browser (SPEC-009 §5).
|
||||||
|
if !strings.Contains(dhBody(t, h, "/"+pendingGUID), `data-webauthn="enroll"`) {
|
||||||
|
t.Error("the enrollment screen carries no enroll ceremony contract (SPEC-009 §5)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(dhBody(t, h, "/"+h.guid), `data-webauthn="login"`) {
|
||||||
|
t.Error("the login screen carries no login ceremony contract (SPEC-009 §5)")
|
||||||
|
}
|
||||||
|
if body := dhBody(t, h, "/"+h.guid+"/auth/passkey", h.cookie()); !strings.Contains(body, `data-webauthn="passkey"`) {
|
||||||
|
t.Error("the nudge screen carries no passkey ceremony contract (SPEC-009 §5)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dhBody issues a GET against a door and returns the body (test sugar).
|
||||||
|
func dhBody(t *testing.T, h *harness, target string, cookies ...*http.Cookie) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := request(h.srv, "GET", target, cookies...)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d, want 200", target, rec.Code)
|
||||||
|
}
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAC3_AuthFormsCarryTheirCSRF: the anonymous ceremonies and the
|
||||||
|
// session-gated nudge submit a token the door accepts — the contract the
|
||||||
|
// vanilla client relies on (A15/A8).
|
||||||
|
func TestAC3_AuthFormsCarryTheirCSRF(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
|
||||||
|
// Anonymous: the pre-session double-submit token, in the form AND in the
|
||||||
|
// shell's meta (which webauthn.js reads).
|
||||||
|
rec := request(h.srv, "GET", "/"+h.guid)
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, `name="csrf"`) {
|
||||||
|
t.Error("the login form carries no csrf field")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `meta name="csrf-token"`) {
|
||||||
|
t.Error("the login screen exposes no csrf-token meta for the WebAuthn client")
|
||||||
|
}
|
||||||
|
if !hasCookie(rec, "myday_csrf") {
|
||||||
|
t.Error("the login screen issued no pre-session CSRF cookie (A15)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session-gated: the nudge's token is the session-bound one (A15 does not
|
||||||
|
// apply) and it is exposed to the client the same way.
|
||||||
|
nudge := request(h.srv, "GET", "/"+h.guid+"/auth/passkey", h.cookie())
|
||||||
|
if nudge.Code != http.StatusOK {
|
||||||
|
t.Fatalf("the nudge screen = %d, want 200", nudge.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(nudge.Body.String(), h.csrf.Token(h.session.ID)) {
|
||||||
|
t.Error("the nudge screen does not carry the session-bound CSRF token")
|
||||||
|
}
|
||||||
|
if !strings.Contains(nudge.Body.String(), "passkey.add") {
|
||||||
|
t.Error("the nudge screen does not offer passkey.add (SPEC-006 §8)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-3/§4: the nav is driven by the module registry (D-038) ────────────────
|
||||||
|
|
||||||
|
// TestSpec009_NavComesFromTheRegistry: the shell renders the ENABLED modules —
|
||||||
|
// and a disabled module's entry disappears (D-038: disabling hides nav).
|
||||||
|
func TestSpec009_NavComesFromTheRegistry(t *testing.T) {
|
||||||
|
mods, err := module.Load(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("module.Load: %v", err)
|
||||||
|
}
|
||||||
|
if err := mods.Add(&testModule{name: "expense"}); err != nil {
|
||||||
|
t.Fatalf("add expense: %v", err)
|
||||||
|
}
|
||||||
|
reg := staticRegistry()
|
||||||
|
mods.Mount(reg)
|
||||||
|
web.SetNav(mods)
|
||||||
|
t.Cleanup(func() { web.SetNav(nil) })
|
||||||
|
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.Registry = reg })
|
||||||
|
|
||||||
|
body := dhBody(t, h, "/"+h.guid, h.cookie())
|
||||||
|
if !strings.Contains(body, `href="/`+h.guid+`/home"`) {
|
||||||
|
t.Errorf("the nav is missing the discovered `home` module:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `href="/`+h.guid+`/expense"`) {
|
||||||
|
t.Errorf("the nav is missing the discovered `expense` module:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable it (D-038): its nav entry must be gone, its data untouched.
|
||||||
|
if err := mods.SetEnabled("expense", false); err != nil {
|
||||||
|
t.Fatalf("SetEnabled: %v", err)
|
||||||
|
}
|
||||||
|
body = dhBody(t, h, "/"+h.guid, h.cookie())
|
||||||
|
if strings.Contains(body, `href="/`+h.guid+`/expense"`) {
|
||||||
|
t.Errorf("a disabled module is still in the nav (D-038):\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The module view contract (SPEC-009 §3): a module renders INSIDE the shell —
|
||||||
|
// it supplies content, the core owns the document, header, nav and flash.
|
||||||
|
func TestSpec009_ModuleRendersInsideTheShell(t *testing.T) {
|
||||||
|
mods, err := module.Load(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("module.Load: %v", err)
|
||||||
|
}
|
||||||
|
reg := staticRegistry()
|
||||||
|
mods.Mount(reg)
|
||||||
|
web.SetNav(mods)
|
||||||
|
t.Cleanup(func() { web.SetNav(nil) })
|
||||||
|
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.Registry = reg })
|
||||||
|
body := dhBody(t, h, "/"+h.guid, h.cookie())
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"<!doctype html>", // the shell owns the document
|
||||||
|
"Your day, at a glance.", // the module's content
|
||||||
|
`href="/static/app.css"`, // the shell's stylesheet
|
||||||
|
`href="/static/manifest.webmanifest"`,
|
||||||
|
"<nav",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("the dashboard is missing %q — a module must render inside the shell:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §3: the reserved `static` workflow ───────────────────────────────────────
|
||||||
|
|
||||||
|
// TestSpec009_StaticWorkflowServesTheEmbeddedAssets exercises the reserved
|
||||||
|
// `static` workflow through the door (D-004).
|
||||||
|
func TestSpec009_StaticWorkflowServesTheEmbeddedAssets(t *testing.T) {
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.Registry = staticRegistry() })
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
status int
|
||||||
|
ctype string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{"/static/app.css", http.StatusOK, "text/css", ".app-header"},
|
||||||
|
{"/static/webauthn.js", http.StatusOK, "text/javascript", "navigator.credentials.create"},
|
||||||
|
{"/static/app.js", http.StatusOK, "text/javascript", "serviceWorker"},
|
||||||
|
{"/static/sw.js", http.StatusOK, "text/javascript", "isCacheable"},
|
||||||
|
{"/static/manifest.webmanifest", http.StatusOK, "application/manifest+json", "start_url"},
|
||||||
|
{"/static/icon-192.png", http.StatusOK, "image/png", ""},
|
||||||
|
{"/static/icon-512.png", http.StatusOK, "image/png", ""},
|
||||||
|
{"/static/nope.css", http.StatusNotFound, "text/plain", ""},
|
||||||
|
{"/static", http.StatusNotFound, "text/plain", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
rec := request(h.srv, "GET", tc.path)
|
||||||
|
if rec.Code != tc.status {
|
||||||
|
t.Fatalf("GET %s = %d, want %d", tc.path, rec.Code, tc.status)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, tc.ctype) {
|
||||||
|
t.Errorf("GET %s Content-Type = %q, want %q", tc.path, got, tc.ctype)
|
||||||
|
}
|
||||||
|
if tc.contains != "" && !strings.Contains(rec.Body.String(), tc.contains) {
|
||||||
|
t.Errorf("GET %s body is missing %q", tc.path, tc.contains)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec009_StaticIsReadOnlyAndTraversalSafe: the handler is GET/HEAD only,
|
||||||
|
// and a traversal cannot escape the embedded directory.
|
||||||
|
func TestSpec009_StaticIsReadOnlyAndTraversalSafe(t *testing.T) {
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.Registry = staticRegistry() })
|
||||||
|
|
||||||
|
if rec := request(h.srv, "POST", "/static/app.css"); rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("POST /static/app.css = %d, want 405", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := request(h.srv, "HEAD", "/static/app.css"); rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("HEAD /static/app.css = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
"/static/../config.yaml",
|
||||||
|
"/static/..%2f..%2fconfig.yaml",
|
||||||
|
"/static/./app.css",
|
||||||
|
} {
|
||||||
|
rec := request(h.srv, "GET", path)
|
||||||
|
if rec.Code == http.StatusOK {
|
||||||
|
t.Errorf("GET %s = 200, want a refusal (traversal must not escape the assets)", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec009_ServiceWorkerAllowedHeader: a worker served from /static/ is
|
||||||
|
// registered with scope "/" (the manifest's start_url is "/", D-011), which the
|
||||||
|
// browser refuses unless the response grants it. Without this header the
|
||||||
|
// registration silently fails and the slice's PWA half never takes effect.
|
||||||
|
func TestSpec009_ServiceWorkerAllowedHeader(t *testing.T) {
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.Registry = staticRegistry() })
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/static/sw.js")
|
||||||
|
if got := rec.Header().Get("Service-Worker-Allowed"); got != "/" {
|
||||||
|
t.Errorf("Service-Worker-Allowed on sw.js = %q, want %q", got, "/")
|
||||||
|
}
|
||||||
|
// Every other asset is served plainly: the header is the worker's alone.
|
||||||
|
for _, path := range []string{"/static/app.js", "/static/app.css", "/static/manifest.webmanifest"} {
|
||||||
|
if got := request(h.srv, "GET", path).Header().Get("Service-Worker-Allowed"); got != "" {
|
||||||
|
t.Errorf("Service-Worker-Allowed leaked onto %s: %q", path, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec009_StaticIsServedInSetupMode: the bootstrap wizard has a stylesheet
|
||||||
|
// before the app exists — `static` is served in setup mode (SPEC-005 §3).
|
||||||
|
func TestSpec009_StaticIsServedInSetupMode(t *testing.T) {
|
||||||
|
h := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.Registry = staticRegistry()
|
||||||
|
d.SetupMode = func() bool { return true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if rec := request(h.srv, "GET", "/static/app.css"); rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /static/app.css in setup mode = %d, want 200 (SPEC-005 §3)", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET /home in setup mode = %d, want 404 (SPEC-005 §3)", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := request(h.srv, "GET", "/"+h.guid); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("a GUID branch in setup mode = %d, want 404 (SPEC-005 §3)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec009_WizardRendersInsideTheShell: the /setup wizard is restyled into
|
||||||
|
// the shell with the behaviour unchanged (SPEC-009 §7).
|
||||||
|
func TestSpec009_WizardRendersInsideTheShell(t *testing.T) {
|
||||||
|
h := newSetupHarness(t)
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/setup")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /setup = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"<!doctype html>",
|
||||||
|
"MyDay setup",
|
||||||
|
`href="/static/app.css"`,
|
||||||
|
"domain.save",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("the wizard page is missing %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNoInlineScripts(t, "the /setup wizard", body)
|
||||||
|
|
||||||
|
// A guest screen: no identity, so no nav.
|
||||||
|
if strings.Contains(body, "<nav") {
|
||||||
|
t.Error("the wizard rendered a nav — there is no identity in setup mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-4: the manifest, served ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestAC4_ManifestServedAndValid: the door serves the manifest with the right
|
||||||
|
// media type, and it installs at "/" (D-011).
|
||||||
|
func TestAC4_ManifestServedAndValid(t *testing.T) {
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.Registry = staticRegistry() })
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/static/manifest.webmanifest")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET the manifest = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/manifest+json") {
|
||||||
|
t.Errorf("manifest Content-Type = %q, want application/manifest+json", got)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
StartURL string `json:"start_url"`
|
||||||
|
Icons []struct {
|
||||||
|
Sizes string `json:"sizes"`
|
||||||
|
} `json:"icons"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
|
||||||
|
t.Fatalf("the served manifest is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if m.StartURL != "/" {
|
||||||
|
t.Errorf("served start_url = %q, want / (D-011)", m.StartURL)
|
||||||
|
}
|
||||||
|
if m.Name == "" || len(m.Icons) < 2 {
|
||||||
|
t.Errorf("the served manifest is incomplete: %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-5: the service worker, served (A5) ────────────────────────────────────
|
||||||
|
|
||||||
|
// TestAC5_ServiceWorkerServedAndNeverCachesAuthenticated: the worker the door
|
||||||
|
// serves declares only /static/ as cacheable, and the policy rejects an
|
||||||
|
// authenticated response and a /{GUID} page.
|
||||||
|
func TestAC5_ServiceWorkerServedAndNeverCachesAuthenticated(t *testing.T) {
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.Registry = staticRegistry() })
|
||||||
|
|
||||||
|
rec := request(h.srv, "GET", "/static/sw.js")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /static/sw.js = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/javascript") {
|
||||||
|
t.Errorf("sw.js Content-Type = %q, want text/javascript", got)
|
||||||
|
}
|
||||||
|
sw := rec.Body.String()
|
||||||
|
if !strings.Contains(sw, `var CACHEABLE_PREFIXES = ["/static/"];`) {
|
||||||
|
t.Errorf("the served worker's allowlist is not the static namespace alone (A5):\n%s", sw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sw, "if (!isCacheable(event.request)) {") {
|
||||||
|
t.Error("the served worker does not refuse non-cacheable requests before any cache access (A5)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The policy, applied to THIS instance's paths.
|
||||||
|
for _, path := range []string{
|
||||||
|
"/" + h.guid,
|
||||||
|
"/" + h.guid + "/home",
|
||||||
|
"/" + h.guid + "/core/modules",
|
||||||
|
"/access",
|
||||||
|
"/setup",
|
||||||
|
"/home",
|
||||||
|
"/",
|
||||||
|
} {
|
||||||
|
if web.CacheablePath("GET", path) {
|
||||||
|
t.Errorf("the service worker would cache %q (A5)", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/static/app.css", "/static/webauthn.js", "/static/sw.js"} {
|
||||||
|
if !web.CacheablePath("GET", path) {
|
||||||
|
t.Errorf("the service worker would not cache the static asset %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC-6: the collision guard, through the loader main uses ──────────────────
|
||||||
|
|
||||||
|
// TestAC6_CollidingModuleFailsTheLoadTheEntrypointUses proves Step 0 on the
|
||||||
|
// real load path: a discovered module claiming a core dispatch name makes
|
||||||
|
// module.Load fail, so the process refuses to start instead of silently
|
||||||
|
// dropping (or shadowing) the module (SPEC-008 §9 D-9).
|
||||||
|
func TestAC6_CollidingModuleFailsTheLoadTheEntrypointUses(t *testing.T) {
|
||||||
|
if _, err := module.LoadModules([]module.Module{&collidingModule{}}, nil); err == nil {
|
||||||
|
t.Fatal("a module colliding with a core dispatch name loaded without error (AC-6)")
|
||||||
|
}
|
||||||
|
// The discovered set the binary actually ships is unaffected.
|
||||||
|
if _, err := module.Load(nil); err != nil {
|
||||||
|
t.Fatalf("the real discovered set failed to load: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collidingModule is a discovered module claiming the core `auth` namespace.
|
||||||
|
type collidingModule struct{}
|
||||||
|
|
||||||
|
func (*collidingModule) Name() string { return "auth" }
|
||||||
|
func (*collidingModule) Required() bool { return false }
|
||||||
|
func (*collidingModule) Migrations() []module.Migration { return nil }
|
||||||
|
func (*collidingModule) Routes() []module.Route { return nil }
|
||||||
|
func (*collidingModule) Actions() []module.Action { return nil }
|
||||||
|
func (*collidingModule) Start(context.Context) error { return nil }
|
||||||
|
func (*collidingModule) Register(r *module.Registry) error { return r.Add(&collidingModule{}) }
|
||||||
127
internal/core/server/static.go
Normal file
127
internal/core/server/static.go
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaticWorkflow is the reserved level-1 workflow that serves the embedded
|
||||||
|
// assets (D-004: `static` is reserved from the very first commit). It is core
|
||||||
|
// owned — it carries no module and no identity — and it is served in setup mode
|
||||||
|
// too, so the bootstrap wizard has a stylesheet (SPEC-005 §3).
|
||||||
|
const StaticWorkflow = "static"
|
||||||
|
|
||||||
|
// StaticCacheControl is the cache lifetime for an asset. The assets are
|
||||||
|
// versionless static files, so the window is deliberately short.
|
||||||
|
const StaticCacheControl = "public, max-age=300"
|
||||||
|
|
||||||
|
// StaticWorkerName is the one asset that needs a wider service-worker scope.
|
||||||
|
// A worker script served from /static/sw.js may only control /static/ unless
|
||||||
|
// the response says otherwise, and MyDay registers it with scope "/" (the
|
||||||
|
// manifest's start_url is "/", D-011). Service-Worker-Allowed grants that, and
|
||||||
|
// the worker's own cache policy still refuses anything that is not a static
|
||||||
|
// asset (A5).
|
||||||
|
const StaticWorkerName = "sw.js"
|
||||||
|
|
||||||
|
// StaticWorkerAllowed is the scope granted to the worker script.
|
||||||
|
const StaticWorkerAllowed = "/"
|
||||||
|
|
||||||
|
// StaticHandler serves the assets embedded by internal/core/web under
|
||||||
|
// /static/{asset} (SPEC-009 §3). The door has already classified the reserved
|
||||||
|
// `static` workflow and handed off the path remainder; the handler never parses
|
||||||
|
// a raw path (the rule of the door — rc.Page is the joined remainder).
|
||||||
|
func StaticHandler() Handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
writeStaticError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(rc.Page)
|
||||||
|
if !validStaticName(name) {
|
||||||
|
writeStaticError(w, http.StatusNotFound, "not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(web.StaticFS, web.StaticDir+"/"+name)
|
||||||
|
if err != nil {
|
||||||
|
// Absent asset, or a name the embedded FS refuses (it rejects any
|
||||||
|
// path containing "..") — a 404 either way, never a directory list.
|
||||||
|
writeStaticError(w, http.StatusNotFound, "not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Content-Type", staticContentType(name))
|
||||||
|
h.Set("Cache-Control", StaticCacheControl)
|
||||||
|
if name == StaticWorkerName {
|
||||||
|
// Without this the browser refuses `scope: "/"` on a worker served
|
||||||
|
// from a sub-directory (SecurityError) and the registration fails.
|
||||||
|
h.Set("Service-Worker-Allowed", StaticWorkerAllowed)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err = w.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validStaticName accepts a clean, relative asset path only: no absolute path,
|
||||||
|
// no traversal, no empty segment. The embedded FS is already traversal-safe;
|
||||||
|
// this is defence in depth and keeps the 404 shape honest.
|
||||||
|
func validStaticName(name string) bool {
|
||||||
|
if name == "" || strings.HasPrefix(name, "/") || strings.ContainsAny(name, "\\\x00") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if name != path.Clean(name) {
|
||||||
|
return false // catches "..", "./x", "a//b", "a/"
|
||||||
|
}
|
||||||
|
for _, segment := range strings.Split(name, "/") {
|
||||||
|
if segment == "" || segment == "." || segment == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticContentType maps an asset's extension to its media type. The door
|
||||||
|
// always sends X-Content-Type-Options: nosniff, so an unknown extension is
|
||||||
|
// served as opaque bytes rather than something a browser might sniff.
|
||||||
|
func staticContentType(name string) string {
|
||||||
|
switch strings.ToLower(path.Ext(name)) {
|
||||||
|
case ".css":
|
||||||
|
return "text/css; charset=utf-8"
|
||||||
|
case ".js", ".mjs":
|
||||||
|
return "text/javascript; charset=utf-8"
|
||||||
|
case ".json":
|
||||||
|
return "application/json; charset=utf-8"
|
||||||
|
case ".webmanifest":
|
||||||
|
return "application/manifest+json"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml"
|
||||||
|
case ".ico":
|
||||||
|
return "image/vnd.microsoft.icon"
|
||||||
|
case ".txt":
|
||||||
|
return "text/plain; charset=utf-8"
|
||||||
|
case ".woff2":
|
||||||
|
return "font/woff2"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeStaticError writes a terse plain-text error response (matching the
|
||||||
|
// door's).
|
||||||
|
func writeStaticError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(msg + "\n"))
|
||||||
|
}
|
||||||
9
internal/core/store/embed.go
Normal file
9
internal/core/store/embed.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// coreMigrations holds MyDay's core schema, embedded into the binary so a
|
||||||
|
// single-file deploy needs no external SQL (D-003, D-028).
|
||||||
|
//
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var coreMigrations embed.FS
|
||||||
144
internal/core/store/migrate.go
Normal file
144
internal/core/store/migrate.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Migration is one ordered, named SQL migration — one file = one version. A
|
||||||
|
// migration may contain multiple statements; the driver executes the whole
|
||||||
|
// script atomically inside a transaction.
|
||||||
|
type Migration struct {
|
||||||
|
Version string // stable key, e.g. "0001_init" (the file name minus .sql)
|
||||||
|
SQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMigrations reads every <dir>/*.sql in fsys, sorted by file name so the
|
||||||
|
// order is deterministic (D-028 guardrail: "never map iteration").
|
||||||
|
func LoadMigrations(fsys fs.FS, dir string) ([]Migration, error) {
|
||||||
|
pattern := path.Join(dir, "*.sql")
|
||||||
|
names, err := fs.Glob(fsys, pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: glob %q: %w", pattern, err)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
out := make([]Migration, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
b, err := fs.ReadFile(fsys, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: read migration %q: %w", name, err)
|
||||||
|
}
|
||||||
|
version := strings.TrimSuffix(path.Base(name), ".sql")
|
||||||
|
out = append(out, Migration{Version: version, SQL: string(b)})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate applies the core schema to the database. It is idempotent: already
|
||||||
|
// applied versions are skipped.
|
||||||
|
func (db *DB) Migrate() error {
|
||||||
|
migrations, err := LoadMigrations(coreMigrations, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return RunMigrations(db.DB, migrations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigrations applies each migration not yet recorded, in order, each inside
|
||||||
|
// its own transaction. It is the runner the core owns; modules will call it
|
||||||
|
// with their own embedded migration sets later (D-028).
|
||||||
|
func RunMigrations(db *sql.DB, migrations []Migration) error {
|
||||||
|
if err := ensureMigrationsTable(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applied, err := appliedVersions(db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
if applied[m.Version] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := applyMigration(db, m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureMigrationsTable creates the bookkeeping table if it does not exist.
|
||||||
|
func ensureMigrationsTable(db *sql.DB) error {
|
||||||
|
const ddl = `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
)`
|
||||||
|
if _, err := db.Exec(ddl); err != nil {
|
||||||
|
return fmt.Errorf("store: create schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appliedVersions returns the set of versions already recorded.
|
||||||
|
func appliedVersions(db *sql.DB) (map[string]bool, error) {
|
||||||
|
rows, err := db.Query(`SELECT version FROM schema_migrations`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: read schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
applied := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var v string
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
applied[v] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: iterate schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
return applied, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMigration runs one migration and records it in a single transaction —
|
||||||
|
// SQLite DDL is transactional, so a failure leaves no partial schema.
|
||||||
|
func applyMigration(db *sql.DB, m Migration) error {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: begin migration %q: %w", m.Version, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(m.SQL); err != nil {
|
||||||
|
return fmt.Errorf("store: apply migration %q: %w", m.Version, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
|
||||||
|
m.Version, FormatTime(time.Now()),
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("store: record migration %q: %w", m.Version, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("store: commit migration %q: %w", m.Version, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTime renders a timestamp in the canonical storage format: RFC 3339,
|
||||||
|
// UTC, second precision. Fixed width and UTC mean lexicographic order equals
|
||||||
|
// chronological order, so SQL range comparisons are sound.
|
||||||
|
func FormatTime(t time.Time) string {
|
||||||
|
return t.UTC().Truncate(time.Second).Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTime parses the canonical storage format produced by FormatTime.
|
||||||
|
func ParseTime(s string) (time.Time, error) {
|
||||||
|
return time.Parse(time.RFC3339, s)
|
||||||
|
}
|
||||||
103
internal/core/store/migrations/0001_init.sql
Normal file
103
internal/core/store/migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
-- 0001_init.sql — SPEC-004 §4: the identity & session schema.
|
||||||
|
--
|
||||||
|
-- Storage conventions:
|
||||||
|
-- * timestamps are TEXT, RFC 3339, always UTC, second precision — so
|
||||||
|
-- lexicographic comparison equals chronological comparison;
|
||||||
|
-- * booleans are INTEGER 0/1;
|
||||||
|
-- * secret material (TOTP) is stored ENCRYPTED (A9) — never plaintext.
|
||||||
|
|
||||||
|
-- ── users ───────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE users (
|
||||||
|
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('admin','director','manager','team')),
|
||||||
|
manager_user_id INTEGER REFERENCES users(user_id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
-- The chain (D-020/D-034): only the admin sits at the root, so only the
|
||||||
|
-- admin has a NULL manager.
|
||||||
|
CHECK ((role = 'admin') = (manager_user_id IS NULL)),
|
||||||
|
-- user_id 1 IS the top admin (D-013, D-021).
|
||||||
|
CHECK (user_id <> 1 OR role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── identities ──────────────────────────────────────────────────────────────
|
||||||
|
-- Credentials hang off an identity, never the user; the GUID is the rotating
|
||||||
|
-- username and is NEVER recycled (D-008) — revoked rows remain as tombstones.
|
||||||
|
CREATE TABLE identities (
|
||||||
|
identity_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||||
|
guid TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending_enrollment','active','revoked')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
enrolled_at TEXT,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invariant (SPEC-004 §4, AC-1): exactly one ACTIVE identity per user.
|
||||||
|
CREATE UNIQUE INDEX identities_one_active_per_user
|
||||||
|
ON identities(user_id) WHERE status = 'active';
|
||||||
|
CREATE INDEX identities_user ON identities(user_id);
|
||||||
|
|
||||||
|
-- ── webauthn_credentials ────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE webauthn_credentials (
|
||||||
|
credential_id BLOB PRIMARY KEY,
|
||||||
|
identity_id INTEGER NOT NULL REFERENCES identities(identity_id) ON DELETE CASCADE,
|
||||||
|
public_key BLOB NOT NULL,
|
||||||
|
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transports TEXT NOT NULL DEFAULT '',
|
||||||
|
aaguid BLOB,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX webauthn_credentials_identity ON webauthn_credentials(identity_id);
|
||||||
|
|
||||||
|
-- ── totp_secrets ────────────────────────────────────────────────────────────
|
||||||
|
-- A9: secret_encrypted holds ciphertext (nonce || AES-GCM), never plaintext.
|
||||||
|
CREATE TABLE totp_secrets (
|
||||||
|
identity_id INTEGER PRIMARY KEY REFERENCES identities(identity_id) ON DELETE CASCADE,
|
||||||
|
secret_encrypted BLOB NOT NULL,
|
||||||
|
verified_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── sessions ────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
identity_id INTEGER NOT NULL REFERENCES identities(identity_id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
client_ip TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX sessions_identity ON sessions(identity_id);
|
||||||
|
CREATE INDEX sessions_expires ON sessions(expires_at);
|
||||||
|
|
||||||
|
-- ── recovery_requests ───────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE recovery_requests (
|
||||||
|
request_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending','approved','denied','expired')),
|
||||||
|
requested_at TEXT NOT NULL,
|
||||||
|
decided_by INTEGER REFERENCES users(user_id),
|
||||||
|
decided_at TEXT,
|
||||||
|
client_ip TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX recovery_requests_user_status ON recovery_requests(user_id, status);
|
||||||
|
|
||||||
|
-- ── audit_log ───────────────────────────────────────────────────────────────
|
||||||
|
-- Append-only security trail (A11). No FK on the actor: history outlives users.
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
at TEXT NOT NULL,
|
||||||
|
actor_user_id INTEGER,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
subject TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
client_ip TEXT,
|
||||||
|
location TEXT,
|
||||||
|
correlation_id TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX audit_log_at ON audit_log(at);
|
||||||
|
CREATE INDEX audit_log_action ON audit_log(action);
|
||||||
7
internal/core/store/migrations/0002_passkey_opt_out.sql
Normal file
7
internal/core/store/migrations/0002_passkey_opt_out.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- 0002_passkey_opt_out.sql — SPEC-004 §4 / D-041: the passkey-nudge opt-out.
|
||||||
|
--
|
||||||
|
-- A user preference, NOT a credential: it hangs off the stable user record so
|
||||||
|
-- it survives identity rotation (a recovered user is not re-nudged). 0001_init
|
||||||
|
-- is never edited — migrations are ordered and append-only.
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN passkey_opt_out INTEGER NOT NULL DEFAULT 0;
|
||||||
99
internal/core/store/store.go
Normal file
99
internal/core/store/store.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// Package store owns MyDay's SQLite connection and migration runner
|
||||||
|
// (PROJECT_PLAN.md §12.1: "the core owns the DB connection and the migration
|
||||||
|
// runner; each module owns its own tables, queries, and migrations").
|
||||||
|
//
|
||||||
|
// It uses the CGO-free pure-Go driver modernc.org/sqlite (D-012), so the
|
||||||
|
// shipped binary stays static — no mattn/go-sqlite3, no libc dependency — and
|
||||||
|
// runs with WAL journaling and foreign keys ON.
|
||||||
|
//
|
||||||
|
// Spec: docs/specs/spec-004-identity-sessions.md §3–§4.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // registers the "sqlite" driver (D-012)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver is the SQLite driver name registered by modernc.org/sqlite (D-012).
|
||||||
|
const Driver = "sqlite"
|
||||||
|
|
||||||
|
// DB wraps the standard connection pool. The embedded *sql.DB is what the core
|
||||||
|
// services (users, auth, audit) consume.
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens (creating if needed) the SQLite database at path.
|
||||||
|
//
|
||||||
|
// The DSN carries the connection pragmas so they apply to every connection the
|
||||||
|
// pool opens: WAL journaling, foreign keys ON, a busy timeout, and NORMAL
|
||||||
|
// synchronous (safe with WAL, faster than FULL).
|
||||||
|
//
|
||||||
|
// SQLite is a single-writer store, so the pool is pinned to ONE connection:
|
||||||
|
// this removes lock contention (D-012: "one instance only") and keeps an
|
||||||
|
// in-memory database coherent across the whole process.
|
||||||
|
//
|
||||||
|
// A non-empty ":memory:" (or file:...mode=memory) path opens a private
|
||||||
|
// in-memory database — used by the unit tests.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("store: database path is empty")
|
||||||
|
}
|
||||||
|
if !isMemory(path) {
|
||||||
|
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: create data directory %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open(Driver, buildDSN(path))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: open %q: %w", path, err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxLifetime(0)
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("store: ping %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return &DB{DB: db, path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the database path this connection was opened with.
|
||||||
|
func (db *DB) Path() string { return db.path }
|
||||||
|
|
||||||
|
// isMemory reports whether path names an in-memory database.
|
||||||
|
func isMemory(path string) bool {
|
||||||
|
return path == ":memory:" || strings.Contains(path, "mode=memory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDSN assembles the modernc.org/sqlite DSN. The `_pragma` parameters are
|
||||||
|
// applied on every connection open (D-012: WAL · foreign keys ON).
|
||||||
|
func buildDSN(path string) string {
|
||||||
|
pragmas := []string{
|
||||||
|
"journal_mode(WAL)",
|
||||||
|
"foreign_keys(1)",
|
||||||
|
"busy_timeout(5000)",
|
||||||
|
"synchronous(NORMAL)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if isMemory(path) {
|
||||||
|
b.WriteString("file::memory:")
|
||||||
|
} else {
|
||||||
|
b.WriteString("file:")
|
||||||
|
b.WriteString(path)
|
||||||
|
}
|
||||||
|
b.WriteString("?_pragma=")
|
||||||
|
b.WriteString(strings.Join(pragmas, "&_pragma="))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
139
internal/core/store/store_test.go
Normal file
139
internal/core/store/store_test.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openTestDB opens a fresh temp-file database and applies the core schema.
|
||||||
|
func openTestDB(t *testing.T) *DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := Open(filepath.Join(t.TempDir(), "myday.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenSetsPragmas(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
var journal string
|
||||||
|
if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&journal); err != nil {
|
||||||
|
t.Fatalf("PRAGMA journal_mode: %v", err)
|
||||||
|
}
|
||||||
|
if journal != "wal" {
|
||||||
|
t.Errorf("journal_mode = %q, want wal (D-012)", journal)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fk int
|
||||||
|
if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&fk); err != nil {
|
||||||
|
t.Fatalf("PRAGMA foreign_keys: %v", err)
|
||||||
|
}
|
||||||
|
if fk != 1 {
|
||||||
|
t.Errorf("foreign_keys = %d, want 1 (D-012)", fk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateIsIdempotentAndCreatesSchema(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
// A second Migrate must be a no-op, not an error.
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
t.Fatalf("second Migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"users", "identities", "webauthn_credentials",
|
||||||
|
"totp_secrets", "sessions", "recovery_requests",
|
||||||
|
"audit_log", "schema_migrations",
|
||||||
|
}
|
||||||
|
for _, table := range want {
|
||||||
|
var name string
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table,
|
||||||
|
).Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("table %q missing after Migrate: %v", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-active-identity partial index is part of the schema (AC-1).
|
||||||
|
var name string
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'identities_one_active_per_user'`,
|
||||||
|
).Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("partial unique index missing after Migrate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateForeignKeysEnforced(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
// An identity for a non-existent user must be rejected — foreign keys ON.
|
||||||
|
_, err := db.Exec(
|
||||||
|
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (999, 'abc', 'pending_enrollment', '2026-01-01T00:00:00Z')`,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("insert with dangling user_id succeeded, want FK violation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenInMemory(t *testing.T) {
|
||||||
|
db, err := Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open(:memory:): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := db.Migrate(); err != nil {
|
||||||
|
t.Fatalf("Migrate(:memory:): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO users (email, display_name, role, status, created_at) VALUES ('a@b.c','A','admin','active','2026-01-01T00:00:00Z')`); err != nil {
|
||||||
|
t.Fatalf("insert into in-memory db: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMigrationsSorted(t *testing.T) {
|
||||||
|
ms, err := LoadMigrations(coreMigrations, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMigrations: %v", err)
|
||||||
|
}
|
||||||
|
if len(ms) == 0 {
|
||||||
|
t.Fatal("no migrations loaded")
|
||||||
|
}
|
||||||
|
for i := 1; i < len(ms); i++ {
|
||||||
|
if ms[i-1].Version >= ms[i].Version {
|
||||||
|
t.Errorf("migrations not sorted: %q before %q", ms[i-1].Version, ms[i].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ms[0].Version != "0001_init" {
|
||||||
|
t.Errorf("first migration = %q, want 0001_init", ms[0].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatParseTimeRoundTrip(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 9, 30, 15, 0, time.UTC)
|
||||||
|
s := FormatTime(now)
|
||||||
|
if s != "2026-10-13T09:30:15Z" {
|
||||||
|
t.Errorf("FormatTime = %q, want %q", s, "2026-10-13T09:30:15Z")
|
||||||
|
}
|
||||||
|
got, err := ParseTime(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTime: %v", err)
|
||||||
|
}
|
||||||
|
if !got.Equal(now) {
|
||||||
|
t.Errorf("ParseTime round-trip = %v, want %v", got, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-UTC time is normalised to UTC.
|
||||||
|
loc := time.FixedZone("BST", 3600)
|
||||||
|
s2 := FormatTime(time.Date(2026, 7, 1, 12, 0, 0, 0, loc))
|
||||||
|
if s2 != "2026-07-01T11:00:00Z" {
|
||||||
|
t.Errorf("FormatTime(non-UTC) = %q, want 2026-07-01T11:00:00Z", s2)
|
||||||
|
}
|
||||||
|
}
|
||||||
256
internal/core/users/store.go
Normal file
256
internal/core/users/store.go
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the SQLite-backed Users service.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
audit audit.Audit
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Users service over db. aud may be nil (tests) — audit is then
|
||||||
|
// skipped rather than panicking.
|
||||||
|
func New(db *sql.DB, aud audit.Audit) *Store {
|
||||||
|
return &Store{db: db, audit: aud, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Users = (*Store)(nil)
|
||||||
|
|
||||||
|
// Create implements Users.Create (SPEC-004 §5, AC-6, D-019).
|
||||||
|
func (s *Store) Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID, error) {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
displayName = strings.TrimSpace(displayName)
|
||||||
|
if email == "" {
|
||||||
|
return 0, ErrEmailRequired
|
||||||
|
}
|
||||||
|
if displayName == "" {
|
||||||
|
return 0, ErrNameRequired
|
||||||
|
}
|
||||||
|
if !role.Valid() {
|
||||||
|
return 0, ErrInvalidRole
|
||||||
|
}
|
||||||
|
|
||||||
|
var manager Optional[UserID]
|
||||||
|
switch {
|
||||||
|
case creatorID == 0:
|
||||||
|
// Bootstrap (D-021/D-022): the first user IS the top admin.
|
||||||
|
n, err := s.Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
return 0, ErrBootstrapClosed
|
||||||
|
}
|
||||||
|
if role != RoleAdmin {
|
||||||
|
return 0, ErrBootstrapAdmin
|
||||||
|
}
|
||||||
|
manager = None[UserID]()
|
||||||
|
default:
|
||||||
|
creator, err := s.Get(ctx, creatorID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// One admin only — user 1 already exists (AC-6).
|
||||||
|
if role == RoleAdmin {
|
||||||
|
return 0, ErrOneAdmin
|
||||||
|
}
|
||||||
|
// Creation authority: a role strictly above the new user's (D-019).
|
||||||
|
if !creator.Role.Above(role) {
|
||||||
|
return 0, fmt.Errorf("%w: %s cannot create %s", ErrRoleNotAllowed, creator.Role, role)
|
||||||
|
}
|
||||||
|
manager = Some(creatorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt := store.FormatTime(s.now())
|
||||||
|
res, err := s.db.Exec(
|
||||||
|
`INSERT INTO users (email, display_name, role, manager_user_id, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
email, displayName, string(role), nullableID(manager), StatusActive, createdAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return 0, fmt.Errorf("%w: %s", ErrDuplicateEmail, email)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("users: insert: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("users: last insert id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.record(ctx, audit.Event{
|
||||||
|
ActorUserID: creatorID,
|
||||||
|
Action: "user.create",
|
||||||
|
Subject: fmt.Sprintf("user:%d", id),
|
||||||
|
Detail: fmt.Sprintf("email=%s role=%s", email, role),
|
||||||
|
})
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get implements Users.Get.
|
||||||
|
func (s *Store) Get(ctx context.Context, id int64) (User, error) {
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at
|
||||||
|
FROM users WHERE user_id = ?`, id)
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRole implements Users.SetRole. The role ripples down (D-034); the
|
||||||
|
// one-admin invariant is preserved (AC-6) and user 1 is protected (A14).
|
||||||
|
func (s *Store) SetRole(ctx context.Context, id int64, role Role) error {
|
||||||
|
if !role.Valid() {
|
||||||
|
return ErrInvalidRole
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case id == 1 && role != RoleAdmin:
|
||||||
|
return ErrTopAdmin
|
||||||
|
case id != 1 && role == RoleAdmin:
|
||||||
|
return ErrOneAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.Get(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`UPDATE users SET role = ? WHERE user_id = ?`, string(role), id); err != nil {
|
||||||
|
return fmt.Errorf("users: update role: %w", err)
|
||||||
|
}
|
||||||
|
// The actor of a role change is whoever the door authenticated, not the
|
||||||
|
// user being changed — it comes from the audit.Meta on ctx (R-1/R-3).
|
||||||
|
s.record(ctx, audit.Event{
|
||||||
|
Action: "user.set_role",
|
||||||
|
Subject: fmt.Sprintf("user:%d", id),
|
||||||
|
Detail: fmt.Sprintf("role=%s", role),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager implements Users.Manager — the chain link above id.
|
||||||
|
func (s *Store) Manager(ctx context.Context, id int64) (Optional[UserID], error) {
|
||||||
|
var m sql.NullInt64
|
||||||
|
err := s.db.QueryRow(`SELECT manager_user_id FROM users WHERE user_id = ?`, id).Scan(&m)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return None[UserID](), ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return None[UserID](), fmt.Errorf("users: manager lookup: %w", err)
|
||||||
|
}
|
||||||
|
if m.Valid {
|
||||||
|
return Some(m.Int64), nil
|
||||||
|
}
|
||||||
|
return None[UserID](), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of users. It answers "is the instance in setup
|
||||||
|
// mode?" (SPEC-005 §3: no user exists).
|
||||||
|
func (s *Store) Count(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRow(`SELECT count(*) FROM users`).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("users: count: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByEmail returns the user with the given email (recovery lookup, SPEC-007).
|
||||||
|
// The match is case-insensitive.
|
||||||
|
func (s *Store) ByEmail(ctx context.Context, email string) (User, error) {
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at
|
||||||
|
FROM users WHERE email = ?`, strings.ToLower(strings.TrimSpace(email)))
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyOptOut implements Users.PasskeyOptOut (D-041): the user's stored
|
||||||
|
// preference about the post-login passkey nudge.
|
||||||
|
func (s *Store) PasskeyOptOut(ctx context.Context, id int64) (bool, error) {
|
||||||
|
var optOut int
|
||||||
|
err := s.db.QueryRow(`SELECT passkey_opt_out FROM users WHERE user_id = ?`, id).Scan(&optOut)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("users: passkey opt-out lookup: %w", err)
|
||||||
|
}
|
||||||
|
return optOut != 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPasskeyOptOut implements Users.SetPasskeyOptOut (D-041). It is a plain
|
||||||
|
// preference write — no one-admin or role rule applies — and is audited (A11).
|
||||||
|
// The actor comes from the context (ruling R-1); the user is the subject.
|
||||||
|
func (s *Store) SetPasskeyOptOut(ctx context.Context, id int64, optOut bool) error {
|
||||||
|
if _, err := s.Get(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v := 0
|
||||||
|
if optOut {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`UPDATE users SET passkey_opt_out = ? WHERE user_id = ?`, v, id); err != nil {
|
||||||
|
return fmt.Errorf("users: set passkey opt-out: %w", err)
|
||||||
|
}
|
||||||
|
s.record(ctx, audit.Event{
|
||||||
|
Action: "user.passkey_opt_out",
|
||||||
|
Subject: fmt.Sprintf("user:%d", id),
|
||||||
|
Detail: fmt.Sprintf("passkey_opt_out=%t", optOut),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowScanner is satisfied by *sql.Row and *sql.Rows.
|
||||||
|
type rowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUser(row rowScanner) (User, error) {
|
||||||
|
var (
|
||||||
|
u User
|
||||||
|
role string
|
||||||
|
manager sql.NullInt64
|
||||||
|
optOut int
|
||||||
|
created string
|
||||||
|
)
|
||||||
|
err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &role, &manager, &optOut, &u.Status, &created)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return User{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("users: scan: %w", err)
|
||||||
|
}
|
||||||
|
u.Role = Role(role)
|
||||||
|
u.PasskeyOptOut = optOut != 0
|
||||||
|
if manager.Valid {
|
||||||
|
u.Manager = Some(manager.Int64)
|
||||||
|
}
|
||||||
|
if u.CreatedAt, err = store.ParseTime(created); err != nil {
|
||||||
|
return User{}, fmt.Errorf("users: parse created_at: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableID(o Optional[UserID]) any {
|
||||||
|
if !o.Set {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return o.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) record(ctx context.Context, e audit.Event) {
|
||||||
|
if s.audit != nil {
|
||||||
|
s.audit.Record(ctx, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUniqueViolation reports whether err is a SQLite UNIQUE-constraint failure.
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||||
|
}
|
||||||
168
internal/core/users/users.go
Normal file
168
internal/core/users/users.go
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
// Package users is MyDay's user service: the stable person record, the role
|
||||||
|
// ladder, and the manager chain (PROJECT_PLAN.md §7, D-020/D-024/D-034).
|
||||||
|
//
|
||||||
|
// The user is the stable identity anchor; credentials hang off a rotating
|
||||||
|
// identity owned by the auth package (D-008). This package never touches
|
||||||
|
// credentials.
|
||||||
|
//
|
||||||
|
// Spec: docs/specs/spec-004-identity-sessions.md §3, §5.
|
||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role is a user's single tier in the ladder admin > director > manager > team
|
||||||
|
// (D-034: one role per user, no ACL — the role ripples down).
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleAdmin Role = "admin"
|
||||||
|
RoleDirector Role = "director"
|
||||||
|
RoleManager Role = "manager"
|
||||||
|
RoleTeam Role = "team"
|
||||||
|
)
|
||||||
|
|
||||||
|
// roleRank orders the ladder. A higher number implies every lower role.
|
||||||
|
var roleRank = map[Role]int{
|
||||||
|
RoleTeam: 1,
|
||||||
|
RoleManager: 2,
|
||||||
|
RoleDirector: 3,
|
||||||
|
RoleAdmin: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether r is one of the four defined roles.
|
||||||
|
func (r Role) Valid() bool {
|
||||||
|
_, ok := roleRank[r]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank returns the ladder position (higher = more authority); 0 for invalid.
|
||||||
|
func (r Role) Rank() int { return roleRank[r] }
|
||||||
|
|
||||||
|
// Above reports whether r is strictly higher than other on the ladder.
|
||||||
|
func (r Role) Above(other Role) bool { return r.Rank() > other.Rank() }
|
||||||
|
|
||||||
|
// String renders the role.
|
||||||
|
func (r Role) String() string { return string(r) }
|
||||||
|
|
||||||
|
// ParseRole validates a stored/entered role string.
|
||||||
|
func ParseRole(s string) (Role, error) {
|
||||||
|
r := Role(s)
|
||||||
|
if !r.Valid() {
|
||||||
|
return "", fmt.Errorf("%w: %q", ErrInvalidRole, s)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional carries a value that may be absent (Go has no nullable generics).
|
||||||
|
// It models the manager chain: the top admin has NO manager (D-021).
|
||||||
|
type Optional[T any] struct {
|
||||||
|
Value T
|
||||||
|
Set bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some returns a set Optional.
|
||||||
|
func Some[T any](v T) Optional[T] { return Optional[T]{Value: v, Set: true} }
|
||||||
|
|
||||||
|
// None returns an unset Optional.
|
||||||
|
func None[T any]() Optional[T] { return Optional[T]{} }
|
||||||
|
|
||||||
|
// UserID is the internal, stable person id. The first user is 1 (D-013).
|
||||||
|
type UserID = int64
|
||||||
|
|
||||||
|
// User statuses. A disabled user keeps their record and history but may not act.
|
||||||
|
const (
|
||||||
|
StatusActive = "active"
|
||||||
|
StatusDisabled = "disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CanApprove reports whether approver may decide a recovery request for
|
||||||
|
// requester (SPEC-007 §4, ruling R-7). The authority is the CHAIN, not the
|
||||||
|
// ladder: the approver must be an ANCESTOR of the requester — on their manager
|
||||||
|
// line, upward — or the admin. The requester may never approve their own
|
||||||
|
// request (AC-2).
|
||||||
|
//
|
||||||
|
// approverIsAncestor is computed by the caller, which owns the chain lookup
|
||||||
|
// (users.Users.Manager); this predicate stays pure. A role merely *above* the
|
||||||
|
// requester is NOT sufficient — that was R-7's correction: an unrelated
|
||||||
|
// director must not be able to recover a team member outside their subtree.
|
||||||
|
func CanApprove(approver, requester User, approverIsAncestor bool) bool {
|
||||||
|
if approver.ID == requester.ID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if approver.Role == RoleAdmin {
|
||||||
|
return true // the admin is the root of the chain and approves anyone (§4)
|
||||||
|
}
|
||||||
|
return approverIsAncestor
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is the stable person record (SPEC-004 §4 `users`).
|
||||||
|
type User struct {
|
||||||
|
ID UserID
|
||||||
|
Email string
|
||||||
|
DisplayName string
|
||||||
|
Role Role
|
||||||
|
// Manager is the chain link: who approves this user (D-020). Unset only
|
||||||
|
// for the top admin.
|
||||||
|
Manager Optional[UserID]
|
||||||
|
// PasskeyOptOut records that the user declined the passkey nudge (D-041).
|
||||||
|
// It is a USER preference (not a credential), so it survives identity
|
||||||
|
// rotation (D-008): an opted-out user is never re-nudged after recovery.
|
||||||
|
PasskeyOptOut bool
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users is the service contract (SPEC-004 §5).
|
||||||
|
//
|
||||||
|
// Per ruling R-1 every method takes the request context: the door attaches
|
||||||
|
// audit.Meta (actor · client IP · correlation) before calling, and each
|
||||||
|
// mutation writes its audit row through it (A11).
|
||||||
|
type Users interface {
|
||||||
|
// Create creates a user below the creator on the chain.
|
||||||
|
//
|
||||||
|
// * creatorID != 0 — the creator must hold a role strictly ABOVE role
|
||||||
|
// (D-019/§7.3); the new user's manager defaults to the creator. A
|
||||||
|
// second admin is always rejected (AC-6).
|
||||||
|
// * creatorID == 0 — bootstrap: allowed ONLY while no user exists, and
|
||||||
|
// it must create the top admin (user 1, role admin, manager NULL).
|
||||||
|
Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID int64, err error)
|
||||||
|
|
||||||
|
// Get returns one user.
|
||||||
|
Get(ctx context.Context, id int64) (User, error)
|
||||||
|
|
||||||
|
// SetRole changes a user's role (ripple-down model, D-034). It preserves
|
||||||
|
// the one-admin invariant: only user 1 may be admin, and user 1 may not be
|
||||||
|
// demoted (A14).
|
||||||
|
SetRole(ctx context.Context, id int64, role Role) error
|
||||||
|
|
||||||
|
// Manager returns the user above id in the chain.
|
||||||
|
Manager(ctx context.Context, id int64) (Optional[UserID], error)
|
||||||
|
|
||||||
|
// PasskeyOptOut reports whether the user has declined the passkey nudge
|
||||||
|
// (D-041). The marker is a USER preference, so it survives identity
|
||||||
|
// rotation.
|
||||||
|
PasskeyOptOut(ctx context.Context, id int64) (bool, error)
|
||||||
|
|
||||||
|
// SetPasskeyOptOut records the user's choice about the passkey nudge
|
||||||
|
// (D-041) and audits it (A11).
|
||||||
|
SetPasskeyOptOut(ctx context.Context, id int64, optOut bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel errors.
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("users: user not found")
|
||||||
|
ErrInvalidRole = errors.New("users: invalid role")
|
||||||
|
ErrRoleNotAllowed = errors.New("users: creator's role is not above the new user's role")
|
||||||
|
ErrOneAdmin = errors.New("users: exactly one admin is allowed")
|
||||||
|
ErrTopAdmin = errors.New("users: the top admin (user 1) cannot be demoted")
|
||||||
|
ErrDuplicateEmail = errors.New("users: email already in use")
|
||||||
|
ErrEmailRequired = errors.New("users: email is required")
|
||||||
|
ErrNameRequired = errors.New("users: display name is required")
|
||||||
|
ErrBootstrapClosed = errors.New("users: bootstrap is only allowed while no users exist")
|
||||||
|
ErrBootstrapAdmin = errors.New("users: the bootstrap user must be the admin")
|
||||||
|
)
|
||||||
405
internal/core/users/users_test.go
Normal file
405
internal/core/users/users_test.go
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
package users_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
||||||
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testCtx is the request context every service call needs under ruling R-1.
|
||||||
|
var testCtx = context.Background()
|
||||||
|
|
||||||
|
func newUsers(t *testing.T) (*users.Store, *store.DB) {
|
||||||
|
t.Helper()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return users.New(db.DB, audit.New(db.DB)), db
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrap creates the top admin (user 1) and returns its id.
|
||||||
|
func bootstrap(t *testing.T, s *users.Store) int64 {
|
||||||
|
t.Helper()
|
||||||
|
id, err := s.Create(testCtx, 0, "admin@example.com", "Admin", users.RoleAdmin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bootstrap Create: %v", err)
|
||||||
|
}
|
||||||
|
if id != 1 {
|
||||||
|
t.Fatalf("bootstrap created user %d, want 1 (D-013)", id)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCanApproveChainRule pins ruling R-7: recovery authority is the CHAIN —
|
||||||
|
// the approver must be an ancestor of the requester (their manager upward) or
|
||||||
|
// the admin, and never the requester. A role merely ABOVE the requester is not
|
||||||
|
// enough.
|
||||||
|
func TestCanApproveChainRule(t *testing.T) {
|
||||||
|
admin := users.User{ID: 1, Role: users.RoleAdmin}
|
||||||
|
director := users.User{ID: 2, Role: users.RoleDirector}
|
||||||
|
manager := users.User{ID: 3, Role: users.RoleManager}
|
||||||
|
team := users.User{ID: 4, Role: users.RoleTeam}
|
||||||
|
otherDirector := users.User{ID: 5, Role: users.RoleDirector} // a different subtree
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
approver users.User
|
||||||
|
requester users.User
|
||||||
|
isAncestor bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"self (team)", team, team, false, false},
|
||||||
|
{"self (admin)", admin, admin, false, false},
|
||||||
|
{"manager is an ancestor", manager, team, true, true},
|
||||||
|
{"director is an ancestor", director, team, true, true},
|
||||||
|
{"admin is the root (walked)", admin, team, true, true},
|
||||||
|
{"admin with no walked chain still approves", admin, team, false, true},
|
||||||
|
{"peer (same role, not ancestor)", team, users.User{ID: 9, Role: users.RoleTeam}, false, false},
|
||||||
|
{"role above but NOT an ancestor", otherDirector, team, false, false},
|
||||||
|
{"ancestor flag cannot override self", manager, manager, true, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := users.CanApprove(c.approver, c.requester, c.isAncestor); got != c.want {
|
||||||
|
t.Errorf("%s: CanApprove = %v, want %v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleLadder(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
higher, lower users.Role
|
||||||
|
}{
|
||||||
|
{users.RoleAdmin, users.RoleDirector},
|
||||||
|
{users.RoleAdmin, users.RoleManager},
|
||||||
|
{users.RoleAdmin, users.RoleTeam},
|
||||||
|
{users.RoleDirector, users.RoleManager},
|
||||||
|
{users.RoleDirector, users.RoleTeam},
|
||||||
|
{users.RoleManager, users.RoleTeam},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if !c.higher.Above(c.lower) {
|
||||||
|
t.Errorf("%s should be above %s", c.higher, c.lower)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range []users.Role{users.RoleAdmin, users.RoleDirector, users.RoleManager, users.RoleTeam} {
|
||||||
|
if !r.Valid() {
|
||||||
|
t.Errorf("%s should be valid", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if users.Role("boss").Valid() {
|
||||||
|
t.Error("unknown role reported valid")
|
||||||
|
}
|
||||||
|
if _, err := users.ParseRole("nope"); !errors.Is(err, users.ErrInvalidRole) {
|
||||||
|
t.Errorf("ParseRole(nope) = %v, want ErrInvalidRole", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapRules(t *testing.T) {
|
||||||
|
s, _ := newUsers(t)
|
||||||
|
|
||||||
|
// A bootstrap must be the admin.
|
||||||
|
if _, err := s.Create(testCtx, 0, "x@example.com", "X", users.RoleTeam); !errors.Is(err, users.ErrBootstrapAdmin) {
|
||||||
|
t.Errorf("bootstrap non-admin = %v, want ErrBootstrapAdmin", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := bootstrap(t, s)
|
||||||
|
u, err := s.Get(testCtx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get(1): %v", err)
|
||||||
|
}
|
||||||
|
if u.Role != users.RoleAdmin {
|
||||||
|
t.Errorf("user 1 role = %s, want admin", u.Role)
|
||||||
|
}
|
||||||
|
if u.Manager.Set {
|
||||||
|
t.Errorf("admin manager = %v, want none (root of the chain)", u.Manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second bootstrap is refused once users exist.
|
||||||
|
if _, err := s.Create(testCtx, 0, "y@example.com", "Y", users.RoleAdmin); !errors.Is(err, users.ErrBootstrapClosed) {
|
||||||
|
t.Errorf("second bootstrap = %v, want ErrBootstrapClosed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AC-6: creating an admin once user 1 exists is rejected.
|
||||||
|
func TestAC6_SecondAdminRejected(t *testing.T) {
|
||||||
|
s, _ := newUsers(t)
|
||||||
|
admin := bootstrap(t, s)
|
||||||
|
|
||||||
|
if _, err := s.Create(testCtx, admin, "admin2@example.com", "Admin2", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
|
||||||
|
t.Fatalf("Create(role=admin) = %v, want ErrOneAdmin (AC-6)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second director is fine — only admin is unique.
|
||||||
|
director, err := s.Create(testCtx, admin, "director@example.com", "Director", users.RoleDirector)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin creates director: %v", err)
|
||||||
|
}
|
||||||
|
// Promoting that director to admin is also rejected (one admin).
|
||||||
|
if err := s.SetRole(testCtx, director, users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
|
||||||
|
t.Errorf("SetRole(admin) = %v, want ErrOneAdmin", err)
|
||||||
|
}
|
||||||
|
// Demoting the top admin is refused (A14).
|
||||||
|
if err := s.SetRole(testCtx, admin, users.RoleDirector); !errors.Is(err, users.ErrTopAdmin) {
|
||||||
|
t.Errorf("demote top admin = %v, want ErrTopAdmin", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreationAuthorityAndChain(t *testing.T) {
|
||||||
|
s, _ := newUsers(t)
|
||||||
|
admin := bootstrap(t, s)
|
||||||
|
|
||||||
|
director, err := s.Create(testCtx, admin, "d@example.com", "D", users.RoleDirector)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin -> director: %v", err)
|
||||||
|
}
|
||||||
|
// The new user's manager defaults to the creator.
|
||||||
|
mgr, err := s.Manager(testCtx, director)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Manager(director): %v", err)
|
||||||
|
}
|
||||||
|
if !mgr.Set || mgr.Value != admin {
|
||||||
|
t.Errorf("director manager = %+v, want admin %d", mgr, admin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A peer cannot be created: a director may not create a director.
|
||||||
|
if _, err := s.Create(testCtx, director, "d2@example.com", "D2", users.RoleDirector); !errors.Is(err, users.ErrRoleNotAllowed) {
|
||||||
|
t.Errorf("director -> director = %v, want ErrRoleNotAllowed", err)
|
||||||
|
}
|
||||||
|
// Nor may a director create an admin.
|
||||||
|
if _, err := s.Create(testCtx, director, "a2@example.com", "A2", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
|
||||||
|
t.Errorf("director -> admin = %v, want ErrOneAdmin", err)
|
||||||
|
}
|
||||||
|
// A manager below the director is fine.
|
||||||
|
manager, err := s.Create(testCtx, director, "m@example.com", "M", users.RoleManager)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("director -> manager: %v", err)
|
||||||
|
}
|
||||||
|
// A team member below the manager is fine.
|
||||||
|
if _, err := s.Create(testCtx, manager, "t@example.com", "T", users.RoleTeam); err != nil {
|
||||||
|
t.Fatalf("manager -> team: %v", err)
|
||||||
|
}
|
||||||
|
// A manager may not create a manager.
|
||||||
|
if _, err := s.Create(testCtx, manager, "m2@example.com", "M2", users.RoleManager); !errors.Is(err, users.ErrRoleNotAllowed) {
|
||||||
|
t.Errorf("manager -> manager = %v, want ErrRoleNotAllowed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec007AC1_CreationAuthorityAndDefaultManager restates D-019 as the
|
||||||
|
// SPEC-007 acceptance criterion: creation requires a role strictly ABOVE the
|
||||||
|
// new user's, and the creator is the new user's default manager.
|
||||||
|
func TestSpec007AC1_CreationAuthorityAndDefaultManager(t *testing.T) {
|
||||||
|
s, _ := newUsers(t)
|
||||||
|
admin := bootstrap(t, s)
|
||||||
|
|
||||||
|
// Each level creates the next; the creator becomes the default manager.
|
||||||
|
director, err := s.Create(testCtx, admin, "ac1-director@example.com", "D", users.RoleDirector)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin -> director: %v", err)
|
||||||
|
}
|
||||||
|
manager, err := s.Create(testCtx, director, "ac1-manager@example.com", "M", users.RoleManager)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("director -> manager: %v", err)
|
||||||
|
}
|
||||||
|
team, err := s.Create(testCtx, manager, "ac1-team@example.com", "T", users.RoleTeam)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("manager -> team: %v", err)
|
||||||
|
}
|
||||||
|
for want, id := range map[int64]int64{director: admin, manager: director, team: manager} {
|
||||||
|
mgr, err := s.Manager(testCtx, want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Manager(%d): %v", want, err)
|
||||||
|
}
|
||||||
|
if !mgr.Set || mgr.Value != id {
|
||||||
|
t.Errorf("user %d manager = %+v, want the creator %d (AC-1)", want, mgr, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A role may NOT create its own tier (a peer) — only strictly below.
|
||||||
|
if _, err := s.Create(testCtx, director, "ac1-peer@example.com", "P", users.RoleDirector); !errors.Is(err, users.ErrRoleNotAllowed) {
|
||||||
|
t.Errorf("director -> director = %v, want ErrRoleNotAllowed (AC-1)", err)
|
||||||
|
}
|
||||||
|
// Nor a role ABOVE (a second admin).
|
||||||
|
if _, err := s.Create(testCtx, director, "ac1-admin@example.com", "A", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
|
||||||
|
t.Errorf("director -> admin = %v, want ErrOneAdmin (AC-1)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRoleValid(t *testing.T) {
|
||||||
|
s, _ := newUsers(t)
|
||||||
|
admin := bootstrap(t, s)
|
||||||
|
team, err := s.Create(testCtx, admin, "t@example.com", "T", users.RoleTeam)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create team: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetRole(testCtx, team, users.RoleManager); err != nil {
|
||||||
|
t.Fatalf("SetRole(manager): %v", err)
|
||||||
|
}
|
||||||
|
u, err := s.Get(testCtx, team)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if u.Role != users.RoleManager {
|
||||||
|
t.Errorf("role = %s, want manager", u.Role)
|
||||||
|
}
|
||||||
|
if err := s.SetRole(testCtx, team, users.Role("boss")); !errors.Is(err, users.ErrInvalidRole) {
|
||||||
|
t.Errorf("SetRole(invalid) = %v, want ErrInvalidRole", err)
|
||||||
|
}
|
||||||
|
if err := s.SetRole(testCtx, 999, users.RoleTeam); !errors.Is(err, users.ErrNotFound) {
|
||||||
|
t.Errorf("SetRole(unknown) = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateValidation(t *testing.T) {
|
||||||
|
s, _ := newUsers(t)
|
||||||
|
admin := bootstrap(t, s)
|
||||||
|
|
||||||
|
if _, err := s.Create(testCtx, admin, " ", "X", users.RoleTeam); !errors.Is(err, users.ErrEmailRequired) {
|
||||||
|
t.Errorf("empty email = %v, want ErrEmailRequired", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(testCtx, admin, "x@example.com", " ", users.RoleTeam); !errors.Is(err, users.ErrNameRequired) {
|
||||||
|
t.Errorf("empty name = %v, want ErrNameRequired", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(testCtx, admin, "x@example.com", "X", users.Role("boss")); !errors.Is(err, users.ErrInvalidRole) {
|
||||||
|
t.Errorf("invalid role = %v, want ErrInvalidRole", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(testCtx, 999, "x@example.com", "X", users.RoleTeam); !errors.Is(err, users.ErrNotFound) {
|
||||||
|
t.Errorf("unknown creator = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email is unique (and normalised to lower case).
|
||||||
|
if _, err := s.Create(testCtx, admin, "Team@Example.com", "T", users.RoleTeam); err != nil {
|
||||||
|
t.Fatalf("create team: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(testCtx, admin, "team@example.com", "T2", users.RoleTeam); !errors.Is(err, users.ErrDuplicateEmail) {
|
||||||
|
t.Errorf("duplicate email = %v, want ErrDuplicateEmail", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditCarriesContextMeta proves ruling R-1 end to end: the door attaches
|
||||||
|
// audit.Meta to the context, and a service mutation WITHOUT an explicit actor
|
||||||
|
// records the context's actor · client IP · correlation in `audit_log`.
|
||||||
|
func TestAuditCarriesContextMeta(t *testing.T) {
|
||||||
|
s, db := newUsers(t)
|
||||||
|
admin := bootstrap(t, s)
|
||||||
|
|
||||||
|
ctx := audit.WithMeta(context.Background(), audit.Meta{
|
||||||
|
ActorUserID: admin,
|
||||||
|
ClientIP: "203.0.113.9",
|
||||||
|
CorrelationID: "corr-42",
|
||||||
|
})
|
||||||
|
|
||||||
|
team, err := s.Create(ctx, admin, "t@example.com", "T", users.RoleTeam)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
// SetRole carries no explicit actor: it must come from the context.
|
||||||
|
if err := s.SetRole(ctx, team, users.RoleManager); err != nil {
|
||||||
|
t.Fatalf("SetRole: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
actor sql.NullInt64
|
||||||
|
ip sql.NullString
|
||||||
|
corr sql.NullString
|
||||||
|
)
|
||||||
|
err = db.QueryRow(`
|
||||||
|
SELECT actor_user_id, client_ip, correlation_id
|
||||||
|
FROM audit_log WHERE action = 'user.set_role' ORDER BY id DESC LIMIT 1`,
|
||||||
|
).Scan(&actor, &ip, &corr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read audit row: %v", err)
|
||||||
|
}
|
||||||
|
if !actor.Valid || actor.Int64 != admin {
|
||||||
|
t.Errorf("actor_user_id = %+v, want %d (from context meta, R-1)", actor, admin)
|
||||||
|
}
|
||||||
|
if ip.String != "203.0.113.9" {
|
||||||
|
t.Errorf("client_ip = %q, want 203.0.113.9", ip.String)
|
||||||
|
}
|
||||||
|
if corr.String != "corr-42" {
|
||||||
|
t.Errorf("correlation_id = %q, want corr-42", corr.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyOptOut (D-041): the marker is a USER preference — default false,
|
||||||
|
// settable, readable through Get, and audited.
|
||||||
|
func TestPasskeyOptOut(t *testing.T) {
|
||||||
|
s, db := newUsers(t)
|
||||||
|
admin := bootstrap(t, s)
|
||||||
|
|
||||||
|
// Default: not opted out, and reflected on the user record.
|
||||||
|
opt, err := s.PasskeyOptOut(testCtx, admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PasskeyOptOut: %v", err)
|
||||||
|
}
|
||||||
|
if opt {
|
||||||
|
t.Error("passkey_opt_out defaults to true, want false (D-041)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetPasskeyOptOut(testCtx, admin, true); err != nil {
|
||||||
|
t.Fatalf("SetPasskeyOptOut(true): %v", err)
|
||||||
|
}
|
||||||
|
if opt, err = s.PasskeyOptOut(testCtx, admin); err != nil || !opt {
|
||||||
|
t.Errorf("after SetPasskeyOptOut(true) opt-out = %v (err %v), want true", opt, err)
|
||||||
|
}
|
||||||
|
u, err := s.Get(testCtx, admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if !u.PasskeyOptOut {
|
||||||
|
t.Error("Get did not carry passkey_opt_out (D-041)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The write is audited (A11).
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT count(*) FROM audit_log WHERE action = 'user.passkey_opt_out'`).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count audit: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("opt-out audit rows = %d, want 1 (A11)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown user -> ErrNotFound.
|
||||||
|
if _, err := s.PasskeyOptOut(testCtx, 999); !errors.Is(err, users.ErrNotFound) {
|
||||||
|
t.Errorf("PasskeyOptOut(unknown) = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := s.SetPasskeyOptOut(testCtx, 999, true); !errors.Is(err, users.ErrNotFound) {
|
||||||
|
t.Errorf("SetPasskeyOptOut(unknown) = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestByEmailAndCount(t *testing.T) {
|
||||||
|
s, _ := newUsers(t)
|
||||||
|
admin := bootstrap(t, s)
|
||||||
|
if _, err := s.Create(testCtx, admin, "team@example.com", "T", users.RoleTeam); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
u, err := s.ByEmail(testCtx, "TEAM@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if u.Role != users.RoleTeam {
|
||||||
|
t.Errorf("ByEmail role = %s, want team", u.Role)
|
||||||
|
}
|
||||||
|
if _, err := s.ByEmail(testCtx, "nobody@example.com"); !errors.Is(err, users.ErrNotFound) {
|
||||||
|
t.Errorf("ByEmail(unknown) = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
n, err := s.Count(testCtx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Count: %v", err)
|
||||||
|
}
|
||||||
|
if n != 2 {
|
||||||
|
t.Errorf("Count = %d, want 2", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue