Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
989d13b69c | ||
|
|
8be5c56f6f | ||
|
|
ea4047a852 | ||
|
|
41ff2ab114 | ||
|
|
df692ef214 | ||
|
|
88f7d0a5fa | ||
|
|
5e78465d34 | ||
|
|
6d5c14f778 |
@@ -44,6 +44,7 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
make dev
|
||||
```
|
||||
|
||||
@@ -51,11 +52,16 @@ make dev
|
||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE) and
|
||||
trusted proxy list (every entry must be an IP or CIDR), and
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE),
|
||||
trusted proxy list (every entry must be an IP or CIDR) and
|
||||
`MEALQUEST_DEFAULT_TZ`, and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
|
||||
with on first boot: until those settings exist, boot fails unless it is a
|
||||
valid IANA zone. Later boots ignore it, set or not (change the zone in
|
||||
household settings instead).
|
||||
|
||||
## Common commands
|
||||
|
||||
|
||||
+281
-7
@@ -3,15 +3,22 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -24,6 +31,51 @@ func useBootMigrations(t *testing.T, m fstest.MapFS) {
|
||||
t.Cleanup(func() { bootMigrations = saved })
|
||||
}
|
||||
|
||||
// loadConfig loads a configuration through config.Load whose secret key file
|
||||
// holds keyFile. Tests that call it must not run in parallel.
|
||||
func loadConfig(t *testing.T, keyFile string) config.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
keyPath := filepath.Join(dir, "secret_key")
|
||||
if err := os.WriteFile(keyPath, []byte(keyFile), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
|
||||
if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0")
|
||||
t.Setenv(config.EnvPublicURL, "http://localhost")
|
||||
t.Setenv(config.EnvDataDir, t.TempDir())
|
||||
t.Setenv(config.EnvShutdownTimeout, "1s")
|
||||
t.Setenv(config.EnvSecretKeyFile, keyPath)
|
||||
t.Setenv(config.EnvOIDCIssuer, "https://auth.example.com/application/o/mealquest/")
|
||||
t.Setenv(config.EnvOIDCClientID, "mealquest")
|
||||
t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath)
|
||||
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
||||
t.Setenv(config.EnvDefaultTZ, "")
|
||||
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// validKeyFile returns secret key file contents as written by the README:
|
||||
// base64 of 32 random bytes followed by a newline.
|
||||
func validKeyFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key) + "\n"
|
||||
}
|
||||
|
||||
var brokenMigrations = fstest.MapFS{
|
||||
"0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")},
|
||||
"0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")},
|
||||
@@ -73,12 +125,7 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
||||
useBootMigrations(t, brokenMigrations)
|
||||
|
||||
var logs bytes.Buffer
|
||||
cfg := config.Config{
|
||||
HTTPAddr: "127.0.0.1:0",
|
||||
PublicURL: &url.URL{Scheme: "http", Host: "localhost"},
|
||||
DataDir: t.TempDir(),
|
||||
ShutdownTimeout: time.Second,
|
||||
}
|
||||
cfg := loadConfig(t, validKeyFile(t))
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
@@ -94,3 +141,230 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
||||
t.Errorf("run started listening despite the migration failure: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSecretBoxAcceptsReadmeKey(t *testing.T) {
|
||||
cfg := loadConfig(t, validKeyFile(t))
|
||||
box, err := openSecretBox(cfg.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := box.Encrypt([]byte("x"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := box.Decrypt(token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBadSecretKeyStopsBoot(t *testing.T) {
|
||||
short := base64.StdEncoding.EncodeToString(make([]byte, 31))
|
||||
tests := []struct {
|
||||
name string
|
||||
keyFile string
|
||||
want error
|
||||
}{
|
||||
{"not base64", "this is not a base64 key!\n", secretbox.ErrKeyEncoding},
|
||||
{"wrong length", short + "\n", secretbox.ErrKeyLength},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := loadConfig(t, tt.keyFile)
|
||||
var logs bytes.Buffer
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
var err error
|
||||
select {
|
||||
case err = <-errc:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("run kept going with a bad secret key")
|
||||
}
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("run error = %v, want %v", err, tt.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
|
||||
t.Errorf("error does not name %s: %v", config.EnvSecretKeyFile, err)
|
||||
}
|
||||
if key := strings.TrimSpace(tt.keyFile); strings.Contains(err.Error(), key) {
|
||||
t.Errorf("error leaks the key: %v", err)
|
||||
}
|
||||
if strings.Contains(logs.String(), `"listening"`) {
|
||||
t.Errorf("run started listening despite the bad key: %s", logs.String())
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(cfg.DataDir, store.FileName)); !errors.Is(statErr, fs.ErrNotExist) {
|
||||
t.Errorf("run opened the database before rejecting the key (stat: %v)", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) {
|
||||
loadConfig(t, validKeyFile(t)) // sets every other variable
|
||||
t.Setenv(config.EnvSecretKeyFile, filepath.Join(t.TempDir(), "missing"))
|
||||
_, err := config.Load()
|
||||
var cfgErr *config.Error
|
||||
if !errors.As(err, &cfgErr) || !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
|
||||
t.Fatalf("Load error = %v, want a config error naming %s", err, config.EnvSecretKeyFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithoutValidDefaultZoneOnFirstBootStops(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultTZ *string // nil leaves MEALQUEST_DEFAULT_TZ unset
|
||||
want error
|
||||
}{
|
||||
{"unset", nil, household.ErrNoDefaultZone},
|
||||
{"not an IANA zone", ptr("Mars/Olympus_Mons"), household.ErrInvalidDefaultZone},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
loadConfig(t, validKeyFile(t))
|
||||
if tt.defaultTZ != nil {
|
||||
t.Setenv(config.EnvDefaultTZ, *tt.defaultTZ)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var logs bytes.Buffer
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
select {
|
||||
case err = <-errc:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("run kept going with no household settings and no valid default zone")
|
||||
}
|
||||
if !errors.Is(err, tt.want) || !strings.Contains(err.Error(), config.EnvDefaultTZ) {
|
||||
t.Fatalf("run error = %v, want %v naming %s", err, tt.want, config.EnvDefaultTZ)
|
||||
}
|
||||
if strings.Contains(logs.String(), `"listening"`) {
|
||||
t.Errorf("run started listening without household settings: %s", logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A reboot over existing household settings must not depend on
|
||||
// MEALQUEST_DEFAULT_TZ: config.Load accepts an invalid value and seeding
|
||||
// ignores it, keeping the stored zone.
|
||||
func TestRebootWithSettingsIgnoresInvalidDefaultZone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cfg := loadConfig(t, validKeyFile(t))
|
||||
|
||||
db, err := openStore(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seedSettings(ctx, db, "America/Denver"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv(config.EnvDefaultTZ, "Mars/Olympus_Mons")
|
||||
cfg, err = config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load with an invalid %s and existing settings: %v", config.EnvDefaultTZ, err)
|
||||
}
|
||||
db, err = openStore(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil {
|
||||
t.Fatalf("reboot with %s=%q: %v", config.EnvDefaultTZ, cfg.DefaultTZ, err)
|
||||
}
|
||||
got, err := household.NewSettingsService(db).Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Timezone != "America/Denver" {
|
||||
t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestSeedSettingsAtBoot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// First boot seeds from the default zone.
|
||||
db, err := openStore(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seedSettings(ctx, db, "America/Denver"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A later boot needs no default zone and keeps the stored one.
|
||||
db, err = openStore(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := seedSettings(ctx, db, ""); err != nil {
|
||||
t.Fatalf("reboot without %s: %v", config.EnvDefaultTZ, err)
|
||||
}
|
||||
got, err := household.NewSettingsService(db).Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Timezone != "America/Denver" {
|
||||
t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDefaultsAtBoot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := openStore(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := seedDefaults(ctx, db, "America/Denver"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := household.NewSettingsService(db).Get(ctx); err != nil {
|
||||
t.Errorf("household settings: %v", err)
|
||||
}
|
||||
var provider string
|
||||
if err := db.Reader.QueryRowContext(ctx, `SELECT provider FROM ai_settings`).Scan(&provider); err != nil {
|
||||
t.Errorf("AI settings: %v", err)
|
||||
} else if provider != household.SeedAIProvider {
|
||||
t.Errorf("AI provider = %q, want %q", provider, household.SeedAIProvider)
|
||||
}
|
||||
var prefs int
|
||||
if err := db.Reader.QueryRowContext(ctx, `SELECT count(*) FROM notification_prefs`).Scan(&prefs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prefs != 5 {
|
||||
t.Errorf("%d notification preferences, want 5", prefs)
|
||||
}
|
||||
|
||||
// A second boot seeds nothing new and succeeds.
|
||||
if err := seedDefaults(ctx, db, ""); err != nil {
|
||||
t.Fatalf("reboot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+45
-1
@@ -17,6 +17,8 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
@@ -43,6 +45,10 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if _, err := openSecretBox(cfg.SecretKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openStore(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -53,6 +59,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := seedDefaults(ctx, db, cfg.DefaultTZ); err != nil {
|
||||
return err
|
||||
}
|
||||
settings := household.NewSettingsService(db)
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -69,7 +80,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -104,6 +115,16 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// openSecretBox builds the secret box from the configured key, naming the
|
||||
// variable whose file holds a bad key. The key itself is never reported.
|
||||
func openSecretBox(key config.Secret) (*secretbox.Box, error) {
|
||||
box, err := secretbox.New(key.Reveal())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", config.EnvSecretKeyFile, err)
|
||||
}
|
||||
return box, nil
|
||||
}
|
||||
|
||||
// bootMigrations is the migration set applied at boot. Tests replace it to
|
||||
// prove a failing migration stops the boot.
|
||||
var bootMigrations fs.FS = migrations.FS
|
||||
@@ -121,6 +142,29 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// seedSettings creates the household settings on first boot, naming the
|
||||
// variable that supplies the zone when it is missing or invalid. Once the
|
||||
// settings exist the variable is ignored.
|
||||
func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error {
|
||||
err := household.SeedSettings(ctx, db, defaultTZ)
|
||||
if errors.Is(err, household.ErrNoDefaultZone) || errors.Is(err, household.ErrInvalidDefaultZone) {
|
||||
return fmt.Errorf("%s: a valid IANA zone is required on first boot to create the household settings: %w", config.EnvDefaultTZ, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// seedDefaults creates the household settings, AI settings and notification
|
||||
// preferences that do not exist yet. Existing rows are never changed.
|
||||
func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error {
|
||||
if err := seedSettings(ctx, db, defaultTZ); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := household.SeedAISettings(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return household.SeedNotificationPrefs(ctx, db)
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
+44
-3
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
@@ -107,9 +108,12 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
|
||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer)
|
||||
discovery := *client
|
||||
recorder := &statusRecorder{next: http.DefaultTransport}
|
||||
discovery.Transport = recorder
|
||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, &discovery), cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
return nil, discoveryError(err, int(recorder.status.Load()))
|
||||
}
|
||||
endpoint := provider.Endpoint()
|
||||
var discovered struct {
|
||||
@@ -159,6 +163,32 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
}, nil
|
||||
}
|
||||
|
||||
// statusRecorder records the status of the last response it carries.
|
||||
type statusRecorder struct {
|
||||
next http.RoundTripper
|
||||
status atomic.Int32
|
||||
}
|
||||
|
||||
func (s *statusRecorder) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := s.next.RoundTrip(req)
|
||||
if err == nil {
|
||||
s.status.Store(int32(resp.StatusCode))
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// discoveryError reports a failed discovery. go-oidc puts the provider's
|
||||
// response body in its error for a non-200 discovery response, so that
|
||||
// error is replaced by the status alone. Transport errors carry no response
|
||||
// content and keep their cause.
|
||||
func discoveryError(err error, status int) error {
|
||||
var urlErr *url.Error
|
||||
if !errors.As(err, &urlErr) && status != 0 && status != http.StatusOK {
|
||||
return fmt.Errorf("auth: OIDC discovery: provider answered HTTP %d", status)
|
||||
}
|
||||
return fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
}
|
||||
|
||||
// checkEndpoint applies secureurl.Check to the provider URL raw, naming the
|
||||
// endpoint in the error.
|
||||
func checkEndpoint(name, raw string) error {
|
||||
@@ -323,7 +353,7 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
|
||||
}
|
||||
idToken, err := a.verifier.Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("verify ID token: %w", err)
|
||||
return Claims{}, verifyError(err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
|
||||
return Claims{}, errors.New("ID token nonce mismatch")
|
||||
@@ -374,6 +404,17 @@ func userinfoError(err error) error {
|
||||
return errors.New("userinfo: endpoint returned an unusable response")
|
||||
}
|
||||
|
||||
// verifyError reduces a failed ID token verification to a fixed category.
|
||||
// go-oidc's error text can carry the JWKS endpoint's response body or
|
||||
// token-supplied values, so only the expiry of an expired token is kept.
|
||||
func verifyError(err error) error {
|
||||
var expired *oidc.TokenExpiredError
|
||||
if errors.As(err, &expired) {
|
||||
return fmt.Errorf("verify ID token: token expired at %s", expired.Expiry.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return errors.New("verify ID token: token or provider key set rejected")
|
||||
}
|
||||
|
||||
// member reports whether groups contains an allowed group.
|
||||
func (a *Auth) member(groups []string) bool {
|
||||
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
|
||||
|
||||
+109
-1
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
@@ -59,6 +60,8 @@ type fakeProvider struct {
|
||||
userinfoHits int
|
||||
// userinfoReply, when set, is returned by the userinfo endpoint instead.
|
||||
userinfoReply *tokenReply
|
||||
// jwksReply, when set, is returned by the JWKS endpoint instead.
|
||||
jwksReply *tokenReply
|
||||
}
|
||||
|
||||
type tokenReply struct {
|
||||
@@ -109,6 +112,15 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (p *fakeProvider) jwks(w http.ResponseWriter, _ *http.Request) {
|
||||
p.mu.Lock()
|
||||
reply := p.jwksReply
|
||||
p.mu.Unlock()
|
||||
if reply != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(reply.status)
|
||||
io.WriteString(w, reply.body)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"keys": []map[string]string{{
|
||||
"kty": "RSA",
|
||||
"kid": testKID,
|
||||
@@ -307,6 +319,7 @@ func (m *memorySessions) started() []Claims {
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
auth *Auth
|
||||
provider *fakeProvider
|
||||
sessions *memorySessions
|
||||
router http.Handler
|
||||
@@ -349,7 +362,7 @@ func newHarness(t *testing.T) *harness {
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
a.Mount(r)
|
||||
return &harness{provider: p, sessions: sessions, router: r, logs: logs}
|
||||
return &harness{auth: a, provider: p, sessions: sessions, router: r, logs: logs}
|
||||
}
|
||||
|
||||
func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response {
|
||||
@@ -727,6 +740,66 @@ func TestCallbackExchangeFailureDoesNotLogProviderResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackKeySetFailureDoesNotLogProviderResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "sentinel-jwks-body-5c1e"
|
||||
tests := []struct {
|
||||
name string
|
||||
reply tokenReply
|
||||
}{
|
||||
{"error response", tokenReply{http.StatusInternalServerError, sentinel}},
|
||||
{"malformed success", tokenReply{http.StatusOK, `{"keys":"` + sentinel + `"`}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.mu.Lock()
|
||||
h.provider.jwksReply = &tc.reply
|
||||
h.provider.mu.Unlock()
|
||||
|
||||
// The returned error, checked through verify directly.
|
||||
l := h.login(t, LoginPath)
|
||||
req := httptest.NewRequest(http.MethodGet, CallbackPath, nil)
|
||||
req.AddCookie(l.cookie)
|
||||
state, err := h.auth.cookie.read(req, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
_, err = h.auth.verify(context.Background(), code, state)
|
||||
if err == nil {
|
||||
t.Fatal("verify succeeded with an unusable key set")
|
||||
}
|
||||
if strings.Contains(err.Error(), sentinel) {
|
||||
t.Errorf("verify error contains the JWKS response: %v", err)
|
||||
}
|
||||
|
||||
// The logs and response, through the callback.
|
||||
l = h.login(t, LoginPath)
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if strings.Contains(string(body), sentinel) {
|
||||
t.Errorf("response contains the JWKS response: %s", body)
|
||||
}
|
||||
logs := h.logs.String()
|
||||
if strings.Contains(logs, sentinel) {
|
||||
t.Errorf("logs contain the JWKS response: %s", logs)
|
||||
}
|
||||
if want := "token or provider key set rejected"; !strings.Contains(logs, want) {
|
||||
t.Errorf("logs = %q, want %q", logs, want)
|
||||
}
|
||||
if len(h.sessions.started()) != 0 {
|
||||
t.Error("a session started")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCookieExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newLoginCookie([]byte("server-secret-key"))
|
||||
@@ -789,6 +862,41 @@ func TestNewFailsWhenDiscoveryFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDiscoveryErrorOmitsProviderBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "sentinel-discovery-body-7f3a"
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, sentinel)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
logs := &syncBuffer{}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: srv.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err == nil {
|
||||
t.Fatal("New succeeded without a discovery document")
|
||||
}
|
||||
// cmd/server logs the returned error at startup.
|
||||
slog.New(slog.NewTextHandler(logs, nil)).Error("startup failed", "err", err)
|
||||
if strings.Contains(logs.String(), sentinel) {
|
||||
t.Errorf("logs contain the provider response body: %s", logs)
|
||||
}
|
||||
if want := fmt.Sprintf("auth: OIDC discovery: provider answered HTTP %d", status); err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFailsOnIssuerMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
|
||||
@@ -33,6 +33,8 @@ const (
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
|
||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
||||
|
||||
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
@@ -65,6 +67,12 @@ type Config struct {
|
||||
// TrustedProxy lists the reverse proxies whose X-Forwarded-For and
|
||||
// X-Forwarded-Proto headers are honoured. Empty trusts no peer.
|
||||
TrustedProxy forwarded.Trusted
|
||||
|
||||
// DefaultTZ is the IANA zone the household settings are created with on
|
||||
// first boot. It is optional and not validated here: boot requires and
|
||||
// validates it only while the settings do not exist yet, and ignores it
|
||||
// once they do. Empty when unset.
|
||||
DefaultTZ string
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -97,6 +105,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||
|
||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
||||
|
||||
DefaultTZ: r.optional(EnvDefaultTZ),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
@@ -323,6 +333,12 @@ func (r *reader) parseDuration(name, v string) time.Duration {
|
||||
return d
|
||||
}
|
||||
|
||||
// optional returns the variable's value, or "" when it is unset.
|
||||
func (r *reader) optional(name string) string {
|
||||
v, _ := r.lookup(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// secretFile reads a secret from the file named by name, which must end in
|
||||
// _FILE. Setting the secret inline (name without _FILE) is rejected.
|
||||
func (r *reader) secretFile(name string) Secret {
|
||||
|
||||
@@ -68,6 +68,16 @@ func TestLoad(t *testing.T) {
|
||||
name: "all required vars present",
|
||||
env: valid(),
|
||||
},
|
||||
{
|
||||
name: "default time zone set",
|
||||
env: with(map[string]string{EnvDefaultTZ: "America/New_York"}),
|
||||
},
|
||||
{
|
||||
// Validated only when seeding the household settings, so a
|
||||
// stale value cannot stop a boot that no longer uses it.
|
||||
name: "default time zone not an IANA zone",
|
||||
env: with(map[string]string{EnvDefaultTZ: "Mars/Olympus_Mons"}),
|
||||
},
|
||||
{
|
||||
name: "one missing",
|
||||
env: with(nil, EnvHTTPAddr),
|
||||
@@ -410,6 +420,8 @@ func TestLoadValues(t *testing.T) {
|
||||
EnvOIDCClientID: "mealquest",
|
||||
EnvOIDCClientSecretFile: clientSecretPath,
|
||||
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
||||
|
||||
EnvDefaultTZ: "Europe/London",
|
||||
}
|
||||
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
@@ -465,6 +477,9 @@ func TestLoadValues(t *testing.T) {
|
||||
if !slices.Equal(cfg.TrustedProxy, want) {
|
||||
t.Errorf("TrustedProxy = %v, want %v", cfg.TrustedProxy, want)
|
||||
}
|
||||
if cfg.DefaultTZ != "Europe/London" {
|
||||
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
@@ -85,6 +85,7 @@ type HouseholdSetting struct {
|
||||
UpdatesSeenAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
UpdatedBy sql.NullString
|
||||
}
|
||||
|
||||
type IntegrationsGoogle struct {
|
||||
|
||||
@@ -57,7 +57,7 @@ func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) {
|
||||
}
|
||||
|
||||
const getHouseholdSettings = `-- name: GetHouseholdSettings :one
|
||||
SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at FROM household_settings
|
||||
SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by FROM household_settings
|
||||
WHERE id = 1
|
||||
`
|
||||
|
||||
@@ -81,6 +81,7 @@ func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, e
|
||||
&i.UpdatesSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UpdatedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -248,12 +249,12 @@ INSERT INTO household_settings (
|
||||
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
|
||||
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
||||
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
||||
created_at, updated_at
|
||||
updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?
|
||||
?, ?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
timezone = excluded.timezone,
|
||||
@@ -268,8 +269,9 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
chat_autosend_time = excluded.chat_autosend_time,
|
||||
onboarding_completed_at = excluded.onboarding_completed_at,
|
||||
updates_seen_at = excluded.updates_seen_at,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at
|
||||
RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by
|
||||
`
|
||||
|
||||
type UpsertHouseholdSettingsParams struct {
|
||||
@@ -285,6 +287,7 @@ type UpsertHouseholdSettingsParams struct {
|
||||
ChatAutosendTime string
|
||||
OnboardingCompletedAt sql.NullString
|
||||
UpdatesSeenAt sql.NullString
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
@@ -305,6 +308,7 @@ func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseho
|
||||
arg.ChatAutosendTime,
|
||||
arg.OnboardingCompletedAt,
|
||||
arg.UpdatesSeenAt,
|
||||
arg.UpdatedBy,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
@@ -325,6 +329,7 @@ func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseho
|
||||
&i.UpdatesSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UpdatedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4).
|
||||
// SeedAISettings stores it on first boot and the Admin reset restores it.
|
||||
const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners
|
||||
similar to their regular rotation, nudging them to try something a little new each
|
||||
week. Prefer weeknight-friendly recipes with common grocery-store ingredients.
|
||||
Flag crock pot meals and anything 30 minutes or less as "quick". Keep explanations
|
||||
short, warm and specific — mention the household's own regular dinners when you
|
||||
explain why something fits. Never suggest a dinner they already make or have
|
||||
declined.`
|
||||
|
||||
// Seed values for the AI settings (CTXD CONTEXT.MD). The base URL and model
|
||||
// are empty until the household picks a provider preset; there is no key.
|
||||
const (
|
||||
SeedAIProvider = "anthropic"
|
||||
SeedAIBaseURL = ""
|
||||
SeedAIModel = ""
|
||||
SeedAITemperature = 0.7
|
||||
)
|
||||
|
||||
// seedNotificationPrefs are the notification preferences for a new
|
||||
// household, one per event: push for every event, chat only for the plan
|
||||
// and the shopping list.
|
||||
var seedNotificationPrefs = []struct {
|
||||
Event string
|
||||
Push, Chat bool
|
||||
}{
|
||||
{Event: "plan_ready", Push: true, Chat: true},
|
||||
{Event: "list_ready", Push: true, Chat: true},
|
||||
{Event: "day_of_reminder", Push: true, Chat: false},
|
||||
{Event: "new_suggestions", Push: true, Chat: false},
|
||||
{Event: "rotation_review", Push: true, Chat: false},
|
||||
}
|
||||
|
||||
// SeedAISettings creates the AI settings row with the seed values and
|
||||
// DefaultPersonaPrompt if it does not exist. An existing row is never
|
||||
// changed.
|
||||
func SeedAISettings(ctx context.Context, st *store.Store) error {
|
||||
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
_, err := q.GetAISettings(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("household: read AI settings: %w", err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
_, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
||||
Provider: SeedAIProvider,
|
||||
BaseUrl: SeedAIBaseURL,
|
||||
Model: SeedAIModel,
|
||||
Temperature: SeedAITemperature,
|
||||
SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: seed AI settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SeedNotificationPrefs creates the seed notification preference row of
|
||||
// every event that has none. Existing rows are never changed.
|
||||
func SeedNotificationPrefs(ctx context.Context, st *store.Store) error {
|
||||
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
now := store.Now().String()
|
||||
for _, p := range seedNotificationPrefs {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO notification_prefs (event, push, chat, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (event) DO NOTHING`,
|
||||
p.Event, p.Push, p.Chat, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: seed notification preference %s: %w", p.Event, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// notificationPrefs returns the stored notification preferences by event as
|
||||
// [push, chat].
|
||||
func notificationPrefs(t *testing.T, st *store.Store) map[string][2]int64 {
|
||||
t.Helper()
|
||||
rows, err := st.Reader.QueryContext(context.Background(), `SELECT event, push, chat FROM notification_prefs`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
got := make(map[string][2]int64)
|
||||
for rows.Next() {
|
||||
var event string
|
||||
var push, chat int64
|
||||
if err := rows.Scan(&event, &push, &chat); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[event] = [2]int64{push, chat}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestSeedAISettingsCreatesRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
|
||||
if err := SeedAISettings(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.New(st.Reader).GetAISettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := db.AiSetting{
|
||||
ID: 1,
|
||||
Provider: "anthropic",
|
||||
BaseUrl: "",
|
||||
Model: "",
|
||||
Temperature: 0.7,
|
||||
SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true},
|
||||
CreatedAt: got.CreatedAt,
|
||||
UpdatedAt: got.UpdatedAt,
|
||||
}
|
||||
if got.ID != want.ID || got.Provider != want.Provider || got.BaseUrl != want.BaseUrl ||
|
||||
got.Model != want.Model || got.Temperature != want.Temperature || got.SystemPrompt != want.SystemPrompt ||
|
||||
got.ApiKeyCt != nil || got.ApiKeyLast4.Valid || got.KeyVerifiedAt.Valid {
|
||||
t.Errorf("seeded AI settings = %+v\nwant %+v", got, want)
|
||||
}
|
||||
var created store.Timestamp
|
||||
if err := created.Scan(got.CreatedAt); err != nil {
|
||||
t.Errorf("created_at: %v", err)
|
||||
}
|
||||
if got.UpdatedAt != got.CreatedAt {
|
||||
t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPersonaPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
const want = "You are MealQuest, a dinner planning assistant for one household. Suggest dinners\n" +
|
||||
"similar to their regular rotation, nudging them to try something a little new each\n" +
|
||||
"week. Prefer weeknight-friendly recipes with common grocery-store ingredients.\n" +
|
||||
"Flag crock pot meals and anything 30 minutes or less as \"quick\". Keep explanations\n" +
|
||||
"short, warm and specific — mention the household's own regular dinners when you\n" +
|
||||
"explain why something fits. Never suggest a dinner they already make or have\n" +
|
||||
"declined."
|
||||
if DefaultPersonaPrompt != want {
|
||||
t.Errorf("DefaultPersonaPrompt = %q\nwant %q", DefaultPersonaPrompt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedAISettingsNeverOverwrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// First boot seeds; the household then configures a provider.
|
||||
first := migratedStore(t, dir)
|
||||
if err := SeedAISettings(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seeded, err := db.New(first.Reader).GetAISettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := db.New(first.Writer).UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
||||
Provider: "ollama",
|
||||
BaseUrl: "http://ollama:11434",
|
||||
Model: "llama3",
|
||||
Temperature: 0.2,
|
||||
SystemPrompt: sql.NullString{String: "Be brief.", Valid: true},
|
||||
ApiKeyCt: []byte{1, 2, 3},
|
||||
ApiKeyLast4: sql.NullString{String: "wxyz", Valid: true},
|
||||
KeyVerifiedAt: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
CreatedAt: seeded.CreatedAt,
|
||||
UpdatedAt: store.Now().String(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A later boot reopens the same database and seeds again.
|
||||
st := migratedStore(t, dir)
|
||||
if err := SeedAISettings(ctx, st); err != nil {
|
||||
t.Fatalf("reboot: %v", err)
|
||||
}
|
||||
got, err := db.New(st.Reader).GetAISettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Provider != changed.Provider || got.BaseUrl != changed.BaseUrl || got.Model != changed.Model ||
|
||||
got.Temperature != changed.Temperature || got.SystemPrompt != changed.SystemPrompt ||
|
||||
string(got.ApiKeyCt) != string(changed.ApiKeyCt) || got.ApiKeyLast4 != changed.ApiKeyLast4 ||
|
||||
got.KeyVerifiedAt != changed.KeyVerifiedAt || got.CreatedAt != changed.CreatedAt || got.UpdatedAt != changed.UpdatedAt {
|
||||
t.Errorf("reboot changed AI settings:\n got %+v\nwant %+v", got, changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedNotificationPrefsCreatesRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
|
||||
if err := SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string][2]int64{
|
||||
"plan_ready": {1, 1},
|
||||
"list_ready": {1, 1},
|
||||
"day_of_reminder": {1, 0},
|
||||
"new_suggestions": {1, 0},
|
||||
"rotation_review": {1, 0},
|
||||
}
|
||||
got := notificationPrefs(t, st)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("seeded %d notification preferences, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for event, w := range want {
|
||||
if g, ok := got[event]; !ok || g != w {
|
||||
t.Errorf("%s [push, chat] = %v (present %t), want %v", event, g, ok, w)
|
||||
}
|
||||
}
|
||||
|
||||
var created, updated string
|
||||
err := st.Reader.QueryRowContext(ctx, `SELECT created_at, updated_at FROM notification_prefs WHERE event = 'plan_ready'`).Scan(&created, &updated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(created); err != nil {
|
||||
t.Errorf("created_at: %v", err)
|
||||
}
|
||||
if updated != created {
|
||||
t.Errorf("updated_at = %q, want created_at %q", updated, created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedNotificationPrefsNeverOverwrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// First boot seeds; the household then changes two preferences, and one
|
||||
// event's row goes missing.
|
||||
first := migratedStore(t, dir)
|
||||
if err := SeedNotificationPrefs(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`UPDATE notification_prefs SET push = 0, chat = 0 WHERE event = 'plan_ready'`,
|
||||
`UPDATE notification_prefs SET chat = 1 WHERE event = 'rotation_review'`,
|
||||
`DELETE FROM notification_prefs WHERE event = 'new_suggestions'`,
|
||||
} {
|
||||
if _, err := first.Writer.ExecContext(ctx, stmt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A later boot keeps the changed rows and seeds only the missing one.
|
||||
st := migratedStore(t, dir)
|
||||
if err := SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatalf("reboot: %v", err)
|
||||
}
|
||||
want := map[string][2]int64{
|
||||
"plan_ready": {0, 0},
|
||||
"list_ready": {1, 1},
|
||||
"day_of_reminder": {1, 0},
|
||||
"new_suggestions": {1, 0},
|
||||
"rotation_review": {1, 1},
|
||||
}
|
||||
got := notificationPrefs(t, st)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("%d notification preferences after reboot, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for event, w := range want {
|
||||
if g, ok := got[event]; !ok || g != w {
|
||||
t.Errorf("after reboot %s [push, chat] = %v (present %t), want %v", event, g, ok, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Seed values for a new household (CTXD CONTEXT.MD). Days count from
|
||||
// Sunday = 0, as time.Weekday does.
|
||||
const (
|
||||
SeedDinnerTime = "17:30"
|
||||
SeedReminderTime = "16:30"
|
||||
SeedReviewTime = "09:00" // rotation check
|
||||
SeedPlanDraftDow = 5 // Friday
|
||||
SeedPlanDraftTime = "09:00"
|
||||
SeedSuggestionsDow = 0 // Sunday
|
||||
SeedSuggestionsTime = "08:00"
|
||||
SeedChatAutosendDow = 6 // Saturday
|
||||
SeedChatAutosendTime = "08:00"
|
||||
)
|
||||
|
||||
// ErrNoDefaultZone is returned by SeedSettings when the household settings
|
||||
// do not exist yet and no default zone was configured to create them with.
|
||||
var ErrNoDefaultZone = errors.New("household settings do not exist yet and no default time zone is configured")
|
||||
|
||||
// ErrInvalidDefaultZone is returned by SeedSettings when the household
|
||||
// settings do not exist yet and the configured default zone cannot be loaded.
|
||||
var ErrInvalidDefaultZone = errors.New("household settings do not exist yet and the default time zone is invalid")
|
||||
|
||||
// Settings are the shared household settings. Times are household-local
|
||||
// 24-hour HH:MM in Timezone; days of the week count from Sunday = 0.
|
||||
type Settings struct {
|
||||
Timezone string `json:"timezone"`
|
||||
DinnerTime string `json:"dinner_time"`
|
||||
ReminderTime string `json:"reminder_time"`
|
||||
ReviewTime string `json:"review_time"`
|
||||
PlanDraftDow int `json:"plan_draft_dow"`
|
||||
PlanDraftTime string `json:"plan_draft_time"`
|
||||
SuggestionsDow int `json:"suggestions_dow"`
|
||||
SuggestionsTime string `json:"suggestions_time"`
|
||||
ChatAutosendDow int `json:"chat_autosend_dow"`
|
||||
ChatAutosendTime string `json:"chat_autosend_time"`
|
||||
OnboardingCompletedAt *string `json:"onboarding_completed_at"`
|
||||
UpdatesSeenAt *string `json:"updates_seen_at"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SettingsPatch changes the household settings. Nil fields are left as they
|
||||
// are.
|
||||
type SettingsPatch struct {
|
||||
Timezone *string
|
||||
DinnerTime *string
|
||||
ReminderTime *string
|
||||
ReviewTime *string
|
||||
PlanDraftDow *int
|
||||
PlanDraftTime *string
|
||||
SuggestionsDow *int
|
||||
SuggestionsTime *string
|
||||
ChatAutosendDow *int
|
||||
ChatAutosendTime *string
|
||||
}
|
||||
|
||||
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every invalid field of a SettingsPatch.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid household settings: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// SeedSettings creates the household settings row with zone defaultTZ and the
|
||||
// seed times and days if it does not exist. An existing row is never changed,
|
||||
// and defaultTZ is then ignored, valid or not. With no row, an empty
|
||||
// defaultTZ is ErrNoDefaultZone and an unloadable one ErrInvalidDefaultZone.
|
||||
func SeedSettings(ctx context.Context, st *store.Store, defaultTZ string) error {
|
||||
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
_, err := q.GetHouseholdSettings(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
if defaultTZ == "" {
|
||||
return ErrNoDefaultZone
|
||||
}
|
||||
if _, err := LoadZone(defaultTZ); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidDefaultZone, err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
_, err = q.UpsertHouseholdSettings(ctx, db.UpsertHouseholdSettingsParams{
|
||||
Timezone: defaultTZ,
|
||||
DinnerTime: SeedDinnerTime,
|
||||
ReminderTime: SeedReminderTime,
|
||||
ReviewTime: SeedReviewTime,
|
||||
PlanDraftDow: SeedPlanDraftDow,
|
||||
PlanDraftTime: SeedPlanDraftTime,
|
||||
SuggestionsDow: SeedSuggestionsDow,
|
||||
SuggestionsTime: SeedSuggestionsTime,
|
||||
ChatAutosendDow: SeedChatAutosendDow,
|
||||
ChatAutosendTime: SeedChatAutosendTime,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: seed settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SettingsService reads and changes the household settings, and tells
|
||||
// in-process subscribers (the scheduler) about every change.
|
||||
type SettingsService struct {
|
||||
st *store.Store
|
||||
|
||||
// updateMu serialises Update with its publish, so subscribers see
|
||||
// changes in commit order.
|
||||
updateMu sync.Mutex
|
||||
|
||||
subsMu sync.Mutex
|
||||
subs map[int]func(Settings)
|
||||
nextID int
|
||||
}
|
||||
|
||||
// NewSettingsService returns a service over st. Call SeedSettings first.
|
||||
func NewSettingsService(st *store.Store) *SettingsService {
|
||||
return &SettingsService{st: st, subs: make(map[int]func(Settings))}
|
||||
}
|
||||
|
||||
// Get returns the household settings.
|
||||
func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
|
||||
row, err := db.New(s.st.Reader).GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// Update validates patch, applies it, records updatedBy (the signed-in
|
||||
// member's subject, which must not be empty) and publishes the new settings
|
||||
// to every subscriber. An invalid patch is a *ValidationError and changes
|
||||
// nothing.
|
||||
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
|
||||
if updatedBy == "" {
|
||||
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
|
||||
}
|
||||
if err := patch.validate(); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
s.updateMu.Lock()
|
||||
defer s.updateMu.Unlock()
|
||||
|
||||
var row db.HouseholdSetting
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
p := db.UpsertHouseholdSettingsParams{
|
||||
Timezone: cur.Timezone,
|
||||
DinnerTime: cur.DinnerTime,
|
||||
ReminderTime: cur.ReminderTime,
|
||||
ReviewTime: cur.ReviewTime,
|
||||
PlanDraftDow: cur.PlanDraftDow,
|
||||
PlanDraftTime: cur.PlanDraftTime,
|
||||
SuggestionsDow: cur.SuggestionsDow,
|
||||
SuggestionsTime: cur.SuggestionsTime,
|
||||
ChatAutosendDow: cur.ChatAutosendDow,
|
||||
ChatAutosendTime: cur.ChatAutosendTime,
|
||||
OnboardingCompletedAt: cur.OnboardingCompletedAt,
|
||||
UpdatesSeenAt: cur.UpdatesSeenAt,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
CreatedAt: cur.CreatedAt,
|
||||
UpdatedAt: store.Now().String(),
|
||||
}
|
||||
setString(&p.Timezone, patch.Timezone)
|
||||
setString(&p.DinnerTime, patch.DinnerTime)
|
||||
setString(&p.ReminderTime, patch.ReminderTime)
|
||||
setString(&p.ReviewTime, patch.ReviewTime)
|
||||
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
|
||||
setString(&p.PlanDraftTime, patch.PlanDraftTime)
|
||||
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
|
||||
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
||||
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: update settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
settings := fromRow(row)
|
||||
s.publish(settings)
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// Subscribe registers fn to receive the new settings after every successful
|
||||
// Update. fn runs synchronously on the updating goroutine, so it must return
|
||||
// promptly and must not call Update. The returned function unsubscribes fn.
|
||||
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
|
||||
s.subsMu.Lock()
|
||||
defer s.subsMu.Unlock()
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.subs[id] = fn
|
||||
return func() {
|
||||
s.subsMu.Lock()
|
||||
defer s.subsMu.Unlock()
|
||||
delete(s.subs, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SettingsService) publish(settings Settings) {
|
||||
s.subsMu.Lock()
|
||||
fns := make([]func(Settings), 0, len(s.subs))
|
||||
for _, fn := range s.subs {
|
||||
fns = append(fns, fn)
|
||||
}
|
||||
s.subsMu.Unlock()
|
||||
for _, fn := range fns {
|
||||
fn(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func (p SettingsPatch) validate() error {
|
||||
var problems []Problem
|
||||
fail := func(field, reason string) {
|
||||
problems = append(problems, Problem{Field: field, Reason: reason})
|
||||
}
|
||||
if p == (SettingsPatch{}) {
|
||||
fail("settings", "no settings to change")
|
||||
}
|
||||
if p.Timezone != nil {
|
||||
if _, err := LoadZone(*p.Timezone); err != nil {
|
||||
fail("timezone", err.Error())
|
||||
}
|
||||
}
|
||||
for _, t := range []struct {
|
||||
field string
|
||||
value *string
|
||||
}{
|
||||
{"dinner_time", p.DinnerTime},
|
||||
{"reminder_time", p.ReminderTime},
|
||||
{"review_time", p.ReviewTime},
|
||||
{"plan_draft_time", p.PlanDraftTime},
|
||||
{"suggestions_time", p.SuggestionsTime},
|
||||
{"chat_autosend_time", p.ChatAutosendTime},
|
||||
} {
|
||||
if t.value != nil && !clockTime.MatchString(*t.value) {
|
||||
fail(t.field, fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", *t.value))
|
||||
}
|
||||
}
|
||||
for _, d := range []struct {
|
||||
field string
|
||||
value *int
|
||||
}{
|
||||
{"plan_draft_dow", p.PlanDraftDow},
|
||||
{"suggestions_dow", p.SuggestionsDow},
|
||||
{"chat_autosend_dow", p.ChatAutosendDow},
|
||||
} {
|
||||
if d.value != nil && (*d.value < 0 || *d.value > 6) {
|
||||
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &ValidationError{Problems: problems}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clockTime matches a zero-padded 24-hour HH:MM time.
|
||||
var clockTime = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||
|
||||
func setString(dst *string, v *string) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
}
|
||||
}
|
||||
|
||||
func setDow(dst *int64, v *int) {
|
||||
if v != nil {
|
||||
*dst = int64(*v)
|
||||
}
|
||||
}
|
||||
|
||||
func fromRow(r db.HouseholdSetting) Settings {
|
||||
return Settings{
|
||||
Timezone: r.Timezone,
|
||||
DinnerTime: r.DinnerTime,
|
||||
ReminderTime: r.ReminderTime,
|
||||
ReviewTime: r.ReviewTime,
|
||||
PlanDraftDow: int(r.PlanDraftDow),
|
||||
PlanDraftTime: r.PlanDraftTime,
|
||||
SuggestionsDow: int(r.SuggestionsDow),
|
||||
SuggestionsTime: r.SuggestionsTime,
|
||||
ChatAutosendDow: int(r.ChatAutosendDow),
|
||||
ChatAutosendTime: r.ChatAutosendTime,
|
||||
OnboardingCompletedAt: nullable(r.OnboardingCompletedAt),
|
||||
UpdatesSeenAt: nullable(r.UpdatesSeenAt),
|
||||
UpdatedBy: nullable(r.UpdatedBy),
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func nullable(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// migratedStore opens a temp store in dir migrated with the embedded
|
||||
// migrations, closed when the test ends.
|
||||
func migratedStore(t *testing.T, dir string) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// seededService returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func seededService(t *testing.T) *SettingsService {
|
||||
t.Helper()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewSettingsService(st)
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestSeedSettingsCreatesRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
|
||||
if err := SeedSettings(ctx, st, "America/Chicago"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := NewSettingsService(st).Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Settings{
|
||||
Timezone: "America/Chicago",
|
||||
DinnerTime: "17:30",
|
||||
ReminderTime: "16:30",
|
||||
ReviewTime: "09:00",
|
||||
PlanDraftDow: 5,
|
||||
PlanDraftTime: "09:00",
|
||||
SuggestionsDow: 0,
|
||||
SuggestionsTime: "08:00",
|
||||
ChatAutosendDow: 6,
|
||||
ChatAutosendTime: "08:00",
|
||||
CreatedAt: got.CreatedAt,
|
||||
UpdatedAt: got.UpdatedAt,
|
||||
}
|
||||
if !equalSettings(got, want) {
|
||||
t.Errorf("seeded settings = %+v\nwant %+v", got, want)
|
||||
}
|
||||
var created store.Timestamp
|
||||
if err := created.Scan(got.CreatedAt); err != nil {
|
||||
t.Errorf("created_at: %v", err)
|
||||
}
|
||||
if got.UpdatedAt != got.CreatedAt {
|
||||
t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedSettingsNeverOverwrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// First boot seeds; the household then changes a setting.
|
||||
first := migratedStore(t, dir)
|
||||
if err := SeedSettings(ctx, first, "America/Chicago"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := NewSettingsService(first).Update(ctx, SettingsPatch{DinnerTime: ptr("18:15")}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Later boots reopen the same database with a different default zone,
|
||||
// an invalid one and none at all.
|
||||
for _, defaultTZ := range []string{"Europe/London", "Mars/Olympus_Mons", ""} {
|
||||
st := migratedStore(t, dir)
|
||||
if err := SeedSettings(ctx, st, defaultTZ); err != nil {
|
||||
t.Fatalf("reboot with default zone %q: %v", defaultTZ, err)
|
||||
}
|
||||
got, err := NewSettingsService(st).Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !equalSettings(got, changed) {
|
||||
t.Errorf("reboot with default zone %q changed settings:\n got %+v\nwant %+v", defaultTZ, got, changed)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedSettingsRequiresDefaultZone(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultTZ string
|
||||
is error
|
||||
}{
|
||||
{"unset", "", ErrNoDefaultZone},
|
||||
{"not an IANA zone", "Mars/Olympus_Mons", ErrInvalidDefaultZone},
|
||||
{"Local", "Local", ErrInvalidDefaultZone},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
err := SeedSettings(ctx, st, tt.defaultTZ)
|
||||
if err == nil {
|
||||
t.Fatal("SeedSettings succeeded")
|
||||
}
|
||||
if !errors.Is(err, tt.is) {
|
||||
t.Errorf("error = %v, want %v", err, tt.is)
|
||||
}
|
||||
if _, err := NewSettingsService(st).Get(ctx); err == nil {
|
||||
t.Error("a settings row was created despite the error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc := seededService(t)
|
||||
before, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := svc.Update(ctx, SettingsPatch{
|
||||
Timezone: ptr("Europe/Paris"),
|
||||
DinnerTime: ptr("19:00"),
|
||||
ReminderTime: ptr("00:00"),
|
||||
ReviewTime: ptr("23:59"),
|
||||
PlanDraftDow: ptr(4),
|
||||
PlanDraftTime: ptr("10:30"),
|
||||
SuggestionsDow: ptr(1),
|
||||
SuggestionsTime: ptr("07:45"),
|
||||
ChatAutosendDow: ptr(0),
|
||||
ChatAutosendTime: ptr("12:00"),
|
||||
}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Settings{
|
||||
Timezone: "Europe/Paris",
|
||||
DinnerTime: "19:00",
|
||||
ReminderTime: "00:00",
|
||||
ReviewTime: "23:59",
|
||||
PlanDraftDow: 4,
|
||||
PlanDraftTime: "10:30",
|
||||
SuggestionsDow: 1,
|
||||
SuggestionsTime: "07:45",
|
||||
ChatAutosendDow: 0,
|
||||
ChatAutosendTime: "12:00",
|
||||
UpdatedBy: ptr("sam"),
|
||||
CreatedAt: before.CreatedAt,
|
||||
UpdatedAt: got.UpdatedAt,
|
||||
}
|
||||
if !equalSettings(got, want) {
|
||||
t.Errorf("Update = %+v\nwant %+v", got, want)
|
||||
}
|
||||
if got.UpdatedAt < before.UpdatedAt {
|
||||
t.Errorf("updated_at went back: %s < %s", got.UpdatedAt, before.UpdatedAt)
|
||||
}
|
||||
stored, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !equalSettings(stored, got) {
|
||||
t.Errorf("stored = %+v\nwant %+v", stored, got)
|
||||
}
|
||||
|
||||
// A partial patch changes only its fields and records its own member.
|
||||
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "alex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want.DinnerTime = "18:00"
|
||||
want.UpdatedBy = ptr("alex")
|
||||
want.UpdatedAt = got.UpdatedAt
|
||||
if !equalSettings(got, want) {
|
||||
t.Errorf("partial Update = %+v\nwant %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRequiresMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc := seededService(t)
|
||||
notified := 0
|
||||
svc.Subscribe(func(Settings) { notified++ })
|
||||
|
||||
if _, err := svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil {
|
||||
t.Fatal("Update with no member succeeded")
|
||||
}
|
||||
got, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DinnerTime != SeedDinnerTime || got.UpdatedBy != nil || notified != 0 {
|
||||
t.Errorf("Update with no member changed settings or notified: %+v, notified %d", got, notified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
patch SettingsPatch
|
||||
fields []string
|
||||
}{
|
||||
{"empty patch", SettingsPatch{}, []string{"settings"}},
|
||||
{"unknown zone", SettingsPatch{Timezone: ptr("Mars/Olympus_Mons")}, []string{"timezone"}},
|
||||
{"empty zone", SettingsPatch{Timezone: ptr("")}, []string{"timezone"}},
|
||||
{"Local zone", SettingsPatch{Timezone: ptr("Local")}, []string{"timezone"}},
|
||||
{"zone path traversal", SettingsPatch{Timezone: ptr("../../etc/passwd")}, []string{"timezone"}},
|
||||
{"hour 24", SettingsPatch{DinnerTime: ptr("24:00")}, []string{"dinner_time"}},
|
||||
{"minute 60", SettingsPatch{ReminderTime: ptr("16:60")}, []string{"reminder_time"}},
|
||||
{"unpadded hour", SettingsPatch{ReviewTime: ptr("9:00")}, []string{"review_time"}},
|
||||
{"seconds", SettingsPatch{PlanDraftTime: ptr("09:00:00")}, []string{"plan_draft_time"}},
|
||||
{"12-hour", SettingsPatch{SuggestionsTime: ptr("08:00 AM")}, []string{"suggestions_time"}},
|
||||
{"empty time", SettingsPatch{ChatAutosendTime: ptr("")}, []string{"chat_autosend_time"}},
|
||||
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
|
||||
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
|
||||
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
|
||||
{
|
||||
"every problem is reported",
|
||||
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
|
||||
[]string{"timezone", "dinner_time", "plan_draft_dow"},
|
||||
},
|
||||
{
|
||||
"valid fields do not mask an invalid one",
|
||||
SettingsPatch{DinnerTime: ptr("18:00"), ChatAutosendDow: ptr(8)},
|
||||
[]string{"chat_autosend_dow"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := seededService(t)
|
||||
before, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
published := 0
|
||||
svc.Subscribe(func(Settings) { published++ })
|
||||
|
||||
_, err = svc.Update(ctx, tt.patch, "sam")
|
||||
var verr *ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("Update error = %v, want *ValidationError", err)
|
||||
}
|
||||
var fields []string
|
||||
for _, p := range verr.Problems {
|
||||
fields = append(fields, p.Field)
|
||||
}
|
||||
if !slices.Equal(fields, tt.fields) {
|
||||
t.Errorf("problem fields = %v, want %v", fields, tt.fields)
|
||||
}
|
||||
|
||||
after, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !equalSettings(after, before) {
|
||||
t.Errorf("invalid patch changed settings:\n got %+v\nwant %+v", after, before)
|
||||
}
|
||||
if published != 0 {
|
||||
t.Errorf("invalid patch published %d times", published)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePublishesToSubscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc := seededService(t)
|
||||
|
||||
var mu sync.Mutex
|
||||
var first, second []Settings
|
||||
unsubscribe := svc.Subscribe(func(s Settings) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
first = append(first, s)
|
||||
})
|
||||
svc.Subscribe(func(s Settings) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
second = append(second, s)
|
||||
})
|
||||
|
||||
updated, err := svc.Update(ctx, SettingsPatch{Timezone: ptr("Asia/Tokyo")}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
if len(first) != 1 || !equalSettings(first[0], updated) {
|
||||
t.Errorf("first subscriber got %+v, want [%+v]", first, updated)
|
||||
}
|
||||
if len(second) != 1 || !equalSettings(second[0], updated) {
|
||||
t.Errorf("second subscriber got %+v, want [%+v]", second, updated)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
unsubscribe()
|
||||
updated, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:45")}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(first) != 1 {
|
||||
t.Errorf("unsubscribed subscriber got %d notifications, want 1", len(first))
|
||||
}
|
||||
if len(second) != 2 || !equalSettings(second[1], updated) {
|
||||
t.Errorf("second subscriber got %+v, want a second notification %+v", second, updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWithoutSeedFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := NewSettingsService(migratedStore(t, t.TempDir()))
|
||||
if _, err := svc.Update(context.Background(), SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil {
|
||||
t.Fatal("Update succeeded with no settings row")
|
||||
}
|
||||
}
|
||||
|
||||
// equalSettings compares settings by value, following the nullable pointers.
|
||||
func equalSettings(a, b Settings) bool {
|
||||
eq := func(x, y *string) bool { return (x == nil) == (y == nil) && (x == nil || *x == *y) }
|
||||
if !eq(a.OnboardingCompletedAt, b.OnboardingCompletedAt) || !eq(a.UpdatesSeenAt, b.UpdatesSeenAt) || !eq(a.UpdatedBy, b.UpdatedBy) {
|
||||
return false
|
||||
}
|
||||
a.OnboardingCompletedAt, a.UpdatesSeenAt, a.UpdatedBy = nil, nil, nil
|
||||
b.OnboardingCompletedAt, b.UpdatesSeenAt, b.UpdatedBy = nil, nil, nil
|
||||
return a == b
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Package household owns household-local calendar and clock logic. Every
|
||||
// function works in the household's IANA zone, passed explicitly, and never
|
||||
// in the process zone (time.Local).
|
||||
//
|
||||
// Weeks are ISO weeks: Monday to Sunday, numbered by ISO 8601, so the period
|
||||
// key of 2026-12-28 is "2026-W53" and of 2027-01-04 is "2027-W01".
|
||||
//
|
||||
// Calendar arithmetic is done on UTC midnights, which have no DST, so a date
|
||||
// plus seven days is always the same weekday a week later.
|
||||
package household
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Today returns the household-local date at instant now in zone loc.
|
||||
func Today(now time.Time, loc *time.Location) store.LocalDate {
|
||||
return store.LocalDateOf(now.In(loc))
|
||||
}
|
||||
|
||||
// WeekStart returns the ISO Monday of the week containing d. It panics if d is
|
||||
// the zero LocalDate.
|
||||
func WeekStart(d store.LocalDate) store.LocalDate {
|
||||
t := midnightUTC(d)
|
||||
// Weekday counts from Sunday = 0; shift so Monday = 0 and Sunday = 6.
|
||||
sinceMonday := (int(t.Weekday()) + 6) % 7
|
||||
return store.LocalDateOf(t.AddDate(0, 0, -sinceMonday))
|
||||
}
|
||||
|
||||
// WeekDays returns the seven dates Monday to Sunday of the week starting on
|
||||
// weekStart. It is an error if weekStart is not a Monday.
|
||||
func WeekDays(weekStart store.LocalDate) ([7]store.LocalDate, error) {
|
||||
t, err := monday(weekStart)
|
||||
if err != nil {
|
||||
return [7]store.LocalDate{}, err
|
||||
}
|
||||
var days [7]store.LocalDate
|
||||
for i := range days {
|
||||
days[i] = store.LocalDateOf(t.AddDate(0, 0, i))
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
// WeekLabel returns the display label of the week starting on weekStart, such
|
||||
// as "Week 41 · Oct 5 – 11", or "Week 40 · Sep 28 – Oct 4" when the week
|
||||
// spans two months. It is an error if weekStart is not a Monday.
|
||||
func WeekLabel(weekStart store.LocalDate) (string, error) {
|
||||
start, err := monday(weekStart)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, week := start.ISOWeek()
|
||||
end := start.AddDate(0, 0, 6)
|
||||
if end.Month() == start.Month() {
|
||||
return fmt.Sprintf("Week %d · %s – %d", week, start.Format("Jan 2"), end.Day()), nil
|
||||
}
|
||||
return fmt.Sprintf("Week %d · %s – %s", week, start.Format("Jan 2"), end.Format("Jan 2")), nil
|
||||
}
|
||||
|
||||
// PeriodKey returns the ISO week key of the week containing d, such as
|
||||
// "2026-W41". The year is the ISO week-numbering year, which differs from the
|
||||
// calendar year around New Year. It panics if d is the zero LocalDate.
|
||||
func PeriodKey(d store.LocalDate) string {
|
||||
year, week := midnightUTC(d).ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", year, week)
|
||||
}
|
||||
|
||||
// LocalTime returns the instant at which household clocks in zone loc read
|
||||
// hhmm (24-hour "HH:MM") on date d.
|
||||
//
|
||||
// DST rule: a wall time that does not exist (spring forward) or occurs twice
|
||||
// (fall back) is read with the UTC offset in effect before the transition. A
|
||||
// skipped time therefore moves forward by the length of the gap (02:30 on a
|
||||
// day clocks jump 02:00→03:00 is 03:30), and a repeated time is its first
|
||||
// occurrence. The rule assumes at most one offset change in the 48 hours
|
||||
// around the wall time, which holds for every real zone.
|
||||
func LocalTime(loc *time.Location, d store.LocalDate, hhmm string) (time.Time, error) {
|
||||
if d.IsZero() {
|
||||
return time.Time{}, errors.New("household: zero date")
|
||||
}
|
||||
clock, err := time.Parse("15:04", hhmm)
|
||||
if err != nil || clock.Format("15:04") != hhmm {
|
||||
return time.Time{}, fmt.Errorf("household: invalid time %q: want HH:MM", hhmm)
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
// wall is the wall-clock reading expressed as if it were UTC; the real
|
||||
// instant is wall minus the zone's UTC offset at that instant.
|
||||
wall := time.Date(y, m, day, clock.Hour(), clock.Minute(), 0, 0, time.UTC)
|
||||
before := offset(wall.Add(-24*time.Hour), loc)
|
||||
after := offset(wall.Add(24*time.Hour), loc)
|
||||
early := wall.Add(-before)
|
||||
late := wall.Add(-after)
|
||||
// Use the post-transition offset only when the pre-transition reading is
|
||||
// not a real wall time and the post-transition one is.
|
||||
if offset(early, loc) != before && offset(late, loc) == after {
|
||||
return late.In(loc), nil
|
||||
}
|
||||
return early.In(loc), nil
|
||||
}
|
||||
|
||||
// offset returns the UTC offset of zone loc at instant t.
|
||||
func offset(t time.Time, loc *time.Location) time.Duration {
|
||||
_, secs := t.In(loc).Zone()
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
|
||||
// midnightUTC returns d as midnight UTC. It panics if d is the zero LocalDate.
|
||||
func midnightUTC(d store.LocalDate) time.Time {
|
||||
if d.IsZero() {
|
||||
panic("household: zero date")
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// monday returns weekStart as midnight UTC, or an error if it is not a Monday.
|
||||
func monday(weekStart store.LocalDate) (time.Time, error) {
|
||||
if weekStart.IsZero() {
|
||||
return time.Time{}, errors.New("household: zero week start")
|
||||
}
|
||||
t := midnightUTC(weekStart)
|
||||
if t.Weekday() != time.Monday {
|
||||
return time.Time{}, fmt.Errorf("household: week start %s is a %s, want Monday", weekStart, t.Weekday())
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// TestMain runs the package with the process zone set to UTC, so any use of
|
||||
// time.Local in place of the household zone shows up as a failure.
|
||||
func TestMain(m *testing.M) {
|
||||
time.Local = time.UTC
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func loadZone(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation(%q): %v", name, err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
func date(t *testing.T, s string) store.LocalDate {
|
||||
t.Helper()
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func utc(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
ts, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestTodayUsesHouseholdZone(t *testing.T) {
|
||||
chicago := loadZone(t, "America/Chicago")
|
||||
auckland := loadZone(t, "Pacific/Auckland")
|
||||
for _, tc := range []struct {
|
||||
now string
|
||||
loc *time.Location
|
||||
want string
|
||||
}{
|
||||
// 2026-10-05T04:30Z is Sunday 23:30 in Chicago (CDT, UTC−5) and
|
||||
// Monday 17:30 in Auckland (NZDT, UTC+13).
|
||||
{"2026-10-05T04:30:00Z", chicago, "2026-10-04"},
|
||||
{"2026-10-05T04:30:00Z", auckland, "2026-10-05"},
|
||||
// 2026-10-04T11:30Z is Sunday 06:30 in Chicago and already Monday
|
||||
// 00:30 in Auckland.
|
||||
{"2026-10-04T11:30:00Z", chicago, "2026-10-04"},
|
||||
{"2026-10-04T11:30:00Z", auckland, "2026-10-05"},
|
||||
} {
|
||||
if got := Today(utc(t, tc.now), tc.loc).String(); got != tc.want {
|
||||
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.loc, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSundayNightMondayMorningWeekAssignment(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
zone, now string
|
||||
wantDay string
|
||||
wantWeekStart string
|
||||
wantPeriodKey string
|
||||
wantWeekday time.Weekday
|
||||
}{
|
||||
// Sunday 23:59 local belongs to the week that started the previous
|
||||
// Monday; one minute later, Monday 00:00 starts a new week.
|
||||
{"America/Chicago", "2026-10-12T04:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
|
||||
{"America/Chicago", "2026-10-12T05:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
|
||||
{"Pacific/Auckland", "2026-10-11T10:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
|
||||
{"Pacific/Auckland", "2026-10-11T11:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
|
||||
} {
|
||||
loc := loadZone(t, tc.zone)
|
||||
now := utc(t, tc.now)
|
||||
if wd := now.In(loc).Weekday(); wd != tc.wantWeekday {
|
||||
t.Fatalf("%s in %s is %s, want %s", tc.now, tc.zone, wd, tc.wantWeekday)
|
||||
}
|
||||
today := Today(now, loc)
|
||||
if today.String() != tc.wantDay {
|
||||
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.zone, today, tc.wantDay)
|
||||
}
|
||||
if got := WeekStart(today).String(); got != tc.wantWeekStart {
|
||||
t.Errorf("WeekStart(%s) = %s, want %s", today, got, tc.wantWeekStart)
|
||||
}
|
||||
if got := PeriodKey(today); got != tc.wantPeriodKey {
|
||||
t.Errorf("PeriodKey(%s) = %s, want %s", today, got, tc.wantPeriodKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekStartEveryWeekday(t *testing.T) {
|
||||
for _, d := range []string{
|
||||
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08",
|
||||
"2026-10-09", "2026-10-10", "2026-10-11",
|
||||
} {
|
||||
if got := WeekStart(date(t, d)).String(); got != "2026-10-05" {
|
||||
t.Errorf("WeekStart(%s) = %s, want 2026-10-05", d, got)
|
||||
}
|
||||
}
|
||||
if got := WeekStart(date(t, "2026-10-12")).String(); got != "2026-10-12" {
|
||||
t.Errorf("WeekStart(2026-10-12) = %s, want 2026-10-12", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekDays(t *testing.T) {
|
||||
days, err := WeekDays(date(t, "2026-09-28"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01",
|
||||
"2026-10-02", "2026-10-03", "2026-10-04",
|
||||
}
|
||||
for i, d := range days {
|
||||
if d.String() != want[i] {
|
||||
t.Errorf("WeekDays[%d] = %s, want %s", i, d, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekDaysAcrossDSTChange(t *testing.T) {
|
||||
// Chicago falls back on Sunday 2026-11-01 and Auckland springs forward on
|
||||
// Sunday 2026-09-27; neither shifts the dates of those weeks.
|
||||
for _, tc := range []struct{ start, last string }{
|
||||
{"2026-10-26", "2026-11-01"},
|
||||
{"2026-09-21", "2026-09-27"},
|
||||
{"2026-03-02", "2026-03-08"},
|
||||
} {
|
||||
days, err := WeekDays(date(t, tc.start))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if days[0].String() != tc.start || days[6].String() != tc.last {
|
||||
t.Errorf("WeekDays(%s) = %s … %s, want %s … %s", tc.start, days[0], days[6], tc.start, tc.last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestISOWeekYearBoundary(t *testing.T) {
|
||||
for _, tc := range []struct{ day, weekStart, key string }{
|
||||
{"2026-12-27", "2026-12-21", "2026-W52"},
|
||||
{"2026-12-28", "2026-12-28", "2026-W53"},
|
||||
{"2026-12-31", "2026-12-28", "2026-W53"},
|
||||
{"2027-01-01", "2026-12-28", "2026-W53"},
|
||||
{"2027-01-03", "2026-12-28", "2026-W53"},
|
||||
{"2027-01-04", "2027-01-04", "2027-W01"},
|
||||
{"2027-01-10", "2027-01-04", "2027-W01"},
|
||||
{"2026-01-01", "2025-12-29", "2026-W01"},
|
||||
{"2026-10-07", "2026-10-05", "2026-W41"},
|
||||
} {
|
||||
d := date(t, tc.day)
|
||||
if got := WeekStart(d).String(); got != tc.weekStart {
|
||||
t.Errorf("WeekStart(%s) = %s, want %s", tc.day, got, tc.weekStart)
|
||||
}
|
||||
if got := PeriodKey(d); got != tc.key {
|
||||
t.Errorf("PeriodKey(%s) = %s, want %s", tc.day, got, tc.key)
|
||||
}
|
||||
}
|
||||
days, err := WeekDays(date(t, "2026-12-28"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if days[6].String() != "2027-01-03" {
|
||||
t.Errorf("W53 Sunday = %s, want 2027-01-03", days[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekLabel(t *testing.T) {
|
||||
for _, tc := range []struct{ start, want string }{
|
||||
{"2026-10-05", "Week 41 · Oct 5 – 11"},
|
||||
{"2026-09-28", "Week 40 · Sep 28 – Oct 4"},
|
||||
{"2026-12-28", "Week 53 · Dec 28 – Jan 3"},
|
||||
{"2027-01-04", "Week 1 · Jan 4 – 10"},
|
||||
} {
|
||||
got, err := WeekLabel(date(t, tc.start))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("WeekLabel(%s) = %q, want %q", tc.start, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekStartRequired(t *testing.T) {
|
||||
for _, d := range []store.LocalDate{date(t, "2026-10-07"), date(t, "2026-10-11"), {}} {
|
||||
if _, err := WeekDays(d); err == nil {
|
||||
t.Errorf("WeekDays(%s) succeeded, want error", d)
|
||||
}
|
||||
if _, err := WeekLabel(d); err == nil {
|
||||
t.Errorf("WeekLabel(%s) succeeded, want error", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroDatePanics(t *testing.T) {
|
||||
for name, fn := range map[string]func(){
|
||||
"WeekStart": func() { WeekStart(store.LocalDate{}) },
|
||||
"PeriodKey": func() { PeriodKey(store.LocalDate{}) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s(zero) did not panic", name)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTime(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
zone, day, hhmm string
|
||||
want string // UTC instant
|
||||
wantWall string // local wall clock of the result
|
||||
}{
|
||||
// Ordinary days.
|
||||
{"America/Chicago", "2026-10-05", "18:30", "2026-10-05T23:30:00Z", "2026-10-05 18:30 CDT"},
|
||||
{"America/Chicago", "2026-12-28", "00:00", "2026-12-28T06:00:00Z", "2026-12-28 00:00 CST"},
|
||||
{"Pacific/Auckland", "2026-10-05", "18:30", "2026-10-05T05:30:00Z", "2026-10-05 18:30 NZDT"},
|
||||
{"Pacific/Auckland", "2026-07-01", "23:59", "2026-07-01T11:59:00Z", "2026-07-01 23:59 NZST"},
|
||||
|
||||
// Chicago spring forward, Sunday 2026-03-08: 02:00 CST → 03:00 CDT.
|
||||
{"America/Chicago", "2026-03-08", "01:59", "2026-03-08T07:59:00Z", "2026-03-08 01:59 CST"},
|
||||
{"America/Chicago", "2026-03-08", "02:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
|
||||
{"America/Chicago", "2026-03-08", "02:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
|
||||
{"America/Chicago", "2026-03-08", "03:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
|
||||
{"America/Chicago", "2026-03-08", "03:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
|
||||
|
||||
// Chicago fall back, Sunday 2026-11-01: 02:00 CDT → 01:00 CST, so
|
||||
// 01:00–01:59 happens twice; the first (CDT) occurrence is chosen.
|
||||
{"America/Chicago", "2026-11-01", "00:59", "2026-11-01T05:59:00Z", "2026-11-01 00:59 CDT"},
|
||||
{"America/Chicago", "2026-11-01", "01:00", "2026-11-01T06:00:00Z", "2026-11-01 01:00 CDT"},
|
||||
{"America/Chicago", "2026-11-01", "01:30", "2026-11-01T06:30:00Z", "2026-11-01 01:30 CDT"},
|
||||
{"America/Chicago", "2026-11-01", "02:00", "2026-11-01T08:00:00Z", "2026-11-01 02:00 CST"},
|
||||
|
||||
// Auckland spring forward, Sunday 2026-09-27: 02:00 NZST → 03:00 NZDT.
|
||||
{"Pacific/Auckland", "2026-09-27", "01:59", "2026-09-26T13:59:00Z", "2026-09-27 01:59 NZST"},
|
||||
{"Pacific/Auckland", "2026-09-27", "02:30", "2026-09-26T14:30:00Z", "2026-09-27 03:30 NZDT"},
|
||||
{"Pacific/Auckland", "2026-09-27", "03:00", "2026-09-26T14:00:00Z", "2026-09-27 03:00 NZDT"},
|
||||
|
||||
// Auckland fall back, Sunday 2026-04-05: 03:00 NZDT → 02:00 NZST, so
|
||||
// 02:00–02:59 happens twice; the first (NZDT) occurrence is chosen.
|
||||
{"Pacific/Auckland", "2026-04-05", "01:59", "2026-04-04T12:59:00Z", "2026-04-05 01:59 NZDT"},
|
||||
{"Pacific/Auckland", "2026-04-05", "02:30", "2026-04-04T13:30:00Z", "2026-04-05 02:30 NZDT"},
|
||||
{"Pacific/Auckland", "2026-04-05", "03:00", "2026-04-04T15:00:00Z", "2026-04-05 03:00 NZST"},
|
||||
} {
|
||||
loc := loadZone(t, tc.zone)
|
||||
got, err := LocalTime(loc, date(t, tc.day), tc.hhmm)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalTime(%s, %s, %s): %v", tc.zone, tc.day, tc.hhmm, err)
|
||||
}
|
||||
if !got.Equal(utc(t, tc.want)) {
|
||||
t.Errorf("LocalTime(%s, %s, %s) = %s, want %s", tc.zone, tc.day, tc.hhmm, got.UTC().Format(time.RFC3339), tc.want)
|
||||
}
|
||||
if wall := got.Format("2006-01-02 15:04 MST"); wall != tc.wantWall {
|
||||
t.Errorf("LocalTime(%s, %s, %s) wall = %s, want %s", tc.zone, tc.day, tc.hhmm, wall, tc.wantWall)
|
||||
}
|
||||
if got.Location() != loc {
|
||||
t.Errorf("LocalTime(%s, …) location = %s, want %s", tc.zone, got.Location(), loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTimeDeterministic(t *testing.T) {
|
||||
// The same ambiguous or skipped input always resolves to the same instant.
|
||||
chicago := loadZone(t, "America/Chicago")
|
||||
for _, tc := range []struct{ day, hhmm string }{{"2026-03-08", "02:30"}, {"2026-11-01", "01:30"}} {
|
||||
first, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 100 {
|
||||
again, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !again.Equal(first) {
|
||||
t.Fatalf("LocalTime(%s, %s) = %s then %s", tc.day, tc.hhmm, first, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTimeRejectsInvalid(t *testing.T) {
|
||||
chicago := loadZone(t, "America/Chicago")
|
||||
d := date(t, "2026-10-05")
|
||||
for _, hhmm := range []string{
|
||||
"", "9:30", "09:3", "24:00", "23:60", "09:30:00", "0930", " 09:30", "09:30 ", "9am", "-1:00",
|
||||
} {
|
||||
if got, err := LocalTime(chicago, d, hhmm); err == nil {
|
||||
t.Errorf("LocalTime(%q) = %s, want error", hhmm, got)
|
||||
} else if !strings.Contains(err.Error(), "HH:MM") {
|
||||
t.Errorf("LocalTime(%q) error %q does not name the HH:MM form", hhmm, err)
|
||||
}
|
||||
}
|
||||
if _, err := LocalTime(chicago, store.LocalDate{}, "09:30"); err == nil {
|
||||
t.Error("LocalTime(zero date) succeeded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// Embeds the IANA zone database in the binary, so zones load the same
|
||||
// way in a minimal image with no /usr/share/zoneinfo.
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
// LoadZone loads the IANA zone name, such as "America/New_York". The empty
|
||||
// name and "Local", which time.LoadLocation maps to UTC and the process zone,
|
||||
// are refused: the household zone is never implied.
|
||||
func LoadZone(name string) (*time.Location, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("time zone is empty (want an IANA zone such as America/New_York)")
|
||||
}
|
||||
if name == "Local" {
|
||||
return nil, errors.New(`time zone "Local" is not an IANA zone (want e.g. America/New_York)`)
|
||||
}
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unknown time zone %q (want an IANA zone such as America/New_York)", name)
|
||||
}
|
||||
return loc, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package secretbox encrypts secrets at rest with AES-256-GCM. It is the only
|
||||
// owner of secret encryption: every _ct column holds a token produced by
|
||||
// Box.Encrypt, laid out as nonce ‖ ciphertext.
|
||||
//
|
||||
// Errors from this package never contain plaintext, ciphertext or key
|
||||
// material.
|
||||
package secretbox
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// KeySize is the decoded key length in bytes (AES-256).
|
||||
const KeySize = 32
|
||||
|
||||
var (
|
||||
// ErrKeyEncoding means the key is not valid standard base64.
|
||||
ErrKeyEncoding = errors.New("secretbox: key is not valid standard base64")
|
||||
// ErrKeyLength means the decoded key is not exactly KeySize bytes.
|
||||
ErrKeyLength = errors.New("secretbox: key must decode to exactly 32 bytes")
|
||||
// ErrMalformed means a token is too short to hold a nonce and tag.
|
||||
ErrMalformed = errors.New("secretbox: token is too short")
|
||||
// ErrDecrypt means a token failed authentication: it was corrupted or
|
||||
// sealed under a different key.
|
||||
ErrDecrypt = errors.New("secretbox: token failed authentication")
|
||||
)
|
||||
|
||||
// Box seals and opens secrets under one key. It is safe for concurrent use.
|
||||
type Box struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
// New returns a Box for encodedKey, the standard-base64 encoding of a
|
||||
// KeySize-byte key.
|
||||
func New(encodedKey string) (*Box, error) {
|
||||
key, err := base64.StdEncoding.Strict().DecodeString(encodedKey)
|
||||
if err != nil {
|
||||
// The decoder's error is not wrapped: it describes the key's content.
|
||||
return nil, ErrKeyEncoding
|
||||
}
|
||||
if len(key) != KeySize {
|
||||
return nil, fmt.Errorf("%w, got %d", ErrKeyLength, len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secretbox: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secretbox: %w", err)
|
||||
}
|
||||
return &Box{aead: aead}, nil
|
||||
}
|
||||
|
||||
// Encrypt seals plaintext under a fresh random nonce and returns
|
||||
// nonce ‖ ciphertext.
|
||||
func (b *Box) Encrypt(plaintext []byte) ([]byte, error) {
|
||||
nonce := make([]byte, b.aead.NonceSize(), b.aead.NonceSize()+len(plaintext)+b.aead.Overhead())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, fmt.Errorf("secretbox: generate nonce: %w", err)
|
||||
}
|
||||
return b.aead.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// Decrypt authenticates and opens a token produced by Encrypt.
|
||||
func (b *Box) Decrypt(token []byte) ([]byte, error) {
|
||||
n := b.aead.NonceSize()
|
||||
if len(token) < n+b.aead.Overhead() {
|
||||
return nil, ErrMalformed
|
||||
}
|
||||
plaintext, err := b.aead.Open(nil, token[:n], token[n:], nil)
|
||||
if err != nil {
|
||||
return nil, ErrDecrypt
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package secretbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newKey returns a fresh random key and its standard-base64 encoding.
|
||||
func newKey(t *testing.T) ([]byte, string) {
|
||||
t.Helper()
|
||||
key := make([]byte, KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, base64.StdEncoding.EncodeToString(key)
|
||||
}
|
||||
|
||||
func newBox(t *testing.T) (*Box, []byte, string) {
|
||||
t.Helper()
|
||||
key, encoded := newKey(t)
|
||||
box, err := New(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return box, key, encoded
|
||||
}
|
||||
|
||||
// assertNoLeak fails if err's message contains any of the secrets, either
|
||||
// raw or base64-encoded.
|
||||
func assertNoLeak(t *testing.T, err error, secrets ...[]byte) {
|
||||
t.Helper()
|
||||
msg := err.Error()
|
||||
for _, s := range secrets {
|
||||
if len(s) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, form := range []string{string(s), base64.StdEncoding.EncodeToString(s), base64.RawStdEncoding.EncodeToString(s)} {
|
||||
if strings.Contains(msg, form) {
|
||||
t.Errorf("error %q leaks secret material %q", msg, form)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
for _, plaintext := range [][]byte{
|
||||
[]byte("ya29.oauth-refresh-token"),
|
||||
{},
|
||||
bytes.Repeat([]byte{0x00, 0xff}, 4096),
|
||||
} {
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plaintext) > 0 && bytes.Contains(token, plaintext) {
|
||||
t.Errorf("token contains the plaintext")
|
||||
}
|
||||
got, err := box.Decrypt(token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Errorf("Decrypt = %q, want %q", got, plaintext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenLayout(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
plaintext := []byte("secret")
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// nonce (12) ‖ ciphertext (len(plaintext)) ‖ GCM tag (16).
|
||||
if want := 12 + len(plaintext) + 16; len(token) != want {
|
||||
t.Errorf("token length = %d, want %d", len(token), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptUsesFreshNonce(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
plaintext := []byte("same plaintext")
|
||||
a, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("two encryptions of the same plaintext are identical")
|
||||
}
|
||||
if bytes.Equal(a[:12], b[:12]) {
|
||||
t.Error("two encryptions reused the same nonce")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptRejectsFlippedByte(t *testing.T) {
|
||||
box, key, encoded := newBox(t)
|
||||
plaintext := []byte("do-not-leak-me")
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Flip one bit in every position: nonce, ciphertext and tag.
|
||||
for i := range token {
|
||||
tampered := bytes.Clone(token)
|
||||
tampered[i] ^= 0x01
|
||||
got, err := box.Decrypt(tampered)
|
||||
if !errors.Is(err, ErrDecrypt) {
|
||||
t.Fatalf("byte %d flipped: Decrypt = %q, %v; want ErrDecrypt", i, got, err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("byte %d flipped: Decrypt returned plaintext alongside the error", i)
|
||||
}
|
||||
assertNoLeak(t, err, plaintext, key, []byte(encoded), tampered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptRejectsWrongKey(t *testing.T) {
|
||||
box, key, encoded := newBox(t)
|
||||
other, otherKey, otherEncoded := newBox(t)
|
||||
plaintext := []byte("do-not-leak-me")
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := other.Decrypt(token)
|
||||
if !errors.Is(err, ErrDecrypt) {
|
||||
t.Fatalf("Decrypt with wrong key = %q, %v; want ErrDecrypt", got, err)
|
||||
}
|
||||
assertNoLeak(t, err, plaintext, key, []byte(encoded), otherKey, []byte(otherEncoded), token)
|
||||
}
|
||||
|
||||
func TestDecryptRejectsShortToken(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
for _, n := range []int{0, 1, 12, 12 + 15} {
|
||||
token := bytes.Repeat([]byte{0xab}, n)
|
||||
if _, err := box.Decrypt(token); !errors.Is(err, ErrMalformed) {
|
||||
t.Errorf("Decrypt(%d bytes) error = %v, want ErrMalformed", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsBadKey(t *testing.T) {
|
||||
key, _ := newKey(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
encoded string
|
||||
want error
|
||||
}{
|
||||
{"empty", "", ErrKeyLength},
|
||||
{"not base64", "not*base64!" + strings.Repeat("A", 33), ErrKeyEncoding},
|
||||
{"url-safe alphabet", strings.Repeat("_-", 22), ErrKeyEncoding},
|
||||
{"unpadded", base64.RawStdEncoding.EncodeToString(key), ErrKeyEncoding},
|
||||
{"raw key bytes", string(key), ErrKeyEncoding},
|
||||
{"too short", base64.StdEncoding.EncodeToString(key[:31]), ErrKeyLength},
|
||||
{"too long", base64.StdEncoding.EncodeToString(append(bytes.Clone(key), 0x00)), ErrKeyLength},
|
||||
{"AES-128 length", base64.StdEncoding.EncodeToString(key[:16]), ErrKeyLength},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
box, err := New(tt.encoded)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("New error = %v, want %v", err, tt.want)
|
||||
}
|
||||
if box != nil {
|
||||
t.Error("New returned a Box alongside the error")
|
||||
}
|
||||
assertNoLeak(t, err, key, []byte(tt.encoded))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAcceptsBase64Key(t *testing.T) {
|
||||
key, encoded := newKey(t)
|
||||
box, err := New(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A Box built from the same key opens tokens sealed by the first.
|
||||
token, err := box.Encrypt([]byte("x"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := again.Decrypt(token); err != nil {
|
||||
t.Errorf("same key failed to decrypt: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// GET /api/v1/me.
|
||||
const CSRFHeader = "X-MealQuest-CSRF"
|
||||
|
||||
// requireCSRF passes GET, HEAD and OPTIONS requests to next; every other
|
||||
// requireCSRF passes GET requests to next; every other
|
||||
// request must carry CSRFHeader equal to its session's CSRF token or get 403
|
||||
// csrf. It runs behind requireSession, which puts the session in the context.
|
||||
func requireCSRF(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
@@ -21,7 +21,7 @@ func requireCSRF(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
case http.MethodGet:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,14 +34,14 @@ func TestCSRFThroughRouter(t *testing.T) {
|
||||
{"PATCH without header", http.MethodPatch, "/api/v1/settings/household", nil, http.StatusForbidden, csrfJSON},
|
||||
{"DELETE with wrong header", http.MethodDelete, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON},
|
||||
// The right token passes the check and reaches the router, which has
|
||||
// no POST /api/v1/me.
|
||||
// no POST /api/v1/me, and the settings handler, which refuses the
|
||||
// empty body.
|
||||
{"POST with right header", http.MethodPost, "/api/v1/me", []string{testSession.CSRFToken}, http.StatusMethodNotAllowed, methodNotAllowedJSON},
|
||||
{"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusNotFound, notFoundJSON},
|
||||
{"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"request body must be a JSON object","retryable":false}}`},
|
||||
{"GET without header", http.MethodGet, "/api/v1/me", nil, http.StatusOK, ""},
|
||||
// HEAD and OPTIONS pass the check and reach the router, which
|
||||
// answers neither for /api/v1/me.
|
||||
{"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusMethodNotAllowed, ""},
|
||||
{"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusMethodNotAllowed, methodNotAllowedJSON},
|
||||
// Only GET is exempt; HEAD and OPTIONS need the header too.
|
||||
{"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||
{"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -89,6 +89,8 @@ func TestRequireCSRF(t *testing.T) {
|
||||
{"right header reaches handler", http.MethodPost, &testSession, testSession.CSRFToken, http.StatusNoContent},
|
||||
{"wrong header", http.MethodPut, &testSession, "nope", http.StatusForbidden},
|
||||
{"GET needs no header", http.MethodGet, &testSession, "", http.StatusNoContent},
|
||||
{"HEAD needs the header", http.MethodHead, &testSession, "", http.StatusForbidden},
|
||||
{"OPTIONS needs the header", http.MethodOptions, &testSession, "", http.StatusForbidden},
|
||||
{"no session in context", http.MethodPost, nil, "", http.StatusInternalServerError},
|
||||
{"session without token", http.MethodPost, &auth.Session{Subject: "user-123"}, "", http.StatusInternalServerError},
|
||||
} {
|
||||
|
||||
@@ -82,7 +82,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions,
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -44,7 +44,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{},
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+12
-16
@@ -3,6 +3,7 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
// reservedPrefixes are owned by the API router and are never answered with the
|
||||
@@ -26,10 +28,13 @@ type RouteMounter interface {
|
||||
// has its client address and scheme resolved, honouring forwarded headers
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; assets is the web build served for every other GET. /health, /auth
|
||||
// and the web build are public. Requests to /api/v1 other than GET, HEAD and
|
||||
// OPTIONS also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, assets fs.FS) (http.Handler, error) {
|
||||
// /media; settings serves /api/v1/settings/household; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
@@ -56,6 +61,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
sub.NotFound(notFound)
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
@@ -81,19 +88,8 @@ type apiError struct {
|
||||
|
||||
// apiErrorHandler responds with status and a fixed, non-retryable API error.
|
||||
func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc {
|
||||
body, err := json.Marshal(errorBody{Error: apiError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}})
|
||||
if err != nil {
|
||||
panic(err) // fixed string and bool fields cannot fail to marshal
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
|
||||
}
|
||||
writeError(w, r, logger, status, code, message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -12,6 +13,10 @@ import (
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,13 +26,18 @@ const (
|
||||
)
|
||||
|
||||
func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
t.Helper()
|
||||
return newTestRouterWith(t, version, newTestSettings(t))
|
||||
}
|
||||
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -60,6 +70,38 @@ func TestAuthRoutesMounted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestSettings returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return household.NewSettingsService(st)
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
// maxSettingsBody bounds a PATCH /settings/household request body.
|
||||
const maxSettingsBody = 16 << 10
|
||||
|
||||
// settingsPatch is the PATCH /settings/household body. Every field is
|
||||
// optional; unknown fields and null values are refused.
|
||||
type settingsPatch struct {
|
||||
Timezone *string `json:"timezone"`
|
||||
DinnerTime *string `json:"dinner_time"`
|
||||
ReminderTime *string `json:"reminder_time"`
|
||||
ReviewTime *string `json:"review_time"`
|
||||
PlanDraftDow *int `json:"plan_draft_dow"`
|
||||
PlanDraftTime *string `json:"plan_draft_time"`
|
||||
SuggestionsDow *int `json:"suggestions_dow"`
|
||||
SuggestionsTime *string `json:"suggestions_time"`
|
||||
ChatAutosendDow *int `json:"chat_autosend_dow"`
|
||||
ChatAutosendTime *string `json:"chat_autosend_time"`
|
||||
}
|
||||
|
||||
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get household settings", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// patchHouseholdSettings records the session's actor subject as updated_by.
|
||||
func patchHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := auth.ActorFrom(r.Context())
|
||||
if !ok {
|
||||
logger.ErrorContext(r.Context(), "PATCH /api/v1/settings/household without a session")
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
patch, err := decodeSettingsPatch(http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
settings, err := svc.Update(r.Context(), household.SettingsPatch(patch), actor.Subject)
|
||||
var invalid *household.ValidationError
|
||||
if errors.As(err, &invalid) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "update household settings", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeSettingsPatch reads a single JSON object, refusing unknown fields,
|
||||
// null values and trailing data.
|
||||
func decodeSettingsPatch(body io.Reader) (settingsPatch, error) {
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return settingsPatch{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
|
||||
}
|
||||
return settingsPatch{}, errors.New("cannot read request body")
|
||||
}
|
||||
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil || fields == nil {
|
||||
return settingsPatch{}, errors.New("request body must be a JSON object")
|
||||
}
|
||||
for name, raw := range fields {
|
||||
if bytes.Equal(raw, []byte("null")) {
|
||||
return settingsPatch{}, fmt.Errorf("%s: must not be null", name)
|
||||
}
|
||||
}
|
||||
|
||||
var patch settingsPatch
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&patch); err != nil {
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
return settingsPatch{}, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
|
||||
}
|
||||
return settingsPatch{}, fmt.Errorf("invalid settings: %v", err)
|
||||
}
|
||||
return patch, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, v any) {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "encode response", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeError responds with status and a non-retryable API error.
|
||||
func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) {
|
||||
body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}})
|
||||
if err != nil {
|
||||
panic(err) // string and bool fields cannot fail to marshal
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const settingsPath = "/api/v1/settings/household"
|
||||
|
||||
// signedIn returns a request carrying stubSessions' valid session cookie and
|
||||
// its CSRF token.
|
||||
func signedIn(method, target string, body io.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method, target, body)
|
||||
req.AddCookie(validSessionCookie)
|
||||
req.Header.Set(CSRFHeader, testSession.CSRFToken)
|
||||
return req
|
||||
}
|
||||
|
||||
func serve(t *testing.T, h http.Handler, req *http.Request) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return rec, body
|
||||
}
|
||||
|
||||
func TestGetHouseholdSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodGet, settingsPath, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
want := map[string]any{
|
||||
"timezone": "America/New_York",
|
||||
"dinner_time": "17:30",
|
||||
"reminder_time": "16:30",
|
||||
"review_time": "09:00",
|
||||
"plan_draft_dow": float64(5),
|
||||
"plan_draft_time": "09:00",
|
||||
"suggestions_dow": float64(0),
|
||||
"suggestions_time": "08:00",
|
||||
"chat_autosend_dow": float64(6),
|
||||
"chat_autosend_time": "08:00",
|
||||
"onboarding_completed_at": nil,
|
||||
"updates_seen_at": nil,
|
||||
"updated_by": nil,
|
||||
}
|
||||
for k, v := range want {
|
||||
got, ok := body[k]
|
||||
if !ok || got != v {
|
||||
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"created_at", "updated_at"} {
|
||||
if s, ok := body[k].(string); !ok || s == "" {
|
||||
t.Errorf("%s = %#v, want a timestamp", k, body[k])
|
||||
}
|
||||
}
|
||||
if len(body) != len(want)+2 {
|
||||
t.Errorf("body has %d fields, want %d: %v", len(body), len(want)+2, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchHouseholdSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings)
|
||||
|
||||
var notified []household.Settings
|
||||
settings.Subscribe(func(s household.Settings) { notified = append(notified, s) })
|
||||
|
||||
rec, body := serve(t, router, signedIn(http.MethodPatch, settingsPath,
|
||||
strings.NewReader(`{"timezone":"Europe/Berlin","dinner_time":"18:00","plan_draft_dow":4}`)))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for k, v := range map[string]any{
|
||||
"timezone": "Europe/Berlin",
|
||||
"dinner_time": "18:00",
|
||||
"plan_draft_dow": float64(4),
|
||||
"reminder_time": "16:30", // unchanged
|
||||
"updated_by": testSession.Subject,
|
||||
} {
|
||||
if body[k] != v {
|
||||
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
||||
}
|
||||
}
|
||||
if len(notified) != 1 || notified[0].Timezone != "Europe/Berlin" {
|
||||
t.Errorf("subscriber notified with %+v, want one change to Europe/Berlin", notified)
|
||||
}
|
||||
|
||||
_, body = serve(t, router, signedIn(http.MethodGet, settingsPath, nil))
|
||||
if body["timezone"] != "Europe/Berlin" || body["updated_by"] != testSession.Subject {
|
||||
t.Errorf("GET after PATCH = %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
code string
|
||||
in string // the error message must contain this
|
||||
}{
|
||||
{"bad timezone", `{"timezone":"Mars/Olympus_Mons"}`, "invalid_settings", "timezone"},
|
||||
{"bad time", `{"dinner_time":"25:00"}`, "invalid_settings", "dinner_time"},
|
||||
{"unpadded time", `{"reminder_time":"9:30"}`, "invalid_settings", "reminder_time"},
|
||||
{"bad dow", `{"suggestions_dow":7}`, "invalid_settings", "suggestions_dow"},
|
||||
{"negative dow", `{"chat_autosend_dow":-1}`, "invalid_settings", "chat_autosend_dow"},
|
||||
{"empty object", `{}`, "invalid_settings", "no settings to change"},
|
||||
{"fractional dow", `{"plan_draft_dow":5.5}`, "invalid_request", "plan_draft_dow"},
|
||||
{"string dow", `{"plan_draft_dow":"5"}`, "invalid_request", "plan_draft_dow"},
|
||||
{"numeric time", `{"dinner_time":1730}`, "invalid_request", "dinner_time"},
|
||||
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
|
||||
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
|
||||
{"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"},
|
||||
{"not an object", `["timezone"]`, "invalid_request", "JSON object"},
|
||||
{"null body", `null`, "invalid_request", "JSON object"},
|
||||
{"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"},
|
||||
{"malformed", `{"dinner_time":`, "invalid_request", "JSON object"},
|
||||
{"too large", `{"timezone":"` + strings.Repeat("a", maxSettingsBody) + `"}`, "invalid_request", "exceeds"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings)
|
||||
notified := 0
|
||||
settings.Subscribe(func(household.Settings) { notified++ })
|
||||
|
||||
rec, body := serve(t, router, signedIn(http.MethodPatch, settingsPath, strings.NewReader(tt.body)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
|
||||
}
|
||||
apiErr, ok := body["error"].(map[string]any)
|
||||
if !ok || len(body) != 1 || len(apiErr) != 3 {
|
||||
t.Fatalf("body = %s, want {\"error\":{code,message,retryable}}", rec.Body)
|
||||
}
|
||||
if apiErr["code"] != tt.code || apiErr["retryable"] != false {
|
||||
t.Errorf("error = %v, want code %q, retryable false", apiErr, tt.code)
|
||||
}
|
||||
if msg, _ := apiErr["message"].(string); !strings.Contains(msg, tt.in) {
|
||||
t.Errorf("message %q does not contain %q", msg, tt.in)
|
||||
}
|
||||
if notified != 0 {
|
||||
t.Errorf("rejected PATCH notified subscribers %d times", notified)
|
||||
}
|
||||
_, after := serve(t, router, signedIn(http.MethodGet, settingsPath, nil))
|
||||
if after["timezone"] != "America/New_York" || after["dinner_time"] != "17:30" || after["updated_by"] != nil {
|
||||
t.Errorf("rejected PATCH changed settings: %v", after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseholdSettingsMethodNotAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodPut, settingsPath, strings.NewReader(`{}`)))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", rec.Code)
|
||||
}
|
||||
if apiErr, _ := body["error"].(map[string]any); apiErr["code"] != "method_not_allowed" {
|
||||
t.Errorf("body = %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHouseholdSettingsWithRealSession drives the settings API through the
|
||||
// production router and session manager: a member signs in, reads the CSRF
|
||||
// token from GET /api/v1/me and PATCHes; updated_by comes from the session.
|
||||
// Without a session, or without the CSRF token, the PATCH is refused and
|
||||
// nothing changes.
|
||||
func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions, err := auth.NewSessionManager(st, time.Hour, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Sign in as the callback would.
|
||||
login := httptest.NewRecorder()
|
||||
if err := sessions.StartSession(login, httptest.NewRequest(http.MethodGet, "/auth/callback", nil), auth.Claims{
|
||||
Subject: "authentik-sub-42",
|
||||
Email: "cook@example.com",
|
||||
Name: "Home Cook",
|
||||
Groups: []string{"mealquest-admin"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookies := login.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != auth.SessionCookieName {
|
||||
t.Fatalf("StartSession set cookies %v, want the session cookie", cookies)
|
||||
}
|
||||
cookie := cookies[0]
|
||||
withCookie := func(req *http.Request) *http.Request {
|
||||
req.AddCookie(cookie)
|
||||
return req
|
||||
}
|
||||
|
||||
rec, me := serve(t, router, withCookie(httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)))
|
||||
token, _ := me["csrfToken"].(string)
|
||||
if rec.Code != http.StatusOK || token == "" {
|
||||
t.Fatalf("GET /api/v1/me = %d %v, want 200 with a CSRF token", rec.Code, me)
|
||||
}
|
||||
|
||||
patch := `{"dinner_time":"18:45"}`
|
||||
refused := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
status int
|
||||
}{
|
||||
{"no session", httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch)), http.StatusUnauthorized},
|
||||
{"no CSRF token", withCookie(httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch))), http.StatusForbidden},
|
||||
}
|
||||
for _, tt := range refused {
|
||||
if rec, body := serve(t, router, tt.req); rec.Code != tt.status {
|
||||
t.Errorf("%s: PATCH status = %d, want %d: %v", tt.name, rec.Code, tt.status, body)
|
||||
}
|
||||
}
|
||||
_, body := serve(t, router, withCookie(httptest.NewRequest(http.MethodGet, settingsPath, nil)))
|
||||
if body["dinner_time"] != household.SeedDinnerTime || body["updated_by"] != nil {
|
||||
t.Fatalf("refused PATCH changed settings: %v", body)
|
||||
}
|
||||
|
||||
req := withCookie(httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch)))
|
||||
req.Header.Set(CSRFHeader, token)
|
||||
rec, body = serve(t, router, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH status = %d, want 200: %v", rec.Code, body)
|
||||
}
|
||||
if body["dinner_time"] != "18:45" || body["updated_by"] != "authentik-sub-42" {
|
||||
t.Errorf("PATCH = %v, want dinner_time 18:45 updated_by authentik-sub-42", body)
|
||||
}
|
||||
}
|
||||
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,15 +188,16 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A database migrated by the bootstrap-only build upgrades to the
|
||||
// embedded set, which adds 0001_init.sql alongside the unchanged bootstrap.
|
||||
// embedded set, which adds the later migrations alongside the unchanged
|
||||
// bootstrap.
|
||||
if err := Migrate(ctx, s.Writer, fstest.MapFS{"0000_bootstrap.sql": {Data: bootstrap}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
|
||||
+16
-1
@@ -86,6 +86,17 @@ func ParseLocalDate(s string) (LocalDate, error) {
|
||||
return LocalDate{t.Year(), t.Month(), t.Day()}, nil
|
||||
}
|
||||
|
||||
// LocalDateOf returns the calendar date of t in t's own location.
|
||||
func LocalDateOf(t time.Time) LocalDate {
|
||||
y, m, d := t.Date()
|
||||
return LocalDate{y, m, d}
|
||||
}
|
||||
|
||||
// Date returns the year, month and day of d.
|
||||
func (d LocalDate) Date() (year int, month time.Month, day int) {
|
||||
return d.year, d.month, d.day
|
||||
}
|
||||
|
||||
// String returns the YYYY-MM-DD form.
|
||||
func (d LocalDate) String() string {
|
||||
return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day)
|
||||
@@ -96,11 +107,15 @@ func (d LocalDate) IsZero() bool {
|
||||
return d == LocalDate{}
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer. The zero LocalDate is an error.
|
||||
// Value implements driver.Valuer. The zero LocalDate is an error, as is a
|
||||
// year outside 0000–9999, which the four-digit stored form cannot represent.
|
||||
func (d LocalDate) Value() (driver.Value, error) {
|
||||
if d.IsZero() {
|
||||
return nil, errors.New("store: zero LocalDate")
|
||||
}
|
||||
if d.year < 0 || d.year > 9999 {
|
||||
return nil, fmt.Errorf("store: LocalDate year %d outside 0000-9999", d.year)
|
||||
}
|
||||
return d.String(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,25 @@ func TestLocalDateRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateOf(t *testing.T) {
|
||||
auckland, err := time.LoadLocation("Pacific/Auckland")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 2026-10-03T20:00Z is already 2026-10-04 in Auckland (UTC+13).
|
||||
instant := time.Date(2026, time.October, 3, 20, 0, 0, 0, time.UTC)
|
||||
if got := LocalDateOf(instant).String(); got != "2026-10-03" {
|
||||
t.Errorf("LocalDateOf(UTC) = %s, want 2026-10-03", got)
|
||||
}
|
||||
d := LocalDateOf(instant.In(auckland))
|
||||
if got := d.String(); got != "2026-10-04" {
|
||||
t.Errorf("LocalDateOf(Auckland) = %s, want 2026-10-04", got)
|
||||
}
|
||||
if y, m, day := d.Date(); y != 2026 || m != time.October || day != 4 {
|
||||
t.Errorf("Date() = %d-%d-%d, want 2026-10-4", y, m, day)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateRejectsInvalid(t *testing.T) {
|
||||
for _, str := range []string{
|
||||
"", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00",
|
||||
@@ -119,6 +138,12 @@ func TestLocalDateRejectsInvalid(t *testing.T) {
|
||||
if _, err := (LocalDate{}).Value(); err == nil {
|
||||
t.Error("zero LocalDate Value succeeded")
|
||||
}
|
||||
for _, y := range []int{-1, 10000} {
|
||||
d := LocalDateOf(time.Date(y, time.January, 1, 0, 0, 0, 0, time.UTC))
|
||||
if _, err := d.Value(); err == nil {
|
||||
t.Errorf("LocalDate year %d Value succeeded", y)
|
||||
}
|
||||
}
|
||||
var d LocalDate
|
||||
if err := d.Scan(nil); err == nil {
|
||||
t.Error("Scan(nil) succeeded")
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 0002_household_settings_updated_by: record who last changed the household
|
||||
-- settings, as dinners and suggestion_batches already do. NULL until the
|
||||
-- first change made by a signed-in member.
|
||||
ALTER TABLE household_settings ADD COLUMN updated_by TEXT;
|
||||
@@ -10,12 +10,12 @@ INSERT INTO household_settings (
|
||||
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
|
||||
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
||||
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
||||
created_at, updated_at
|
||||
updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?
|
||||
?, ?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
timezone = excluded.timezone,
|
||||
@@ -30,6 +30,7 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
chat_autosend_time = excluded.chat_autosend_time,
|
||||
onboarding_completed_at = excluded.onboarding_completed_at,
|
||||
updates_seen_at = excluded.updates_seen_at,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user