Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 899f917fa4 Retry job claims after a database error instead of waiting for a wake
CI / ci (push) Successful in 1m27s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m29s
A claim that failed with anything but sql.ErrNoRows parked the worker
on the wake channel, so a job queued across a restart (which has no
wake token) stayed queued until another enqueue or restart. The worker
now retries such a claim with backoff from 50ms doubling to 5s, reset
on any successful or empty claim, and stops promptly on shutdown. The
claim error is logged by category only.

The claim query is a Runner field so a test can inject one failing
claim; TestClaimErrorIsRetried drops the wake token and asserts the
queued job still runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:20:10 -04:00
dev_agentandClaude Opus 5.5 985fea6639 Store "interrupted by restart" for jobs recovered at boot
CI / ci (push) Successful in 1m27s
CI / ci (pull_request) Successful in 1m28s
Boot recovery now writes the required fixed text to the job error column,
and the recovery test asserts that literal text instead of the constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:16:18 -04:00
dev_agentandClaude Opus 5.5 f160c9f3a9 Register a built-in noop job kind and store only failure codes
CI / ci (push) Successful in 1m27s
CI / ci (pull_request) Successful in 1m27s
The server now registers the framework's built-in noop kind through
registerJobKinds before the runner starts, so production can run a job; a
noop job succeeds with {"ok":true}. Each feature's kinds are registered in
the same function.

Handlers report failure only with a *jobs.Failure code their kind declared
at registration. The runner stores and returns only that code, or
internal_error, interrupted or unknown_kind, and logs only the job id, kind,
code and a fixed reason: handler error text is never stored, returned or
logged. Results implement jobs.Public, whose PublicJSON names exactly the
data that is stored and returned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:12:30 -04:00
dev_agentandClaude Opus 5.5 f37f240b26 Add async job runner and GET /api/v1/jobs/{id}
CI / ci (pull_request) Successful in 1m29s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (2 high)
CI / ci (push) Successful in 1m27s
internal/jobs persists jobs in the jobs table, which is also the queue: a
bounded pool of workers claims queued jobs oldest first. Handlers are
registered per kind before Start with typed payloads and results, and can
publish partial results to the row while they run. Only typed results and
*jobs.Failure messages are stored; any other handler error is logged and
stored as "internal error", so dependency error text cannot surface a
secret. Start fails jobs a previous process left running with
"interrupted by restart" before any worker runs.

GET /api/v1/jobs/{id} returns {id, kind, status, result, error} inside the
protected /api/v1 group; an unknown id is a 404 not_found.

Migration 0003_jobs_status rebuilds jobs with status queued, running,
succeeded, failed, mapping legacy pending and done rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:05:23 -04:00
dev_agent 3ba78d47bc Feature 04 task 4: Default seeding for AI settings and notification preferences (#28)
CI / ci (push) Successful in 1m26s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 14:58:30 -04:00
dev_agent 8be5c56f6f Feature 04 task 3 (rebased): Household settings seeding and API (#27)
CI / ci (push) Successful in 1m22s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 11:50:15 -04:00
dev_agentandClaude Opus 5.5 ea4047a852 Merge origin/dev (Feature 03) into Feature 04 integration
CI / ci (push) Successful in 1m26s
Feature 03 made the OIDC variables required, so the Feature 04 boot tests'
loadConfig helper now sets them alongside the secret key file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:38:18 -04:00
approver_agent 41ff2ab114 Merge pull request 'Feature 03: Authentik OIDC Sessions' (#24) from feature/9c4eba48-9a49-4b73-a85f-a4bc19eb4062-authentik-oidc-sessions into dev
CI / ci (push) Successful in 1m30s
2026-10-03 11:36:30 -04:00
dev_agent df692ef214 Feature 03 integration fix: sanitize ID-token verification errors (#26)
CI / ci (push) Successful in 1m26s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m33s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 11:32:36 -04:00
dev_agent 88f7d0a5fa Feature 03 integration fix: sanitize discovery errors and narrow CSRF exemption (#25)
CI / ci (push) Successful in 1m25s
CI / ci (pull_request) Failing after 1m2s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 11:22:53 -04:00
dev_agent 5833a73541 Feature 03 task 5: Trusted proxy handling for forwarded headers (#23)
CI / ci (push) Successful in 1m25s
CI / ci (pull_request) Successful in 1m24s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (2 medium)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 11:05:33 -04:00
dev_agent 36b975ff5b Feature 03 task 4: CSRF protection and GET /api/v1/me (#22)
CI / ci (push) Successful in 1m26s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 10:51:26 -04:00
dev_agent b2201c5da1 Feature 03 task 3: Household group gate and auth middleware (#21)
CI / ci (push) Successful in 1m27s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 10:30:07 -04:00
dev_agent f0bdd0ef40 Feature 03 task 2: Server-side sessions, cookie and logout (#19)
CI / ci (push) Successful in 1m24s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 10:20:14 -04:00
dev_agent 5e78465d34 Feature 04 task 2: Household week and local-time utilities (#18)
CI / ci (push) Successful in 1m25s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:58:06 -04:00
dev_agent 99c8f1625f Feature 03 task 1: OIDC login and callback against Authentik (#16)
CI / ci (push) Successful in 1m25s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:58:05 -04:00
dev_agent 6d5c14f778 Feature 04 task 1: secretbox: AES-256-GCM secrets at rest (#17)
CI / ci (push) Successful in 1m26s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:46:29 -04:00
approver_agent 19ea8d52d8 Merge pull request 'Feature 02: SQLite Data Layer and Schema' (#14) from feature/b9c21d67-bf72-411a-901d-77e57352d398-sqlite-data-layer-and-schema into dev
CI / ci (push) Successful in 1m23s
2026-10-03 09:30:47 -04:00
dev_agent c1a868c48b Feature 02 integration fix: repository coverage and boot-path tests (#15)
CI / ci (push) Successful in 1m22s
CI / ci (pull_request) Successful in 1m24s
Independent review Codex gpt-6-sol high: APPROVED (0 high/medium; 1 low)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:27:22 -04:00
dev_agent 17a69f4de7 Feature 02 task 4: sqlc configuration and core queries (#13)
CI / ci (push) Successful in 1m25s
CI / ci (pull_request) Successful in 1m24s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (4 medium)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:15:40 -04:00
dev_agent fa7c6b7218 Feature 02 task 3: Initial v1 schema migration (#12)
CI / ci (push) Successful in 50s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:06:21 -04:00
dev_agent b06d1680a3 Feature 02 task 2: Store helpers: transactions, ULIDs and time conventions (#11)
CI / ci (push) Successful in 48s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 08:54:55 -04:00
dev_agent e9ee4e768a Feature 02 task 1: SQLite connection and embedded migration runner (#10)
CI / ci (push) Successful in 49s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 00:54:02 -04:00
approver_agent 65b27a7689 Merge pull request 'Feature 01: Repository Scaffold and CI' (#8) from feature/19ad7feb-aba0-4b76-ab70-c47d71ed5e76-repository-scaffold-and-ci into dev
CI / ci (push) Successful in 27s
2026-10-03 00:40:39 -04:00
dev_agent 645b3c00b2 Feature 01 integration fix: CI build parity with make build (#9)
CI / ci (push) Successful in 26s
CI / ci (pull_request) Successful in 25s
Independent review Approved — 0 high findings (feature #8)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 00:36:48 -04:00
82 changed files with 13547 additions and 69 deletions
+15 -36
View File
@@ -1,6 +1,13 @@
# Runs on every push and pull request (no branch filter) so feature branches,
# their pull requests, and dev/main are all checked. Runs directly on the
# self-hosted act_runner; no container: key.
#
# The Makefile is the single build definition: CI runs `make test` and
# `make build` so it fails whenever either target would, and the binary
# carries the Makefile's -trimpath and version-stamped -ldflags (the
# `make build` log shows the -X main.version=... value). `make sqlc-check`
# fails when the committed internal/db code is stale against sqlc.yaml,
# migrations/ and migrations/queries/.
name: CI
on: [push, pull_request]
@@ -16,45 +23,17 @@ jobs:
go-version-file: go.mod
cache: false
- name: gofmt
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt needed:"
echo "$unformatted"
exit 1
fi
- name: go vet
run: go vet ./...
- name: go test
run: go test -race ./...
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: npm ci
working-directory: web
run: npm ci
- name: make test
run: make test
- name: npm run typecheck
working-directory: web
run: npm run typecheck
- name: npm test
working-directory: web
run: npm test
- name: npm run build
working-directory: web
run: npm run build
# Embed the real PWA, as `make embed` does, then build the static binary.
- name: go build (embedded PWA)
- name: make sqlc-check
run: |
rm -rf internal/frontend/dist
mkdir -p internal/frontend/dist
cp -R web/dist/. internal/frontend/dist/
CGO_ENABLED=0 go build -o bin/mealquest ./cmd/server
make sqlc-install
make sqlc-check SQLC="$(go env GOPATH)/bin/sqlc"
- name: make build
run: make build
+29 -1
View File
@@ -5,6 +5,9 @@
# make test gofmt check, go vet, go test -race, npm typecheck and test.
# make build Build the PWA, embed it, build the static binary bin/mealquest.
# make docker Build the container image from ./Dockerfile.
# make sqlc Regenerate internal/db from sqlc.yaml with the pinned sqlc.
# make sqlc-check Regenerate; fail if internal/db differs from the commit.
# make sqlc-install Install the pinned sqlc into the Go bin directory.
FRONTEND_DIST := internal/frontend/dist
# Dirty means a tracked change outside the generated embed output, which
@@ -14,7 +17,13 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)$(if $(G
IMAGE ?= mealquest:$(VERSION)
LDFLAGS := -s -w -X main.version=$(VERSION)
.PHONY: dev test go-test web-test build web embed check-embed-clean docker
# sqlc is a dev tool only, never a go.mod dependency. Generated code records
# the version that wrote it, so every run must use exactly this one.
SQLC_VERSION := v1.31.1
SQLC ?= sqlc
SQLC_OUT := internal/db
.PHONY: dev test go-test web-test build web embed check-embed-clean docker sqlc sqlc-check sqlc-install
# Run both servers; Ctrl-C (or either exiting) stops both. The binary is built
# first and run directly because `go run` would orphan its child on kill.
@@ -58,3 +67,22 @@ check-embed-clean:
docker:
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) .
# sqlc $(SQLC_VERSION) needs Go 1.26 to build; GOTOOLCHAIN=auto lets go fetch
# it for this install only, even where the environment pins the local Go.
sqlc-install:
GOTOOLCHAIN=auto go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION)
# Previously generated files are removed first so a dropped query file does
# not leave its stale output behind; hand-written files are kept.
sqlc:
@v="$$($(SQLC) version)"; \
if [ "$$v" != "$(SQLC_VERSION)" ]; then echo "sqlc $(SQLC_VERSION) required, $(SQLC) is $$v"; exit 1; fi
find $(SQLC_OUT) -maxdepth 1 -name '*.go' -exec grep -l -x '// Code generated by sqlc. DO NOT EDIT.' {} + | xargs -r rm --
$(SQLC) generate
# Fails on any difference from the committed generated code, including new
# or deleted files.
sqlc-check: sqlc
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
+26 -2
View File
@@ -30,14 +30,38 @@ export MEALQUEST_PUBLIC_URL=http://localhost:5173
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
export MEALQUEST_OIDC_CLIENT_ID=mealquest
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
# export MEALQUEST_SESSION_IDLE=720h
# export MEALQUEST_SESSION_MAX=4320h
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
# 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
```
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required, and secrets are read only from files named
by `*_FILE` variables.
every variable above is required except the commented-out session
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
+370
View File
@@ -0,0 +1,370 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"io/fs"
"log/slog"
"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"
)
// useBootMigrations swaps bootMigrations for the test and restores it after.
// Tests that call it must not run in parallel.
func useBootMigrations(t *testing.T, m fstest.MapFS) {
t.Helper()
saved := bootMigrations
bootMigrations = m
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;")},
}
func TestOpenStoreMigrationFailureStopsBoot(t *testing.T) {
useBootMigrations(t, brokenMigrations)
db, err := openStore(context.Background(), t.TempDir())
if err == nil {
db.Close()
t.Fatal("openStore succeeded with a broken migration")
}
if db != nil {
t.Error("openStore returned a store alongside the error")
}
if !strings.Contains(err.Error(), "0001_broken.sql") {
t.Errorf("error does not name the broken migration: %v", err)
}
}
func TestOpenStoreAppliesEmbeddedMigrations(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)
}
})
var n int
if err := db.Reader.QueryRowContext(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil {
t.Fatal(err)
}
files, err := fs.Glob(migrations.FS, "*.sql")
if err != nil {
t.Fatal(err)
}
if n != len(files) {
t.Errorf("schema_migrations has %d rows, want %d", n, len(files))
}
}
func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
useBootMigrations(t, brokenMigrations)
var logs bytes.Buffer
cfg := loadConfig(t, validKeyFile(t))
errc := make(chan error, 1)
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
select {
case err := <-errc:
if err == nil || !strings.Contains(err.Error(), "0001_broken.sql") {
t.Fatalf("run error = %v, want the broken migration's error", err)
}
case <-time.After(10 * time.Second):
t.Fatal("run kept going after a migration failure")
}
if strings.Contains(logs.String(), `"listening"`) {
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)
}
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"io"
"log/slog"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
)
// TestNewJobRunnerRunsNoop proves the runner run builds can execute a job:
// a noop job enqueued before Start, as at boot, succeeds with its result.
func TestNewJobRunnerRunsNoop(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)
}
})
runner, kinds, err := newJobRunner(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
id, err := kinds.builtins.Noop.Enqueue(ctx, jobs.NoopPayload{})
if err != nil {
t.Fatal(err)
}
jobsCtx, stopJobs := context.WithCancel(ctx)
if err := runner.Start(jobsCtx); err != nil {
stopJobs()
t.Fatal(err)
}
t.Cleanup(func() {
stopJobs()
runner.Wait()
})
deadline := time.Now().Add(5 * time.Second)
for {
job, err := runner.Get(ctx, id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
if job.Kind != jobs.KindNoop || job.Status != jobs.StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
t.Fatalf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("noop job never finished; last %+v", job)
}
time.Sleep(5 * time.Millisecond)
}
}
+144 -2
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
@@ -12,14 +13,24 @@ import (
"syscall"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"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/jobs"
"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"
)
// version is set at build time with -ldflags "-X main.version=<version>".
var version = "dev"
// jobWorkers is the number of background jobs run at once.
const jobWorkers = 2
func main() {
cfg, err := config.Load()
if err != nil {
@@ -27,7 +38,7 @@ func main() {
os.Exit(1)
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(os.Stdout, nil)))
if err := run(cfg, logger); err != nil {
logger.Error("server stopped", "error", err)
os.Exit(1)
@@ -38,10 +49,61 @@ func run(cfg config.Config, logger *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
router, err := server.NewRouter(logger, version, frontend.Assets())
if _, err := openSecretBox(cfg.SecretKey); err != nil {
return err
}
db, err := openStore(ctx, cfg.DataDir)
if err != nil {
return err
}
defer func() {
if err := db.Close(); err != nil {
logger.Error("close database", "error", err)
}
}()
if err := seedDefaults(ctx, db, cfg.DefaultTZ); err != nil {
return err
}
settings := household.NewSettingsService(db)
runner, _, err := newJobRunner(db, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
}
authFlow, err := auth.New(ctx, auth.Config{
Issuer: cfg.OIDCIssuer.String(),
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret.Reveal(),
PublicURL: cfg.PublicURL,
SecretKey: []byte(cfg.SecretKey.Reveal()),
AllowedGroups: cfg.OIDCAllowedGroups,
}, sessions, logger)
if err != nil {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, runner, frontend.Assets())
if err != nil {
return err
}
// Start fails the jobs a previous process left running before any
// worker runs. The workers stop, and are waited for, before the
// database closes.
jobsCtx, stopJobs := context.WithCancel(ctx)
defer func() {
stopJobs()
runner.Wait()
}()
if err := runner.Start(jobsCtx); err != nil {
return err
}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: router,
@@ -73,12 +135,92 @@ 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
// openStore opens the database in dataDir and applies bootMigrations. On a
// migration failure it closes the database and returns the error.
func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
db, err := store.Open(ctx, dataDir)
if err != nil {
return nil, err
}
if err := store.Migrate(ctx, db.Writer, bootMigrations); err != nil {
return nil, errors.Join(err, db.Close())
}
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)
}
// jobKinds enqueues every job kind the server runs.
type jobKinds struct {
builtins *jobs.Builtins
}
// newJobRunner returns the job runner with every job kind registered, ready
// to Start.
func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds, error) {
runner, err := jobs.NewRunner(db, logger, jobWorkers)
if err != nil {
return nil, nil, err
}
kinds, err := registerJobKinds(runner)
if err != nil {
return nil, nil, err
}
return runner, kinds, nil
}
// registerJobKinds registers every job kind's handler on runner. Each
// feature's job kinds are registered here.
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
return nil, err
}
return &jobKinds{builtins: builtins}, nil
}
// 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) {
logger.Info("listening",
"addr", cfg.HTTPAddr,
"public_origin", cfg.PublicOrigin(),
"trusted_proxy", cfg.TrustedProxy,
"data_dir", cfg.DataDir,
"version", version,
)
+9
View File
@@ -29,6 +29,15 @@ docker compose project "mealquest" — 10.60.1.14
One Go service serves the API, the auth flow, media and the PWA. Its SQLite
database is replicated by Litestream to the NAS.
Traefik terminates TLS. The service honours `X-Forwarded-For` and
`X-Forwarded-Proto` only from peers listed in `MEALQUEST_TRUSTED_PROXY`
(comma-separated IPs/CIDRs; production lists `10.60.1.33`); from any other
peer they are ignored. The client is the rightmost `X-Forwarded-For` address
that is not a trusted proxy. The resolved client IP is added as `client_ip`
to every log line written while handling a request. The OIDC redirect URI
and the Secure cookies are fixed by `MEALQUEST_PUBLIC_URL`, never by request
headers, so sign-in works whichever peer the request arrives from.
## Repository layout
| Path | Purpose |
+21 -2
View File
@@ -1,5 +1,24 @@
module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
go 1.25
go 1.25.0
require github.com/go-chi/chi/v5 v5.3.1
require (
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.3.1
github.com/oklog/ulid/v2 v2.1.2
golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.59.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+59
View File
@@ -1,2 +1,61 @@
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+45
View File
@@ -0,0 +1,45 @@
package auth
import (
"context"
"slices"
)
// Actor is the signed-in household member a request acts for. Handlers
// record Actor.Subject as updated_by on edits.
type Actor struct {
Subject string
Email string
Name string
Groups []string
}
// Actor returns the household member s belongs to.
func (s Session) Actor() Actor {
return Actor{Subject: s.Subject, Email: s.Email, Name: s.Name, Groups: slices.Clone(s.Groups)}
}
type sessionKey struct{}
// WithSession returns a copy of ctx carrying s. The session middleware calls
// it for every authenticated request.
func WithSession(ctx context.Context, s Session) context.Context {
return context.WithValue(ctx, sessionKey{}, s)
}
// SessionFrom returns the session WithSession stored in ctx. ok is false when
// ctx has none, which behind the session middleware means a wiring bug.
func SessionFrom(ctx context.Context) (s Session, ok bool) {
s, ok = ctx.Value(sessionKey{}).(Session)
return s, ok
}
// ActorFrom returns the actor of the session WithSession stored in ctx. ok is
// false when ctx has none.
func ActorFrom(ctx context.Context) (actor Actor, ok bool) {
s, ok := SessionFrom(ctx)
if !ok {
return Actor{}, false
}
return s.Actor(), true
}
+40
View File
@@ -0,0 +1,40 @@
package auth
import (
"context"
"slices"
"testing"
)
func TestActorContext(t *testing.T) {
t.Parallel()
if _, ok := ActorFrom(context.Background()); ok {
t.Error("ActorFrom found an actor in an empty context")
}
if _, ok := SessionFrom(context.Background()); ok {
t.Error("SessionFrom found a session in an empty context")
}
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
ctx := WithSession(context.Background(), s)
gotSession, ok := SessionFrom(ctx)
if !ok || gotSession.Subject != s.Subject || gotSession.CSRFToken != s.CSRFToken {
t.Errorf("SessionFrom = %+v (ok=%v), want %+v", gotSession, ok, s)
}
got, ok := ActorFrom(ctx)
if !ok || got.Subject != s.Subject || got.Email != s.Email || got.Name != s.Name || !slices.Equal(got.Groups, s.Groups) {
t.Errorf("ActorFrom = %+v (ok=%v), want the session's actor", got, ok)
}
}
func TestSessionActor(t *testing.T) {
t.Parallel()
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
a := s.Actor()
if a.Subject != s.Subject || a.Email != s.Email || a.Name != s.Name || !slices.Equal(a.Groups, s.Groups) {
t.Errorf("Actor = %+v, want the session's identity", a)
}
a.Groups[0] = "changed"
if s.Groups[0] != "mealquest-admin" {
t.Error("Actor shares the session's groups slice")
}
}
+439
View File
@@ -0,0 +1,439 @@
// Package auth signs the household in with Authentik over OpenID Connect.
// The service is a confidential client: the authorization code, tokens and
// client secret stay on the server and never reach JavaScript.
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"sync/atomic"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
)
// Routes owned by this package.
const (
LoginPath = "/auth/login"
CallbackPath = "/auth/callback"
LogoutPath = "/auth/logout"
)
// providerTimeout bounds every request to the identity provider.
const providerTimeout = 10 * time.Second
// scopes requested at login.
var scopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", oidc.ScopeOfflineAccess}
// Claims are the verified ID token claims handed to the session layer.
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups"`
}
// Sessions is the session layer. StartSession is called once per successful
// login with claims from a verified ID token; it must set the session cookie
// on w. EndSession is called on logout; it must end the session r carries,
// if any, and clear the cookie on w. A returned error fails the request with
// 500.
type Sessions interface {
StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error
EndSession(w http.ResponseWriter, r *http.Request) error
}
// Config is what New needs to reach the identity provider.
type Config struct {
// Issuer is the exact issuer URL; discovery runs against it.
Issuer string
ClientID string
ClientSecret string
// PublicURL is the service's public URL; its origin prefixes CallbackPath
// to form the redirect URI.
PublicURL *url.URL
// SecretKey is the server key the login cookie's signing key derives from.
SecretKey []byte
// AllowedGroups are the household's groups; a sign-in is refused unless
// the account is in at least one of them.
AllowedGroups []string
}
// Auth serves the login and callback routes.
type Auth struct {
oauth oauth2.Config
verifier *oidc.IDTokenVerifier
provider *oidc.Provider
client *http.Client
cookie loginCookie
sessions Sessions
allowed []string
// endSessionURL is the provider's end_session_endpoint; logout ends
// there so the provider's own session ends too.
endSessionURL string
logger *slog.Logger
}
// New discovers the provider at cfg.Issuer and returns the login flow. It
// fails if discovery fails or the issuer the provider reports differs.
func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger) (*Auth, error) {
switch {
case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "":
return nil, errors.New("auth: issuer, client ID and client secret are required")
case cfg.PublicURL == nil:
return nil, errors.New("auth: public URL is required")
case len(cfg.SecretKey) == 0:
return nil, errors.New("auth: secret key is required")
case sessions == nil:
return nil, errors.New("auth: session layer is required")
case len(cfg.AllowedGroups) == 0:
return nil, errors.New("auth: at least one allowed group is required")
}
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
return nil, err
}
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
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, discoveryError(err, int(recorder.status.Load()))
}
endpoint := provider.Endpoint()
var discovered struct {
JWKSURI string `json:"jwks_uri"`
EndSessionURI string `json:"end_session_endpoint"`
}
if err := provider.Claims(&discovered); err != nil {
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
}
// The token endpoint receives the client secret and the key set decides
// which tokens verify, so every endpoint used must pass the same rule.
for _, e := range []struct{ name, raw string }{
{"authorization endpoint", endpoint.AuthURL},
{"token endpoint", endpoint.TokenURL},
{"JWKS endpoint", discovered.JWKSURI},
{"end session endpoint", discovered.EndSessionURI},
// Groups come from userinfo when the ID token carries none, and the
// access token is sent there.
{"userinfo endpoint", provider.UserInfoEndpoint()},
} {
if err := checkEndpoint(e.name, e.raw); err != nil {
return nil, err
}
}
// client_secret_basic, the OIDC default; no auto-detection retries.
endpoint.AuthStyle = oauth2.AuthStyleInHeader
return &Auth{
oauth: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: endpoint,
RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath,
Scopes: scopes,
},
verifier: provider.Verifier(&oidc.Config{
ClientID: cfg.ClientID,
SupportedSigningAlgs: []string{oidc.RS256},
}),
provider: provider,
client: client,
allowed: slices.Clone(cfg.AllowedGroups),
cookie: newLoginCookie(cfg.SecretKey),
sessions: sessions,
endSessionURL: discovered.EndSessionURI,
logger: 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 {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("auth: OIDC %s %q is not a valid URL", name, raw)
}
if err := secureurl.Check(u); err != nil {
return fmt.Errorf("auth: OIDC %s %q: %w", name, raw, err)
}
return nil
}
// maxRedirects matches net/http's default redirect limit.
const maxRedirects = 10
// checkRedirect is the provider client's redirect policy. On a 307 or 308 to
// the same host Go resends the body and Authorization header, which carry the
// code and client secret, so every target must pass secureurl.Check and an
// https:// request never follows a redirect to plain http://, even loopback.
func checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
if err := secureurl.Check(req.URL); err != nil {
return fmt.Errorf("redirect refused: %w", err)
}
if via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme != "https" {
return errors.New("redirect refused: https:// to plain http:// downgrade")
}
return nil
}
// Mount registers GET LoginPath, GET CallbackPath and POST LogoutPath on r.
func (a *Auth) Mount(r chi.Router) {
r.Get(LoginPath, a.login)
r.Get(CallbackPath, a.callback)
r.Post(LogoutPath, a.logout)
}
// EndSessionURL is the provider's end_session_endpoint.
func (a *Auth) EndSessionURL() string { return a.endSessionURL }
// login starts the authorization code flow with PKCE (S256). The optional
// "return" query parameter must be a same-origin path; anything else fails
// the login with 400.
func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
returnTo, err := returnPath(r.URL.Query().Get("return"))
if err != nil {
http.Error(w, "invalid return path", http.StatusBadRequest)
return
}
state := loginState{
State: rand.Text(),
Nonce: rand.Text(),
Verifier: oauth2.GenerateVerifier(),
Return: returnTo,
}
if err := a.cookie.set(w, state, time.Now()); err != nil {
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, a.oauth.AuthCodeURL(state.State,
oidc.Nonce(state.Nonce),
oauth2.S256ChallengeOption(state.Verifier),
), http.StatusFound)
}
// callback completes the flow: it checks state against the login cookie,
// exchanges the code, verifies the ID token and nonce, then starts a session
// if the account is in an allowed group. Membership is checked on every
// login, so an account removed from the group is refused at its next one.
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
ctx := r.Context()
// The login cookie is single-use.
a.cookie.clear(w)
state, err := a.cookie.read(r, time.Now())
if err != nil {
a.logger.WarnContext(ctx, "login callback without a valid login cookie", "error", err)
http.Error(w, "sign-in expired; start again", http.StatusBadRequest)
return
}
q := r.URL.Query()
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 {
a.logger.WarnContext(ctx, "login callback state mismatch")
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
return
}
if q.Get("error") != "" {
// error and error_description are provider-supplied text; only the
// fixed category is logged.
a.logger.WarnContext(ctx, "identity provider returned an authorization error")
http.Error(w, "sign-in was refused", http.StatusUnauthorized)
return
}
code := q.Get("code")
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
claims, err := a.verify(ctx, code, state)
if err != nil {
a.logger.WarnContext(ctx, "sign-in failed", "error", err)
http.Error(w, "sign-in failed", http.StatusUnauthorized)
return
}
if !a.member(claims.Groups) {
if claims.Groups == nil {
a.logger.WarnContext(ctx, "sign-in refused: no groups claim in the ID token or userinfo", "subject", claims.Subject)
} else {
a.logger.WarnContext(ctx, "sign-in refused: not in an allowed group", "subject", claims.Subject)
}
// A session this browser already holds ends too, so a refused
// sign-in never leaves it signed in.
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(ctx, "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.refuse(w, r)
return
}
if err := a.sessions.StartSession(w, r, claims); err != nil {
a.logger.ErrorContext(ctx, "start session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, state.Return, http.StatusSeeOther)
}
// logout ends the session, clears the cookie and sends the browser to the
// provider's end_session_endpoint to end the provider session as well.
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(r.Context(), "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, a.endSessionURL, http.StatusSeeOther)
}
// verify exchanges code for tokens and returns the verified ID token claims.
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
// here. When the ID token has no groups claim, Groups comes from userinfo;
// it stays nil when neither has one.
func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) {
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
oauth2.VerifierOption(state.Verifier))
if err != nil {
return Claims{}, exchangeError(err)
}
raw, ok := token.Extra("id_token").(string)
if !ok || raw == "" {
return Claims{}, errors.New("token response has no id_token")
}
idToken, err := a.verifier.Verify(ctx, raw)
if err != nil {
return Claims{}, verifyError(err)
}
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
return Claims{}, errors.New("ID token nonce mismatch")
}
var claims Claims
if err := idToken.Claims(&claims); err != nil {
return Claims{}, fmt.Errorf("decode ID token claims: %w", err)
}
if claims.Subject == "" {
return Claims{}, errors.New("ID token has no subject")
}
if claims.Groups == nil {
groups, err := a.userinfoGroups(ctx, token, claims.Subject)
if err != nil {
return Claims{}, err
}
claims.Groups = groups
}
return claims, nil
}
// userinfoGroups returns the groups claim from the userinfo endpoint, nil if
// it has none. The userinfo subject must match the ID token's subject.
func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject string) ([]string, error) {
info, err := a.provider.UserInfo(oidc.ClientContext(ctx, a.client), oauth2.StaticTokenSource(token))
if err != nil {
return nil, userinfoError(err)
}
if info.Subject != subject {
return nil, errors.New("userinfo subject does not match the ID token")
}
var claims struct {
Groups []string `json:"groups"`
}
if err := info.Claims(&claims); err != nil {
return nil, fmt.Errorf("decode userinfo claims: %w", err)
}
return claims.Groups, nil
}
// userinfoError reduces a failed userinfo request to a fixed category, like
// exchangeError: go-oidc's error text can carry the response body.
func userinfoError(err error) error {
var transport *url.Error
if errors.As(err, &transport) {
return fmt.Errorf("userinfo: request failed: %s: %w", transport.Op, transport.Err)
}
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) })
}
// exchangeError reduces a failed code exchange to a fixed category and safe
// metadata. The oauth2 error text can carry the token endpoint's response
// body or error_description, which may echo credentials, so it is dropped.
func exchangeError(err error) error {
var retrieve *oauth2.RetrieveError
var transport *url.Error
switch {
case errors.As(err, &retrieve):
return fmt.Errorf("exchange code: token endpoint rejected the request (HTTP %d)", retrieve.Response.StatusCode)
case errors.As(err, &transport):
// Request failures never carry the response. The URL is dropped: after
// a refused redirect it is the provider-supplied Location.
return fmt.Errorf("exchange code: token request failed: %s: %w", transport.Op, transport.Err)
default:
return errors.New("exchange code: token endpoint returned an unusable response")
}
}
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const (
loginCookieName = "__Host-mealquest_login"
loginCookieTTL = 10 * time.Minute
// loginCookieKeyLabel separates the login cookie's signing key from other
// keys derived from the same server secret.
loginCookieKeyLabel = "mealquest auth login cookie v1"
)
// loginState travels from login to callback in the signed login cookie.
type loginState struct {
State string `json:"s"`
Nonce string `json:"n"`
Verifier string `json:"v"`
Return string `json:"r"`
Expires int64 `json:"e"` // Unix seconds
}
// loginCookie signs and verifies the login cookie with HMAC-SHA256.
type loginCookie struct {
key []byte
}
func newLoginCookie(secretKey []byte) loginCookie {
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(loginCookieKeyLabel))
return loginCookie{key: mac.Sum(nil)}
}
func (c loginCookie) sign(payload string) string {
mac := hmac.New(sha256.New, c.key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
s.Expires = now.Add(loginCookieTTL).Unix()
data, err := json.Marshal(s)
if err != nil {
return err
}
payload := base64.RawURLEncoding.EncodeToString(data)
http.SetCookie(w, &http.Cookie{
Name: loginCookieName,
Value: payload + "." + c.sign(payload),
Path: "/",
MaxAge: int(loginCookieTTL / time.Second),
Secure: true,
HttpOnly: true,
// Lax so the cookie rides the provider's top-level redirect back.
SameSite: http.SameSiteLaxMode,
})
return nil
}
func (c loginCookie) clear(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: loginCookieName,
Path: "/",
MaxAge: -1,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
cookie, err := r.Cookie(loginCookieName)
if err != nil {
return loginState{}, errors.New("login cookie missing")
}
payload, sig, ok := strings.Cut(cookie.Value, ".")
if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) {
return loginState{}, errors.New("login cookie signature invalid")
}
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return loginState{}, fmt.Errorf("login cookie encoding: %w", err)
}
var s loginState
if err := json.Unmarshal(data, &s); err != nil {
return loginState{}, fmt.Errorf("login cookie payload: %w", err)
}
if now.Unix() >= s.Expires {
return loginState{}, errors.New("login cookie expired")
}
return s, nil
}
// returnPath validates the optional post-login return path. Empty means "/".
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
// relative "//" prefix, backslashes or control characters.
func returnPath(raw string) (string, error) {
if raw == "" {
return "/", nil
}
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsRune(raw, '\\') {
return "", errors.New("return path must be a same-origin path")
}
for _, c := range raw {
if c < 0x20 || c == 0x7f {
return "", errors.New("return path contains a control character")
}
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil {
return "", errors.New("return path must be a same-origin path")
}
return raw, nil
}
+264
View File
@@ -0,0 +1,264 @@
package auth
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strings"
"testing"
)
// signInWith completes a login whose ID token has claims edited by edit.
func (h *harness) signInWith(t *testing.T, edit func(claims map[string]any)) *http.Response {
t.Helper()
l := h.login(t, LoginPath)
c := h.provider.claims(l.query.Get("nonce"))
edit(c)
return h.callback(l, l.query.Get("state"), h.provider.signRS256(c))
}
// assertRefused checks resp is the refused-sign-in page and starts no
// session.
func assertRefused(t *testing.T, resp *http.Response) {
t.Helper()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"This kitchen is for the household only",
`<form method="post" action="/auth/logout">`,
"Sign out",
} {
if !strings.Contains(string(body), want) {
t.Errorf("refused page lacks %q:\n%s", want, body)
}
}
for _, c := range resp.Cookies() {
// Clearing a session cookie (negative Max-Age) is expected.
if (c.Name == SessionCookieName || c.Name == "test_session") && c.MaxAge >= 0 {
t.Errorf("refused sign-in set session cookie %s", c.Name)
}
}
assertLoginCookieCleared(t, resp)
}
func TestGroupGateMember(t *testing.T) {
t.Parallel()
// Any one allowed group is enough, alongside groups that are not allowed.
h := newHarness(t)
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests", "mealquest-family"} })
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"guests", "mealquest-family"}) {
t.Errorf("sessions started = %+v, want one with the ID token groups", started)
}
if n := h.provider.userinfoRequests(); n != 0 {
t.Errorf("userinfo requested %d times, want 0 when the ID token has groups", n)
}
}
func TestGroupGateRefused(t *testing.T) {
t.Parallel()
tests := []struct {
name string
groups any // ID token groups claim; nil removes it
userinfo map[string]any
}{
{name: "non-member", groups: []string{"guests", "MEALQUEST-ADMIN"}},
{name: "empty groups claim", groups: []string{}},
{name: "groups claim missing from ID token and userinfo", groups: nil},
{
name: "non-member in userinfo",
groups: nil,
userinfo: map[string]any{"sub": "user-123", "groups": []string{"guests"}},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
if tc.userinfo != nil {
h.provider.setUserinfo(tc.userinfo)
}
resp := h.signInWith(t, func(c map[string]any) {
if tc.groups == nil {
delete(c, "groups")
} else {
c["groups"] = tc.groups
}
})
assertRefused(t, resp)
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
if !strings.Contains(h.logs.String(), "sign-in refused") {
t.Errorf("refusal not logged:\n%s", h.logs.String())
}
})
}
}
func TestGroupGateUsesUserinfoWhenIDTokenHasNoGroups(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.setUserinfo(map[string]any{"sub": "user-123", "groups": []string{"mealquest-admin"}})
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"mealquest-admin"}) {
t.Errorf("sessions started = %+v, want one with the userinfo groups", started)
}
if n := h.provider.userinfoRequests(); n != 1 {
t.Errorf("userinfo requested %d times, want 1", n)
}
}
func TestGroupGateUserinfoFailures(t *testing.T) {
t.Parallel()
tests := []struct {
name string
userinfo map[string]any
}{
{"subject mismatch", map[string]any{"sub": "someone-else", "groups": []string{"mealquest-admin"}}},
{"malformed groups", map[string]any{"sub": "user-123", "groups": "mealquest-admin"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.setUserinfo(tc.userinfo)
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
})
}
}
// A failed userinfo request fails the sign-in without logging the
// endpoint's response, which may echo credentials.
func TestGroupGateUserinfoErrorNotLogged(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.mu.Lock()
h.provider.userinfoReply = &tokenReply{status: http.StatusInternalServerError, body: `{"error":"leaked-s3cret"}`}
h.provider.mu.Unlock()
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
logs := h.logs.String()
if !strings.Contains(logs, "userinfo") || strings.Contains(logs, "leaked-s3cret") {
t.Errorf("logs = %s, want the userinfo failure without the response body", logs)
}
}
// Membership is checked at every login: after removal from the group, the
// next login is refused and starts no session.
func TestGroupGateRemovedMemberRefusedAtNextLogin(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.signIn(t)
if n := len(h.rows(t)); n != 1 {
t.Fatalf("sessions after first login = %d, want 1", n)
}
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"former-household"} })
assertRefused(t, resp)
if n := len(h.rows(t)); n != 1 {
t.Errorf("sessions after refused login = %d, want still 1", n)
}
}
// A refused sign-in from a browser that still holds a session ends that
// session and clears its cookie.
func TestGroupGateRefusalEndsExistingSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
l := h.login(t, LoginPath)
c := h.provider.claims(l.query.Get("nonce"))
c["groups"] = []string{"former-household"}
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(c))
q := url.Values{"code": {code}, "state": {l.query.Get("state")}}
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie, cookie)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("sessions after refused login = %d, want 0", n)
}
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("old session lookup error = %v, want ErrNoSession", err)
}
cleared := false
for _, c := range resp.Cookies() {
cleared = cleared || (c.Name == SessionCookieName && c.MaxAge < 0)
}
if !cleared {
t.Error("refused sign-in did not clear the session cookie")
}
}
func TestGroupGateRefusalEndSessionError(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.sessions.err = errors.New("database down")
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests"} })
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("callback status = %d, want 500", resp.StatusCode)
}
}
func TestNewRequiresAllowedGroups(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "allowed group") {
t.Errorf("New error = %v, want missing allowed groups named", err)
}
}
func TestNewRequiresUserinfoEndpoint(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{"userinfo_endpoint": nil}
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "userinfo endpoint") {
t.Errorf("New error = %v, want the missing userinfo endpoint named", err)
}
}
+37
View File
@@ -0,0 +1,37 @@
package auth
import (
"io"
"net/http"
)
// refusedPage is shown when a sign-in succeeds at the provider but the
// account is not in the household's group. Signing out ends the provider
// session too, so another account can sign in.
const refusedPage = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MealQuest</title>
</head>
<body>
<main>
<h1>This kitchen is for the household only</h1>
<p>The account you signed in with is not part of our kitchen.</p>
<form method="post" action="` + LogoutPath + `">
<button type="submit">Sign out</button>
</form>
</main>
</body>
</html>
`
// refuse writes the refused-sign-in page with 403.
func (a *Auth) refuse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
if _, err := io.WriteString(w, refusedPage); err != nil {
a.logger.WarnContext(r.Context(), "write refused sign-in page", "error", err)
}
}
+238
View File
@@ -0,0 +1,238 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// SessionCookieName is the session cookie. The __Host- prefix makes the
// browser insist on Secure, Path=/ and no Domain.
const SessionCookieName = "__Host-mealquest_session"
// sessionIDBytes is the size of the random session ID: 256 bits.
const sessionIDBytes = 32
// ErrNoSession means the request carries no live session: the cookie is
// missing or malformed, or its session is unknown, ended or expired.
var ErrNoSession = errors.New("auth: no valid session")
// Session is a signed-in household member's server-side session.
type Session struct {
Subject string
Email string
Name string
Groups []string
CSRFToken string
CreatedAt time.Time
// LastSeenAt is the last time the session was used.
LastSeenAt time.Time
// ExpiresAt is when the session ends unless used again first.
ExpiresAt time.Time
}
// SessionManager keeps sessions in the sessions table. The cookie holds only
// the random session ID; the table holds only its SHA-256 hash. A session
// ends after idle without use and always max after it was created.
type SessionManager struct {
db *store.Store
idle time.Duration
max time.Duration
// now is the clock; tests replace it.
now func() time.Time
}
// NewSessionManager returns a SessionManager over db. idle and max must be
// positive with max >= idle.
func NewSessionManager(db *store.Store, idle, max time.Duration) (*SessionManager, error) {
switch {
case db == nil:
return nil, errors.New("auth: session store is required")
case idle <= 0 || max <= 0:
return nil, errors.New("auth: session idle and max lifetimes must be positive")
case max < idle:
return nil, errors.New("auth: session max lifetime must be at least the idle lifetime")
}
return &SessionManager{db: db, idle: idle, max: max, now: time.Now}, nil
}
// StartSession creates a session for claims and sets the session cookie.
func (m *SessionManager) StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error {
if claims.Subject == "" {
return errors.New("auth: session needs a subject")
}
id, csrf := randomToken(), randomToken()
groups := claims.Groups
if groups == nil {
groups = []string{}
}
groupsJSON, err := json.Marshal(groups)
if err != nil {
return fmt.Errorf("auth: encode session groups: %w", err)
}
now := m.timestamp()
if _, err := m.db.Writer.ExecContext(r.Context(), `
INSERT INTO sessions (id_hash, subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
hashSessionID(id), claims.Subject, claims.Email, claims.Name, string(groupsJSON), csrf,
now, now, m.expiry(now.Time, now.Time),
); err != nil {
return fmt.Errorf("auth: create session: %w", err)
}
// The cookie outlives idle gaps so a returning device presents its ID;
// the server decides whether the session is still live. Max-Age is whole
// seconds, rounded up so the cookie never ends before the absolute limit.
http.SetCookie(w, sessionCookie(id, cookieMaxAge(m.max)))
return nil
}
// cookieMaxAge is lifetime in whole seconds, rounded up. It rounds the
// quotient rather than adding to lifetime, which would overflow near the
// largest time.Duration.
func cookieMaxAge(lifetime time.Duration) int {
seconds := lifetime / time.Second
if lifetime%time.Second != 0 {
seconds++
}
return int(seconds)
}
// SessionFor returns the live session r carries and slides its last use to
// now. It returns ErrNoSession when there is none; an expired session is
// deleted.
func (m *SessionManager) SessionFor(r *http.Request) (Session, error) {
idHash, err := sessionIDHash(r)
if err != nil {
return Session{}, err
}
now := m.timestamp()
var s Session
live := false
err = m.db.WithTx(r.Context(), func(tx *sql.Tx) error {
var groupsJSON string
var created, lastSeen, expires store.Timestamp
err := tx.QueryRowContext(r.Context(), `
SELECT subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at
FROM sessions WHERE id_hash = ?`, idHash,
).Scan(&s.Subject, &s.Email, &s.Name, &groupsJSON, &s.CSRFToken, &created, &lastSeen, &expires)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("auth: read session: %w", err)
}
if !now.Before(expires.Time) || now.Sub(lastSeen.Time) >= m.idle || now.Sub(created.Time) >= m.max {
if _, err := tx.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil {
return fmt.Errorf("auth: delete expired session: %w", err)
}
return nil
}
if err := json.Unmarshal([]byte(groupsJSON), &s.Groups); err != nil {
return fmt.Errorf("auth: decode session groups: %w", err)
}
expiresAt := m.expiry(created.Time, now.Time)
if _, err := tx.ExecContext(r.Context(), `
UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id_hash = ?`,
now, expiresAt, idHash,
); err != nil {
return fmt.Errorf("auth: slide session: %w", err)
}
s.CreatedAt, s.LastSeenAt, s.ExpiresAt = created.Time, now.Time, expiresAt.Time
live = true
return nil
})
if err != nil {
return Session{}, err
}
if !live {
return Session{}, ErrNoSession
}
return s, nil
}
// EndSession deletes the session r carries, if any, and clears the cookie.
func (m *SessionManager) EndSession(w http.ResponseWriter, r *http.Request) error {
http.SetCookie(w, sessionCookie("", -1))
idHash, err := sessionIDHash(r)
if errors.Is(err, ErrNoSession) {
return nil
}
if err != nil {
return err
}
if _, err := m.db.Writer.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil {
return fmt.Errorf("auth: delete session: %w", err)
}
return nil
}
// timestamp is the clock's current instant at stored precision.
func (m *SessionManager) timestamp() store.Timestamp {
return store.Timestamp{Time: m.now().UTC().Truncate(time.Millisecond)}
}
// expiry is when a session created at created and last used at lastSeen
// ends: idle after lastSeen, but never later than max after created.
func (m *SessionManager) expiry(created, lastSeen time.Time) store.Timestamp {
idle, absolute := lastSeen.Add(m.idle), created.Add(m.max)
if absolute.Before(idle) {
return store.Timestamp{Time: absolute}
}
return store.Timestamp{Time: idle}
}
// sessionCookie is the session cookie carrying value, with maxAge seconds;
// a negative maxAge clears it.
func sessionCookie(value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: SessionCookieName,
Value: value,
Path: "/",
MaxAge: maxAge,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
}
// sessionIDHash returns the stored hash of r's session ID, or ErrNoSession
// when the cookie is missing or is not a well-formed ID.
func sessionIDHash(r *http.Request) (string, error) {
c, err := r.Cookie(SessionCookieName)
if err != nil {
return "", ErrNoSession
}
raw, err := base64.RawURLEncoding.DecodeString(c.Value)
if err != nil || len(raw) != sessionIDBytes {
return "", ErrNoSession
}
return hashSessionID(c.Value), nil
}
// hashSessionID is the SHA-256 hex digest of the cookie value.
func hashSessionID(id string) string {
sum := sha256.Sum256([]byte(id))
return hex.EncodeToString(sum[:])
}
// randomToken returns 256 random bits, base64url-encoded without padding.
// crypto/rand.Read never returns an error; it crashes the program instead.
func randomToken() string {
b := make([]byte, sessionIDBytes)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+523
View File
@@ -0,0 +1,523 @@
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const (
testIdle = 30 * 24 * time.Hour
testMax = 180 * 24 * time.Hour
)
// testEpoch is the fake clock's starting instant.
var testEpoch = time.Date(2026, 10, 3, 18, 30, 0, 0, time.UTC)
// sessionHarness runs the real login flow against a fake provider with a
// SessionManager over a migrated database and a fake clock.
type sessionHarness struct {
*harness
db *store.Store
sessions *SessionManager
auth *Auth
clock time.Time
}
func newSessionHarness(t *testing.T) *sessionHarness {
t.Helper()
ctx := context.Background()
db, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
sessions, err := NewSessionManager(db, testIdle, testMax)
if err != nil {
t.Fatal(err)
}
h := &sessionHarness{db: db, sessions: sessions, clock: testEpoch}
sessions.now = func() time.Time { return h.clock }
p := newFakeProvider(t)
logs := &syncBuffer{}
a, err := New(ctx, Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
if err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
a.Mount(r)
h.harness = &harness{provider: p, router: r, logs: logs}
h.auth = a
return h
}
func (h *sessionHarness) advance(d time.Duration) { h.clock = h.clock.Add(d) }
// signIn completes a login and returns the callback response and the
// session cookie it set.
func (h *sessionHarness) signIn(t *testing.T) (*http.Response, *http.Cookie) {
t.Helper()
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.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
for _, c := range resp.Cookies() {
if c.Name == SessionCookieName {
return resp, c
}
}
t.Fatalf("callback set no %s cookie", SessionCookieName)
return nil, nil
}
// use looks up the session cookie c names.
func (h *sessionHarness) use(c *http.Cookie) (Session, error) {
req := httptest.NewRequest(http.MethodGet, "/api/anything", nil)
if c != nil {
req.AddCookie(c)
}
return h.sessions.SessionFor(req)
}
type sessionRow struct {
idHash, subject, email, name, groups, csrf string
created, lastSeen, expires store.Timestamp
}
func (h *sessionHarness) rows(t *testing.T) []sessionRow {
t.Helper()
rs, err := h.db.Reader.Query(`SELECT id_hash, subject, email, name, groups, csrf_token,
created_at, last_seen_at, expires_at FROM sessions`)
if err != nil {
t.Fatal(err)
}
defer rs.Close()
var out []sessionRow
for rs.Next() {
var r sessionRow
if err := rs.Scan(&r.idHash, &r.subject, &r.email, &r.name, &r.groups, &r.csrf,
&r.created, &r.lastSeen, &r.expires); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
if err := rs.Err(); err != nil {
t.Fatal(err)
}
return out
}
// sessionSetCookie returns the raw Set-Cookie header for the session cookie.
func sessionSetCookie(t *testing.T, resp *http.Response) string {
t.Helper()
for _, v := range resp.Header.Values("Set-Cookie") {
if strings.HasPrefix(v, SessionCookieName+"=") {
return v
}
}
t.Fatalf("no Set-Cookie for %s in %q", SessionCookieName, resp.Header.Values("Set-Cookie"))
return ""
}
// assertSessionCookieAttributes checks the attributes the __Host- prefix
// and D-004 require, on the raw header so nothing is lost in parsing.
func assertSessionCookieAttributes(t *testing.T, raw string) {
t.Helper()
attrs := map[string]bool{}
for _, a := range strings.Split(raw, ";")[1:] {
name, _, _ := strings.Cut(strings.TrimSpace(a), "=")
attrs[strings.ToLower(name)] = true
}
for _, want := range []string{"Path=/", "HttpOnly", "Secure", "SameSite=Lax"} {
if !slices.Contains(strings.Split(raw, "; "), want) {
t.Errorf("session Set-Cookie %q lacks %s", raw, want)
}
}
if attrs["domain"] {
t.Errorf("session Set-Cookie %q has a Domain", raw)
}
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func TestCallbackStartsSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
resp, cookie := h.signIn(t)
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
if cookie.MaxAge != int(testMax/time.Second) {
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, int(testMax/time.Second))
}
rows := h.rows(t)
if len(rows) != 1 {
t.Fatalf("sessions has %d rows, want 1", len(rows))
}
r := rows[0]
if len(cookie.Value) != 43 {
t.Errorf("session ID %q is %d chars, want 43 (256 bits, base64url)", cookie.Value, len(cookie.Value))
}
if r.idHash != sha256Hex(cookie.Value) {
t.Errorf("id_hash = %q, want the SHA-256 of the cookie value", r.idHash)
}
if r.subject != "user-123" || r.email != "cook@example.com" || r.name != "Home Cook" ||
r.groups != `["mealquest-admin"]` {
t.Errorf("session row = %+v, want the ID token claims", r)
}
if len(r.csrf) != 43 || r.csrf == cookie.Value {
t.Errorf("csrf_token = %q, want a separate 256-bit token", r.csrf)
}
if !r.created.Equal(testEpoch) || !r.lastSeen.Equal(testEpoch) || !r.expires.Equal(testEpoch.Add(testIdle)) {
t.Errorf("created, last_seen, expires = %v, %v, %v, want %v, %v, %v",
r.created, r.lastSeen, r.expires, testEpoch, testEpoch, testEpoch.Add(testIdle))
}
s, err := h.use(cookie)
if err != nil {
t.Fatalf("SessionFor = %v, want the new session", err)
}
if s.Subject != "user-123" || s.CSRFToken != r.csrf || !slices.Equal(s.Groups, []string{"mealquest-admin"}) {
t.Errorf("SessionFor = %+v", s)
}
}
func TestStartSessionStoresOnlyTheHash(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
var n int
err := h.db.Reader.QueryRow(`SELECT count(*) FROM sessions WHERE
id_hash = ?1 OR subject = ?1 OR email = ?1 OR name = ?1 OR groups = ?1 OR csrf_token = ?1`,
cookie.Value).Scan(&n)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Error("the raw session ID is stored in the sessions table")
}
}
func TestSessionLastSeenSlides(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
// Used every 20 days, the session outlives several idle periods.
for range 4 {
h.advance(20 * 24 * time.Hour)
s, err := h.use(cookie)
if err != nil {
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
}
if !s.LastSeenAt.Equal(h.clock) || !s.ExpiresAt.Equal(h.clock.Add(testIdle)) {
t.Errorf("at %v session last seen %v, expires %v, want %v, %v",
h.clock, s.LastSeenAt, s.ExpiresAt, h.clock, h.clock.Add(testIdle))
}
r := h.rows(t)[0]
if !r.lastSeen.Equal(h.clock) || !r.expires.Equal(h.clock.Add(testIdle)) || !r.created.Equal(testEpoch) {
t.Errorf("at %v row created %v, last seen %v, expires %v", h.clock, r.created, r.lastSeen, r.expires)
}
}
}
func TestSessionIdleExpiry(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
h.advance(testIdle - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the idle limit SessionFor = %v, want the session", err)
}
h.advance(testIdle)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Fatalf("after idle SessionFor = %v, want ErrNoSession", err)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("expired session left %d rows, want 0", n)
}
// Turning the clock back cannot revive it.
h.clock = testEpoch
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("after deletion SessionFor = %v, want ErrNoSession", err)
}
}
func TestSessionAbsoluteExpiry(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
for h.clock.Before(testEpoch.Add(testMax - 20*24*time.Hour)) {
h.advance(20 * 24 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
}
}
// Last used 20 days before the absolute limit: the idle window would run
// past it, so expiry is capped there.
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(testMax)) {
t.Errorf("expires_at = %v, want the absolute limit %v", r.expires, testEpoch.Add(testMax))
}
h.clock = testEpoch.Add(testMax - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(testMax)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Fatalf("at the absolute limit, used 1ms ago, SessionFor = %v, want ErrNoSession", err)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("expired session left %d rows, want 0", n)
}
}
// A max with fractional seconds rounds the cookie Max-Age up, so the browser
// keeps the cookie until the server-side absolute limit.
func TestSessionCookieOutlivesFractionalMax(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.sessions.idle, h.sessions.max = 1500*time.Millisecond, 2500*time.Millisecond
_, cookie := h.signIn(t)
if cookie.MaxAge != 3 {
t.Errorf("session cookie MaxAge = %d, want 3", cookie.MaxAge)
}
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(1500 * time.Millisecond)) {
t.Errorf("expires_at = %v, want %v", r.expires, testEpoch.Add(1500*time.Millisecond))
}
h.advance(time.Second)
if _, err := h.use(cookie); err != nil {
t.Fatalf("at 1s SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(2500*time.Millisecond - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(2500 * time.Millisecond)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("at the absolute limit SessionFor = %v, want ErrNoSession", err)
}
}
// A lifetime near the largest accepted duration still yields a positive,
// rounded-up cookie Max-Age rather than overflowing.
func TestSessionCookieMaxAgeNearLargestMax(t *testing.T) {
t.Parallel()
largest, err := time.ParseDuration("2562047h47m16.854s")
if err != nil {
t.Fatal(err)
}
h := newSessionHarness(t)
h.sessions.max = largest
_, cookie := h.signIn(t)
if want := 9223372037; cookie.MaxAge != want {
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, want)
}
}
// A lifetime shortened by configuration applies to existing sessions even
// though their stored expires_at is later.
func TestSessionLifetimeChecksUseCurrentConfig(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
h.sessions.idle = 24 * time.Hour
h.advance(24 * time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past the shortened idle SessionFor = %v, want ErrNoSession", err)
}
_, cookie = h.signIn(t)
h.sessions.idle, h.sessions.max = 24*time.Hour, 48*time.Hour
for range 3 {
h.advance(12 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("SessionFor = %v, want the session", err)
}
}
h.advance(12 * time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past the shortened max SessionFor = %v, want ErrNoSession", err)
}
// Used recently and with expires_at still ahead, but older than a max
// shortened after that use: only the absolute check ends it.
h.sessions.idle, h.sessions.max = testIdle, testMax
_, cookie = h.signIn(t)
h.advance(10 * 24 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("SessionFor = %v, want the session", err)
}
h.sessions.max = 10 * 24 * time.Hour
h.advance(time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past a max shortened after the last use SessionFor = %v, want ErrNoSession", err)
}
}
func TestSessionForRejectsBadCookies(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.signIn(t)
for name, c := range map[string]*http.Cookie{
"missing": nil,
"empty": {Name: SessionCookieName, Value: ""},
"not base64": {Name: SessionCookieName, Value: strings.Repeat("!", 43)},
"short": {Name: SessionCookieName, Value: "AAAA"},
"unknown ID": {Name: SessionCookieName, Value: randomToken()},
"other cookie": {Name: "mealquest_session", Value: randomToken()},
} {
if _, err := h.use(c); !errors.Is(err, ErrNoSession) {
t.Errorf("%s: SessionFor = %v, want ErrNoSession", name, err)
}
}
if n := len(h.rows(t)); n != 1 {
t.Errorf("sessions has %d rows, want the signed-in session only", n)
}
}
func (h *sessionHarness) logout(cookies ...*http.Cookie) *http.Response {
req := httptest.NewRequest(http.MethodPost, LogoutPath, nil)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, req)
return rec.Result()
}
func TestLogout(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
_, other := h.signIn(t)
resp := h.logout(cookie)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("logout status = %d, want 303", resp.StatusCode)
}
want := h.provider.issuer() + "/end-session/"
if got := resp.Header.Get("Location"); got != want || h.auth.EndSessionURL() != want {
t.Errorf("logout redirect = %q, EndSessionURL = %q, want %q", got, h.auth.EndSessionURL(), want)
}
raw := sessionSetCookie(t, resp)
assertSessionCookieAttributes(t, raw)
if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") {
t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw)
}
rows := h.rows(t)
if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) {
t.Errorf("after logout sessions = %+v, want only the other session", rows)
}
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("logged-out cookie: SessionFor = %v, want ErrNoSession", err)
}
if _, err := h.use(other); err != nil {
t.Errorf("other session: SessionFor = %v, want it untouched", err)
}
}
func TestLogoutWithoutSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
resp := h.logout()
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != h.provider.issuer()+"/end-session/" {
t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location"))
}
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
}
func TestLogoutIsPostOnly(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
if resp := h.get(LogoutPath, cookie); resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET %s status = %d, want 405", LogoutPath, resp.StatusCode)
}
if _, err := h.use(cookie); err != nil {
t.Errorf("after GET %s SessionFor = %v, want the session", LogoutPath, err)
}
}
func TestLogoutSessionError(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.sessions.err = errors.New("database down")
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, LogoutPath, nil))
if rec.Code != http.StatusInternalServerError {
t.Errorf("logout status = %d, want 500", rec.Code)
}
}
func TestNewRequiresEndSessionEndpoint(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{"end_session_endpoint": nil}
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "end session endpoint") {
t.Errorf("New error = %v, want the missing end session endpoint named", err)
}
}
func TestNewSessionManagerValidates(t *testing.T) {
t.Parallel()
db := &store.Store{}
for _, tc := range []struct {
name string
db *store.Store
idle, max time.Duration
}{
{"nil store", nil, time.Hour, time.Hour},
{"zero idle", db, 0, time.Hour},
{"negative max", db, time.Hour, -time.Hour},
{"max below idle", db, 2 * time.Hour, time.Hour},
} {
if _, err := NewSessionManager(tc.db, tc.idle, tc.max); err == nil {
t.Errorf("%s: NewSessionManager succeeded", tc.name)
}
}
}
+136 -4
View File
@@ -11,6 +11,9 @@ import (
"strconv"
"strings"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
)
// Environment variable names.
@@ -20,6 +23,25 @@ const (
EnvDataDir = "MEALQUEST_DATA_DIR"
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
EnvOIDCIssuer = "MEALQUEST_OIDC_ISSUER"
EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID"
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
EnvSessionMax = "MEALQUEST_SESSION_MAX"
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
)
// Session lifetime defaults (D-004): long enough that the kitchen tablet
// stays signed in.
const (
DefaultSessionIdle = 720 * time.Hour // 30 days
DefaultSessionMax = 4320 * time.Hour // 180 days
)
// Config is the validated service configuration.
@@ -29,6 +51,28 @@ type Config struct {
DataDir string
ShutdownTimeout time.Duration
SecretKey Secret
// OIDCIssuer is the Authentik issuer URL used for discovery.
OIDCIssuer *url.URL
OIDCClientID string
OIDCClientSecret Secret
// OIDCAllowedGroups lists the groups whose members may sign in.
OIDCAllowedGroups []string
// SessionIdle ends a session left unused this long.
SessionIdle time.Duration
// SessionMax ends every session this long after sign-in, used or not.
SessionMax time.Duration
// 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
@@ -47,10 +91,25 @@ func load(lookup func(string) (string, bool)) (Config, error) {
r := reader{lookup: lookup}
cfg := Config{
HTTPAddr: r.listenAddr(EnvHTTPAddr),
PublicURL: r.httpURL(EnvPublicURL),
PublicURL: r.secureURL(EnvPublicURL),
DataDir: r.dir(EnvDataDir),
ShutdownTimeout: r.duration(EnvShutdownTimeout),
SecretKey: r.secretFile(EnvSecretKeyFile),
OIDCIssuer: r.secureURL(EnvOIDCIssuer),
OIDCClientID: r.str(EnvOIDCClientID),
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
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)
}
if len(r.problems) > 0 {
return Config{}, &Error{Problems: r.problems}
@@ -114,6 +173,41 @@ func (r *reader) required(name string) (string, bool) {
return v, true
}
func (r *reader) str(name string) string {
v, _ := r.required(name)
return v
}
// list parses a comma-separated list. Every entry must be non-empty after
// trimming spaces.
func (r *reader) list(name string) []string {
v, ok := r.required(name)
if !ok {
return nil
}
items := strings.Split(v, ",")
for i, item := range items {
items[i] = strings.TrimSpace(item)
if items[i] == "" {
r.fail(name, "empty entry in comma-separated list %q", v)
return nil
}
}
return items
}
// trustedProxy parses an optional comma-separated list of IP addresses and
// CIDR prefixes with forwarded.ParseTrusted. Unset or blank trusts no peer.
func (r *reader) trustedProxy(name string) forwarded.Trusted {
v, _ := r.lookup(name)
t, err := forwarded.ParseTrusted(v)
if err != nil {
r.fail(name, "%v", err)
return nil
}
return t
}
func (r *reader) listenAddr(name string) string {
v, ok := r.required(name)
if !ok {
@@ -155,15 +249,21 @@ func validHostname(h string) bool {
return true
}
func (r *reader) httpURL(name string) *url.URL {
// secureURL reads an absolute URL that must pass secureurl.Check: https://,
// or http:// to a loopback host only.
func (r *reader) secureURL(name string) *url.URL {
v, ok := r.required(name)
if !ok {
return nil
}
// The value is never echoed: it may carry credentials.
u, err := url.Parse(v)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
r.fail(name, "invalid URL (want absolute http:// or https:// URL)")
if err != nil {
r.fail(name, "invalid URL (want absolute https:// URL)")
return nil
}
if err := secureurl.Check(u); err != nil {
r.fail(name, "invalid URL: %v", err)
return nil
}
if u.User != nil {
@@ -195,6 +295,32 @@ func (r *reader) duration(name string) time.Duration {
if !ok {
return 0
}
return r.parseDuration(name, v)
}
// optionalDuration returns def when name is unset. A set value, even an empty
// one, must be a valid positive duration.
func (r *reader) optionalDuration(name string, def time.Duration) time.Duration {
v, ok := r.lookup(name)
if !ok {
return def
}
return r.parseDuration(name, v)
}
// sessionDuration is optionalDuration restricted to whole milliseconds:
// session expiry is stored as a store.Timestamp, which keeps millisecond
// precision, so a finer duration could not be honoured.
func (r *reader) sessionDuration(name string, def time.Duration) time.Duration {
d := r.optionalDuration(name, def)
if d%time.Millisecond != 0 {
r.fail(name, "duration must be a whole number of milliseconds, got %s", d)
return 0
}
return d
}
func (r *reader) parseDuration(name, v string) time.Duration {
d, err := time.ParseDuration(v)
if err != nil {
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
@@ -207,6 +333,12 @@ func (r *reader) duration(name 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 {
+229 -2
View File
@@ -3,13 +3,17 @@ package config
import (
"errors"
"fmt"
"net/netip"
"net/url"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
)
func TestLoad(t *testing.T) {
@@ -21,6 +25,10 @@ func TestLoad(t *testing.T) {
t.Fatal(err)
}
unreadablePath := filepath.Join(dir, "missing_secret")
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
emptyPath := filepath.Join(dir, "empty_secret")
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
t.Fatal(err)
@@ -33,6 +41,11 @@ func TestLoad(t *testing.T) {
EnvDataDir: dir,
EnvShutdownTimeout: "15s",
EnvSecretKeyFile: secretPath,
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
EnvOIDCClientID: "mealquest",
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: "mealquest-admin",
}
}
with := func(changes map[string]string, unset ...string) map[string]string {
@@ -55,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),
@@ -70,6 +93,7 @@ func TestLoad(t *testing.T) {
env: map[string]string{},
badVars: []string{
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
},
},
{
@@ -139,6 +163,24 @@ func TestLoad(t *testing.T) {
env: with(map[string]string{EnvPublicURL: "http://:8080"}),
badVars: []string{EnvPublicURL},
},
{
name: "plain HTTP public URL on a remote host",
env: with(map[string]string{EnvPublicURL: "http://meals.example.com"}),
badVars: []string{EnvPublicURL},
},
{
name: "plain HTTP public URL on a non-loopback IP",
env: with(map[string]string{EnvPublicURL: "http://192.168.1.20:8080"}),
badVars: []string{EnvPublicURL},
},
{
name: "plain HTTP public URL on 127.0.0.1",
env: with(map[string]string{EnvPublicURL: "http://127.0.0.1:8080"}),
},
{
name: "plain HTTP public URL on [::1]",
env: with(map[string]string{EnvPublicURL: "http://[::1]:8080"}),
},
{
name: "relative public URL",
env: with(map[string]string{EnvPublicURL: "meals.example.com"}),
@@ -169,6 +211,143 @@ func TestLoad(t *testing.T) {
env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}),
badVars: []string{"MEALQUEST_SECRET_KEY"},
},
{
name: "inline OIDC client secret rejected",
env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}),
badVars: []string{"MEALQUEST_OIDC_CLIENT_SECRET"},
},
{
name: "unreadable OIDC client secret file",
env: with(map[string]string{EnvOIDCClientSecretFile: unreadablePath}),
badVars: []string{EnvOIDCClientSecretFile},
},
{
name: "relative OIDC issuer",
env: with(map[string]string{EnvOIDCIssuer: "auth.example.com"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "non-http OIDC issuer",
env: with(map[string]string{EnvOIDCIssuer: "ftp://auth.example.com"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "plain HTTP OIDC issuer on a remote host",
env: with(map[string]string{EnvOIDCIssuer: "http://auth.example.com/application/o/mealquest/"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "plain HTTP OIDC issuer on localhost",
env: with(map[string]string{EnvOIDCIssuer: "http://localhost:9000/application/o/mealquest/"}),
},
{
name: "several allowed groups",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin, family"}),
},
{
name: "empty allowed group entry",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,,family"}),
badVars: []string{EnvOIDCAllowedGroups},
},
{
name: "trailing comma in allowed groups",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,"}),
badVars: []string{EnvOIDCAllowedGroups},
},
{
name: "blank OIDC client ID",
env: with(map[string]string{EnvOIDCClientID: " "}),
badVars: []string{EnvOIDCClientID},
},
{
name: "session lifetimes set",
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "48h"}),
},
{
name: "session max equal to idle",
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "24h"}),
},
{
name: "invalid session idle",
env: with(map[string]string{EnvSessionIdle: "a month"}),
badVars: []string{EnvSessionIdle},
},
{
name: "empty session idle",
env: with(map[string]string{EnvSessionIdle: ""}),
badVars: []string{EnvSessionIdle},
},
{
name: "non-positive session max",
env: with(map[string]string{EnvSessionMax: "-1h"}),
badVars: []string{EnvSessionMax},
},
{
name: "whole-millisecond session lifetimes",
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "2ms"}),
},
{
name: "sub-millisecond session idle",
env: with(map[string]string{EnvSessionIdle: "1500us", EnvSessionMax: "2ms"}),
badVars: []string{EnvSessionIdle},
},
{
name: "sub-millisecond session max",
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "1ms500us"}),
badVars: []string{EnvSessionMax},
},
{
name: "session max below idle",
env: with(map[string]string{EnvSessionIdle: "48h", EnvSessionMax: "24h"}),
badVars: []string{EnvSessionMax},
},
{
name: "session max below default idle",
env: with(map[string]string{EnvSessionMax: "719h"}),
badVars: []string{EnvSessionMax},
},
{
name: "trusted proxy IP",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33"}),
},
{
name: "trusted proxy IPs and CIDRs",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33, 172.16.0.0/12,2001:db8::/32,::1"}),
},
{
name: "blank trusted proxy trusts no peer",
env: with(map[string]string{EnvTrustedProxy: " "}),
},
{
name: "trusted proxy not an IP",
env: with(map[string]string{EnvTrustedProxy: "traefik"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "trusted proxy invalid CIDR",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.0/33"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "trusted proxy CIDR with host bits",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33/24"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "trusted proxy empty entry",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33,,10.60.1.34"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "trusted proxy trusting everyone",
env: with(map[string]string{EnvTrustedProxy: "0.0.0.0/0"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "trusted proxy with port",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33:443"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "several invalid values are all named",
env: with(map[string]string{
@@ -211,7 +390,7 @@ func TestLoad(t *testing.T) {
t.Errorf("error message %q does not name %s", err.Error(), v)
}
}
if cfg != (Config{}) {
if !reflect.ValueOf(cfg).IsZero() {
t.Errorf("load() returned non-zero Config alongside error: %+v", cfg)
}
})
@@ -226,12 +405,23 @@ func TestLoadValues(t *testing.T) {
if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil {
t.Fatal(err)
}
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{
EnvHTTPAddr: "127.0.0.1:9000",
EnvPublicURL: "http://localhost:9000",
EnvDataDir: dir,
EnvShutdownTimeout: "1m30s",
EnvSecretKeyFile: secretPath,
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
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 {
@@ -253,6 +443,43 @@ func TestLoadValues(t *testing.T) {
if cfg.SecretKey.Reveal() != "s3cret-value" {
t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal())
}
if got, want := cfg.OIDCIssuer.String(), "https://auth.example.com/application/o/mealquest/"; got != want {
t.Errorf("OIDCIssuer = %q, want %q", got, want)
}
if cfg.OIDCClientID != "mealquest" {
t.Errorf("OIDCClientID = %q", cfg.OIDCClientID)
}
if cfg.OIDCClientSecret.Reveal() != "client-s3cret" {
t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal())
}
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
}
if cfg.SessionIdle != 720*time.Hour || cfg.SessionMax != 4320*time.Hour {
t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax)
}
if cfg.TrustedProxy != nil {
t.Errorf("TrustedProxy = %v, want none when unset", cfg.TrustedProxy)
}
env[EnvSessionIdle] = "12h"
env[EnvSessionMax] = "36h"
env[EnvTrustedProxy] = "10.60.1.33, 172.16.0.0/12"
cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
t.Fatalf("load() error = %v", err)
}
if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour {
t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax)
}
want := forwarded.Trusted{netip.MustParsePrefix("10.60.1.33/32"), netip.MustParsePrefix("172.16.0.0/12")}
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) {
@@ -260,7 +487,7 @@ func TestSecretRedacted(t *testing.T) {
s := Secret{value: "s3cret-value"}
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
cfg := Config{SecretKey: s}
cfg := Config{SecretKey: s, OIDCClientSecret: s}
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
if strings.Contains(out, "s3cret-value") {
t.Errorf("format %s leaked secret: %s", format, out)
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
+564
View File
@@ -0,0 +1,564 @@
package db_test
import (
"context"
"database/sql"
"errors"
"reflect"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const (
created = "2026-10-03T12:00:00.000Z"
updated = "2026-10-04T12:00:00.000Z"
)
// openQueries returns queries on the writer of a freshly migrated database
// in a temp directory.
func openQueries(t *testing.T) (*store.Store, *db.Queries) {
t.Helper()
ctx := context.Background()
s, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() {
if err := s.Close(); err != nil {
t.Errorf("Close: %v", err)
}
})
if err := store.Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatalf("Migrate: %v", err)
}
return s, db.New(s.Writer)
}
func str(s string) sql.NullString { return sql.NullString{String: s, Valid: true} }
func num(n int64) sql.NullInt64 { return sql.NullInt64{Int64: n, Valid: true} }
func mustNoRows(t *testing.T, what string, err error) {
t.Helper()
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("%s: got %v, want sql.ErrNoRows", what, err)
}
}
func createPhoto(t *testing.T, q *db.Queries, sha string) db.Photo {
t.Helper()
p, err := q.CreatePhoto(context.Background(), db.CreatePhotoParams{
ID: store.NewID(),
SourceUrl: "https://example.com/" + sha + ".jpg",
LocalPath: "photos/" + sha + ".jpg",
Width: 800,
Height: 600,
Sha256: sha,
FetchedAt: created,
CreatedAt: created,
UpdatedAt: created,
})
if err != nil {
t.Fatalf("CreatePhoto: %v", err)
}
return p
}
func upsertRecipe(t *testing.T, q *db.Queries, id, url, title, at string) db.Recipe {
t.Helper()
r, err := q.UpsertRecipe(context.Background(), db.UpsertRecipeParams{
ID: id,
SourceUrl: url,
SiteName: str("Example"),
Title: title,
TotalMinutes: num(30),
PrepMinutes: num(10),
CookMinutes: num(20),
Yield: str("4 servings"),
RawJsonld: `{"@type":"Recipe"}`,
FetchedAt: at,
CreatedAt: at,
UpdatedAt: at,
})
if err != nil {
t.Fatalf("UpsertRecipe: %v", err)
}
return r
}
func TestDinners(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
recipe := upsertRecipe(t, q, store.NewID(), "https://example.com/tacos", "Tacos", created)
photo := createPhoto(t, q, "aa11")
create := db.CreateDinnerParams{
ID: store.NewID(),
Name: "Taco Tuesday",
NormalizedName: "taco tuesday",
Status: "regular",
Origin: "regular",
Method: str("stovetop"),
MinutesTotal: num(30),
Difficulty: str("easy"),
Cuisine: str("Mexican"),
IsFavorite: 0,
RecipeID: str(recipe.ID),
PhotoID: str(photo.ID),
GroundingStatus: "grounded",
UpdatedBy: str("alex@example.com"),
CreatedAt: created,
UpdatedAt: created,
}
want := db.Dinner(create)
got, err := q.CreateDinner(ctx, create)
if err != nil {
t.Fatalf("CreateDinner: %v", err)
}
if got != want {
t.Fatalf("CreateDinner returned %+v, want %+v", got, want)
}
if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want {
t.Fatalf("GetDinner = %+v, %v; want %+v", got, err, want)
}
_, err = q.GetDinner(ctx, "missing")
mustNoRows(t, "GetDinner missing", err)
// Second dinner sorts before the first by normalized name, in a
// different status so the status filter is exercised.
other, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: "Apple Pork Chops",
NormalizedName: "apple pork chops",
Status: "quest_log",
Origin: "side_quest",
GroundingStatus: "pending",
CreatedAt: created,
UpdatedAt: created,
})
if err != nil {
t.Fatalf("CreateDinner other: %v", err)
}
third, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: "Beef Stew",
NormalizedName: "beef stew",
Status: "regular",
Origin: "manual",
GroundingStatus: "manual",
CreatedAt: created,
UpdatedAt: created,
})
if err != nil {
t.Fatalf("CreateDinner third: %v", err)
}
listIDs := func(status string) []string {
t.Helper()
ds, err := q.ListDinnersByStatus(ctx, status)
if err != nil {
t.Fatalf("ListDinnersByStatus(%s): %v", status, err)
}
ids := []string{}
for _, d := range ds {
ids = append(ids, d.ID)
}
return ids
}
assertIDs(t, "regular", listIDs("regular"), third.ID, create.ID)
assertIDs(t, "quest_log", listIDs("quest_log"), other.ID)
assertIDs(t, "tried", listIDs("tried"))
update := db.UpdateDinnerParams{
ID: create.ID,
Name: "Fish Tacos",
NormalizedName: "fish tacos",
Status: "tried",
Origin: "side_quest",
Method: str("grill"),
MinutesTotal: num(25),
Difficulty: str("medium"),
Cuisine: sql.NullString{},
RecipeID: sql.NullString{},
PhotoID: str(photo.ID),
GroundingStatus: "failed",
UpdatedBy: str("sam@example.com"),
UpdatedAt: updated,
}
got, err = q.UpdateDinner(ctx, update)
if err != nil {
t.Fatalf("UpdateDinner: %v", err)
}
want = db.Dinner{
ID: create.ID, Name: update.Name, NormalizedName: update.NormalizedName,
Status: update.Status, Origin: update.Origin, Method: update.Method,
MinutesTotal: update.MinutesTotal, Difficulty: update.Difficulty,
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
}
if got != want {
t.Fatalf("UpdateDinner returned %+v, want %+v", got, want)
}
if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want {
t.Fatalf("GetDinner after update = %+v, %v; want %+v", got, err, want)
}
update.ID = "missing"
_, err = q.UpdateDinner(ctx, update)
mustNoRows(t, "UpdateDinner missing", err)
got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{
ID: create.ID, IsFavorite: 1, UpdatedBy: str("alex@example.com"), UpdatedAt: updated,
})
if err != nil {
t.Fatalf("SetDinnerFavorite: %v", err)
}
want.IsFavorite, want.UpdatedBy = 1, str("alex@example.com")
if got != want {
t.Fatalf("SetDinnerFavorite returned %+v, want %+v", got, want)
}
got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{
ID: create.ID, IsFavorite: 0, UpdatedBy: str("sam@example.com"), UpdatedAt: updated,
})
if err != nil || got.IsFavorite != 0 || got.UpdatedBy != str("sam@example.com") {
t.Fatalf("SetDinnerFavorite off = %+v, %v", got, err)
}
_, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated})
mustNoRows(t, "SetDinnerFavorite missing", err)
const archivedAt = "2026-10-05T12:00:00.000Z"
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt,
})
if err != nil {
t.Fatalf("ArchiveDinner: %v", err)
}
want.IsFavorite, want.Status, want.UpdatedBy, want.UpdatedAt = 0, "archived", str("alex@example.com"), archivedAt
if got != want {
t.Fatalf("ArchiveDinner returned %+v, want %+v", got, want)
}
assertIDs(t, "archived", listIDs("archived"), create.ID)
assertIDs(t, "tried after archive", listIDs("tried"))
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
mustNoRows(t, "ArchiveDinner missing", err)
}
func assertIDs(t *testing.T, what string, got []string, want ...string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s: got ids %v, want %v", what, got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("%s: got ids %v, want %v", what, got, want)
}
}
}
func TestRecipeUpsertWithIngredientsAndSteps(t *testing.T) {
ctx := context.Background()
s, q := openQueries(t)
const url = "https://example.com/chili"
_, err := q.GetRecipeBySourceURL(ctx, url)
mustNoRows(t, "GetRecipeBySourceURL before insert", err)
// saveRecipe is how a caller stores a fetched recipe: upsert by
// source_url, then replace the children, all in one transaction.
saveRecipe := func(title, at string, ingredients, steps []string) db.Recipe {
t.Helper()
var r db.Recipe
err := s.WithTx(ctx, func(tx *sql.Tx) error {
qtx := q.WithTx(tx)
var err error
r, err = qtx.UpsertRecipe(ctx, db.UpsertRecipeParams{
ID: store.NewID(), SourceUrl: url, SiteName: str("Example"), Title: title,
TotalMinutes: num(60), RawJsonld: `{"name":"` + title + `"}`,
FetchedAt: at, CreatedAt: at, UpdatedAt: at,
})
if err != nil {
return err
}
if err := qtx.DeleteRecipeIngredients(ctx, r.ID); err != nil {
return err
}
if err := qtx.DeleteRecipeSteps(ctx, r.ID); err != nil {
return err
}
for i, text := range ingredients {
if err := qtx.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
ID: store.NewID(), RecipeID: r.ID, Position: int64(i), RawText: text,
Name: str(text), Qty: str("1"), Unit: str("cup"), Note: sql.NullString{},
CreatedAt: at, UpdatedAt: at,
}); err != nil {
return err
}
}
for i, text := range steps {
if err := qtx.CreateRecipeStep(ctx, db.CreateRecipeStepParams{
ID: store.NewID(), RecipeID: r.ID, Position: int64(i), Text: text,
CreatedAt: at, UpdatedAt: at,
}); err != nil {
return err
}
}
return nil
})
if err != nil {
t.Fatalf("save recipe %q: %v", title, err)
}
return r
}
assertRecipe := func(id, title, createdAt, updatedAt string, ingredients, steps []string) {
t.Helper()
r, err := q.GetRecipe(ctx, id)
if err != nil {
t.Fatalf("GetRecipe: %v", err)
}
if r.Title != title || r.SourceUrl != url || r.CreatedAt != createdAt || r.UpdatedAt != updatedAt || r.FetchedAt != updatedAt {
t.Fatalf("GetRecipe = %+v; want title %q, created %s, updated %s", r, title, createdAt, updatedAt)
}
byURL, err := q.GetRecipeBySourceURL(ctx, url)
if err != nil || byURL != r {
t.Fatalf("GetRecipeBySourceURL = %+v, %v; want %+v", byURL, err, r)
}
ings, err := q.ListRecipeIngredients(ctx, id)
if err != nil {
t.Fatalf("ListRecipeIngredients: %v", err)
}
gotIngs := []string{}
for i, ing := range ings {
if ing.RecipeID != id || ing.Position != int64(i) || ing.Name != str(ing.RawText) {
t.Fatalf("ingredient %d = %+v", i, ing)
}
gotIngs = append(gotIngs, ing.RawText)
}
assertIDs(t, "ingredients", gotIngs, ingredients...)
sts, err := q.ListRecipeSteps(ctx, id)
if err != nil {
t.Fatalf("ListRecipeSteps: %v", err)
}
gotSteps := []string{}
for i, st := range sts {
if st.RecipeID != id || st.Position != int64(i) {
t.Fatalf("step %d = %+v", i, st)
}
gotSteps = append(gotSteps, st.Text)
}
assertIDs(t, "steps", gotSteps, steps...)
}
first := saveRecipe("Chili", created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."})
assertRecipe(first.ID, "Chili", created, created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."})
// Re-fetching the same URL keeps the id and created_at and replaces the
// fields and children.
second := saveRecipe("White Chili", updated, []string{"chicken", "white beans"}, []string{"Simmer."})
if second.ID != first.ID {
t.Fatalf("upsert changed id: %s -> %s", first.ID, second.ID)
}
assertRecipe(first.ID, "White Chili", created, updated, []string{"chicken", "white beans"}, []string{"Simmer."})
// A different recipe's children are not listed.
upsertRecipe(t, q, store.NewID(), "https://example.com/other", "Other", created)
var n int
if err := s.Reader.QueryRowContext(ctx, "SELECT count(*) FROM recipes").Scan(&n); err != nil || n != 2 {
t.Fatalf("recipes count = %d, %v; want 2", n, err)
}
_, err = q.GetRecipe(ctx, "missing")
mustNoRows(t, "GetRecipe missing", err)
}
func TestPhotos(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetPhotoBySHA256(ctx, "bb22")
mustNoRows(t, "GetPhotoBySHA256 before insert", err)
first := createPhoto(t, q, "bb22")
createPhoto(t, q, "cc33")
if first.Sha256 != "bb22" || first.Width != 800 || first.Height != 600 || first.CreatedAt != created {
t.Fatalf("CreatePhoto returned %+v", first)
}
got, err := q.GetPhotoBySHA256(ctx, "bb22")
if err != nil || got != first {
t.Fatalf("GetPhotoBySHA256 = %+v, %v; want %+v", got, err, first)
}
// sha256 is not unique in the schema; the earliest photo is returned.
createPhoto(t, q, "bb22")
if got, err = q.GetPhotoBySHA256(ctx, "bb22"); err != nil || got != first {
t.Fatalf("GetPhotoBySHA256 with duplicate = %+v, %v; want %+v", got, err, first)
}
}
func TestHouseholdSettings(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetHouseholdSettings(ctx)
mustNoRows(t, "GetHouseholdSettings before upsert", err)
p := db.UpsertHouseholdSettingsParams{
Timezone: "America/New_York", 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: created, UpdatedAt: created,
}
want := db.HouseholdSetting{
ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime,
ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime,
SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime,
ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime,
CreatedAt: created, UpdatedAt: created,
}
assertSettings(t, "insert", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want)
p = db.UpsertHouseholdSettingsParams{
Timezone: "America/Chicago", DinnerTime: "18:00", ReminderTime: "17:00",
ReviewTime: "10:00", PlanDraftDow: 4, PlanDraftTime: "20:00",
SuggestionsDow: 1, SuggestionsTime: "07:00", ChatAutosendDow: 0,
ChatAutosendTime: "09:30", OnboardingCompletedAt: str(updated),
UpdatesSeenAt: str(updated), CreatedAt: updated, UpdatedAt: updated,
}
want = db.HouseholdSetting{
ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime,
ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime,
SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime,
ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime,
OnboardingCompletedAt: p.OnboardingCompletedAt, UpdatesSeenAt: p.UpdatesSeenAt,
CreatedAt: created, UpdatedAt: updated,
}
assertSettings(t, "update", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want)
}
func TestAISettings(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetAISettings(ctx)
mustNoRows(t, "GetAISettings before upsert", err)
p := db.UpsertAISettingsParams{
Provider: "ollama", BaseUrl: "http://ollama:11434", Model: "llama3",
Temperature: 0.7, CreatedAt: created, UpdatedAt: created,
}
want := db.AiSetting{
ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model,
Temperature: p.Temperature, CreatedAt: created, UpdatedAt: created,
}
assertSettings(t, "insert", q.UpsertAISettings, q.GetAISettings, p, want)
p = db.UpsertAISettingsParams{
Provider: "anthropic", BaseUrl: "https://api.anthropic.com", Model: "claude",
Temperature: 0.2, SystemPrompt: str("Be brief."), ApiKeyCt: []byte{1, 2, 3},
ApiKeyLast4: str("wxyz"), KeyVerifiedAt: str(updated), CreatedAt: updated, UpdatedAt: updated,
}
want = db.AiSetting{
ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model,
Temperature: p.Temperature, SystemPrompt: p.SystemPrompt, ApiKeyCt: p.ApiKeyCt,
ApiKeyLast4: p.ApiKeyLast4, KeyVerifiedAt: p.KeyVerifiedAt,
CreatedAt: created, UpdatedAt: updated,
}
assertSettings(t, "update", q.UpsertAISettings, q.GetAISettings, p, want)
}
func TestRecipeSearchSettings(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetRecipeSearchSettings(ctx)
mustNoRows(t, "GetRecipeSearchSettings before upsert", err)
p := db.UpsertRecipeSearchSettingsParams{
Provider: "searxng", BaseUrl: str("http://searxng:8080"), CreatedAt: created, UpdatedAt: created,
}
want := db.RecipeSearchSetting{
ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, CreatedAt: created, UpdatedAt: created,
}
assertSettings(t, "insert", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want)
p = db.UpsertRecipeSearchSettingsParams{
Provider: "brave", ApiKeyCt: []byte{9, 8, 7}, CreatedAt: updated, UpdatedAt: updated,
}
want = db.RecipeSearchSetting{
ID: 1, Provider: p.Provider, ApiKeyCt: p.ApiKeyCt, CreatedAt: created, UpdatedAt: updated,
}
assertSettings(t, "update", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want)
}
func TestIntegrationsGoogle(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetIntegrationsGoogle(ctx)
mustNoRows(t, "GetIntegrationsGoogle before upsert", err)
p := db.UpsertIntegrationsGoogleParams{
RefreshTokenCt: []byte{1}, ConnectedAt: created, CreatedAt: created, UpdatedAt: created,
}
want := db.IntegrationsGoogle{
ID: 1, RefreshTokenCt: p.RefreshTokenCt, ConnectedAt: created, CreatedAt: created, UpdatedAt: created,
}
assertSettings(t, "insert", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want)
p = db.UpsertIntegrationsGoogleParams{
RefreshTokenCt: []byte{2, 3}, CalendarID: str("family@group.calendar.google.com"),
CalendarName: str("Family"), ConnectedAt: updated, LastSyncError: str("quota exceeded"),
CreatedAt: updated, UpdatedAt: updated,
}
want = db.IntegrationsGoogle{
ID: 1, RefreshTokenCt: p.RefreshTokenCt, CalendarID: p.CalendarID,
CalendarName: p.CalendarName, ConnectedAt: updated, LastSyncError: p.LastSyncError,
CreatedAt: created, UpdatedAt: updated,
}
assertSettings(t, "update", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want)
}
func TestChatSettings(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetChatSettings(ctx)
mustNoRows(t, "GetChatSettings before upsert", err)
p := db.UpsertChatSettingsParams{
WebhookUrlCt: []byte{4, 5}, CreatedAt: created, UpdatedAt: created,
}
want := db.ChatSetting{
ID: 1, WebhookUrlCt: p.WebhookUrlCt, CreatedAt: created, UpdatedAt: created,
}
assertSettings(t, "insert", q.UpsertChatSettings, q.GetChatSettings, p, want)
p = db.UpsertChatSettingsParams{
WebhookUrlCt: []byte{6}, SpaceLabel: str("Kitchen"), LastTestAt: str(updated),
LastTestStatus: str("ok"), CreatedAt: updated, UpdatedAt: updated,
}
want = db.ChatSetting{
ID: 1, WebhookUrlCt: p.WebhookUrlCt, SpaceLabel: p.SpaceLabel, LastTestAt: p.LastTestAt,
LastTestStatus: p.LastTestStatus, CreatedAt: created, UpdatedAt: updated,
}
assertSettings(t, "update", q.UpsertChatSettings, q.GetChatSettings, p, want)
}
// assertSettings upserts p and checks that both the returned row and a
// fresh Get equal want. On update, want keeps the original created_at.
func assertSettings[P, R any](
t *testing.T, what string,
upsert func(context.Context, P) (R, error), get func(context.Context) (R, error),
p P, want R,
) {
t.Helper()
ctx := context.Background()
got, err := upsert(ctx, p)
if err != nil {
t.Fatalf("%s upsert: %v", what, err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%s upsert returned %+v, want %+v", what, got, want)
}
got, err = get(ctx)
if err != nil {
t.Fatalf("%s get: %v", what, err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%s get = %+v, want %+v", what, got, want)
}
}
+321
View File
@@ -0,0 +1,321 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: dinners.sql
package db
import (
"context"
"database/sql"
)
const archiveDinner = `-- name: ArchiveDinner :one
UPDATE dinners
SET status = 'archived',
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
`
type ArchiveDinnerParams struct {
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, archiveDinner, arg.UpdatedBy, arg.UpdatedAt, arg.ID)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const createDinner = `-- name: CreateDinner :one
INSERT INTO dinners (
id, name, normalized_name, status, origin, method, minutes_total,
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
updated_by, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?
)
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
`
type CreateDinnerParams struct {
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, createDinner,
arg.ID,
arg.Name,
arg.NormalizedName,
arg.Status,
arg.Origin,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.Cuisine,
arg.IsFavorite,
arg.RecipeID,
arg.PhotoID,
arg.GroundingStatus,
arg.UpdatedBy,
arg.CreatedAt,
arg.UpdatedAt,
)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDinner = `-- name: GetDinner :one
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
WHERE id = ?
`
func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
row := q.db.QueryRowContext(ctx, getDinner, id)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
WHERE status = ?
ORDER BY normalized_name, id
`
func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listDinnersByStatus, status)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Dinner
for rows.Next() {
var i Dinner
if err := rows.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
UPDATE dinners
SET is_favorite = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
`
type SetDinnerFavoriteParams struct {
IsFavorite int64
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, setDinnerFavorite,
arg.IsFavorite,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateDinner = `-- name: UpdateDinner :one
UPDATE dinners
SET name = ?,
normalized_name = ?,
status = ?,
origin = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
cuisine = ?,
recipe_id = ?,
photo_id = ?,
grounding_status = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
`
type UpdateDinnerParams struct {
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, updateDinner,
arg.Name,
arg.NormalizedName,
arg.Status,
arg.Origin,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.Cuisine,
arg.RecipeID,
arg.PhotoID,
arg.GroundingStatus,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+166
View File
@@ -0,0 +1,166 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: jobs.sql
package db
import (
"context"
"database/sql"
)
const claimNextJob = `-- name: ClaimNextJob :one
UPDATE jobs
SET status = 'running', started_at = ?1, updated_at = ?1
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
RETURNING id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at
`
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
func (q *Queries) ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) {
row := q.db.QueryRowContext(ctx, claimNextJob, now)
var i Job
err := row.Scan(
&i.ID,
&i.Kind,
&i.Status,
&i.Payload,
&i.Result,
&i.Error,
&i.StartedAt,
&i.FinishedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const createJob = `-- name: CreateJob :exec
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
VALUES (?, ?, 'queued', ?, ?, ?)
`
type CreateJobParams struct {
ID string
Kind string
Payload string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) error {
_, err := q.db.ExecContext(ctx, createJob,
arg.ID,
arg.Kind,
arg.Payload,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const failJob = `-- name: FailJob :execrows
UPDATE jobs
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'running'
`
type FailJobParams struct {
Error sql.NullString
Now sql.NullString
ID string
}
func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failJob, arg.Error, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const failRunningJobs = `-- name: FailRunningJobs :execrows
UPDATE jobs
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
WHERE status = 'running'
`
type FailRunningJobsParams struct {
Error sql.NullString
Now sql.NullString
}
// Fails every job left running, at boot before any worker starts.
func (q *Queries) FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failRunningJobs, arg.Error, arg.Now)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getJob = `-- name: GetJob :one
SELECT id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs
WHERE id = ?
`
func (q *Queries) GetJob(ctx context.Context, id string) (Job, error) {
row := q.db.QueryRowContext(ctx, getJob, id)
var i Job
err := row.Scan(
&i.ID,
&i.Kind,
&i.Status,
&i.Payload,
&i.Result,
&i.Error,
&i.StartedAt,
&i.FinishedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const publishJobResult = `-- name: PublishJobResult :execrows
UPDATE jobs
SET result = ?, updated_at = ?
WHERE id = ? AND status = 'running'
`
type PublishJobResultParams struct {
Result sql.NullString
UpdatedAt string
ID string
}
// Stores a partial result of a running job.
func (q *Queries) PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) {
result, err := q.db.ExecContext(ctx, publishJobResult, arg.Result, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const succeedJob = `-- name: SucceedJob :execrows
UPDATE jobs
SET status = 'succeeded', result = ?1, finished_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'running'
`
type SucceedJobParams struct {
Result sql.NullString
Now sql.NullString
ID string
}
func (q *Queries) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) {
result, err := q.db.ExecContext(ctx, succeedJob, arg.Result, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+310
View File
@@ -0,0 +1,310 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"database/sql"
)
type AiRun struct {
ID string
Skill string
SkillVersion string
Provider string
Model string
Temperature float64
Status string
LatencyMs sql.NullInt64
TokensIn sql.NullInt64
TokensOut sql.NullInt64
Error sql.NullString
RequestRedacted string
ResponseRedacted sql.NullString
CreatedAt string
UpdatedAt string
}
type AiSetting struct {
ID int64
Provider string
BaseUrl string
Model string
Temperature float64
SystemPrompt sql.NullString
ApiKeyCt []byte
ApiKeyLast4 sql.NullString
KeyVerifiedAt sql.NullString
CreatedAt string
UpdatedAt string
}
type ChatSetting struct {
ID int64
WebhookUrlCt []byte
SpaceLabel sql.NullString
LastTestAt sql.NullString
LastTestStatus sql.NullString
CreatedAt string
UpdatedAt string
}
type Dinner struct {
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
}
type HouseholdSetting struct {
ID int64
Timezone string
DinnerTime string
ReminderTime string
ReviewTime string
PlanDraftDow int64
PlanDraftTime string
SuggestionsDow int64
SuggestionsTime string
ChatAutosendDow int64
ChatAutosendTime string
OnboardingCompletedAt sql.NullString
UpdatesSeenAt sql.NullString
CreatedAt string
UpdatedAt string
UpdatedBy sql.NullString
}
type IntegrationsGoogle struct {
ID int64
RefreshTokenCt []byte
CalendarID sql.NullString
CalendarName sql.NullString
ConnectedAt string
LastSyncError sql.NullString
CreatedAt string
UpdatedAt string
}
type Job struct {
ID string
Kind string
Status string
Payload string
Result sql.NullString
Error sql.NullString
StartedAt sql.NullString
FinishedAt sql.NullString
CreatedAt string
UpdatedAt string
}
type NotificationOutbox struct {
ID string
EventKey string
Channel string
Payload string
Status string
Attempts int64
NextAttemptAt sql.NullString
LastError sql.NullString
CreatedAt string
UpdatedAt string
}
type NotificationPref struct {
Event string
Push int64
Chat int64
CreatedAt string
UpdatedAt string
}
type Photo struct {
ID string
SourceUrl string
LocalPath string
Width int64
Height int64
Sha256 string
FetchedAt string
CreatedAt string
UpdatedAt string
}
type PlanDay struct {
ID string
PlanID string
Date string
DinnerID sql.NullString
IsNew int64
CalendarEventID sql.NullString
CreatedAt string
UpdatedAt string
}
type PushSubscription struct {
ID string
Endpoint string
P256dh string
Auth string
UserAgent sql.NullString
FailureCount int64
LastSuccessAt sql.NullString
CreatedAt string
UpdatedAt string
}
type Recipe struct {
ID string
SourceUrl string
SiteName sql.NullString
Title string
TotalMinutes sql.NullInt64
PrepMinutes sql.NullInt64
CookMinutes sql.NullInt64
Yield sql.NullString
RawJsonld string
FetchedAt string
CreatedAt string
UpdatedAt string
}
type RecipeIngredient struct {
ID string
RecipeID string
Position int64
RawText string
Name sql.NullString
Qty sql.NullString
Unit sql.NullString
Note sql.NullString
CreatedAt string
UpdatedAt string
}
type RecipeSearchSetting struct {
ID int64
Provider string
BaseUrl sql.NullString
ApiKeyCt []byte
CreatedAt string
UpdatedAt string
}
type RecipeStep struct {
ID string
RecipeID string
Position int64
Text string
CreatedAt string
UpdatedAt string
}
type RotationReview struct {
ID string
DinnerID string
PlanDate string
Status string
Decision sql.NullString
DecidedAt sql.NullString
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
}
type ScheduleRun struct {
ID string
Schedule string
PeriodKey string
CreatedAt string
}
type Session struct {
IDHash string
Subject string
Email string
Name string
Groups string
CsrfToken string
CreatedAt string
LastSeenAt string
ExpiresAt string
}
type ShoppingGroup struct {
ID string
ListID string
DinnerID string
Position int64
CreatedAt string
UpdatedAt string
}
type ShoppingItem struct {
ID string
GroupID string
Name string
Qty sql.NullString
Needed int64
Position int64
CreatedAt string
UpdatedAt string
}
type ShoppingList struct {
ID string
WeekStart string
PlanID string
Title string
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
CreatedAt string
UpdatedAt string
}
type Suggestion struct {
ID string
DinnerID string
BatchID string
Reason string
CitedRegularIds string
Status string
DecidedAt sql.NullString
CreatedAt string
UpdatedAt string
}
type SuggestionBatch struct {
ID string
Trigger string
Status string
AiRunID sql.NullString
CreatedAt string
UpdatedAt string
}
type WeekPlan struct {
ID string
WeekStart string
Status string
AiNote sql.NullString
AiRunID sql.NullString
CalendarSyncedAt sql.NullString
CreatedAt string
UpdatedAt string
}
+86
View File
@@ -0,0 +1,86 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: photos.sql
package db
import (
"context"
)
const createPhoto = `-- name: CreatePhoto :one
INSERT INTO photos (
id, source_url, local_path, width, height, sha256, fetched_at,
created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?
)
RETURNING id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at
`
type CreatePhotoParams struct {
ID string
SourceUrl string
LocalPath string
Width int64
Height int64
Sha256 string
FetchedAt string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) {
row := q.db.QueryRowContext(ctx, createPhoto,
arg.ID,
arg.SourceUrl,
arg.LocalPath,
arg.Width,
arg.Height,
arg.Sha256,
arg.FetchedAt,
arg.CreatedAt,
arg.UpdatedAt,
)
var i Photo
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.LocalPath,
&i.Width,
&i.Height,
&i.Sha256,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getPhotoBySHA256 = `-- name: GetPhotoBySHA256 :one
SELECT id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at FROM photos
WHERE sha256 = ?
ORDER BY id
LIMIT 1
`
// Finds stored image bytes by content hash. The schema does not make sha256
// unique, so the earliest-created (lowest id) match is returned.
func (q *Queries) GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error) {
row := q.db.QueryRowContext(ctx, getPhotoBySHA256, sha256)
var i Photo
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.LocalPath,
&i.Width,
&i.Height,
&i.Sha256,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+77
View File
@@ -0,0 +1,77 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"database/sql"
)
type Querier interface {
// An unknown id returns sql.ErrNoRows.
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
CreateJob(ctx context.Context, arg CreateJobParams) error
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Fails every job left running, at boot before any worker starts.
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error)
// Returns sql.ErrNoRows until the row exists.
GetChatSettings(ctx context.Context) (ChatSetting, error)
GetDinner(ctx context.Context, id string) (Dinner, error)
// Returns sql.ErrNoRows until the row exists.
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
// Returns sql.ErrNoRows until the row exists.
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
GetJob(ctx context.Context, id string) (Job, error)
// Finds stored image bytes by content hash. The schema does not make sha256
// unique, so the earliest-created (lowest id) match is returned.
GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error)
GetRecipe(ctx context.Context, id string) (Recipe, error)
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
// Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// Stores a partial result of a running job.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
// A recipe is keyed by source_url: re-fetching the same URL updates the
// existing row and keeps its id and created_at, which the returned row
// carries. Replace its ingredients and steps in the same transaction with
// DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries.
UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error)
}
var _ Querier = (*Queries)(nil)
+298
View File
@@ -0,0 +1,298 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: recipes.sql
package db
import (
"context"
"database/sql"
)
const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec
INSERT INTO recipe_ingredients (
id, recipe_id, position, raw_text, name, qty, unit, note,
created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?,
?, ?
)
`
type CreateRecipeIngredientParams struct {
ID string
RecipeID string
Position int64
RawText string
Name sql.NullString
Qty sql.NullString
Unit sql.NullString
Note sql.NullString
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error {
_, err := q.db.ExecContext(ctx, createRecipeIngredient,
arg.ID,
arg.RecipeID,
arg.Position,
arg.RawText,
arg.Name,
arg.Qty,
arg.Unit,
arg.Note,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createRecipeStep = `-- name: CreateRecipeStep :exec
INSERT INTO recipe_steps (
id, recipe_id, position, text, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?
)
`
type CreateRecipeStepParams struct {
ID string
RecipeID string
Position int64
Text string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error {
_, err := q.db.ExecContext(ctx, createRecipeStep,
arg.ID,
arg.RecipeID,
arg.Position,
arg.Text,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deleteRecipeIngredients = `-- name: DeleteRecipeIngredients :exec
DELETE FROM recipe_ingredients
WHERE recipe_id = ?
`
func (q *Queries) DeleteRecipeIngredients(ctx context.Context, recipeID string) error {
_, err := q.db.ExecContext(ctx, deleteRecipeIngredients, recipeID)
return err
}
const deleteRecipeSteps = `-- name: DeleteRecipeSteps :exec
DELETE FROM recipe_steps
WHERE recipe_id = ?
`
func (q *Queries) DeleteRecipeSteps(ctx context.Context, recipeID string) error {
_, err := q.db.ExecContext(ctx, deleteRecipeSteps, recipeID)
return err
}
const getRecipe = `-- name: GetRecipe :one
SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes
WHERE id = ?
`
func (q *Queries) GetRecipe(ctx context.Context, id string) (Recipe, error) {
row := q.db.QueryRowContext(ctx, getRecipe, id)
var i Recipe
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.SiteName,
&i.Title,
&i.TotalMinutes,
&i.PrepMinutes,
&i.CookMinutes,
&i.Yield,
&i.RawJsonld,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getRecipeBySourceURL = `-- name: GetRecipeBySourceURL :one
SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes
WHERE source_url = ?
`
func (q *Queries) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) {
row := q.db.QueryRowContext(ctx, getRecipeBySourceURL, sourceUrl)
var i Recipe
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.SiteName,
&i.Title,
&i.TotalMinutes,
&i.PrepMinutes,
&i.CookMinutes,
&i.Yield,
&i.RawJsonld,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listRecipeIngredients = `-- name: ListRecipeIngredients :many
SELECT id, recipe_id, position, raw_text, name, qty, unit, note, created_at, updated_at FROM recipe_ingredients
WHERE recipe_id = ?
ORDER BY position, id
`
func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) {
rows, err := q.db.QueryContext(ctx, listRecipeIngredients, recipeID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecipeIngredient
for rows.Next() {
var i RecipeIngredient
if err := rows.Scan(
&i.ID,
&i.RecipeID,
&i.Position,
&i.RawText,
&i.Name,
&i.Qty,
&i.Unit,
&i.Note,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecipeSteps = `-- name: ListRecipeSteps :many
SELECT id, recipe_id, position, text, created_at, updated_at FROM recipe_steps
WHERE recipe_id = ?
ORDER BY position, id
`
func (q *Queries) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) {
rows, err := q.db.QueryContext(ctx, listRecipeSteps, recipeID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecipeStep
for rows.Next() {
var i RecipeStep
if err := rows.Scan(
&i.ID,
&i.RecipeID,
&i.Position,
&i.Text,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertRecipe = `-- name: UpsertRecipe :one
INSERT INTO recipes (
id, source_url, site_name, title, total_minutes, prep_minutes,
cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?
)
ON CONFLICT (source_url) DO UPDATE SET
site_name = excluded.site_name,
title = excluded.title,
total_minutes = excluded.total_minutes,
prep_minutes = excluded.prep_minutes,
cook_minutes = excluded.cook_minutes,
yield = excluded.yield,
raw_jsonld = excluded.raw_jsonld,
fetched_at = excluded.fetched_at,
updated_at = excluded.updated_at
RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at
`
type UpsertRecipeParams struct {
ID string
SourceUrl string
SiteName sql.NullString
Title string
TotalMinutes sql.NullInt64
PrepMinutes sql.NullInt64
CookMinutes sql.NullInt64
Yield sql.NullString
RawJsonld string
FetchedAt string
CreatedAt string
UpdatedAt string
}
// A recipe is keyed by source_url: re-fetching the same URL updates the
// existing row and keeps its id and created_at, which the returned row
// carries. Replace its ingredients and steps in the same transaction with
// DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries.
func (q *Queries) UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error) {
row := q.db.QueryRowContext(ctx, upsertRecipe,
arg.ID,
arg.SourceUrl,
arg.SiteName,
arg.Title,
arg.TotalMinutes,
arg.PrepMinutes,
arg.CookMinutes,
arg.Yield,
arg.RawJsonld,
arg.FetchedAt,
arg.CreatedAt,
arg.UpdatedAt,
)
var i Recipe
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.SiteName,
&i.Title,
&i.TotalMinutes,
&i.PrepMinutes,
&i.CookMinutes,
&i.Yield,
&i.RawJsonld,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+433
View File
@@ -0,0 +1,433 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: settings.sql
package db
import (
"context"
"database/sql"
)
const getAISettings = `-- name: GetAISettings :one
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings
WHERE id = 1
`
// Returns sql.ErrNoRows until the row exists.
func (q *Queries) GetAISettings(ctx context.Context) (AiSetting, error) {
row := q.db.QueryRowContext(ctx, getAISettings)
var i AiSetting
err := row.Scan(
&i.ID,
&i.Provider,
&i.BaseUrl,
&i.Model,
&i.Temperature,
&i.SystemPrompt,
&i.ApiKeyCt,
&i.ApiKeyLast4,
&i.KeyVerifiedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getChatSettings = `-- name: GetChatSettings :one
SELECT id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at FROM chat_settings
WHERE id = 1
`
// Returns sql.ErrNoRows until the row exists.
func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) {
row := q.db.QueryRowContext(ctx, getChatSettings)
var i ChatSetting
err := row.Scan(
&i.ID,
&i.WebhookUrlCt,
&i.SpaceLabel,
&i.LastTestAt,
&i.LastTestStatus,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
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, updated_by FROM household_settings
WHERE id = 1
`
// Returns sql.ErrNoRows until the row exists.
func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) {
row := q.db.QueryRowContext(ctx, getHouseholdSettings)
var i HouseholdSetting
err := row.Scan(
&i.ID,
&i.Timezone,
&i.DinnerTime,
&i.ReminderTime,
&i.ReviewTime,
&i.PlanDraftDow,
&i.PlanDraftTime,
&i.SuggestionsDow,
&i.SuggestionsTime,
&i.ChatAutosendDow,
&i.ChatAutosendTime,
&i.OnboardingCompletedAt,
&i.UpdatesSeenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.UpdatedBy,
)
return i, err
}
const getIntegrationsGoogle = `-- name: GetIntegrationsGoogle :one
SELECT id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at FROM integrations_google
WHERE id = 1
`
// Returns sql.ErrNoRows until the row exists.
func (q *Queries) GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) {
row := q.db.QueryRowContext(ctx, getIntegrationsGoogle)
var i IntegrationsGoogle
err := row.Scan(
&i.ID,
&i.RefreshTokenCt,
&i.CalendarID,
&i.CalendarName,
&i.ConnectedAt,
&i.LastSyncError,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getRecipeSearchSettings = `-- name: GetRecipeSearchSettings :one
SELECT id, provider, base_url, api_key_ct, created_at, updated_at FROM recipe_search_settings
WHERE id = 1
`
// Returns sql.ErrNoRows until the row exists.
func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) {
row := q.db.QueryRowContext(ctx, getRecipeSearchSettings)
var i RecipeSearchSetting
err := row.Scan(
&i.ID,
&i.Provider,
&i.BaseUrl,
&i.ApiKeyCt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertAISettings = `-- name: UpsertAISettings :one
INSERT INTO ai_settings (
id, provider, base_url, model, temperature, system_prompt, api_key_ct,
api_key_last4, key_verified_at, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
provider = excluded.provider,
base_url = excluded.base_url,
model = excluded.model,
temperature = excluded.temperature,
system_prompt = excluded.system_prompt,
api_key_ct = excluded.api_key_ct,
api_key_last4 = excluded.api_key_last4,
key_verified_at = excluded.key_verified_at,
updated_at = excluded.updated_at
RETURNING id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at
`
type UpsertAISettingsParams struct {
Provider string
BaseUrl string
Model string
Temperature float64
SystemPrompt sql.NullString
ApiKeyCt []byte
ApiKeyLast4 sql.NullString
KeyVerifiedAt sql.NullString
CreatedAt string
UpdatedAt string
}
// Creates the single row (id 1) or replaces every column except id and
// created_at.
func (q *Queries) UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) {
row := q.db.QueryRowContext(ctx, upsertAISettings,
arg.Provider,
arg.BaseUrl,
arg.Model,
arg.Temperature,
arg.SystemPrompt,
arg.ApiKeyCt,
arg.ApiKeyLast4,
arg.KeyVerifiedAt,
arg.CreatedAt,
arg.UpdatedAt,
)
var i AiSetting
err := row.Scan(
&i.ID,
&i.Provider,
&i.BaseUrl,
&i.Model,
&i.Temperature,
&i.SystemPrompt,
&i.ApiKeyCt,
&i.ApiKeyLast4,
&i.KeyVerifiedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertChatSettings = `-- name: UpsertChatSettings :one
INSERT INTO chat_settings (
id, webhook_url_ct, space_label, last_test_at, last_test_status,
created_at, updated_at
) VALUES (
1, ?, ?, ?, ?,
?, ?
)
ON CONFLICT (id) DO UPDATE SET
webhook_url_ct = excluded.webhook_url_ct,
space_label = excluded.space_label,
last_test_at = excluded.last_test_at,
last_test_status = excluded.last_test_status,
updated_at = excluded.updated_at
RETURNING id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at
`
type UpsertChatSettingsParams struct {
WebhookUrlCt []byte
SpaceLabel sql.NullString
LastTestAt sql.NullString
LastTestStatus sql.NullString
CreatedAt string
UpdatedAt string
}
// Creates the single row (id 1) or replaces every column except id and
// created_at.
func (q *Queries) UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error) {
row := q.db.QueryRowContext(ctx, upsertChatSettings,
arg.WebhookUrlCt,
arg.SpaceLabel,
arg.LastTestAt,
arg.LastTestStatus,
arg.CreatedAt,
arg.UpdatedAt,
)
var i ChatSetting
err := row.Scan(
&i.ID,
&i.WebhookUrlCt,
&i.SpaceLabel,
&i.LastTestAt,
&i.LastTestStatus,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertHouseholdSettings = `-- name: UpsertHouseholdSettings :one
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,
updated_by, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?,
?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
timezone = excluded.timezone,
dinner_time = excluded.dinner_time,
reminder_time = excluded.reminder_time,
review_time = excluded.review_time,
plan_draft_dow = excluded.plan_draft_dow,
plan_draft_time = excluded.plan_draft_time,
suggestions_dow = excluded.suggestions_dow,
suggestions_time = excluded.suggestions_time,
chat_autosend_dow = excluded.chat_autosend_dow,
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, updated_by
`
type UpsertHouseholdSettingsParams struct {
Timezone string
DinnerTime string
ReminderTime string
ReviewTime string
PlanDraftDow int64
PlanDraftTime string
SuggestionsDow int64
SuggestionsTime string
ChatAutosendDow int64
ChatAutosendTime string
OnboardingCompletedAt sql.NullString
UpdatesSeenAt sql.NullString
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
}
// Creates the single row (id 1) or replaces every column except id and
// created_at.
func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error) {
row := q.db.QueryRowContext(ctx, upsertHouseholdSettings,
arg.Timezone,
arg.DinnerTime,
arg.ReminderTime,
arg.ReviewTime,
arg.PlanDraftDow,
arg.PlanDraftTime,
arg.SuggestionsDow,
arg.SuggestionsTime,
arg.ChatAutosendDow,
arg.ChatAutosendTime,
arg.OnboardingCompletedAt,
arg.UpdatesSeenAt,
arg.UpdatedBy,
arg.CreatedAt,
arg.UpdatedAt,
)
var i HouseholdSetting
err := row.Scan(
&i.ID,
&i.Timezone,
&i.DinnerTime,
&i.ReminderTime,
&i.ReviewTime,
&i.PlanDraftDow,
&i.PlanDraftTime,
&i.SuggestionsDow,
&i.SuggestionsTime,
&i.ChatAutosendDow,
&i.ChatAutosendTime,
&i.OnboardingCompletedAt,
&i.UpdatesSeenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.UpdatedBy,
)
return i, err
}
const upsertIntegrationsGoogle = `-- name: UpsertIntegrationsGoogle :one
INSERT INTO integrations_google (
id, refresh_token_ct, calendar_id, calendar_name, connected_at,
last_sync_error, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?,
?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
refresh_token_ct = excluded.refresh_token_ct,
calendar_id = excluded.calendar_id,
calendar_name = excluded.calendar_name,
connected_at = excluded.connected_at,
last_sync_error = excluded.last_sync_error,
updated_at = excluded.updated_at
RETURNING id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at
`
type UpsertIntegrationsGoogleParams struct {
RefreshTokenCt []byte
CalendarID sql.NullString
CalendarName sql.NullString
ConnectedAt string
LastSyncError sql.NullString
CreatedAt string
UpdatedAt string
}
// Creates the single row (id 1) or replaces every column except id and
// created_at.
func (q *Queries) UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) {
row := q.db.QueryRowContext(ctx, upsertIntegrationsGoogle,
arg.RefreshTokenCt,
arg.CalendarID,
arg.CalendarName,
arg.ConnectedAt,
arg.LastSyncError,
arg.CreatedAt,
arg.UpdatedAt,
)
var i IntegrationsGoogle
err := row.Scan(
&i.ID,
&i.RefreshTokenCt,
&i.CalendarID,
&i.CalendarName,
&i.ConnectedAt,
&i.LastSyncError,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertRecipeSearchSettings = `-- name: UpsertRecipeSearchSettings :one
INSERT INTO recipe_search_settings (
id, provider, base_url, api_key_ct, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
provider = excluded.provider,
base_url = excluded.base_url,
api_key_ct = excluded.api_key_ct,
updated_at = excluded.updated_at
RETURNING id, provider, base_url, api_key_ct, created_at, updated_at
`
type UpsertRecipeSearchSettingsParams struct {
Provider string
BaseUrl sql.NullString
ApiKeyCt []byte
CreatedAt string
UpdatedAt string
}
// Creates the single row (id 1) or replaces every column except id and
// created_at.
func (q *Queries) UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error) {
row := q.db.QueryRowContext(ctx, upsertRecipeSearchSettings,
arg.Provider,
arg.BaseUrl,
arg.ApiKeyCt,
arg.CreatedAt,
arg.UpdatedAt,
)
var i RecipeSearchSetting
err := row.Scan(
&i.ID,
&i.Provider,
&i.BaseUrl,
&i.ApiKeyCt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+221
View File
@@ -0,0 +1,221 @@
// Package forwarded resolves where a request really came from. The service
// runs behind a reverse proxy (Traefik), so the TCP peer is usually the proxy;
// the X-Forwarded-For and X-Forwarded-Proto headers it adds are believed only
// when the peer is a trusted proxy. From anyone else they are ignored, so a
// client cannot spoof its address or scheme.
package forwarded
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/netip"
"strings"
)
// Header names honoured from trusted proxies.
const (
HeaderFor = "X-Forwarded-For"
HeaderProto = "X-Forwarded-Proto"
)
// ErrInvalidHeader marks a malformed forwarded header from a trusted peer.
var ErrInvalidHeader = errors.New("invalid forwarded header")
// Trusted is the set of proxy addresses whose forwarded headers are honoured.
// The zero value trusts no peer.
type Trusted []netip.Prefix
// ParseTrusted parses a comma-separated list of IP addresses and CIDR
// prefixes. A blank list trusts no peer. Each entry must be a plain address
// or a prefix with no host bits set; zones, IPv4-mapped IPv6 forms and
// zero-length prefixes, which would trust every peer, are rejected.
func ParseTrusted(s string) (Trusted, error) {
if strings.TrimSpace(s) == "" {
return nil, nil
}
var t Trusted
for _, entry := range strings.Split(s, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
return nil, fmt.Errorf("empty entry in comma-separated list %q", s)
}
p, err := parseEntry(entry)
if err != nil {
return nil, err
}
t = append(t, p)
}
return t, nil
}
func parseEntry(entry string) (netip.Prefix, error) {
var p netip.Prefix
if strings.Contains(entry, "/") {
var err error
p, err = netip.ParsePrefix(entry)
if err != nil {
return netip.Prefix{}, fmt.Errorf("invalid CIDR %q", entry)
}
if p != p.Masked() {
return netip.Prefix{}, fmt.Errorf("CIDR %q has host bits set (did you mean %s?)", entry, p.Masked())
}
} else {
a, err := netip.ParseAddr(entry)
if err != nil {
return netip.Prefix{}, fmt.Errorf("invalid IP address %q", entry)
}
if a.Zone() != "" {
return netip.Prefix{}, fmt.Errorf("IP address %q must not have a zone", entry)
}
p = netip.PrefixFrom(a, a.BitLen())
}
switch {
case p.Addr().Is4In6():
return netip.Prefix{}, fmt.Errorf("%q is IPv4-mapped IPv6; write the IPv4 form", entry)
case p.Bits() == 0:
return netip.Prefix{}, fmt.Errorf("%q would trust every peer", entry)
}
return p, nil
}
// Contains reports whether a is a trusted proxy address. IPv4-mapped IPv6
// addresses, as a dual-stack listener reports IPv4 peers, match their IPv4
// form.
func (t Trusted) Contains(a netip.Addr) bool {
a = normalize(a)
for _, p := range t {
if p.Contains(a) {
return true
}
}
return false
}
func normalize(a netip.Addr) netip.Addr { return a.Unmap().WithZone("") }
// Origin is where a request came from.
type Origin struct {
// ClientIP is the client's address.
ClientIP netip.Addr
// Scheme is "https" or "http": the scheme the client used.
Scheme string
}
// Resolve works out r's Origin. When the TCP peer (r.RemoteAddr) is not in
// trusted, both forwarded headers are ignored: the client is the peer and the
// scheme is that of the connection.
//
// When the peer is trusted:
// - X-Forwarded-For entries (across all its header lines, in order) are
// walked right to left, skipping trusted proxy addresses; the first
// untrusted address is the client. Entries left of it are client-supplied
// and not examined. If every entry is trusted the leftmost is the client;
// with no X-Forwarded-For the peer is the client.
// - X-Forwarded-Proto, if present, must be a single "http" or "https" and
// is the scheme; without it the connection's scheme is used.
//
// A malformed header from a trusted peer is an error wrapping
// ErrInvalidHeader; any other error means RemoteAddr is not an IP and port.
func Resolve(r *http.Request, trusted Trusted) (Origin, error) {
peer, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return Origin{}, fmt.Errorf("peer address %q is not an IP and port", r.RemoteAddr)
}
o := Origin{ClientIP: normalize(peer.Addr()), Scheme: "http"}
if r.TLS != nil {
o.Scheme = "https"
}
if !trusted.Contains(o.ClientIP) {
return o, nil
}
if values := r.Header.Values(HeaderFor); len(values) > 0 {
o.ClientIP, err = clientFromChain(strings.Split(strings.Join(values, ","), ","), trusted)
if err != nil {
return Origin{}, err
}
}
switch values := r.Header.Values(HeaderProto); len(values) {
case 0:
case 1:
switch scheme := strings.ToLower(strings.TrimSpace(values[0])); scheme {
case "http", "https":
o.Scheme = scheme
default:
return Origin{}, fmt.Errorf("%w: %s %q is not http or https", ErrInvalidHeader, HeaderProto, values[0])
}
default:
return Origin{}, fmt.Errorf("%w: %d %s headers, want one", ErrInvalidHeader, len(values), HeaderProto)
}
return o, nil
}
// clientFromChain returns the rightmost untrusted address in chain, or the
// leftmost address when all are trusted.
func clientFromChain(chain []string, trusted Trusted) (netip.Addr, error) {
var client netip.Addr
for i := len(chain) - 1; i >= 0; i-- {
a, err := netip.ParseAddr(strings.TrimSpace(chain[i]))
if err != nil {
return netip.Addr{}, fmt.Errorf("%w: %s entry %q is not an IP address", ErrInvalidHeader, HeaderFor, strings.TrimSpace(chain[i]))
}
client = normalize(a)
if !trusted.Contains(client) {
break
}
}
return client, nil
}
type contextKey struct{}
// NewContext returns ctx carrying o.
func NewContext(ctx context.Context, o Origin) context.Context {
return context.WithValue(ctx, contextKey{}, o)
}
// FromContext returns the Origin ctx carries; ok is false when the request
// was not resolved.
func FromContext(ctx context.Context) (o Origin, ok bool) {
o, ok = ctx.Value(contextKey{}).(Origin)
return o, ok
}
// ClientIP returns the resolved client address ctx carries.
func ClientIP(ctx context.Context) (netip.Addr, bool) {
o, ok := FromContext(ctx)
return o.ClientIP, ok
}
// Scheme returns the resolved request scheme ctx carries.
func Scheme(ctx context.Context) (string, bool) {
o, ok := FromContext(ctx)
return o.Scheme, ok
}
// LogAttr is the log attribute key for the resolved client address.
const LogAttr = "client_ip"
// LogHandler wraps h so every record logged with a resolved request's
// context carries the client address as LogAttr.
func LogHandler(h slog.Handler) slog.Handler { return logHandler{h} }
type logHandler struct{ slog.Handler }
func (h logHandler) Handle(ctx context.Context, rec slog.Record) error {
if ip, ok := ClientIP(ctx); ok {
rec = rec.Clone()
rec.AddAttrs(slog.String(LogAttr, ip.String()))
}
return h.Handler.Handle(ctx, rec)
}
func (h logHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return logHandler{h.Handler.WithAttrs(attrs)}
}
func (h logHandler) WithGroup(name string) slog.Handler {
return logHandler{h.Handler.WithGroup(name)}
}
+260
View File
@@ -0,0 +1,260 @@
package forwarded
import (
"bytes"
"context"
"crypto/tls"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"slices"
"strings"
"testing"
)
func TestParseTrusted(t *testing.T) {
t.Parallel()
tests := []struct {
in string
want []string
wantErr string
}{
{in: "", want: nil},
{in: " ", want: nil},
{in: "10.60.1.33", want: []string{"10.60.1.33/32"}},
{in: "10.60.1.33, 172.16.0.0/12", want: []string{"10.60.1.33/32", "172.16.0.0/12"}},
{in: "2001:db8::1,2001:db8::/32", want: []string{"2001:db8::1/128", "2001:db8::/32"}},
{in: "traefik", wantErr: "invalid IP address"},
{in: "10.60.1.33:443", wantErr: "invalid IP address"},
{in: "10.60.1.0/33", wantErr: "invalid CIDR"},
{in: "10.60.1.33/24", wantErr: "did you mean 10.60.1.0/24"},
{in: "10.60.1.33,,10.60.1.34", wantErr: "empty entry"},
{in: "10.60.1.33,", wantErr: "empty entry"},
{in: "fe80::1%eth0", wantErr: "zone"},
{in: "::ffff:10.60.1.33", wantErr: "IPv4-mapped"},
{in: "0.0.0.0/0", wantErr: "every peer"},
{in: "::/0", wantErr: "every peer"},
}
for _, tt := range tests {
got, err := ParseTrusted(tt.in)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("ParseTrusted(%q) error = %v, want one containing %q", tt.in, err, tt.wantErr)
}
continue
}
if err != nil {
t.Errorf("ParseTrusted(%q) error = %v", tt.in, err)
continue
}
var gotStr []string
for _, p := range got {
gotStr = append(gotStr, p.String())
}
if !slices.Equal(gotStr, tt.want) {
t.Errorf("ParseTrusted(%q) = %v, want %v", tt.in, gotStr, tt.want)
}
}
}
func TestTrustedContainsMappedIPv4(t *testing.T) {
t.Parallel()
trusted := mustTrusted(t, "10.60.1.33")
if !trusted.Contains(netip.MustParseAddr("::ffff:10.60.1.33")) {
t.Error("IPv4-mapped form of a trusted address is not trusted")
}
if Trusted(nil).Contains(netip.MustParseAddr("10.60.1.33")) {
t.Error("an empty set trusts a peer")
}
}
func mustTrusted(t *testing.T, s string) Trusted {
t.Helper()
tr, err := ParseTrusted(s)
if err != nil {
t.Fatal(err)
}
return tr
}
func TestResolve(t *testing.T) {
t.Parallel()
// 10.60.1.33 is Traefik; 10.60.2.0/24 is an inner proxy tier.
trusted := mustTrusted(t, "10.60.1.33,10.60.2.0/24")
tests := []struct {
name string
peer string
tls bool
xff []string
xfp []string
wantIP string
wantScheme string
}{
{
name: "untrusted peer, no headers",
peer: "198.51.100.9:5000", wantIP: "198.51.100.9", wantScheme: "http",
},
{
name: "untrusted peer over TLS",
peer: "198.51.100.9:5000", tls: true, wantIP: "198.51.100.9", wantScheme: "https",
},
{
name: "untrusted peer spoofing both headers is ignored",
peer: "198.51.100.9:5000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
wantIP: "198.51.100.9", wantScheme: "http",
},
{
name: "untrusted peer's malformed headers are ignored, not rejected",
peer: "198.51.100.9:5000", xff: []string{"not-an-ip"}, xfp: []string{"gopher"},
wantIP: "198.51.100.9", wantScheme: "http",
},
{
name: "trusted peer: client from X-Forwarded-For, scheme from X-Forwarded-Proto",
peer: "10.60.1.33:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
wantIP: "203.0.113.7", wantScheme: "https",
},
{
name: "trusted peer seen as IPv4-mapped IPv6",
peer: "[::ffff:10.60.1.33]:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
wantIP: "203.0.113.7", wantScheme: "https",
},
{
name: "trusted peer without headers is itself the client",
peer: "10.60.1.33:41000", wantIP: "10.60.1.33", wantScheme: "http",
},
{
name: "rightmost untrusted address wins over a client-supplied prefix",
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7"}, xfp: []string{"https"},
wantIP: "203.0.113.7", wantScheme: "https",
},
{
name: "trusted proxies in the chain are skipped",
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7, 10.60.2.5"}, xfp: []string{"https"},
wantIP: "203.0.113.7", wantScheme: "https",
},
{
name: "several header lines form one chain",
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4", "203.0.113.7, 10.60.2.5"},
wantIP: "203.0.113.7", wantScheme: "http",
},
{
name: "garbage left of the client is not examined",
peer: "10.60.1.33:41000", xff: []string{"spoofed, 203.0.113.7"},
wantIP: "203.0.113.7", wantScheme: "http",
},
{
name: "all trusted: leftmost is the client",
peer: "10.60.1.33:41000", xff: []string{"10.60.2.5, 10.60.2.6"},
wantIP: "10.60.2.5", wantScheme: "http",
},
{
name: "IPv6 client",
peer: "10.60.1.33:41000", xff: []string{"2001:db8::7"}, xfp: []string{"HTTPS"},
wantIP: "2001:db8::7", wantScheme: "https",
},
{
name: "trusted peer may downgrade to http",
peer: "10.60.1.33:41000", tls: true, xfp: []string{"http"},
wantIP: "10.60.1.33", wantScheme: "http",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := request(tt.peer, tt.xff, tt.xfp)
if tt.tls {
r.TLS = &tls.ConnectionState{}
}
o, err := Resolve(r, trusted)
if err != nil {
t.Fatalf("Resolve error = %v", err)
}
if o.ClientIP.String() != tt.wantIP || o.Scheme != tt.wantScheme {
t.Errorf("Resolve = %s %s, want %s %s", o.ClientIP, o.Scheme, tt.wantIP, tt.wantScheme)
}
})
}
}
func TestResolveRejectsMalformedHeadersFromTrustedPeer(t *testing.T) {
t.Parallel()
trusted := mustTrusted(t, "10.60.1.33")
for _, tt := range []struct {
name string
xff, xfp []string
}{
{"X-Forwarded-For not an IP", []string{"not-an-ip"}, nil},
{"X-Forwarded-For with a port", []string{"203.0.113.7:5000"}, nil},
{"X-Forwarded-For empty entry", []string{"203.0.113.7, "}, nil},
{"X-Forwarded-For empty", []string{""}, nil},
{"X-Forwarded-Proto unknown scheme", nil, []string{"ftp"}},
{"X-Forwarded-Proto list", nil, []string{"https, http"}},
{"X-Forwarded-Proto repeated", nil, []string{"https", "https"}},
} {
_, err := Resolve(request("10.60.1.33:41000", tt.xff, tt.xfp), trusted)
if !errors.Is(err, ErrInvalidHeader) {
t.Errorf("%s: Resolve error = %v, want ErrInvalidHeader", tt.name, err)
}
}
}
func TestResolveBadPeerAddress(t *testing.T) {
t.Parallel()
r := request("@", nil, nil)
_, err := Resolve(r, nil)
if err == nil || errors.Is(err, ErrInvalidHeader) {
t.Errorf("Resolve error = %v, want a peer address error", err)
}
}
func request(peer string, xff, xfp []string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = peer
for _, v := range xff {
r.Header.Add(HeaderFor, v)
}
for _, v := range xfp {
r.Header.Add(HeaderProto, v)
}
return r
}
func TestContextHelpers(t *testing.T) {
t.Parallel()
if _, ok := ClientIP(context.Background()); ok {
t.Error("ClientIP ok on an unresolved context")
}
if _, ok := Scheme(context.Background()); ok {
t.Error("Scheme ok on an unresolved context")
}
ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"})
if ip, ok := ClientIP(ctx); !ok || ip.String() != "203.0.113.7" {
t.Errorf("ClientIP = %s %v, want 203.0.113.7 true", ip, ok)
}
if s, ok := Scheme(ctx); !ok || s != "https" {
t.Errorf("Scheme = %q %v, want https true", s, ok)
}
}
func TestLogHandler(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(LogHandler(slog.NewTextHandler(&buf, nil))).With("component", "test")
ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"})
logger.InfoContext(ctx, "resolved")
logger.InfoContext(context.Background(), "unresolved")
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != 2 {
t.Fatalf("logged %d lines, want 2: %q", len(lines), buf.String())
}
if !strings.Contains(lines[0], "client_ip=203.0.113.7") || !strings.Contains(lines[0], "component=test") {
t.Errorf("resolved line = %q, want client_ip and the With attribute", lines[0])
}
if strings.Contains(lines[1], "client_ip") {
t.Errorf("unresolved line = %q, want no client_ip", lines[1])
}
}
+93
View File
@@ -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
})
}
+220
View File
@@ -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)
}
}
}
+340
View File
@@ -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
}
+374
View File
@@ -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
}
+130
View File
@@ -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
}
+311
View File
@@ -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")
}
}
+28
View File
@@ -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
}
+44
View File
@@ -0,0 +1,44 @@
package jobs
import (
"context"
"encoding/json"
)
// KindNoop is the framework's built-in job kind: it does no work and
// succeeds with {"ok":true}. It is the runner's own trivial kind, as Feature
// 09's trivial test skill is the harness's, and the startup and readiness
// job: a noop job reaching succeeded proves the runner claims, runs and
// records jobs.
const KindNoop = "noop"
// NoopPayload is the payload of a noop job.
type NoopPayload struct{}
// NoopResult is the result of a noop job.
type NoopResult struct{}
// PublicJSON returns {"ok":true}.
func (NoopResult) PublicJSON() ([]byte, error) {
return json.Marshal(struct {
OK bool `json:"ok"`
}{OK: true})
}
func runNoop(context.Context, NoopPayload, func(NoopResult) error) (NoopResult, error) {
return NoopResult{}, nil
}
// Builtins enqueues the framework's built-in job kinds.
type Builtins struct {
Noop *Kind[NoopPayload]
}
// RegisterBuiltins registers the framework's built-in job kinds on r.
func RegisterBuiltins(r *Runner) (*Builtins, error) {
noop, err := Register(r, KindNoop, runNoop)
if err != nil {
return nil, err
}
return &Builtins{Noop: noop}, nil
}
+410
View File
@@ -0,0 +1,410 @@
// Package jobs runs long work in the background. A job is a row in the jobs
// table: Enqueue inserts it queued, and a fixed pool of workers claims queued
// jobs oldest first, so the table itself is the queue and a job enqueued
// before a restart still runs after it.
//
// Each job kind has one handler, registered before Start. A handler takes a
// typed payload and returns a result implementing Public; it may publish
// partial results while it runs. The runner stores only what a result's
// PublicJSON returns and, for a failed job, only a failure code: either a
// *Failure code the kind declared at registration or one of the runner's own
// codes. Handler error text, which may quote a URL, token or response body
// from a dependency, is never stored, returned by the API or logged.
package jobs
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"regexp"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Status is the state of a job.
type Status string
// Job states. A job moves queued → running → succeeded or failed.
const (
StatusQueued Status = "queued"
StatusRunning Status = "running"
StatusSucceeded Status = "succeeded"
StatusFailed Status = "failed"
)
// Failure codes the runner stores itself. A kind cannot declare them.
const (
// CodeInterrupted is the error of a job a previous process left running.
// It is fixed framework text, not a snake-case code, so no kind can
// declare it.
CodeInterrupted = "interrupted by restart"
// CodeInternal is the error of a job whose handler failed with anything
// but a *Failure with a code its kind declared, or whose payload or
// result could not be encoded. The underlying error is neither stored
// nor logged.
CodeInternal = "internal_error"
// CodeUnknownKind is the error of a job whose kind has no handler.
CodeUnknownKind = "unknown_kind"
)
// Claim retry backoff. A worker whose claim fails with a database error
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
// when the error clears is claimed without waiting for a wake token.
const (
claimRetryMin = 50 * time.Millisecond
claimRetryMax = 5 * time.Second
)
// codePattern is the form of every failure code: lower-case snake case.
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
// ErrNotFound is returned by Get for an unknown job id.
var ErrNotFound = errors.New("jobs: job not found")
// Failure is a handler error reporting a failure code the household may
// see. Code must be one the job's kind declared at registration; the runner
// stores it as the job's error. Any other handler error, including a Failure
// with an undeclared code, is stored as CodeInternal.
type Failure struct {
Code string
}
func (f *Failure) Error() string { return "jobs: failure " + f.Code }
// Public is a job result. PublicJSON returns the JSON the runner stores and
// GET /api/v1/jobs/{id} returns, so a result type states exactly which of its
// data is public; nothing else of the value is ever encoded.
type Public interface {
PublicJSON() ([]byte, error)
}
// Handler runs one job with its payload. It may call publish any number of
// times with a partial result, which Get returns while the job runs; its
// return value is the final result. ctx is cancelled when the runner stops.
type Handler[P any, R Public] func(ctx context.Context, payload P, publish func(partial R) error) (R, error)
// Job is a job as reported by GET /api/v1/jobs/{id}. Result is the latest
// partial or final result, and is null until the handler publishes one.
// Error is set only on a failed job.
type Job struct {
ID string `json:"id"`
Kind string `json:"kind"`
Status Status `json:"status"`
Result json.RawMessage `json:"result"`
Error *string `json:"error"`
}
// runFunc is a registered Handler with its payload and results as JSON. It
// returns the failure code to store, or "" on success; the logged reason
// never quotes an error.
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
// Runner owns the job handlers and the worker pool.
type Runner struct {
st *store.Store
logger *slog.Logger
workers int
// wake holds up to one token per worker; Enqueue adds one so an idle
// worker looks for the new job.
wake chan struct{}
// claim claims the oldest queued job, marking it running at now.
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
mu sync.Mutex
handlers map[string]runFunc
started bool
wg sync.WaitGroup
}
// NewRunner returns a runner over st with workers concurrent workers.
func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, error) {
if st == nil {
return nil, errors.New("jobs: nil store")
}
if logger == nil {
return nil, errors.New("jobs: nil logger")
}
if workers < 1 {
return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers)
}
return &Runner{
st: st,
logger: logger,
workers: workers,
wake: make(chan struct{}, workers),
claim: db.New(st.Writer).ClaimNextJob,
handlers: make(map[string]runFunc),
}, nil
}
// Kind enqueues jobs of one registered kind.
type Kind[P any] struct {
r *Runner
name string
}
// Register makes h the handler for kind and returns the Kind that enqueues
// its jobs. failureCodes are the *Failure codes h may report. It must be
// called before Start, once per kind.
func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureCodes ...string) (*Kind[P], error) {
if kind == "" {
return nil, errors.New("jobs: register: empty kind")
}
if h == nil {
return nil, fmt.Errorf("jobs: register %q: nil handler", kind)
}
declared := make(map[string]bool, len(failureCodes))
for _, code := range failureCodes {
if !codePattern.MatchString(code) {
return nil, fmt.Errorf("jobs: register %q: failure code %q is not lower-case snake case", kind, code)
}
if code == CodeInternal || code == CodeUnknownKind {
return nil, fmt.Errorf("jobs: register %q: failure code %q is reserved for the runner", kind, code)
}
declared[code] = true
}
r.mu.Lock()
defer r.mu.Unlock()
if r.started {
return nil, fmt.Errorf("jobs: register %q: runner already started", kind)
}
if _, ok := r.handlers[kind]; ok {
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
}
r.handlers[kind] = func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
var p P
dec := json.NewDecoder(bytes.NewReader([]byte(payload)))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return nil, CodeInternal, "payload does not decode"
}
result, err := h(ctx, p, func(partial R) error {
data, err := publicJSON(partial)
if err != nil {
return err
}
return publish(data)
})
if err != nil {
var failure *Failure
if !errors.As(err, &failure) {
return nil, CodeInternal, "handler error"
}
if !declared[failure.Code] {
return nil, CodeInternal, "undeclared failure code"
}
return nil, failure.Code, "handler failure"
}
data, err := publicJSON(result)
if err != nil {
return nil, CodeInternal, "result does not encode"
}
return data, "", ""
}
return &Kind[P]{r: r, name: kind}, nil
}
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
// value. Its error never quotes PublicJSON's error.
func publicJSON(v Public) (json.RawMessage, error) {
data, err := v.PublicJSON()
if err != nil || !json.Valid(data) {
return nil, errors.New("jobs: result has no valid public JSON")
}
return data, nil
}
// Enqueue stores a queued job with payload and returns its id. A job
// enqueued before Start waits for it.
func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
}
id := store.NewID()
now := store.Now().String()
if err := db.New(k.r.st.Writer).CreateJob(ctx, db.CreateJobParams{
ID: id,
Kind: k.name,
Payload: string(data),
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return "", fmt.Errorf("jobs: enqueue %s: %w", k.name, err)
}
select {
case k.r.wake <- struct{}{}:
default: // every worker already has a token and will look again
}
return id, nil
}
// Start fails every job a previous process left running with
// CodeInterrupted, then starts the workers. They stop when ctx is cancelled;
// Wait waits for them. A job whose handler returns an error after ctx is
// cancelled is left running, so the next Start fails it as interrupted.
func (r *Runner) Start(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.started {
return errors.New("jobs: runner already started")
}
now := store.Now().String()
n, err := db.New(r.st.Writer).FailRunningJobs(ctx, db.FailRunningJobsParams{
Error: sql.NullString{String: CodeInterrupted, Valid: true},
Now: sql.NullString{String: now, Valid: true},
})
if err != nil {
return fmt.Errorf("jobs: fail interrupted jobs: %w", err)
}
if n > 0 {
r.logger.WarnContext(ctx, "failed jobs interrupted by restart", "count", n)
}
r.started = true
for range r.workers {
r.wg.Add(1)
go r.work(ctx)
}
return nil
}
// Wait blocks until every worker has stopped.
func (r *Runner) Wait() {
r.wg.Wait()
}
// Get returns the job with id, or ErrNotFound.
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
row, err := db.New(r.st.Reader).GetJob(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return Job{}, ErrNotFound
}
if err != nil {
return Job{}, fmt.Errorf("jobs: read job: %w", err)
}
job := Job{ID: row.ID, Kind: row.Kind, Status: Status(row.Status)}
if row.Result.Valid {
job.Result = json.RawMessage(row.Result.String)
}
if row.Error.Valid {
job.Error = &row.Error.String
}
return job, nil
}
// work claims and runs queued jobs until ctx is cancelled, waiting for a
// wake token whenever none is queued. A claim that fails with a database
// error is retried with backoff rather than waiting for a token, so a job
// already queued is not stranded by a transient error.
func (r *Runner) work(ctx context.Context) {
defer r.wg.Done()
backoff := claimRetryMin
for ctx.Err() == nil {
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
switch {
case err == nil:
backoff = claimRetryMin
r.run(ctx, job)
continue
case errors.Is(err, sql.ErrNoRows):
backoff = claimRetryMin
select {
case <-r.wake:
case <-ctx.Done():
}
continue
case ctx.Err() != nil:
return
}
r.logger.ErrorContext(ctx, "claim job failed; retrying", "reason", "database error", "retry_in", backoff)
timer := time.NewTimer(backoff)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
}
backoff = min(2*backoff, claimRetryMax)
}
}
// run runs a claimed job to completion. The final write is made even when
// ctx has been cancelled, so a handler that finishes during shutdown is
// recorded.
func (r *Runner) run(ctx context.Context, job db.Job) {
write := context.WithoutCancel(ctx)
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
q := db.New(r.st.Writer)
h, ok := r.handlers[job.Kind] // read-only once started
if !ok {
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
r.fail(write, logger, job.ID, CodeUnknownKind)
return
}
publish := func(partial json.RawMessage) error {
n, err := q.PublishJobResult(ctx, db.PublishJobResultParams{
Result: sql.NullString{String: string(partial), Valid: true},
UpdatedAt: store.Now().String(),
ID: job.ID,
})
if err != nil {
return fmt.Errorf("jobs: publish result: %w", err)
}
if n != 1 {
return fmt.Errorf("jobs: publish result: job %s is not running", job.ID)
}
return nil
}
result, code, reason := h(ctx, job.Payload, publish)
if code != "" {
if ctx.Err() != nil {
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
return
}
level := slog.LevelInfo
if code == CodeInternal {
level = slog.LevelError
}
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
r.fail(write, logger, job.ID, code)
return
}
n, err := q.SucceedJob(write, db.SucceedJobParams{
Result: sql.NullString{String: string(result), Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: job.ID,
})
if err == nil && n != 1 {
err = errors.New("job is not running")
}
if err != nil {
logger.ErrorContext(write, "record job success", "error", err)
}
}
// fail records the job as failed with code. The record errors it logs come
// from the database, never from a handler.
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
Error: sql.NullString{String: code, Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: id,
})
if err == nil && n != 1 {
err = errors.New("job is not running")
}
if err != nil {
logger.ErrorContext(ctx, "record job failure", "error", err)
}
}
+570
View File
@@ -0,0 +1,570 @@
package jobs
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// sentinelSecret stands in for a credential a handler holds while it runs.
const sentinelSecret = "sk-sentinel-5f2c9a"
type countPayload struct {
Steps int `json:"steps"`
}
type countResult struct {
Done int `json:"done"`
}
func (c countResult) PublicJSON() ([]byte, error) { return json.Marshal(c) }
func migratedStore(t *testing.T) *store.Store {
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)
}
return st
}
func newRunner(t *testing.T, st *store.Store, workers int) *Runner {
t.Helper()
return newRunnerLogging(t, st, workers, io.Discard)
}
func newRunnerLogging(t *testing.T, st *store.Store, workers int, w io.Writer) *Runner {
t.Helper()
r, err := NewRunner(st, slog.New(slog.NewTextHandler(w, nil)), workers)
if err != nil {
t.Fatal(err)
}
return r
}
// logBuffer collects log output written by concurrent workers.
type logBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *logBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *logBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// start starts r and stops it when the test ends. The returned function
// stops it early and waits for its workers.
func start(t *testing.T, r *Runner) (stop func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := r.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
stop = func() {
cancel()
r.Wait()
}
t.Cleanup(stop)
return stop
}
func register[P any, R Public](t *testing.T, r *Runner, kind string, h Handler[P, R], failureCodes ...string) *Kind[P] {
t.Helper()
k, err := Register(r, kind, h, failureCodes...)
if err != nil {
t.Fatal(err)
}
return k
}
func enqueue[P any](t *testing.T, k *Kind[P], payload P) string {
t.Helper()
id, err := k.Enqueue(context.Background(), payload)
if err != nil {
t.Fatal(err)
}
return id
}
// waitFor polls Get until done accepts the job, failing after five seconds.
func waitFor(t *testing.T, r *Runner, id string, done func(Job) bool) Job {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
job, err := r.Get(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if done(job) {
return job
}
if time.Now().After(deadline) {
t.Fatalf("job %s never reached the expected state; last %+v (result %s)", id, job, job.Result)
}
time.Sleep(5 * time.Millisecond)
}
}
func finished(j Job) bool { return j.Status == StatusSucceeded || j.Status == StatusFailed }
// rawRow returns every column of the job's row joined, for leak checks.
func rawRow(t *testing.T, st *store.Store, id string) string {
t.Helper()
var cols [10]any
ptrs := make([]any, len(cols))
for i := range cols {
ptrs[i] = &cols[i]
}
if err := st.Reader.QueryRow("SELECT * FROM jobs WHERE id = ?", id).Scan(ptrs...); err != nil {
t.Fatal(err)
}
return fmt.Sprint(cols[:]...)
}
func TestJobSucceeds(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 2)
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
start(t, r)
id := enqueue(t, count, countPayload{Steps: 3})
job := waitFor(t, r, id, finished)
if job.ID != id || job.Kind != "count" || job.Status != StatusSucceeded || job.Error != nil {
t.Fatalf("job = %+v, want succeeded count job %s with no error", job, id)
}
if string(job.Result) != `{"done":3}` {
t.Errorf("result = %s, want {\"done\":3}", job.Result)
}
var startedAt, finishedAt, createdAt, updatedAt, payload string
if err := st.Reader.QueryRow("SELECT started_at, finished_at, created_at, updated_at, payload FROM jobs WHERE id = ?", id).
Scan(&startedAt, &finishedAt, &createdAt, &updatedAt, &payload); err != nil {
t.Fatal(err)
}
for name, v := range map[string]string{"started_at": startedAt, "finished_at": finishedAt, "created_at": createdAt, "updated_at": updatedAt} {
var ts store.Timestamp
if err := ts.Scan(v); err != nil {
t.Errorf("%s = %q: %v", name, v, err)
}
}
if payload != `{"steps":3}` {
t.Errorf("payload = %s, want {\"steps\":3}", payload)
}
}
func TestJobFailsWithFailureCode(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, fmt.Errorf("suggest: %w", &Failure{Code: "no_regulars"})
}, "no_regulars")
start(t, r)
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != "no_regulars" {
t.Fatalf("job = %+v, want failed with code no_regulars", job)
}
if job.Result != nil {
t.Errorf("result = %s, want none", job.Result)
}
var stored string
if err := st.Reader.QueryRow("SELECT error FROM jobs WHERE id = ?", job.ID).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != "no_regulars" {
t.Errorf("stored error = %q, want only the code", stored)
}
}
func TestUndeclaredFailureCodeIsInternal(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, &Failure{Code: "not_declared"}
}, "no_regulars")
start(t, r)
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInternal {
t.Fatalf("job = %+v, want failed with %q", job, CodeInternal)
}
}
// credential holds a secret the way config.Secret does: unexported, so
// encoding/json cannot see it.
type credential struct{ value string }
func (credential) String() string { return "[REDACTED]" }
// leakyResult holds a secret in exported and unexported fields; only
// Dinners is public.
type leakyResult struct {
Dinners []string
Key credential
Token string
apiKey string
}
func (l leakyResult) PublicJSON() ([]byte, error) {
return json.Marshal(struct {
Dinners []string `json:"dinners"`
}{l.Dinners})
}
// badResult has no valid public JSON; its encoding error quotes a secret.
type badResult struct{}
func (badResult) PublicJSON() ([]byte, error) {
return nil, fmt.Errorf("encode with key %s", sentinelSecret)
}
// TestSecretsNeverStoredOrLogged proves a handler holding a secret cannot
// surface it in the jobs row, Get's result or the log through its result,
// partial results, *Failure, error text or result encoding error.
func TestSecretsNeverStoredOrLogged(t *testing.T) {
t.Parallel()
st := migratedStore(t)
var logs logBuffer
r := newRunnerLogging(t, st, 1, &logs)
secretResult := leakyResult{Dinners: []string{"Tacos"}, Key: credential{sentinelSecret}, Token: sentinelSecret, apiKey: sentinelSecret}
typed := register(t, r, "typed", func(_ context.Context, _ countPayload, publish func(leakyResult) error) (leakyResult, error) {
if err := publish(secretResult); err != nil {
return leakyResult{}, err
}
return secretResult, nil
})
leakyError := register(t, r, "leaky-error", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
return leakyResult{}, fmt.Errorf("GET https://api.example.com/v1?key=%s: 401 %s", sentinelSecret, sentinelSecret)
})
leakyFailure := register(t, r, "leaky-failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
return leakyResult{}, &Failure{Code: sentinelSecret}
}, "quota_exceeded")
failure := register(t, r, "failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
return leakyResult{}, fmt.Errorf("call with key %s: %w", sentinelSecret, &Failure{Code: "quota_exceeded"})
}, "quota_exceeded")
badEncoding := register(t, r, "bad-encoding", func(context.Context, countPayload, func(badResult) error) (badResult, error) {
return badResult{}, nil
})
stop := start(t, r)
cases := []struct {
kind *Kind[countPayload]
wantStatus Status
wantResult string
wantError string
}{
{typed, StatusSucceeded, `{"dinners":["Tacos"]}`, ""},
{leakyError, StatusFailed, "", CodeInternal},
{leakyFailure, StatusFailed, "", CodeInternal},
{failure, StatusFailed, "", "quota_exceeded"},
{badEncoding, StatusFailed, "", CodeInternal},
}
var ids []string
for _, c := range cases {
job := waitFor(t, r, enqueue(t, c.kind, countPayload{}), finished)
ids = append(ids, job.ID)
gotError := ""
if job.Error != nil {
gotError = *job.Error
}
if job.Status != c.wantStatus || string(job.Result) != c.wantResult || gotError != c.wantError {
t.Errorf("%s job = %+v (result %s), want %s with result %q and error %q", job.Kind, job, job.Result, c.wantStatus, c.wantResult, c.wantError)
}
body, err := json.Marshal(job)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), sentinelSecret) {
t.Errorf("%s job as served contains the secret: %s", job.Kind, body)
}
}
stop()
for _, id := range ids {
if row := rawRow(t, st, id); strings.Contains(row, sentinelSecret) {
t.Errorf("job %s row contains the secret: %s", id, row)
}
}
if strings.Contains(logs.String(), sentinelSecret) {
t.Errorf("log contains the secret:\n%s", logs.String())
}
if !strings.Contains(logs.String(), "code="+CodeInternal) {
t.Errorf("log does not record the internal_error code:\n%s", logs.String())
}
}
func TestProgressiveResultsVisibleMidRun(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
release := make(chan struct{})
k := register(t, r, "draft", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
for i := 1; i < p.Steps; i++ {
if err := publish(countResult{Done: i}); err != nil {
return countResult{}, err
}
select {
case <-release:
case <-ctx.Done():
return countResult{}, ctx.Err()
}
}
return countResult{Done: p.Steps}, nil
})
start(t, r)
id := enqueue(t, k, countPayload{Steps: 3})
for step := 1; step < 3; step++ {
want := fmt.Sprintf(`{"done":%d}`, step)
job := waitFor(t, r, id, func(j Job) bool { return string(j.Result) == want })
if job.Status != StatusRunning || job.Error != nil {
t.Fatalf("mid-run job = %+v, want running with no error", job)
}
release <- struct{}{}
}
job := waitFor(t, r, id, finished)
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
}
func TestRestartFailsInterruptedJobsAndRunsQueuedOnes(t *testing.T) {
t.Parallel()
st := migratedStore(t)
first := newRunner(t, st, 1)
started := make(chan struct{})
k := register(t, first, "slow", func(ctx context.Context, _ countPayload, publish func(countResult) error) (countResult, error) {
if err := publish(countResult{Done: 1}); err != nil {
return countResult{}, err
}
close(started)
<-ctx.Done()
return countResult{}, ctx.Err()
})
stop := start(t, first)
interrupted := enqueue(t, k, countPayload{})
<-started
waiting := enqueue(t, k, countPayload{}) // the only worker is busy
stop()
if job := waitFor(t, first, interrupted, func(Job) bool { return true }); job.Status != StatusRunning {
t.Fatalf("after shutdown job = %+v, want left running", job)
}
second := newRunner(t, st, 1)
register(t, second, "slow", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{Done: 9}, nil
})
start(t, second)
job := waitFor(t, second, interrupted, finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != "interrupted by restart" {
t.Errorf("interrupted job = %+v, want failed with %q", job, "interrupted by restart")
}
if string(job.Result) != `{"done":1}` {
t.Errorf("interrupted job result = %s, want the last partial result kept", job.Result)
}
if job := waitFor(t, second, waiting, finished); job.Status != StatusSucceeded {
t.Errorf("queued job = %+v, want run by the new runner", job)
}
}
func TestWorkerPoolIsBounded(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 2)
var running, peak atomic.Int32
release := make(chan struct{})
k := register(t, r, "busy", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
n := running.Add(1)
for {
p := peak.Load()
if n <= p || peak.CompareAndSwap(p, n) {
break
}
}
<-release
running.Add(-1)
return countResult{}, nil
})
start(t, r)
ids := make([]string, 5)
for i := range ids {
ids[i] = enqueue(t, k, countPayload{})
}
deadline := time.Now().Add(5 * time.Second)
for running.Load() < 2 {
if time.Now().After(deadline) {
t.Fatal("two workers never ran at once")
}
time.Sleep(5 * time.Millisecond)
}
close(release)
for _, id := range ids {
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
t.Errorf("job = %+v, want succeeded", job)
}
}
if got := peak.Load(); got != 2 {
t.Errorf("peak concurrent jobs = %d, want 2", got)
}
}
func TestJobWithoutHandlerFails(t *testing.T) {
t.Parallel()
st := migratedStore(t)
old := newRunner(t, st, 1)
k := register(t, old, "retired", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, nil
})
id := enqueue(t, k, countPayload{}) // never started
r := newRunner(t, st, 1)
start(t, r)
job := waitFor(t, r, id, finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeUnknownKind {
t.Errorf("job = %+v, want failed for a missing handler", job)
}
}
func TestGetUnknownID(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
if _, err := r.Get(context.Background(), store.NewID()); !errors.Is(err, ErrNotFound) {
t.Errorf("Get unknown id error = %v, want ErrNotFound", err)
}
}
func TestRegisterRefusesBadRegistrations(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
h := func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, nil
}
register(t, r, "count", h)
if _, err := Register(r, "count", h); err == nil {
t.Error("duplicate kind registered")
}
if _, err := Register(r, "", h); err == nil {
t.Error("empty kind registered")
}
if _, err := Register[countPayload, countResult](r, "nil", nil); err == nil {
t.Error("nil handler registered")
}
for _, code := range []string{"", "Bad Code", "sk-sentinel", CodeInternal, CodeInterrupted, CodeUnknownKind} {
if _, err := Register(r, "code", h, code); err == nil {
t.Errorf("failure code %q accepted", code)
}
}
start(t, r)
if _, err := Register(r, "late", h); err == nil {
t.Error("kind registered after Start")
}
if err := r.Start(context.Background()); err == nil {
t.Error("second Start succeeded")
}
}
func TestNewRunnerRefusesBadConfig(t *testing.T) {
t.Parallel()
st := migratedStore(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRunner(st, logger, 0); err == nil {
t.Error("zero workers accepted")
}
if _, err := NewRunner(nil, logger, 1); err == nil {
t.Error("nil store accepted")
}
if _, err := NewRunner(st, nil, 1); err == nil {
t.Error("nil logger accepted")
}
}
func TestNoopSucceeds(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
builtins, err := RegisterBuiltins(r)
if err != nil {
t.Fatal(err)
}
start(t, r)
job := waitFor(t, r, enqueue(t, builtins.Noop, NoopPayload{}), finished)
if job.Kind != KindNoop || job.Status != StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
t.Errorf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
}
}
func TestClaimErrorIsRetried(t *testing.T) {
st := migratedStore(t)
logs := &logBuffer{}
r := newRunnerLogging(t, st, 1, logs)
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
id := enqueue(t, k, countPayload{Steps: 3})
// Drop the wake token, as for a job queued before a restart, so only
// the retry can find the job.
<-r.wake
var claims atomic.Int32
claim := r.claim
r.claim = func(ctx context.Context, now sql.NullString) (db.Job, error) {
if claims.Add(1) == 1 {
return db.Job{}, errors.New("database is locked " + sentinelSecret)
}
return claim(ctx, now)
}
start(t, r)
job := waitFor(t, r, id, finished)
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
if n := claims.Load(); n < 2 {
t.Fatalf("claims = %d, want a retry after the error", n)
}
out := logs.String()
if !strings.Contains(out, "claim job failed; retrying") {
t.Fatalf("claim error not logged: %s", out)
}
if strings.Contains(out, sentinelSecret) {
t.Fatalf("claim error text logged: %s", out)
}
}
+81
View File
@@ -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
}
+200
View File
@@ -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)
}
}
+32
View File
@@ -0,0 +1,32 @@
// Package secureurl holds the one transport rule for URLs that carry
// credentials or session cookies: HTTPS, or plain HTTP to a loopback host for
// local development and tests.
package secureurl
import (
"errors"
"fmt"
"net/url"
"strings"
)
// loopbackHosts are the only hosts plain HTTP is allowed for, compared
// case-insensitively. url.URL's Hostname strips the brackets from "[::1]".
var loopbackHosts = map[string]bool{"localhost": true, "127.0.0.1": true, "::1": true}
// Check reports an error unless u is an absolute https:// URL, or an http://
// URL whose host is localhost, 127.0.0.1 or ::1.
func Check(u *url.URL) error {
switch {
case u.Hostname() == "":
return errors.New("URL has no host")
case u.Scheme == "https":
return nil
case u.Scheme == "http" && loopbackHosts[strings.ToLower(u.Hostname())]:
return nil
case u.Scheme == "http":
return errors.New("plain http:// is allowed only for localhost, 127.0.0.1 or [::1]; use https://")
default:
return fmt.Errorf("unsupported scheme %q (want https://)", u.Scheme)
}
}
+40
View File
@@ -0,0 +1,40 @@
package secureurl
import (
"net/url"
"testing"
)
func TestCheck(t *testing.T) {
t.Parallel()
tests := []struct {
raw string
ok bool
}{
{"https://meals.example.com", true},
{"https://auth.example.com/application/o/mealquest/", true},
{"https://192.0.2.10:8443", true},
{"http://localhost:8080", true},
{"http://LOCALHOST:8080", true},
{"http://127.0.0.1:8080/path", true},
{"http://[::1]:8080", true},
{"http://[::1]", true},
{"http://meals.example.com", false},
{"http://192.0.2.10", false},
{"http://127.0.0.2", false},
{"http://localhost.example.com", false},
{"http://0.0.0.0:8080", false},
{"ftp://localhost", false},
{"https://", false},
{"meals.example.com", false},
}
for _, tc := range tests {
u, err := url.Parse(tc.raw)
if err != nil {
t.Fatal(err)
}
if err := Check(u); (err == nil) != tc.ok {
t.Errorf("Check(%q) error = %v, want ok=%v", tc.raw, err, tc.ok)
}
}
}
+42
View File
@@ -0,0 +1,42 @@
package server
import (
"crypto/subtle"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
// CSRFHeader carries the session's CSRF token, which the client reads from
// GET /api/v1/me.
const CSRFHeader = "X-MealQuest-CSRF"
// 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 {
forbidden := apiErrorHandler(logger, http.StatusForbidden, "csrf", "missing or invalid CSRF token")
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
next.ServeHTTP(w, r)
return
}
s, ok := auth.SessionFrom(r.Context())
if !ok || s.CSRFToken == "" {
// An empty token would match a missing header.
logger.ErrorContext(r.Context(), "csrf check without a session token", "session", ok)
internal(w, r)
return
}
if subtle.ConstantTimeCompare([]byte(r.Header.Get(CSRFHeader)), []byte(s.CSRFToken)) != 1 {
forbidden(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}
+167
View File
@@ -0,0 +1,167 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
const (
csrfJSON = `{"error":{"code":"csrf","message":"missing or invalid CSRF token","retryable":false}}`
methodNotAllowedJSON = `{"error":{"code":"method_not_allowed","message":"method not allowed","retryable":false}}`
)
func TestCSRFThroughRouter(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
for _, tc := range []struct {
name, method, path string
header []string // nil sends no CSRF header
wantStatus int
wantBody string
}{
{"POST without header", http.MethodPost, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
{"POST with wrong header", http.MethodPost, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON},
{"POST with empty header", http.MethodPost, "/api/v1/me", []string{""}, http.StatusForbidden, csrfJSON},
{"POST with token prefix", http.MethodPost, "/api/v1/me", []string{"csrf-token"}, http.StatusForbidden, csrfJSON},
{"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, 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.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, ""},
// 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()
req := httptest.NewRequest(tc.method, tc.path, nil)
req.AddCookie(validSessionCookie)
if tc.header != nil {
req.Header.Set(CSRFHeader, tc.header[0])
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body %s)", rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantBody != "" && rec.Body.String() != tc.wantBody {
t.Errorf("body = %s, want %s", rec.Body.String(), tc.wantBody)
}
})
}
}
func TestCSRFUnauthenticatedGets401(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil)
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON)
}
}
func TestRequireCSRF(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
for _, tc := range []struct {
name string
method string
session *auth.Session
header string
wantStatus int
}{
{"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},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
called := false
h := requireCSRF(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.Background()
if tc.session != nil {
ctx = auth.WithSession(ctx, *tc.session)
}
req := httptest.NewRequestWithContext(ctx, tc.method, "/api/v1/anything", nil)
if tc.header != "" {
req.Header.Set(CSRFHeader, tc.header)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
}
if called != (tc.wantStatus == http.StatusNoContent) {
t.Errorf("handler called = %v with status %d", called, rec.Code)
}
})
}
}
func TestMe(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", cc)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
want := map[string]any{
"name": "Home Cook",
"email": "cook@example.com",
"household": map[string]any{"label": "Our kitchen"},
"csrfToken": "csrf-token-123",
}
gotJSON, _ := json.Marshal(got)
wantJSON, _ := json.Marshal(want)
if string(gotJSON) != string(wantJSON) {
t.Errorf("body = %s, want %s", gotJSON, wantJSON)
}
}
func TestMeNeedsSession(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON)
}
}
+29
View File
@@ -0,0 +1,29 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
)
// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest
// result and error.
func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
job, err := runner.Get(r.Context(), chi.URLParam(r, "id"))
if errors.Is(err, jobs.ErrNotFound) {
writeError(w, r, logger, http.StatusNotFound, "not_found", "job not found")
return
}
if err != nil {
logger.ErrorContext(r.Context(), "get job", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, job)
}
}
+153
View File
@@ -0,0 +1,153 @@
package server
import (
"context"
"encoding/json"
"maps"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
type draftPayload struct{}
type draftResult struct {
Days int `json:"days"`
}
func (d draftResult) PublicJSON() ([]byte, error) { return json.Marshal(d) }
// startedRunner registers a "draft" kind that publishes {"days":1}, waits
// for release and returns {"days":7}, or fails with a *jobs.Failure when
// fail is closed first. It starts the runner and stops it when the test ends.
func startedRunner(t *testing.T, release, fail <-chan struct{}) (*jobs.Runner, *jobs.Kind[draftPayload]) {
t.Helper()
runner := newTestRunner(t)
kind, err := jobs.Register(runner, "draft", func(ctx context.Context, _ draftPayload, publish func(draftResult) error) (draftResult, error) {
if err := publish(draftResult{Days: 1}); err != nil {
return draftResult{}, err
}
select {
case <-release:
return draftResult{Days: 7}, nil
case <-fail:
return draftResult{}, &jobs.Failure{Code: "no_regulars"}
case <-ctx.Done():
return draftResult{}, ctx.Err()
}
}, "no_regulars")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
runner.Wait()
})
return runner, kind
}
// pollJob GETs the job until want accepts the body, failing after five
// seconds.
func pollJob(t *testing.T, h http.Handler, id string, want func(map[string]any) bool) map[string]any {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/jobs/"+id, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
}
if want(body) {
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"error", "id", "kind", "result", "status"}) {
t.Errorf("body fields = %v, want id, kind, status, result, error", keys)
}
return body
}
if time.Now().After(deadline) {
t.Fatalf("job %s never reached the expected state; last %v", id, body)
}
time.Sleep(5 * time.Millisecond)
}
}
func resultDays(body map[string]any) float64 {
result, _ := body["result"].(map[string]any)
days, _ := result["days"].(float64)
return days
}
func TestGetJobShowsProgressThenResult(t *testing.T) {
t.Parallel()
release := make(chan struct{})
runner, kind := startedRunner(t, release, nil)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
id, err := kind.Enqueue(context.Background(), draftPayload{})
if err != nil {
t.Fatal(err)
}
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
}
close(release)
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
if resultDays(done) != 7 || done["error"] != nil {
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
}
}
func TestGetJobShowsFailure(t *testing.T) {
t.Parallel()
fail := make(chan struct{})
close(fail)
runner, kind := startedRunner(t, nil, fail)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
id, err := kind.Enqueue(context.Background(), draftPayload{})
if err != nil {
t.Fatal(err)
}
body := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "failed" })
if body["error"] != "no_regulars" {
t.Errorf("error = %v, want the Failure code", body["error"])
}
}
func TestGetJobUnknownIDIsNotFound(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
rec, _ := serve(t, router, signedIn(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if want := (errorBody{Error: apiError{Code: "not_found", Message: "job not found"}}); body != want {
t.Errorf("body = %+v, want %+v", body, want)
}
}
func TestGetJobRequiresSession(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
+140
View File
@@ -0,0 +1,140 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// newDiscoveryProvider serves the OIDC discovery document auth.New needs;
// login only redirects to its authorization endpoint.
func newDiscoveryProvider(t *testing.T) *httptest.Server {
t.Helper()
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/openid-configuration" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/authorize",
"token_endpoint": srv.URL + "/token",
"jwks_uri": srv.URL + "/jwks",
"userinfo_endpoint": srv.URL + "/userinfo",
"end_session_endpoint": srv.URL + "/end-session/",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
})
}))
t.Cleanup(srv.Close)
return srv
}
// newLoginRouter wires the production router as cmd/server does: the real
// auth flow and session manager, trusting Traefik only.
func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Helper()
ctx := context.Background()
db, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
sessions, err := auth.NewSessionManager(db, 720*time.Hour, 4320*time.Hour)
if err != nil {
t.Fatal(err)
}
logger := slog.New(forwarded.LogHandler(slog.NewTextHandler(io.Discard, nil)))
flow, err := auth.New(ctx, auth.Config{
Issuer: issuer,
ClientID: "mealquest",
ClientSecret: "client-s3cret",
PublicURL: &url.URL{Scheme: "https", Host: "meals.example.com"},
SecretKey: []byte("server-secret-key"),
AllowedGroups: []string{"mealquest-admin"},
}, sessions, logger)
if err != nil {
t.Fatal(err)
}
trusted, err := forwarded.ParseTrusted(traefik)
if err != nil {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r
}
// TestLoginThroughRouter starts a login through the production router. The
// redirect URI and the Secure login cookie come from MEALQUEST_PUBLIC_URL
// whatever the direct peer: forwarded headers from an untrusted peer are
// ignored but never fail sign-in.
func TestLoginThroughRouter(t *testing.T) {
t.Parallel()
provider := newDiscoveryProvider(t)
router := newLoginRouter(t, provider.URL)
// Traefik reaches the service over plain http on its internal address;
// the host headers must not leak into the redirect URI.
headers := map[string]string{
forwarded.HeaderFor: "203.0.113.7",
forwarded.HeaderProto: "https",
"X-Forwarded-Host": "evil.example.com",
}
tests := []struct {
name string
peer string
}{
{"trusted proxy", traefik + ":41000"},
{"untrusted peer", "172.17.0.1:5000"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, originRequest("http://mealquest:8080"+auth.LoginPath, tt.peer, headers))
resp := rec.Result()
if resp.StatusCode != http.StatusFound {
t.Fatalf("login status = %d, want 302", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatal(err)
}
if got, want := loc.Scheme+"://"+loc.Host+loc.Path, provider.URL+"/authorize"; got != want {
t.Errorf("redirect to %s, want %s", got, want)
}
if got, want := loc.Query().Get("redirect_uri"), "https://meals.example.com/auth/callback"; got != want {
t.Errorf("redirect_uri = %q, want %q from the public URL", got, want)
}
cookies := resp.Cookies()
if len(cookies) != 1 || cookies[0].Name != "__Host-mealquest_login" || !cookies[0].Secure || !cookies[0].HttpOnly {
t.Errorf("login cookies = %v, want one Secure, HttpOnly __Host-mealquest_login", cookies)
}
})
}
}
+52
View File
@@ -0,0 +1,52 @@
package server
import (
"encoding/json"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
// householdLabel is the single shared household identity.
const householdLabel = "Our kitchen"
type meResponse struct {
Name string `json:"name"`
Email string `json:"email"`
Household householdInfo `json:"household"`
CSRFToken string `json:"csrfToken"`
}
type householdInfo struct {
Label string `json:"label"`
}
// me answers GET /api/v1/me with the signed-in member, the household and the
// session's CSRF token for CSRFHeader.
func me(logger *slog.Logger) http.HandlerFunc {
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
return func(w http.ResponseWriter, r *http.Request) {
s, ok := auth.SessionFrom(r.Context())
if !ok {
logger.ErrorContext(r.Context(), "GET /api/v1/me without a session")
internal(w, r)
return
}
body, err := json.Marshal(meResponse{
Name: s.Name,
Email: s.Email,
Household: householdInfo{Label: householdLabel},
CSRFToken: s.CSRFToken,
})
if err != nil {
panic(err) // string fields cannot fail to marshal
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write me response", "error", err)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
)
// resolveOrigin resolves every request's client address and scheme with
// forwarded.Resolve and puts them in the context (read them with
// forwarded.ClientIP and forwarded.Scheme). Forwarded headers are honoured
// only from a peer in trusted. A malformed forwarded header from a trusted
// peer gets 400 bad_request.
func resolveOrigin(logger *slog.Logger, trusted forwarded.Trusted) func(http.Handler) http.Handler {
badRequest := apiErrorHandler(logger, http.StatusBadRequest, "bad_request", "invalid forwarded headers")
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
o, err := forwarded.Resolve(r, trusted)
switch {
case errors.Is(err, forwarded.ErrInvalidHeader):
logger.WarnContext(r.Context(), "rejected forwarded headers", "peer", r.RemoteAddr, "error", err)
badRequest(w, r)
case err != nil:
logger.ErrorContext(r.Context(), "resolve request origin", "error", err)
internal(w, r)
default:
next.ServeHTTP(w, r.WithContext(forwarded.NewContext(r.Context(), o)))
}
})
}
}
+145
View File
@@ -0,0 +1,145 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
)
// traefik is the production proxy's address.
const traefik = "10.60.1.33"
// probeAuth mounts GET /auth/probe, which reports the request's resolved
// origin, in place of the real auth flow.
type probeAuth struct{}
func (probeAuth) Mount(r chi.Router) {
r.Get("/auth/probe", func(w http.ResponseWriter, r *http.Request) {
ip, ipOK := forwarded.ClientIP(r.Context())
scheme, schemeOK := forwarded.Scheme(r.Context())
if !ipOK || !schemeOK {
http.Error(w, "origin not resolved", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "%s %s", ip, scheme)
})
}
// newOriginRouter builds the production router trusting Traefik, logging
// through forwarded.LogHandler as cmd/server does.
func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
t.Helper()
trusted, err := forwarded.ParseTrusted(traefik)
if err != nil {
t.Fatal(err)
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r
}
func originRequest(target, peer string, headers map[string]string) *http.Request {
req := httptest.NewRequest(http.MethodGet, target, nil)
req.RemoteAddr = peer
for k, v := range headers {
req.Header.Set(k, v)
}
return req
}
func TestRouterResolvesOrigin(t *testing.T) {
t.Parallel()
spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"}
tests := []struct {
name string
peer string
want string
}{
{"trusted proxy's forwarded headers are honoured", traefik + ":41000", "203.0.113.7 https"},
{"untrusted peer spoofing them is ignored", "198.51.100.9:5000", "198.51.100.9 http"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/auth/probe", tt.peer, spoofed))
if rec.Code != http.StatusOK || rec.Body.String() != tt.want {
t.Errorf("probe = %d %q, want 200 %q", rec.Code, rec.Body.String(), tt.want)
}
})
}
}
func TestRouterLogsResolvedClientIP(t *testing.T) {
t.Parallel()
spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"}
tests := []struct {
name string
peer string
want string
}{
{"trusted proxy", traefik + ":41000", "203.0.113.7"},
{"untrusted peer spoofing", "198.51.100.9:5000", "198.51.100.9"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
// A failed session lookup is logged with the request's context.
req := originRequest("/api/v1/me", tt.peer, spoofed)
req.AddCookie(brokenSessionCookie)
rec := httptest.NewRecorder()
newOriginRouter(t, &logs).ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
var line map[string]any
if err := json.Unmarshal(logs.Bytes(), &line); err != nil {
t.Fatalf("log %q: %v", logs.String(), err)
}
if line["msg"] != "look up session" || line[forwarded.LogAttr] != tt.want {
t.Errorf("log = %v, want the session error with %s=%s", line, forwarded.LogAttr, tt.want)
}
})
}
}
func TestRouterRejectsMalformedForwardedHeaders(t *testing.T) {
t.Parallel()
const badRequestJSON = `{"error":{"code":"bad_request","message":"invalid forwarded headers","retryable":false}}`
for _, headers := range []map[string]string{
{forwarded.HeaderFor: "not-an-ip"},
{forwarded.HeaderProto: "ftp"},
} {
var logs bytes.Buffer
rec := httptest.NewRecorder()
newOriginRouter(t, &logs).ServeHTTP(rec, originRequest("/health", traefik+":41000", headers))
if rec.Code != http.StatusBadRequest || rec.Body.String() != badRequestJSON {
t.Errorf("%v: response = %d %s, want 400 %s", headers, rec.Code, rec.Body.String(), badRequestJSON)
}
if !bytes.Contains(logs.Bytes(), []byte("rejected forwarded headers")) {
t.Errorf("%v: logs = %q, want the rejection logged", headers, logs.String())
}
// The same headers from an untrusted peer are not examined at all.
rec = httptest.NewRecorder()
newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/health", "198.51.100.9:5000", headers))
if rec.Code != http.StatusOK {
t.Errorf("%v from an untrusted peer: status = %d, want 200", headers, rec.Code)
}
}
}
+45 -17
View File
@@ -3,22 +3,43 @@ package server
import (
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"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/jobs"
)
// reservedPrefixes are owned by the API router and are never answered with the
// SPA's index.html.
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
// NewRouter returns the HTTP handler for the service. version is reported by
// GET /health; assets is the web build served for every other GET.
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
// RouteMounter registers its own routes on the router.
type RouteMounter interface {
Mount(r chi.Router)
}
// NewRouter returns the HTTP handler for the service. Every request first
// 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; settings serves /api/v1/settings/household; runner serves
// /api/v1/jobs/{id}; 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, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -28,16 +49,34 @@ func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler,
}
r := chi.NewRouter()
r.Use(resolveOrigin(logger, trusted))
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Get("/health", health(logger, version))
auth.Mount(r)
// The session middleware wraps the whole /api/v1 and /media subrouters
// rather than being added with Use: chi skips a subrouter's middleware
// when it has no routes, which would leave it unguarded.
signedIn := requireSession(logger, sessions)
csrf := requireCSRF(logger)
apiV1, media := chi.NewRouter(), chi.NewRouter()
for _, sub := range []*chi.Mux{apiV1, media} {
sub.MethodNotAllowed(methodNotAllowed)
sub.NotFound(notFound)
}
apiV1.Get("/me", me(logger))
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Mount("/v1", signedIn(csrf(apiV1)))
})
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
// handler passes them to notFound rather than the SPA fallback.
r.Mount("/media", signedIn(media))
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
// which passes them to notFound rather than the SPA fallback.
r.Method(http.MethodGet, "/*", web)
r.Method(http.MethodHead, "/*", web)
return r, nil
@@ -55,19 +94,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)
}
}
+108 -3
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
@@ -10,6 +11,13 @@ import (
"strings"
"testing"
"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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const (
@@ -19,19 +27,109 @@ const (
)
func newTestRouter(t *testing.T, version string) http.Handler {
t.Helper()
return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t))
}
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) 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, assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets)
if err != nil {
t.Fatal(err)
}
return r
}
// stubAuth mounts a fixed GET /auth/login in place of the real auth flow.
type stubAuth struct{}
func (stubAuth) Mount(r chi.Router) {
r.Get("/auth/login", func(w http.ResponseWriter, _ *http.Request) {
io.WriteString(w, "stub login")
})
}
func TestAuthRoutesMounted(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
if rec.Code != http.StatusOK || rec.Body.String() != "stub login" {
t.Errorf("GET /auth/login = %d %q, want the mounted handler", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /auth/login status = %d, want 405", rec.Code)
}
}
// newTestStore returns a migrated temp database, closed when the test ends.
func newTestStore(t *testing.T) *store.Store {
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)
}
return st
}
// 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()
st := newTestStore(t)
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
return household.NewSettingsService(st)
}
// newTestRunner returns an unstarted job runner over a migrated temp
// database.
func newTestRunner(t *testing.T) *jobs.Runner {
t.Helper()
runner, err := jobs.NewRunner(newTestStore(t), slog.New(slog.NewTextHandler(io.Discard, nil)), 1)
if err != nil {
t.Fatal(err)
}
return runner
}
func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
func TestHealth(t *testing.T) {
t.Parallel()
@@ -98,8 +196,10 @@ func TestReservedPathsNotFound(t *testing.T) {
}{
{http.MethodGet, "/api"},
{http.MethodGet, "/api/unknown"},
{http.MethodGet, "/api/v1/unknown"},
{http.MethodPost, "/api/v1/unknown"},
{http.MethodPost, "/api/unknown"},
{http.MethodGet, "/auth/callback"},
{http.MethodGet, "/auth/unknown"},
{http.MethodGet, "/media/photo.jpg"},
{http.MethodGet, "/health/deep"},
{http.MethodGet, "/auth/../quests/42"},
@@ -109,8 +209,13 @@ func TestReservedPathsNotFound(t *testing.T) {
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
t.Parallel()
// Signed in with the CSRF token, so /api/v1 and /media reach
// their routers.
req := httptest.NewRequest(tt.method, tt.path, nil)
req.AddCookie(validSessionCookie)
req.Header.Set(CSRFHeader, testSession.CSRFToken)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
+37
View File
@@ -0,0 +1,37 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
// SessionLookup finds the live session a request carries; it returns
// auth.ErrNoSession when there is none. *auth.SessionManager implements it.
type SessionLookup interface {
SessionFor(r *http.Request) (auth.Session, error)
}
// requireSession is the RequireSession middleware. It passes requests with a
// live session to next with the session in the context (read it with
// auth.SessionFrom or auth.ActorFrom); others get 401 unauthenticated.
func requireSession(logger *slog.Logger, sessions SessionLookup) func(http.Handler) http.Handler {
unauthenticated := apiErrorHandler(logger, http.StatusUnauthorized, "unauthenticated", "sign in required")
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s, err := sessions.SessionFor(r)
switch {
case errors.Is(err, auth.ErrNoSession):
unauthenticated(w, r)
case err != nil:
logger.ErrorContext(r.Context(), "look up session", "error", err)
internal(w, r)
default:
next.ServeHTTP(w, r.WithContext(auth.WithSession(r.Context(), s)))
}
})
}
}
+152
View File
@@ -0,0 +1,152 @@
package server
import (
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
const (
unauthenticatedJSON = `{"error":{"code":"unauthenticated","message":"sign in required","retryable":false}}`
internalJSON = `{"error":{"code":"internal","message":"internal error","retryable":false}}`
)
var (
validSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "valid"}
brokenSessionCookie = &http.Cookie{Name: auth.SessionCookieName, Value: "broken"}
testSession = auth.Session{
Subject: "user-123",
Email: "cook@example.com",
Name: "Home Cook",
Groups: []string{"mealquest-admin"},
CSRFToken: "csrf-token-123",
}
)
// stubSessions knows one valid session cookie; the broken cookie fails the
// lookup as a database error would.
type stubSessions struct{}
func (stubSessions) SessionFor(r *http.Request) (auth.Session, error) {
c, err := r.Cookie(auth.SessionCookieName)
switch {
case err != nil:
return auth.Session{}, auth.ErrNoSession
case c.Value == validSessionCookie.Value:
return testSession, nil
case c.Value == brokenSessionCookie.Value:
return auth.Session{}, errors.New("database down")
default:
return auth.Session{}, auth.ErrNoSession
}
}
func TestProtectedPathsRequireSession(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
for _, tc := range []struct {
method, path string
cookie *http.Cookie
}{
{http.MethodGet, "/api/v1/settings/household", nil},
{http.MethodPatch, "/api/v1/settings/household", nil},
{http.MethodGet, "/api/v1", nil},
{http.MethodGet, "/media/photo.jpg", nil},
{http.MethodGet, "/api/v1/settings/household", &http.Cookie{Name: auth.SessionCookieName, Value: "unknown"}},
} {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tc.method, tc.path, nil)
if tc.cookie != nil {
req.AddCookie(tc.cookie)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
if got := rec.Body.String(); got != unauthenticatedJSON {
t.Errorf("body = %s, want %s", got, unauthenticatedJSON)
}
})
}
}
func TestPublicPathsNeedNoSession(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
for _, path := range []string{"/health", "/auth/login", "/", "/assets/index-AbC123.js", "/quests/42"} {
t.Run(path, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("GET %s without a cookie = %d, want 200", path, rec.Code)
}
})
}
}
func TestSessionLookupFailure(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
req.AddCookie(brokenSessionCookie)
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != internalJSON {
t.Errorf("response = %d %s, want 500 %s", rec.Code, rec.Body.String(), internalJSON)
}
}
func TestRequireSessionAttachesActor(t *testing.T) {
t.Parallel()
var got auth.Actor
var ok bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, ok = auth.ActorFrom(r.Context())
w.WriteHeader(http.StatusNoContent)
})
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil)
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want the handler's 204", rec.Code)
}
if !ok || got.Subject != "user-123" || got.Email != "cook@example.com" || got.Name != "Home Cook" ||
!slices.Equal(got.Groups, []string{"mealquest-admin"}) {
t.Errorf("actor = %+v (ok=%v), want the session's household member", got, ok)
}
}
func TestRequireSessionStopsUnauthenticated(t *testing.T) {
t.Parallel()
called := false
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
h := requireSession(slog.New(slog.NewTextHandler(io.Discard, nil)), stubSessions{})(next)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/settings/household", nil))
if called || rec.Code != http.StatusUnauthorized {
t.Errorf("handler called = %v, status = %d; want not called and 401", called, rec.Code)
}
}
+135
View File
@@ -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)
}
}
+278
View File
@@ -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, newTestRunner(t))
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, newTestRunner(t))
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),
newTestRunner(t), 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)
}
}
+27
View File
@@ -0,0 +1,27 @@
package store
import (
"crypto/rand"
"sync"
"time"
"github.com/oklog/ulid/v2"
)
var (
idMu sync.Mutex
idEntropy = ulid.Monotonic(rand.Reader, 0)
idLastMS uint64
)
// NewID returns a new ULID as its canonical 26-character string. IDs from
// this process sort strictly in creation order, including within one
// millisecond and across a backwards wall-clock step. It panics if the
// monotonic entropy overflows within a millisecond.
func NewID() string {
idMu.Lock()
defer idMu.Unlock()
ms := max(ulid.Timestamp(time.Now()), idLastMS)
idLastMS = ms
return ulid.MustNew(ms, idEntropy).String()
}
+25
View File
@@ -0,0 +1,25 @@
package store
import (
"testing"
"github.com/oklog/ulid/v2"
)
func TestNewIDStrictlyIncreasing(t *testing.T) {
const n = 10000
prev := NewID()
for range n {
id := NewID()
if len(id) != ulid.EncodedSize {
t.Fatalf("NewID() = %q, want %d characters", id, ulid.EncodedSize)
}
if _, err := ulid.ParseStrict(id); err != nil {
t.Fatalf("NewID() = %q: %v", id, err)
}
if id <= prev {
t.Fatalf("NewID() = %q, not after previous %q", id, prev)
}
prev = id
}
}
+105
View File
@@ -0,0 +1,105 @@
package store
import (
"context"
"database/sql"
"fmt"
"io/fs"
"strings"
"time"
)
const createSchemaMigrations = `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`
// Migrate applies every *.sql file at the root of migrations that is not yet
// recorded in schema_migrations, in filename order. Each migration runs in its
// own transaction together with the insert that records it, so a failing
// migration leaves the database at the previous version. A version is the
// file name without its .sql extension.
//
// The database must not hold a version unknown to migrations: that means it
// was migrated by a newer build, and Migrate refuses to continue.
func Migrate(ctx context.Context, db *sql.DB, migrations fs.FS) error {
files, err := fs.Glob(migrations, "*.sql")
if err != nil {
return fmt.Errorf("migrate: list migrations: %w", err)
}
if len(files) == 0 {
return fmt.Errorf("migrate: no migrations found")
}
if _, err := db.ExecContext(ctx, createSchemaMigrations); err != nil {
return fmt.Errorf("migrate: create schema_migrations: %w", err)
}
applied, err := appliedVersions(ctx, db)
if err != nil {
return err
}
known := make(map[string]bool, len(files))
for _, name := range files {
known[strings.TrimSuffix(name, ".sql")] = true
}
for version := range applied {
if !known[version] {
return fmt.Errorf("migrate: database has version %q, which this build does not know", version)
}
}
for _, name := range files { // fs.Glob returns names in lexical order
version := strings.TrimSuffix(name, ".sql")
if applied[version] {
continue
}
body, err := fs.ReadFile(migrations, name)
if err != nil {
return fmt.Errorf("migrate: read %s: %w", name, err)
}
if err := apply(ctx, db, version, string(body)); err != nil {
return fmt.Errorf("migrate: apply %s: %w", name, err)
}
}
return nil
}
func appliedVersions(ctx context.Context, db *sql.DB) (map[string]bool, error) {
rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("migrate: read schema_migrations: %w", err)
}
defer rows.Close()
applied := make(map[string]bool)
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("migrate: read schema_migrations: %w", err)
}
applied[v] = true
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("migrate: read schema_migrations: %w", err)
}
return applied, nil
}
func apply(ctx context.Context, db *sql.DB, version, body string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // no-op once committed
if _, err := tx.ExecContext(ctx, body); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
version, time.Now().UTC().Format(time.RFC3339),
); err != nil {
return err
}
return tx.Commit()
}
+311
View File
@@ -0,0 +1,311 @@
package store
import (
"context"
"io/fs"
"slices"
"strings"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
var testMigrations = fstest.MapFS{
"0001_first.sql": {Data: []byte("CREATE TABLE first (id INTEGER PRIMARY KEY);")},
"0002_second.sql": {Data: []byte("CREATE TABLE second (id INTEGER PRIMARY KEY);\nCREATE TABLE third (id INTEGER PRIMARY KEY);")},
"README.md": {Data: []byte("not a migration")},
}
type migrationRow struct {
version string
appliedAt string
}
func migrationRows(t *testing.T, s *Store) []migrationRow {
t.Helper()
rows, err := s.Reader.Query("SELECT version, applied_at FROM schema_migrations ORDER BY version")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []migrationRow
for rows.Next() {
var r migrationRow
if err := rows.Scan(&r.version, &r.appliedAt); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
func versions(rows []migrationRow) string {
vs := make([]string, len(rows))
for i, r := range rows {
vs[i] = r.version
}
return strings.Join(vs, ",")
}
func tableExists(t *testing.T, s *Store, name string) bool {
t.Helper()
var n int
if err := s.Reader.QueryRow("SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = ?", name).Scan(&n); err != nil {
t.Fatal(err)
}
return n == 1
}
func TestMigrateFreshDatabaseReachesLatest(t *testing.T) {
s := openTestStore(t)
if err := Migrate(context.Background(), s.Writer, testMigrations); err != nil {
t.Fatal(err)
}
rows := migrationRows(t, s)
if got := versions(rows); got != "0001_first,0002_second" {
t.Fatalf("versions = %q", got)
}
for _, r := range rows {
if _, err := time.Parse(time.RFC3339, r.appliedAt); err != nil || !strings.HasSuffix(r.appliedAt, "Z") {
t.Errorf("applied_at %q is not UTC RFC 3339", r.appliedAt)
}
}
for _, table := range []string{"first", "second", "third"} {
if !tableExists(t, s, table) {
t.Errorf("table %s missing", table)
}
}
}
func TestMigrateSecondRunIsNoOp(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
t.Fatal(err)
}
before := migrationRows(t, s)
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
t.Fatalf("second run: %v", err)
}
after := migrationRows(t, s)
if len(after) != len(before) {
t.Fatalf("rows after second run = %v, want %v", after, before)
}
for i := range before {
if after[i] != before[i] {
t.Errorf("row %d changed: %v -> %v", i, before[i], after[i])
}
}
}
func TestMigrateBrokenMigrationRollsBack(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
t.Fatal(err)
}
broken := fstest.MapFS{"0003_broken.sql": {Data: []byte(
// The first statement succeeds; the second is invalid, so the
// table created by the first must be rolled back with it.
"CREATE TABLE fourth (id INTEGER PRIMARY KEY);\nCREATE TABLE oops (;",
)}}
for name, f := range testMigrations {
broken[name] = f
}
err := Migrate(ctx, s.Writer, broken)
if err == nil {
t.Fatal("broken migration returned no error")
}
if !strings.Contains(err.Error(), "0003_broken.sql") {
t.Errorf("error %q does not name the failing migration", err)
}
if got := versions(migrationRows(t, s)); got != "0001_first,0002_second" {
t.Errorf("versions after failure = %q, want previous version intact", got)
}
if tableExists(t, s, "fourth") {
t.Error("table from the failed migration was not rolled back")
}
for _, table := range []string{"first", "second", "third"} {
if !tableExists(t, s, table) {
t.Errorf("previous table %s missing after failed migration", table)
}
}
}
func TestMigrateAppliesInFilenameOrder(t *testing.T) {
s := openTestStore(t)
// 0002 depends on 0001; MapFS iteration order is random, so this only
// passes if migrations are sorted by name.
ordered := fstest.MapFS{
"0002_child.sql": {Data: []byte("CREATE TABLE child (parent_id INTEGER REFERENCES parent(id)); INSERT INTO child VALUES (1);")},
"0001_parent.sql": {Data: []byte("CREATE TABLE parent (id INTEGER PRIMARY KEY); INSERT INTO parent VALUES (1);")},
}
if err := Migrate(context.Background(), s.Writer, ordered); err != nil {
t.Fatal(err)
}
}
func TestMigrateUnknownAppliedVersionFails(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
t.Fatal(err)
}
older := fstest.MapFS{"0001_first.sql": testMigrations["0001_first.sql"]}
if err := Migrate(ctx, s.Writer, older); err == nil {
t.Fatal("migrating a database newer than the build returned no error")
}
}
func TestMigrateNoMigrationsFails(t *testing.T) {
s := openTestStore(t)
if err := Migrate(context.Background(), s.Writer, fstest.MapFS{}); err == nil {
t.Fatal("empty migrations returned no error")
}
}
func TestMigrateEmbeddedMigrations(t *testing.T) {
s := openTestStore(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,0002_household_settings_updated_by,0003_jobs_status" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
}
}
func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
bootstrap, err := fs.ReadFile(migrations.FS, "0000_bootstrap.sql")
if err != nil {
t.Fatal(err)
}
// A database migrated by the bootstrap-only build upgrades to the
// 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,0002_household_settings_updated_by,0003_jobs_status" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
}
}
// schemaSQL returns every schema object's definition, so a test can prove a
// later Migrate created or altered nothing.
func schemaSQL(t *testing.T, s *Store) []string {
t.Helper()
rows, err := s.Reader.Query("SELECT type || ' ' || name || ': ' || coalesce(sql, '') FROM sqlite_schema ORDER BY type, name")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var def string
if err := rows.Scan(&def); err != nil {
t.Fatal(err)
}
out = append(out, def)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
first, err := Open(ctx, dir)
if err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, first.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
before, beforeSchema := migrationRows(t, first), schemaSQL(t, first)
if err := first.Close(); err != nil {
t.Fatal(err)
}
second, err := Open(ctx, dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() {
if err := second.Close(); err != nil {
t.Error(err)
}
})
if err := Migrate(ctx, second.Writer, migrations.FS); err != nil {
t.Fatalf("migrate after reopen: %v", err)
}
if after := migrationRows(t, second); !slices.Equal(after, before) {
t.Errorf("schema_migrations changed on reopen:\n got %v\nwant %v", after, before)
}
if afterSchema := schemaSQL(t, second); !slices.Equal(afterSchema, beforeSchema) {
t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema)
}
}
// TestMigrateJobsStatusMapsLegacyRows upgrades a database holding jobs in
// every 0001 status and checks 0003 carries each row over with its status
// mapped and every other column unchanged.
func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
before := fstest.MapFS{}
for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql"} {
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(ctx, s.Writer, before); err != nil {
t.Fatal(err)
}
legacy := map[string]string{"pending": "queued", "running": "running", "done": "succeeded", "failed": "failed"}
for old := range legacy {
if _, err := s.Writer.Exec(`INSERT INTO jobs (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
VALUES (?, 'suggest', ?, '{"n":1}', '{"r":2}', 'boom', 's', 'f', 'c', 'u')`, "job-"+old, old); err != nil {
t.Fatalf("insert %s job: %v", old, err)
}
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
for old, want := range legacy {
var kind, status, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt string
if err := s.Reader.QueryRow(`SELECT kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs WHERE id = ?`, "job-"+old).
Scan(&kind, &status, &payload, &result, &errText, &startedAt, &finishedAt, &createdAt, &updatedAt); err != nil {
t.Fatalf("read %s job: %v", old, err)
}
if status != want {
t.Errorf("%s job status = %q, want %q", old, status, want)
}
got := []string{kind, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt}
if wantCols := []string{"suggest", `{"n":1}`, `{"r":2}`, "boom", "s", "f", "c", "u"}; !slices.Equal(got, wantCols) {
t.Errorf("%s job columns = %v, want %v", old, got, wantCols)
}
}
if tableExists(t, s, "jobs_new") {
t.Error("jobs_new left behind")
}
}
+177
View File
@@ -0,0 +1,177 @@
package store
import (
"context"
"fmt"
"maps"
"reflect"
"slices"
"strings"
"testing"
)
// The tables below have no public sqlc queries yet (they arrive with the
// planning, suggestion and Supply run features), so these tests drive them
// with raw SQL to prove the schema stores, returns and updates their rows.
const repoTime = "2026-10-03T12:00:00Z"
type repoRow struct {
table string
cols map[string]any
}
// repoParents are the rows the tables under test reference, in insert order.
var repoParents = map[string]repoRow{
"dinners": {"dinners", map[string]any{
"id": "dinner-1", "name": "Chili", "normalized_name": "chili",
"status": "regular", "origin": "regular", "is_favorite": int64(0),
"grounding_status": "manual", "created_at": repoTime, "updated_at": repoTime,
}},
"suggestion_batches": {"suggestion_batches", map[string]any{
"id": "batch-1", "trigger": "manual", "status": "done",
"created_at": repoTime, "updated_at": repoTime,
}},
"week_plans": {"week_plans", map[string]any{
"id": "plan-1", "week_start": "2026-10-05", "status": "draft",
"created_at": repoTime, "updated_at": repoTime,
}},
"shopping_lists": {"shopping_lists", map[string]any{
"id": "list-1", "week_start": "2026-10-05", "plan_id": "plan-1",
"title": "Supply run", "status": "current",
"created_at": repoTime, "updated_at": repoTime,
}},
"shopping_groups": {"shopping_groups", map[string]any{
"id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1",
"position": int64(0), "created_at": repoTime, "updated_at": repoTime,
}},
}
// readRepoRow reads cols of the row with id through the reader.
func readRepoRow(t *testing.T, s *Store, table, id string, cols []string) map[string]any {
t.Helper()
quoted := make([]string, len(cols))
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i, c := range cols {
quoted[i] = `"` + c + `"`
ptrs[i] = &vals[i]
}
q := fmt.Sprintf(`SELECT %s FROM "%s" WHERE id = ?`, strings.Join(quoted, ", "), table)
if err := s.Reader.QueryRowContext(context.Background(), q, id).Scan(ptrs...); err != nil {
t.Fatalf("read %s %s: %v", table, id, err)
}
got := make(map[string]any, len(cols))
for i, c := range cols {
got[c] = vals[i]
}
return got
}
func TestRepositoryInsertReadUpdate(t *testing.T) {
cases := []struct {
parents []string
row repoRow
update map[string]any
}{
{
row: repoRow{"week_plans", map[string]any{
"id": "plan-2", "week_start": "2026-10-12", "status": "draft",
"ai_note": "Two Side quests this week.", "ai_run_id": nil, "calendar_synced_at": nil,
"created_at": repoTime, "updated_at": repoTime,
}},
update: map[string]any{"status": "synced", "calendar_synced_at": "2026-10-04T09:00:00Z"},
},
{
parents: []string{"dinners", "week_plans"},
row: repoRow{"plan_days", map[string]any{
"id": "day-1", "plan_id": "plan-1", "date": "2026-10-06", "dinner_id": "dinner-1",
"is_new": int64(0), "calendar_event_id": nil,
"created_at": repoTime, "updated_at": repoTime,
}},
update: map[string]any{"dinner_id": nil, "calendar_event_id": "evt-123"},
},
{
parents: []string{"dinners", "suggestion_batches"},
row: repoRow{"suggestions", map[string]any{
"id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1",
"reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
"status": "pending", "decided_at": nil,
"created_at": repoTime, "updated_at": repoTime,
}},
update: map[string]any{"status": "accepted", "decided_at": "2026-10-04T10:00:00Z"},
},
{
parents: []string{"week_plans"},
row: repoRow{"shopping_lists", map[string]any{
"id": "list-2", "week_start": "2026-10-05", "plan_id": "plan-1",
"title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil,
"created_at": repoTime, "updated_at": repoTime,
}},
update: map[string]any{"status": "archived", "frozen_at": "2026-10-12T00:00:00Z"},
},
{
parents: []string{"dinners", "week_plans", "shopping_lists"},
row: repoRow{"shopping_groups", map[string]any{
"id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "position": int64(0),
"created_at": repoTime, "updated_at": repoTime,
}},
update: map[string]any{"position": int64(3)},
},
{
parents: []string{"dinners", "week_plans", "shopping_lists", "shopping_groups"},
row: repoRow{"shopping_items", map[string]any{
"id": "item-1", "group_id": "group-1", "name": "Onion", "qty": "2",
"needed": int64(1), "position": int64(0),
"created_at": repoTime, "updated_at": repoTime,
}},
update: map[string]any{"needed": int64(0), "qty": "3"},
},
}
for _, tc := range cases {
t.Run(tc.row.table, func(t *testing.T) {
ctx := context.Background()
s := migratedStore(t)
for _, p := range tc.parents {
parent := repoParents[p]
if err := insertSchemaRow(s.Writer, parent.table, parent.cols); err != nil {
t.Fatalf("seed %s: %v", p, err)
}
}
table, id := tc.row.table, tc.row.cols["id"].(string)
cols := slices.Sorted(maps.Keys(tc.row.cols))
if err := insertSchemaRow(s.Writer, table, tc.row.cols); err != nil {
t.Fatalf("insert: %v", err)
}
if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, tc.row.cols) {
t.Fatalf("read after insert:\n got %v\nwant %v", got, tc.row.cols)
}
want := maps.Clone(tc.row.cols)
maps.Copy(want, tc.update)
want["updated_at"] = "2026-10-04T12:00:00Z"
setCols := slices.Sorted(maps.Keys(tc.update))
sets := make([]string, 0, len(setCols)+1)
args := make([]any, 0, len(setCols)+2)
for _, c := range append(setCols, "updated_at") {
sets = append(sets, `"`+c+`" = ?`)
args = append(args, want[c])
}
args = append(args, id)
q := fmt.Sprintf(`UPDATE "%s" SET %s WHERE id = ?`, table, strings.Join(sets, ", "))
res, err := s.Writer.ExecContext(ctx, q, args...)
if err != nil {
t.Fatalf("update: %v", err)
}
if n, err := res.RowsAffected(); err != nil || n != 1 {
t.Fatalf("update affected %d rows (err %v), want 1", n, err)
}
if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, want) {
t.Fatalf("read after update:\n got %v\nwant %v", got, want)
}
})
}
}
+556
View File
@@ -0,0 +1,556 @@
package store
import (
"context"
"database/sql"
"fmt"
"slices"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// schemaTables is every table in the v1 data model, in insert order: parents
// before the children that reference them.
var schemaTables = []string{
"ai_runs",
"recipes",
"recipe_ingredients",
"recipe_steps",
"photos",
"dinners",
"suggestion_batches",
"suggestions",
"week_plans",
"plan_days",
"rotation_reviews",
"shopping_lists",
"shopping_groups",
"shopping_items",
"notification_prefs",
"push_subscriptions",
"notification_outbox",
"jobs",
"schedule_runs",
"sessions",
// Single-row settings tables; row is always id 1.
"household_settings",
"ai_settings",
"recipe_search_settings",
"integrations_google",
"chat_settings",
}
var singleRowTables = []string{
"household_settings",
"ai_settings",
"recipe_search_settings",
"integrations_google",
"chat_settings",
}
const testTime = "2026-10-03T12:00:00Z"
// rowID is the id of the i-th test row of table. The schema stores ids as
// opaque TEXT, so deterministic ids keep the fixtures readable.
func rowID(table string, i int) string { return fmt.Sprintf("%s-%d", table, i) }
// testRow returns a valid row for table. Rows with different i differ in
// every unique column; references point at row 0 of the parent table.
func testRow(table string, i int) map[string]any {
ts := map[string]any{"created_at": testTime, "updated_at": testTime}
r := map[string]any{"id": rowID(table, i)}
switch table {
case "household_settings":
r = map[string]any{"id": 1, "timezone": "America/New_York"}
case "ai_runs":
r["skill"] = "suggest"
r["skill_version"] = "1"
r["provider"] = "anthropic"
r["model"] = "claude"
r["temperature"] = 0.7
r["status"] = "ok"
r["request_redacted"] = "{}"
case "recipes":
r["source_url"] = fmt.Sprintf("https://example.com/recipe/%d", i)
r["title"] = "Chili"
r["raw_jsonld"] = "{}"
r["fetched_at"] = testTime
case "recipe_ingredients":
r["recipe_id"] = rowID("recipes", 0)
r["position"] = i
r["raw_text"] = "1 onion"
case "recipe_steps":
r["recipe_id"] = rowID("recipes", 0)
r["position"] = i
r["text"] = "Chop the onion."
case "photos":
r["source_url"] = "https://example.com/chili.jpg"
r["local_path"] = "photos/chili.jpg"
r["width"] = 800
r["height"] = 600
r["sha256"] = "abc"
r["fetched_at"] = testTime
case "dinners":
r["name"] = "Chili"
r["normalized_name"] = "chili"
r["status"] = "regular"
r["origin"] = "regular"
r["method"] = "crockpot"
r["difficulty"] = "easy"
r["is_favorite"] = 0
r["recipe_id"] = rowID("recipes", 0)
r["photo_id"] = rowID("photos", 0)
r["grounding_status"] = "grounded"
case "suggestion_batches":
r["trigger"] = "scheduled"
r["status"] = "done"
r["ai_run_id"] = rowID("ai_runs", 0)
case "suggestions":
r["dinner_id"] = rowID("dinners", 0)
r["batch_id"] = rowID("suggestion_batches", 0)
r["reason"] = "Like your chili."
r["cited_regular_ids"] = "[]"
r["status"] = "pending"
case "week_plans":
r["week_start"] = fmt.Sprintf("2026-%02d-05", i+1)
r["status"] = "draft"
r["ai_run_id"] = rowID("ai_runs", 0)
case "plan_days":
r["plan_id"] = rowID("week_plans", 0)
r["date"] = fmt.Sprintf("2026-01-%02d", i+5)
r["dinner_id"] = rowID("dinners", 0)
r["is_new"] = 0
case "rotation_reviews":
r["dinner_id"] = rowID("dinners", 0)
r["plan_date"] = fmt.Sprintf("2026-01-%02d", i+5)
r["status"] = "decided"
r["decision"] = "keep"
case "shopping_lists":
r["week_start"] = fmt.Sprintf("2026-%02d-05", i+1)
r["plan_id"] = rowID("week_plans", 0)
r["title"] = "Supply run"
r["status"] = "current"
case "shopping_groups":
r["list_id"] = rowID("shopping_lists", 0)
r["dinner_id"] = rowID("dinners", 0)
r["position"] = i
case "shopping_items":
r["group_id"] = rowID("shopping_groups", 0)
r["name"] = "Onion"
r["needed"] = 1
r["position"] = i
case "ai_settings":
r = map[string]any{"id": 1, "provider": "ollama", "base_url": "http://ollama:11434", "model": "llama", "temperature": 0.7}
case "recipe_search_settings":
r = map[string]any{"id": 1, "provider": "searxng", "base_url": "http://searxng:8080"}
case "integrations_google":
r = map[string]any{"id": 1, "refresh_token_ct": []byte{1}, "connected_at": testTime}
case "chat_settings":
r = map[string]any{"id": 1, "webhook_url_ct": []byte{1}, "last_test_status": "ok"}
case "notification_prefs":
events := []string{"plan_ready", "list_ready", "day_of_reminder", "new_suggestions", "rotation_review"}
r = map[string]any{"event": events[i], "push": 1, "chat": 0}
case "push_subscriptions":
r["endpoint"] = fmt.Sprintf("https://push.example.com/%d", i)
r["p256dh"] = "key"
r["auth"] = "auth"
r["failure_count"] = 0
case "notification_outbox":
r["event_key"] = fmt.Sprintf("plan_ready:2026-W%02d", i+1)
r["channel"] = "push"
r["payload"] = "{}"
r["status"] = "pending"
r["attempts"] = 0
case "jobs":
r["kind"] = "suggest"
r["status"] = "queued"
r["payload"] = "{}"
case "schedule_runs":
r["schedule"] = "plan_draft"
r["period_key"] = fmt.Sprintf("2026-W%02d", i+1)
r["created_at"] = testTime
return r
case "sessions":
return map[string]any{
"id_hash": fmt.Sprintf("hash-%d", i),
"subject": "sub",
"email": "kitchen@example.com",
"name": "Our kitchen",
"groups": `["mealquest-admin"]`,
"csrf_token": "csrf",
"created_at": testTime,
"last_seen_at": testTime,
"expires_at": testTime,
}
default:
panic("testRow: unknown table " + table)
}
for k, v := range ts {
r[k] = v
}
return r
}
type execer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
func insertSchemaRow(db execer, table string, row map[string]any) error {
cols := make([]string, 0, len(row))
for c := range row {
cols = append(cols, c)
}
slices.Sort(cols)
quoted := make([]string, len(cols))
args := make([]any, len(cols))
for i, c := range cols {
quoted[i] = `"` + c + `"`
args[i] = row[c]
}
q := fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s)`,
table, strings.Join(quoted, ", "), strings.TrimSuffix(strings.Repeat("?, ", len(cols)), ", "))
_, err := db.ExecContext(context.Background(), q, args...)
return err
}
// migratedStore opens a temp store migrated with the embedded migrations.
func migratedStore(t *testing.T) *Store {
t.Helper()
s := openTestStore(t)
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
return s
}
// seededStore is a migrated store holding row 0 of every multi-row table.
// Single-row tables are left empty so tests can insert their only row.
func seededStore(t *testing.T) *Store {
t.Helper()
s := migratedStore(t)
for _, table := range schemaTables {
if slices.Contains(singleRowTables, table) {
continue
}
if err := insertSchemaRow(s.Writer, table, testRow(table, 0)); err != nil {
t.Fatalf("seed %s: %v", table, err)
}
}
return s
}
// inRolledBackTx runs fn in a writer transaction that is always rolled back,
// so each case starts from the seeded state.
func inRolledBackTx(t *testing.T, s *Store, fn func(tx *sql.Tx)) {
t.Helper()
tx, err := s.Writer.BeginTx(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
}()
fn(tx)
}
func wantConstraintError(t *testing.T, err error, kind, what string) {
t.Helper()
if err == nil {
t.Fatalf("%s: accepted, want %s constraint failure", what, kind)
}
if !strings.Contains(err.Error(), kind+" constraint failed") {
t.Fatalf("%s: error %q, want %s constraint failure", what, err, kind)
}
}
func TestSchemaTablesExist(t *testing.T) {
s := migratedStore(t)
if len(schemaTables) != 25 {
t.Fatalf("schemaTables lists %d tables, want 25", len(schemaTables))
}
for _, table := range schemaTables {
if !tableExists(t, s, table) {
t.Errorf("table %s missing", table)
}
}
}
func TestSchemaForeignKeysAreValid(t *testing.T) {
s := seededStore(t)
rows, err := s.Writer.Query("PRAGMA foreign_key_check")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
if rows.Next() {
t.Error("foreign_key_check reported violations in the seeded data")
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
}
func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
cases := []struct {
table, column string
bad any
}{
{"household_settings", "plan_draft_dow", 7},
{"household_settings", "suggestions_dow", -1},
{"household_settings", "chat_autosend_dow", 7},
{"household_settings", "plan_draft_dow", 5.5},
{"household_settings", "suggestions_dow", 0.5},
{"household_settings", "chat_autosend_dow", 3.5},
{"ai_runs", "provider", "openai"},
{"ai_runs", "status", "done"},
{"dinners", "status", "deck"},
{"dinners", "origin", "ai"},
{"dinners", "method", "microwave"},
{"dinners", "difficulty", "extreme"},
{"dinners", "is_favorite", 2},
{"dinners", "grounding_status", "unknown"},
{"suggestion_batches", "trigger", "cron"},
{"suggestion_batches", "status", "ok"},
{"suggestions", "status", "maybe"},
{"week_plans", "status", "final"},
{"plan_days", "is_new", 2},
{"rotation_reviews", "status", "open"},
{"rotation_reviews", "decision", "promote"},
{"shopping_lists", "status", "frozen"},
{"shopping_items", "needed", 2},
{"ai_settings", "provider", "openai"},
{"recipe_search_settings", "provider", "google"},
{"chat_settings", "last_test_status", "pending"},
{"notification_prefs", "event", "weekly_digest"},
{"notification_prefs", "push", 2},
{"notification_prefs", "chat", 2},
{"notification_outbox", "channel", "email"},
{"notification_outbox", "status", "queued"},
{"jobs", "status", "bogus"},
{"schedule_runs", "schedule", "hourly"},
}
s := seededStore(t)
for _, c := range cases {
t.Run(fmt.Sprintf("%s.%s=%v", c.table, c.column, c.bad), func(t *testing.T) {
row := testRow(c.table, 1)
if slices.Contains(singleRowTables, c.table) {
row = testRow(c.table, 0)
}
inRolledBackTx(t, s, func(tx *sql.Tx) {
if err := insertSchemaRow(tx, c.table, row); err != nil {
t.Fatalf("valid row rejected: %v", err)
}
})
row[c.column] = c.bad
inRolledBackTx(t, s, func(tx *sql.Tx) {
wantConstraintError(t, insertSchemaRow(tx, c.table, row), "CHECK", fmt.Sprintf("%s.%s = %v", c.table, c.column, c.bad))
})
})
}
}
func TestSchemaRotationReviewEventAllowed(t *testing.T) {
s := migratedStore(t)
if err := insertSchemaRow(s.Writer, "notification_prefs", testRow("notification_prefs", 4)); err != nil {
t.Fatalf("rotation_review event rejected: %v", err)
}
}
func TestSchemaDinnerStatuses(t *testing.T) {
s := seededStore(t)
for _, status := range []string{"suggested", "quest_log", "tried", "regular", "archived"} {
inRolledBackTx(t, s, func(tx *sql.Tx) {
row := testRow("dinners", 1)
row["status"] = status
if err := insertSchemaRow(tx, "dinners", row); err != nil {
t.Errorf("status %s rejected: %v", status, err)
}
})
}
}
func TestSchemaUniqueConstraintsRejectDuplicates(t *testing.T) {
cases := []struct {
table string
columns []string
}{
{"recipes", []string{"source_url"}},
{"week_plans", []string{"week_start"}},
{"plan_days", []string{"plan_id", "date"}},
{"rotation_reviews", []string{"dinner_id", "plan_date"}},
{"shopping_lists", []string{"week_start"}},
{"push_subscriptions", []string{"endpoint"}},
{"notification_outbox", []string{"event_key", "channel"}},
{"schedule_runs", []string{"schedule", "period_key"}},
{"notification_prefs", []string{"event"}},
}
s := seededStore(t)
for _, c := range cases {
t.Run(c.table+"("+strings.Join(c.columns, ",")+")", func(t *testing.T) {
row := testRow(c.table, 1)
inRolledBackTx(t, s, func(tx *sql.Tx) {
if err := insertSchemaRow(tx, c.table, row); err != nil {
t.Fatalf("distinct row rejected: %v", err)
}
})
seeded := testRow(c.table, 0)
for _, col := range c.columns {
row[col] = seeded[col]
}
inRolledBackTx(t, s, func(tx *sql.Tx) {
wantConstraintError(t, insertSchemaRow(tx, c.table, row), "UNIQUE", "duplicate "+c.table)
})
})
}
}
// TestSchemaCompositeUniqueAllowsPartialOverlap shows each composite UNIQUE
// spans all its columns: a row sharing only some of them with the seeded row
// is accepted. (Sharing only the other columns is covered by the distinct row
// in TestSchemaUniqueConstraintsRejectDuplicates.)
func TestSchemaCompositeUniqueAllowsPartialOverlap(t *testing.T) {
cases := []struct {
name string
table string
shared string // column copied from the seeded row
parent string // row 1 of this table is inserted first, if set
set map[string]any // columns that differ from the seeded row
}{
{"same date, different plan", "plan_days", "date", "week_plans", map[string]any{"plan_id": rowID("week_plans", 1)}},
{"same plan_date, different dinner", "rotation_reviews", "plan_date", "dinners", map[string]any{"dinner_id": rowID("dinners", 1)}},
{"same event_key, different channel", "notification_outbox", "event_key", "", map[string]any{"channel": "chat"}},
{"same period_key, different schedule", "schedule_runs", "period_key", "", map[string]any{"schedule": "week_rollover"}},
}
s := seededStore(t)
for _, c := range cases {
t.Run(c.table+": "+c.name, func(t *testing.T) {
row := testRow(c.table, 1)
row[c.shared] = testRow(c.table, 0)[c.shared]
for k, v := range c.set {
row[k] = v
}
inRolledBackTx(t, s, func(tx *sql.Tx) {
if c.parent != "" {
if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil {
t.Fatalf("insert parent: %v", err)
}
}
if err := insertSchemaRow(tx, c.table, row); err != nil {
t.Fatalf("row rejected: %v", err)
}
})
})
}
}
// TestSchemaTextPrimaryKeysRejectNull guards against SQLite's legacy
// acceptance of NULL in non-INTEGER primary keys.
func TestSchemaTextPrimaryKeysRejectNull(t *testing.T) {
s := seededStore(t)
ctx := context.Background()
for _, table := range schemaTables {
if slices.Contains(singleRowTables, table) {
continue
}
key := "id"
switch table {
case "notification_prefs":
key = "event"
case "sessions":
key = "id_hash"
}
t.Run(table+"."+key, func(t *testing.T) {
inRolledBackTx(t, s, func(tx *sql.Tx) {
// Two NULL-key rows: neither may be accepted, so NULL can
// never stand in for a key, once or repeatedly.
for i := 1; i <= 2; i++ {
row := testRow(table, i)
row[key] = nil
wantConstraintError(t, insertSchemaRow(tx, table, row), "NOT NULL", fmt.Sprintf("%s.%s = NULL (row %d)", table, key, i))
}
var n int
if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT count(*) FROM "%s" WHERE "%s" IS NULL`, table, key)).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("%d rows with NULL %s", n, key)
}
})
})
}
}
func TestSchemaSingleRowTablesRejectSecondRow(t *testing.T) {
s := migratedStore(t)
for _, table := range singleRowTables {
if err := insertSchemaRow(s.Writer, table, testRow(table, 0)); err != nil {
t.Fatalf("%s: %v", table, err)
}
row := testRow(table, 0)
row["id"] = 2
wantConstraintError(t, insertSchemaRow(s.Writer, table, row), "CHECK", table+" second row")
}
}
func TestSchemaDeleteParentWithChildrenRefused(t *testing.T) {
cases := []struct{ parent, child, column string }{
{"recipes", "recipe_ingredients", "recipe_id"},
{"recipes", "recipe_steps", "recipe_id"},
{"recipes", "dinners", "recipe_id"},
{"photos", "dinners", "photo_id"},
{"dinners", "suggestions", "dinner_id"},
{"suggestion_batches", "suggestions", "batch_id"},
{"week_plans", "plan_days", "plan_id"},
{"dinners", "plan_days", "dinner_id"},
{"dinners", "rotation_reviews", "dinner_id"},
{"week_plans", "shopping_lists", "plan_id"},
{"shopping_lists", "shopping_groups", "list_id"},
{"dinners", "shopping_groups", "dinner_id"},
{"shopping_groups", "shopping_items", "group_id"},
// ai_runs too: the purge must clear references before deleting.
{"ai_runs", "suggestion_batches", "ai_run_id"},
{"ai_runs", "week_plans", "ai_run_id"},
}
s := seededStore(t)
for _, c := range cases {
t.Run(c.parent+"<-"+c.child+"."+c.column, func(t *testing.T) {
// A fresh parent with exactly one child, through this column only.
parentID := rowID(c.parent, 1)
child := testRow(c.child, 1)
child[c.column] = parentID
del := fmt.Sprintf(`DELETE FROM "%s" WHERE id = ?`, c.parent)
ctx := context.Background()
inRolledBackTx(t, s, func(tx *sql.Tx) {
if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil {
t.Fatalf("insert parent: %v", err)
}
if err := insertSchemaRow(tx, c.child, child); err != nil {
t.Fatalf("insert child: %v", err)
}
_, err := tx.ExecContext(ctx, del, parentID)
wantConstraintError(t, err, "FOREIGN KEY", "delete "+c.parent)
var n int
if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT count(*) FROM "%s" WHERE id = ?`, c.parent), parentID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("parent row gone after refused delete")
}
})
// Control: without the child the same delete succeeds.
inRolledBackTx(t, s, func(tx *sql.Tx) {
if err := insertSchemaRow(tx, c.parent, testRow(c.parent, 1)); err != nil {
t.Fatalf("insert parent: %v", err)
}
if _, err := tx.ExecContext(ctx, del, parentID); err != nil {
t.Fatalf("delete childless parent: %v", err)
}
})
})
}
}
+79
View File
@@ -0,0 +1,79 @@
// Package store owns the MealQuest SQLite database: one writer connection,
// a read pool, and the embedded schema migrations.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"path/filepath"
_ "modernc.org/sqlite" // registers the "sqlite" database/sql driver
)
// FileName is the database file inside the data directory.
const FileName = "mealquest.db"
// readPoolSize is the maximum number of open read connections.
const readPoolSize = 4
// connPragmas are applied by the driver to every new connection.
var connPragmas = []string{
"busy_timeout(5000)",
"journal_mode(WAL)",
"foreign_keys(1)",
}
// Store holds the database handles. All writes go through Writer, which has
// exactly one connection; Reader is a pool of read-only connections.
type Store struct {
Writer *sql.DB
Reader *sql.DB
}
// Open opens <dataDir>/mealquest.db, creating it if absent, and verifies both
// handles can connect. It does not apply migrations; call Migrate for that.
func Open(ctx context.Context, dataDir string) (*Store, error) {
path, err := filepath.Abs(filepath.Join(dataDir, FileName))
if err != nil {
return nil, fmt.Errorf("store: resolve database path: %w", err)
}
// The writer is opened and pinged first so the file exists and is in WAL
// mode before any read-only connection touches it.
writer, err := openDB(ctx, path, 1, url.Values{"_txlock": {"immediate"}})
if err != nil {
return nil, fmt.Errorf("store: open writer: %w", err)
}
reader, err := openDB(ctx, path, readPoolSize, url.Values{"_pragma": {"query_only(1)"}})
if err != nil {
return nil, errors.Join(fmt.Errorf("store: open reader: %w", err), writer.Close())
}
return &Store{Writer: writer, Reader: reader}, nil
}
// Close closes both handles.
func (s *Store) Close() error {
return errors.Join(s.Reader.Close(), s.Writer.Close())
}
func openDB(ctx context.Context, path string, maxConns int, extra url.Values) (*sql.DB, error) {
q := url.Values{"_pragma": connPragmas}
for k, vs := range extra {
q[k] = append(q[k], vs...)
}
dsn := (&url.URL{Scheme: "file", Path: path, RawQuery: q.Encode()}).String()
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxConns)
db.SetMaxIdleConns(maxConns)
if err := db.PingContext(ctx); err != nil {
return nil, errors.Join(err, db.Close())
}
return db, nil
}
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"context"
"database/sql"
"os"
"path/filepath"
"testing"
)
func openTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(context.Background(), t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := s.Close(); err != nil {
t.Error(err)
}
})
return s
}
func TestOpenCreatesDatabaseFile(t *testing.T) {
dir := t.TempDir()
s, err := Open(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if _, err := os.Stat(filepath.Join(dir, FileName)); err != nil {
t.Fatalf("database file: %v", err)
}
}
func TestOpenMissingDirFails(t *testing.T) {
_, err := Open(context.Background(), filepath.Join(t.TempDir(), "missing"))
if err == nil {
t.Fatal("Open succeeded on a missing data directory")
}
}
func TestPoolSizes(t *testing.T) {
s := openTestStore(t)
if got := s.Writer.Stats().MaxOpenConnections; got != 1 {
t.Errorf("writer max open connections = %d, want 1", got)
}
if got := s.Reader.Stats().MaxOpenConnections; got != readPoolSize {
t.Errorf("reader max open connections = %d, want %d", got, readPoolSize)
}
}
// TestPragmasOnEveryConnection holds every reader connection open at once so
// each one is a distinct connection, then checks the pragmas on each and on
// the writer.
func TestPragmasOnEveryConnection(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
conns := []*sql.Conn{}
for range readPoolSize {
c, err := s.Reader.Conn(ctx)
if err != nil {
t.Fatal(err)
}
defer c.Close()
conns = append(conns, c)
}
w, err := s.Writer.Conn(ctx)
if err != nil {
t.Fatal(err)
}
defer w.Close()
conns = append(conns, w)
for i, c := range conns {
var fk, busy int
var journal string
if err := c.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&fk); err != nil {
t.Fatal(err)
}
if err := c.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busy); err != nil {
t.Fatal(err)
}
if err := c.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journal); err != nil {
t.Fatal(err)
}
if fk != 1 || busy != 5000 || journal != "wal" {
t.Errorf("connection %d: foreign_keys=%d busy_timeout=%d journal_mode=%q, want 1, 5000, wal", i, fk, busy, journal)
}
}
}
func TestReaderIsReadOnly(t *testing.T) {
s := openTestStore(t)
if _, err := s.Reader.Exec("CREATE TABLE t (id INTEGER)"); err == nil {
t.Fatal("write through the reader succeeded")
}
if _, err := s.Writer.Exec("CREATE TABLE t (id INTEGER)"); err != nil {
t.Fatalf("write through the writer: %v", err)
}
}
func TestForeignKeysEnforced(t *testing.T) {
s := openTestStore(t)
if _, err := s.Writer.Exec(`
CREATE TABLE parent (id INTEGER PRIMARY KEY);
CREATE TABLE child (parent_id INTEGER NOT NULL REFERENCES parent(id));
`); err != nil {
t.Fatal(err)
}
if _, err := s.Writer.Exec("INSERT INTO child (parent_id) VALUES (42)"); err == nil {
t.Fatal("insert violating a foreign key succeeded")
}
}
+134
View File
@@ -0,0 +1,134 @@
package store
import (
"database/sql/driver"
"errors"
"fmt"
"time"
)
// timestampLayout is RFC 3339 in UTC with fixed millisecond precision, so
// stored timestamps sort lexicographically in time order.
const timestampLayout = "2006-01-02T15:04:05.000Z"
// Timestamp is an instant stored as UTC RFC 3339 TEXT with millisecond
// precision. Sub-millisecond precision is dropped on write. Use
// sql.Null[Timestamp] for a nullable column.
type Timestamp struct {
time.Time
}
// Now returns the current instant as a Timestamp, in UTC at millisecond
// precision, so it equals its stored form.
func Now() Timestamp {
return Timestamp{time.Now().UTC().Truncate(time.Millisecond)}
}
// String returns the stored TEXT form.
func (t Timestamp) String() string {
return t.UTC().Format(timestampLayout)
}
// Value implements driver.Valuer. The zero Timestamp is an error, as is a UTC
// year outside 0000–9999, which the four-digit stored form cannot represent.
func (t Timestamp) Value() (driver.Value, error) {
if t.IsZero() {
return nil, errors.New("store: zero Timestamp")
}
if y := t.UTC().Year(); y < 0 || y > 9999 {
return nil, fmt.Errorf("store: Timestamp year %d outside 0000-9999", y)
}
return t.String(), nil
}
// Scan implements sql.Scanner. It accepts only TEXT in the stored form, and
// rejects the zero instant, which Value refuses to write.
func (t *Timestamp) Scan(src any) error {
s, ok := src.(string)
if !ok {
return fmt.Errorf("store: scan Timestamp: want TEXT, got %T", src)
}
parsed, err := time.Parse(timestampLayout, s)
if err != nil {
return fmt.Errorf("store: scan Timestamp %q: %w", s, err)
}
if parsed.Format(timestampLayout) != s {
return fmt.Errorf("store: scan Timestamp %q: want %s", s, timestampLayout)
}
if parsed.IsZero() {
return fmt.Errorf("store: scan Timestamp %q: zero Timestamp", s)
}
t.Time = parsed
return nil
}
// localDateLayout is the household-local calendar date form.
const localDateLayout = "2006-01-02"
// LocalDate is a household-local calendar date with no time or zone, stored
// as YYYY-MM-DD TEXT. The zero LocalDate is invalid and cannot be stored.
type LocalDate struct {
year int
month time.Month
day int
}
// ParseLocalDate parses a YYYY-MM-DD date, rejecting any other form and any
// date that does not exist on the calendar.
func ParseLocalDate(s string) (LocalDate, error) {
t, err := time.Parse(localDateLayout, s)
if err != nil {
return LocalDate{}, fmt.Errorf("store: invalid date %q: %w", s, err)
}
if t.Format(localDateLayout) != s {
return LocalDate{}, fmt.Errorf("store: invalid date %q: want YYYY-MM-DD", s)
}
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)
}
// IsZero reports whether d is the zero LocalDate.
func (d LocalDate) IsZero() bool {
return d == LocalDate{}
}
// 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
}
// Scan implements sql.Scanner. It accepts only YYYY-MM-DD TEXT.
func (d *LocalDate) Scan(src any) error {
s, ok := src.(string)
if !ok {
return fmt.Errorf("store: scan LocalDate: want TEXT, got %T", src)
}
parsed, err := ParseLocalDate(s)
if err != nil {
return fmt.Errorf("store: scan LocalDate: %w", err)
}
*d = parsed
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package store
import (
"context"
"testing"
"time"
)
func openTypesTestStore(t *testing.T) *Store {
t.Helper()
s := openTestStore(t)
if _, err := s.Writer.Exec("CREATE TABLE v (val TEXT)"); err != nil {
t.Fatal(err)
}
return s
}
// roundTrip writes in through the writer, then reads it back into out and
// returns the stored SQLite type and raw text.
func roundTrip(t *testing.T, s *Store, in, out any) (typ, raw string) {
t.Helper()
ctx := context.Background()
if _, err := s.Writer.ExecContext(ctx, "DELETE FROM v"); err != nil {
t.Fatal(err)
}
if _, err := s.Writer.ExecContext(ctx, "INSERT INTO v (val) VALUES (?)", in); err != nil {
t.Fatal(err)
}
if err := s.Reader.QueryRowContext(ctx, "SELECT typeof(val), val, val FROM v").Scan(&typ, &raw, out); err != nil {
t.Fatal(err)
}
return typ, raw
}
func TestTimestampRoundTrip(t *testing.T) {
s := openTypesTestStore(t)
zone := time.FixedZone("EDT", -4*60*60)
in := Timestamp{time.Date(2026, 10, 3, 8, 37, 5, 123456789, zone)}
var out Timestamp
typ, raw := roundTrip(t, s, in, &out)
if typ != "text" || raw != "2026-10-03T12:37:05.123Z" {
t.Fatalf("stored %s %q, want text %q", typ, raw, "2026-10-03T12:37:05.123Z")
}
if want := in.Truncate(time.Millisecond); !out.Equal(want) {
t.Fatalf("read back %v, want %v", out, want)
}
if out.Location() != time.UTC {
t.Fatalf("read back location %v, want UTC", out.Location())
}
now := Now()
roundTrip(t, s, now, &out)
if out != now {
t.Fatalf("Now() round trip = %#v, want %#v", out, now)
}
}
func TestTimestampRejects(t *testing.T) {
if _, err := (Timestamp{}).Value(); err == nil {
t.Error("zero Timestamp Value succeeded")
}
for _, tm := range []time.Time{
time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(-1, 12, 31, 0, 0, 0, 0, time.UTC),
time.Date(9999, 12, 31, 23, 0, 0, 0, time.FixedZone("west", -2*3600)),
} {
if v, err := (Timestamp{tm}).Value(); err == nil {
t.Errorf("Timestamp{%v}.Value() = %v, want error", tm, v)
}
}
for _, src := range []any{
nil, int64(0), []byte("2026-10-03T12:37:05.123Z"), "2026-10-03T12:37:05Z",
"2026-10-03T08:37:05.123-04:00", "2026-10-03T12:37:05,123Z", "garbage",
"0001-01-01T00:00:00.000Z",
} {
var ts Timestamp
if err := ts.Scan(src); err == nil {
t.Errorf("Scan(%#v) succeeded", src)
}
}
}
func TestLocalDateRoundTrip(t *testing.T) {
s := openTypesTestStore(t)
for _, str := range []string{"2026-10-03", "2028-02-29", "0001-01-01", "9999-12-31"} {
in, err := ParseLocalDate(str)
if err != nil {
t.Fatalf("ParseLocalDate(%q): %v", str, err)
}
if in.String() != str {
t.Fatalf("String() = %q, want %q", in.String(), str)
}
var out LocalDate
typ, raw := roundTrip(t, s, in, &out)
if typ != "text" || raw != str {
t.Fatalf("stored %s %q, want text %q", typ, raw, str)
}
if out != in {
t.Fatalf("read back %v, want %v", out, in)
}
}
}
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",
"2026-10-32", "2026-1-03", "2026-10-3", "26-10-03", "2026/10/03",
"2026-10-03T00:00:00Z", " 2026-10-03", "20261003", "not-a-date",
} {
if d, err := ParseLocalDate(str); err == nil {
t.Errorf("ParseLocalDate(%q) = %v, want error", str, d)
}
var d LocalDate
if err := d.Scan(str); err == nil {
t.Errorf("Scan(%q) succeeded", str)
}
}
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")
}
}
+37
View File
@@ -0,0 +1,37 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
)
// WithTx runs fn in a transaction on the writer. It commits when fn returns
// nil and rolls back when fn returns an error, panics or exits the goroutine.
// A panic is not recovered: it propagates unchanged after the rollback.
func (s *Store) WithTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
tx, err := s.Writer.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin transaction: %w", err)
}
finished := false
defer func() {
if !finished {
_ = tx.Rollback() // fn panicked; the panic carries on regardless
}
}()
if err := fn(tx); err != nil {
finished = true
if rbErr := tx.Rollback(); rbErr != nil {
return errors.Join(err, fmt.Errorf("store: roll back transaction: %w", rbErr))
}
return err
}
finished = true
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: commit transaction: %w", err)
}
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package store
import (
"context"
"database/sql"
"errors"
"testing"
)
func openTxTestStore(t *testing.T) *Store {
t.Helper()
s := openTestStore(t)
if _, err := s.Writer.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); err != nil {
t.Fatal(err)
}
return s
}
func countRows(t *testing.T, s *Store) int {
t.Helper()
var n int
if err := s.Reader.QueryRow("SELECT count(*) FROM t").Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func insertRow(tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO t (id) VALUES (1)")
return err
}
func TestWithTxCommitsOnNil(t *testing.T) {
s := openTxTestStore(t)
if err := s.WithTx(context.Background(), insertRow); err != nil {
t.Fatal(err)
}
if n := countRows(t, s); n != 1 {
t.Fatalf("rows after commit = %d, want 1", n)
}
}
func TestWithTxRollsBackOnError(t *testing.T) {
s := openTxTestStore(t)
errBoom := errors.New("boom")
err := s.WithTx(context.Background(), func(tx *sql.Tx) error {
if err := insertRow(tx); err != nil {
t.Fatal(err)
}
return errBoom
})
if !errors.Is(err, errBoom) {
t.Fatalf("WithTx error = %v, want %v", err, errBoom)
}
if n := countRows(t, s); n != 0 {
t.Fatalf("rows after rollback = %d, want 0", n)
}
}
func TestWithTxRollsBackOnPanic(t *testing.T) {
s := openTxTestStore(t)
const value = "kaboom"
func() {
defer func() {
if p := recover(); p != value {
t.Fatalf("recovered %v, want panic %q to propagate", p, value)
}
}()
_ = s.WithTx(context.Background(), func(tx *sql.Tx) error {
if err := insertRow(tx); err != nil {
t.Fatal(err)
}
panic(value)
})
t.Fatal("WithTx returned instead of panicking")
}()
if n := countRows(t, s); n != 0 {
t.Fatalf("rows after panic = %d, want 0", n)
}
// The single writer connection must have been released.
if err := s.WithTx(context.Background(), insertRow); err != nil {
t.Fatalf("WithTx after panic: %v", err)
}
}
+4
View File
@@ -0,0 +1,4 @@
-- 0000_bootstrap: immutable no-op bootstrap migration. It lets the migrations
-- embed compile and proves the runner end to end. Never edit or remove it:
-- applied versions are recorded by filename and never re-run. The initial v1
-- schema arrives as a new migration, 0001_init.sql (Feature 02 task 3).
+333
View File
@@ -0,0 +1,333 @@
-- 0001_init: the initial v1 schema (CTXD CONTEXT.MD data model).
--
-- Conventions:
-- * Row ids are ULIDs stored as TEXT NOT NULL PRIMARY KEY (SQLite would
-- otherwise accept NULL in a TEXT primary key). Single-row settings tables
-- use id INTEGER PRIMARY KEY CHECK (id = 1) so a second row is refused.
-- * Timestamps are UTC RFC 3339 TEXT. Household-local dates (week_start,
-- plan day) are YYYY-MM-DD TEXT; household-local times are HH:MM TEXT.
-- * Columns ending in _ct only ever hold secretbox ciphertext.
-- * Every enum column has a CHECK. Booleans are INTEGER CHECK (x IN (0, 1)).
-- Days of the week are INTEGER IN (0, 1, 2, 3, 4, 5, 6), Sunday = 0 (Go
-- time.Weekday); IN rather than BETWEEN so fractional values are refused.
-- * Foreign keys use the default NO ACTION, so deleting a parent that still
-- has children is refused. This includes ai_run_id: the 30-day ai_runs
-- purge must clear those references explicitly before deleting.
-- * Defaults exist only where CTXD names one (household_settings times and
-- shopping_items.needed).
--
-- Enum value sets not spelled out in CONTEXT.MD, chosen here:
-- ai_settings.provider, ai_runs.provider: openai_compatible, anthropic, ollama
-- ai_runs.status: running, ok, error
-- chat_settings.last_test_status: ok, failed
-- jobs.status, suggestion_batches.status: pending, running, done, failed
-- notification_outbox.status: pending, sent, failed
-- schedule_runs.schedule: suggestions_weekly, plan_draft,
-- shopping_chat_autosend, day_of_reminder, rotation_check, week_rollover,
-- ai_runs_purge
-- jobs.kind and ai_runs.skill are open identifiers that grow with features,
-- not enums, so they carry no CHECK.
CREATE TABLE household_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
timezone TEXT NOT NULL,
dinner_time TEXT NOT NULL DEFAULT '17:30',
reminder_time TEXT NOT NULL DEFAULT '16:30',
review_time TEXT NOT NULL DEFAULT '09:00',
plan_draft_dow INTEGER NOT NULL DEFAULT 5 CHECK (plan_draft_dow IN (0, 1, 2, 3, 4, 5, 6)),
plan_draft_time TEXT NOT NULL DEFAULT '09:00',
suggestions_dow INTEGER NOT NULL DEFAULT 0 CHECK (suggestions_dow IN (0, 1, 2, 3, 4, 5, 6)),
suggestions_time TEXT NOT NULL DEFAULT '08:00',
chat_autosend_dow INTEGER NOT NULL DEFAULT 6 CHECK (chat_autosend_dow IN (0, 1, 2, 3, 4, 5, 6)),
chat_autosend_time TEXT NOT NULL DEFAULT '08:00',
onboarding_completed_at TEXT,
updates_seen_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE ai_runs (
id TEXT NOT NULL PRIMARY KEY,
skill TEXT NOT NULL,
skill_version TEXT NOT NULL,
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama')),
model TEXT NOT NULL,
temperature REAL NOT NULL,
status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')),
latency_ms INTEGER,
tokens_in INTEGER,
tokens_out INTEGER,
error TEXT,
request_redacted TEXT NOT NULL,
response_redacted TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE recipes (
id TEXT NOT NULL PRIMARY KEY,
source_url TEXT NOT NULL UNIQUE,
site_name TEXT,
title TEXT NOT NULL,
total_minutes INTEGER,
prep_minutes INTEGER,
cook_minutes INTEGER,
yield TEXT,
raw_jsonld TEXT NOT NULL,
fetched_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE recipe_ingredients (
id TEXT NOT NULL PRIMARY KEY,
recipe_id TEXT NOT NULL REFERENCES recipes (id),
position INTEGER NOT NULL,
raw_text TEXT NOT NULL,
name TEXT,
qty TEXT,
unit TEXT,
note TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE recipe_steps (
id TEXT NOT NULL PRIMARY KEY,
recipe_id TEXT NOT NULL REFERENCES recipes (id),
position INTEGER NOT NULL,
text TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE photos (
id TEXT NOT NULL PRIMARY KEY,
source_url TEXT NOT NULL,
local_path TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
sha256 TEXT NOT NULL,
fetched_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE dinners (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('suggested', 'quest_log', 'tried', 'regular', 'archived')),
origin TEXT NOT NULL CHECK (origin IN ('regular', 'side_quest', 'ingredients', 'quick', 'manual')),
method TEXT CHECK (method IN ('stovetop', 'oven', 'crockpot', 'grill', 'other')),
minutes_total INTEGER,
difficulty TEXT CHECK (difficulty IN ('easy', 'medium', 'hard')),
cuisine TEXT,
is_favorite INTEGER NOT NULL CHECK (is_favorite IN (0, 1)),
recipe_id TEXT REFERENCES recipes (id),
photo_id TEXT REFERENCES photos (id),
grounding_status TEXT NOT NULL CHECK (grounding_status IN ('pending', 'grounded', 'failed', 'manual')),
updated_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE suggestion_batches (
id TEXT NOT NULL PRIMARY KEY,
trigger TEXT NOT NULL CHECK (trigger IN ('scheduled', 'manual', 'replenish')),
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')),
ai_run_id TEXT REFERENCES ai_runs (id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE suggestions (
id TEXT NOT NULL PRIMARY KEY,
dinner_id TEXT NOT NULL REFERENCES dinners (id),
batch_id TEXT NOT NULL REFERENCES suggestion_batches (id),
reason TEXT NOT NULL,
cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')),
decided_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE week_plans (
id TEXT NOT NULL PRIMARY KEY,
week_start TEXT NOT NULL UNIQUE,
status TEXT NOT NULL CHECK (status IN ('draft', 'synced', 'changed')),
ai_note TEXT,
ai_run_id TEXT REFERENCES ai_runs (id),
calendar_synced_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE plan_days (
id TEXT NOT NULL PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES week_plans (id),
date TEXT NOT NULL,
dinner_id TEXT REFERENCES dinners (id),
is_new INTEGER NOT NULL CHECK (is_new IN (0, 1)),
calendar_event_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (plan_id, date)
);
CREATE TABLE rotation_reviews (
id TEXT NOT NULL PRIMARY KEY,
dinner_id TEXT NOT NULL REFERENCES dinners (id),
plan_date TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'decided')),
decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')),
decided_at TEXT,
updated_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (dinner_id, plan_date)
);
CREATE TABLE shopping_lists (
id TEXT NOT NULL PRIMARY KEY,
week_start TEXT NOT NULL UNIQUE,
plan_id TEXT NOT NULL REFERENCES week_plans (id),
title TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('current', 'archived')),
sent_to_chat_at TEXT,
frozen_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE shopping_groups (
id TEXT NOT NULL PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES shopping_lists (id),
dinner_id TEXT NOT NULL REFERENCES dinners (id),
position INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE shopping_items (
id TEXT NOT NULL PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES shopping_groups (id),
name TEXT NOT NULL,
qty TEXT,
needed INTEGER NOT NULL DEFAULT 1 CHECK (needed IN (0, 1)),
position INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE ai_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama')),
base_url TEXT NOT NULL,
model TEXT NOT NULL,
temperature REAL NOT NULL,
system_prompt TEXT,
api_key_ct BLOB,
api_key_last4 TEXT,
key_verified_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE recipe_search_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
provider TEXT NOT NULL CHECK (provider IN ('searxng', 'brave')),
base_url TEXT,
api_key_ct BLOB,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE integrations_google (
id INTEGER PRIMARY KEY CHECK (id = 1),
refresh_token_ct BLOB NOT NULL,
calendar_id TEXT,
calendar_name TEXT,
connected_at TEXT NOT NULL,
last_sync_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE chat_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
webhook_url_ct BLOB NOT NULL,
space_label TEXT,
last_test_at TEXT,
last_test_status TEXT CHECK (last_test_status IN ('ok', 'failed')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE notification_prefs (
event TEXT NOT NULL PRIMARY KEY CHECK (event IN ('plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review')),
push INTEGER NOT NULL CHECK (push IN (0, 1)),
chat INTEGER NOT NULL CHECK (chat IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE push_subscriptions (
id TEXT NOT NULL PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
failure_count INTEGER NOT NULL,
last_success_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE notification_outbox (
id TEXT NOT NULL PRIMARY KEY,
event_key TEXT NOT NULL,
channel TEXT NOT NULL CHECK (channel IN ('push', 'chat')),
payload TEXT NOT NULL, -- JSON
status TEXT NOT NULL CHECK (status IN ('pending', 'sent', 'failed')),
attempts INTEGER NOT NULL,
next_attempt_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (event_key, channel)
);
CREATE TABLE jobs (
id TEXT NOT NULL PRIMARY KEY,
kind TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')),
payload TEXT NOT NULL, -- JSON
result TEXT, -- JSON, written progressively
error TEXT,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE schedule_runs (
id TEXT NOT NULL PRIMARY KEY,
schedule TEXT NOT NULL CHECK (schedule IN ('suggestions_weekly', 'plan_draft', 'shopping_chat_autosend', 'day_of_reminder', 'rotation_check', 'week_rollover', 'ai_runs_purge')),
period_key TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (schedule, period_key)
);
CREATE TABLE sessions (
id_hash TEXT NOT NULL PRIMARY KEY,
subject TEXT NOT NULL,
email TEXT NOT NULL,
name TEXT NOT NULL,
groups TEXT NOT NULL, -- JSON array
csrf_token TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
@@ -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;
+32
View File
@@ -0,0 +1,32 @@
-- 0003_jobs_status: jobs.status becomes queued, running, succeeded, failed,
-- the states the job runner (internal/jobs) reports. SQLite cannot change a
-- CHECK in place, so the table is rebuilt: create the new table, copy every
-- row, drop the old one and rename. Nothing references jobs and it has no
-- indexes or triggers, so the rebuild touches no other schema object. Rows
-- written under the 0001 values are mapped (pending -> queued,
-- done -> succeeded) so the copy cannot fail on an existing database.
CREATE TABLE jobs_new (
id TEXT NOT NULL PRIMARY KEY,
kind TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
payload TEXT NOT NULL, -- JSON
result TEXT, -- JSON, written progressively
error TEXT,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO jobs_new (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
SELECT
id,
kind,
CASE status WHEN 'pending' THEN 'queued' WHEN 'done' THEN 'succeeded' ELSE status END,
payload, result, error, started_at, finished_at, created_at, updated_at
FROM jobs;
DROP TABLE jobs;
ALTER TABLE jobs_new RENAME TO jobs;
+10
View File
@@ -0,0 +1,10 @@
// Package migrations embeds the SQL schema migrations applied at boot by
// internal/store. Files are named NNNN_slug.sql and applied in filename order.
package migrations
import "embed"
// FS holds every *.sql migration in this directory.
//
//go:embed *.sql
var FS embed.FS
+57
View File
@@ -0,0 +1,57 @@
-- name: CreateDinner :one
INSERT INTO dinners (
id, name, normalized_name, status, origin, method, minutes_total,
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
updated_by, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?
)
RETURNING *;
-- name: GetDinner :one
SELECT * FROM dinners
WHERE id = ?;
-- name: ListDinnersByStatus :many
SELECT * FROM dinners
WHERE status = ?
ORDER BY normalized_name, id;
-- name: UpdateDinner :one
-- An unknown id returns sql.ErrNoRows.
UPDATE dinners
SET name = ?,
normalized_name = ?,
status = ?,
origin = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
cuisine = ?,
recipe_id = ?,
photo_id = ?,
grounding_status = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
-- name: ArchiveDinner :one
-- An unknown id returns sql.ErrNoRows.
UPDATE dinners
SET status = 'archived',
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
-- name: SetDinnerFavorite :one
-- An unknown id returns sql.ErrNoRows.
UPDATE dinners
SET is_favorite = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
+37
View File
@@ -0,0 +1,37 @@
-- name: CreateJob :exec
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
VALUES (?, ?, 'queued', ?, ?, ?);
-- name: ClaimNextJob :one
-- Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
-- when no job is queued.
UPDATE jobs
SET status = 'running', started_at = sqlc.arg(now), updated_at = sqlc.arg(now)
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
RETURNING *;
-- name: PublishJobResult :execrows
-- Stores a partial result of a running job.
UPDATE jobs
SET result = ?, updated_at = ?
WHERE id = ? AND status = 'running';
-- name: SucceedJob :execrows
UPDATE jobs
SET status = 'succeeded', result = sqlc.arg(result), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
WHERE id = sqlc.arg(id) AND status = 'running';
-- name: FailJob :execrows
UPDATE jobs
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
WHERE id = sqlc.arg(id) AND status = 'running';
-- name: FailRunningJobs :execrows
-- Fails every job left running, at boot before any worker starts.
UPDATE jobs
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
WHERE status = 'running';
-- name: GetJob :one
SELECT * FROM jobs
WHERE id = ?;
+17
View File
@@ -0,0 +1,17 @@
-- name: CreatePhoto :one
INSERT INTO photos (
id, source_url, local_path, width, height, sha256, fetched_at,
created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?
)
RETURNING *;
-- name: GetPhotoBySHA256 :one
-- Finds stored image bytes by content hash. The schema does not make sha256
-- unique, so the earliest-created (lowest id) match is returned.
SELECT * FROM photos
WHERE sha256 = ?
ORDER BY id
LIMIT 1;
+65
View File
@@ -0,0 +1,65 @@
-- name: UpsertRecipe :one
-- A recipe is keyed by source_url: re-fetching the same URL updates the
-- existing row and keeps its id and created_at, which the returned row
-- carries. Replace its ingredients and steps in the same transaction with
-- DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries.
INSERT INTO recipes (
id, source_url, site_name, title, total_minutes, prep_minutes,
cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?
)
ON CONFLICT (source_url) DO UPDATE SET
site_name = excluded.site_name,
title = excluded.title,
total_minutes = excluded.total_minutes,
prep_minutes = excluded.prep_minutes,
cook_minutes = excluded.cook_minutes,
yield = excluded.yield,
raw_jsonld = excluded.raw_jsonld,
fetched_at = excluded.fetched_at,
updated_at = excluded.updated_at
RETURNING *;
-- name: GetRecipe :one
SELECT * FROM recipes
WHERE id = ?;
-- name: GetRecipeBySourceURL :one
SELECT * FROM recipes
WHERE source_url = ?;
-- name: CreateRecipeIngredient :exec
INSERT INTO recipe_ingredients (
id, recipe_id, position, raw_text, name, qty, unit, note,
created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?,
?, ?
);
-- name: ListRecipeIngredients :many
SELECT * FROM recipe_ingredients
WHERE recipe_id = ?
ORDER BY position, id;
-- name: DeleteRecipeIngredients :exec
DELETE FROM recipe_ingredients
WHERE recipe_id = ?;
-- name: CreateRecipeStep :exec
INSERT INTO recipe_steps (
id, recipe_id, position, text, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?
);
-- name: ListRecipeSteps :many
SELECT * FROM recipe_steps
WHERE recipe_id = ?
ORDER BY position, id;
-- name: DeleteRecipeSteps :exec
DELETE FROM recipe_steps
WHERE recipe_id = ?;
+129
View File
@@ -0,0 +1,129 @@
-- name: GetHouseholdSettings :one
-- Returns sql.ErrNoRows until the row exists.
SELECT * FROM household_settings
WHERE id = 1;
-- name: UpsertHouseholdSettings :one
-- Creates the single row (id 1) or replaces every column except id and
-- created_at.
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,
updated_by, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?,
?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
timezone = excluded.timezone,
dinner_time = excluded.dinner_time,
reminder_time = excluded.reminder_time,
review_time = excluded.review_time,
plan_draft_dow = excluded.plan_draft_dow,
plan_draft_time = excluded.plan_draft_time,
suggestions_dow = excluded.suggestions_dow,
suggestions_time = excluded.suggestions_time,
chat_autosend_dow = excluded.chat_autosend_dow,
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 *;
-- name: GetAISettings :one
-- Returns sql.ErrNoRows until the row exists.
SELECT * FROM ai_settings
WHERE id = 1;
-- name: UpsertAISettings :one
-- Creates the single row (id 1) or replaces every column except id and
-- created_at.
INSERT INTO ai_settings (
id, provider, base_url, model, temperature, system_prompt, api_key_ct,
api_key_last4, key_verified_at, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
provider = excluded.provider,
base_url = excluded.base_url,
model = excluded.model,
temperature = excluded.temperature,
system_prompt = excluded.system_prompt,
api_key_ct = excluded.api_key_ct,
api_key_last4 = excluded.api_key_last4,
key_verified_at = excluded.key_verified_at,
updated_at = excluded.updated_at
RETURNING *;
-- name: GetRecipeSearchSettings :one
-- Returns sql.ErrNoRows until the row exists.
SELECT * FROM recipe_search_settings
WHERE id = 1;
-- name: UpsertRecipeSearchSettings :one
-- Creates the single row (id 1) or replaces every column except id and
-- created_at.
INSERT INTO recipe_search_settings (
id, provider, base_url, api_key_ct, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
provider = excluded.provider,
base_url = excluded.base_url,
api_key_ct = excluded.api_key_ct,
updated_at = excluded.updated_at
RETURNING *;
-- name: GetIntegrationsGoogle :one
-- Returns sql.ErrNoRows until the row exists.
SELECT * FROM integrations_google
WHERE id = 1;
-- name: UpsertIntegrationsGoogle :one
-- Creates the single row (id 1) or replaces every column except id and
-- created_at.
INSERT INTO integrations_google (
id, refresh_token_ct, calendar_id, calendar_name, connected_at,
last_sync_error, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?,
?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
refresh_token_ct = excluded.refresh_token_ct,
calendar_id = excluded.calendar_id,
calendar_name = excluded.calendar_name,
connected_at = excluded.connected_at,
last_sync_error = excluded.last_sync_error,
updated_at = excluded.updated_at
RETURNING *;
-- name: GetChatSettings :one
-- Returns sql.ErrNoRows until the row exists.
SELECT * FROM chat_settings
WHERE id = 1;
-- name: UpsertChatSettings :one
-- Creates the single row (id 1) or replaces every column except id and
-- created_at.
INSERT INTO chat_settings (
id, webhook_url_ct, space_label, last_test_at, last_test_status,
created_at, updated_at
) VALUES (
1, ?, ?, ?, ?,
?, ?
)
ON CONFLICT (id) DO UPDATE SET
webhook_url_ct = excluded.webhook_url_ct,
space_label = excluded.space_label,
last_test_at = excluded.last_test_at,
last_test_status = excluded.last_test_status,
updated_at = excluded.updated_at
RETURNING *;
+15
View File
@@ -0,0 +1,15 @@
# sqlc code generation. Regenerate with `make sqlc` (pinned sqlc version in the
# Makefile); `make sqlc-check` fails when internal/db is stale. sqlc reads only
# the top-level *.sql files of the schema directory, so migrations/queries/ is
# not treated as schema.
version: "2"
sql:
- engine: sqlite
schema: migrations
queries: migrations/queries
gen:
go:
package: db
out: internal/db
sql_package: database/sql
emit_interface: true