Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 e73ebe2f2f Sanitize OIDC discovery errors and require CSRF on HEAD and OPTIONS
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m26s
CI / ci (push) Successful in 1m26s
F03-01: go-oidc embeds the provider's response body in its error for a
non-200 discovery response, which cmd/server logged at startup. Discovery
now runs through a status-recording transport and a non-200 response is
reported as "provider answered HTTP <status>" only; transport errors keep
their cause. A test with a sentinel body proves it reaches neither the
error nor the log.

F03-02: the CSRF middleware exempts only GET, so HEAD and OPTIONS under
/api/v1 without X-MealQuest-CSRF get 403 csrf, matching CONTEXT.MD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:11:29 -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 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
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
dev_agent 9f4537d0cb Feature 01 task 6: AGENTS.md, CLAUDE.md, README and architecture docs (#7)
CI / ci (pull_request) Successful in 24s
CI / ci (push) Successful in 23s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 00:26:15 -04:00
63 changed files with 9603 additions and 55 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
+143
View File
@@ -0,0 +1,143 @@
# AGENTS.md — MealQuest agent guide
This is the authoritative guide for every agent (and human) working in this
repository. `CLAUDE.md` only imports this file. Where this guide and a general
habit disagree, this guide wins.
MealQuest is a single Go service with an embedded React PWA for one
household. The architecture of record is [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md);
product requirements are in [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md).
## House rules
1. **Fail fast.** Invalid configuration, input or state is an error reported
immediately and clearly — the service refuses to start on bad config.
2. **No silent fallbacks.** Never substitute a default, retry quietly, or
swallow an error to "keep going". If something is missing, say so.
3. **One way to do a thing.** No parallel code paths, alternative settings or
"just in case" options. Pick one approach and use it everywhere.
4. **No new third-party dependency without owner approval.** Use only the
approved list below; anything else needs the owner's explicit yes first.
5. **Never use `tea`.** The Gitea CLI on this host is authenticated as the
human owner. Agents act only through their own Gitea identities.
## Approved dependencies (from CTXD)
Backend:
- Go 1.25, built with `CGO_ENABLED=0`.
- `github.com/go-chi/chi/v5` — HTTP router.
- `modernc.org/sqlite` — CGO-free SQLite driver.
- `sqlc` — query code generation (dev tool only).
- `github.com/coreos/go-oidc/v3` — OIDC (Authentik).
- `golang.org/x/oauth2` and `golang.org/x/oauth2/google`.
- `github.com/SherClockHolmes/webpush-go` — Web Push.
- `golang.org/x/image` — photo processing.
- `github.com/oklog/ulid/v2` — IDs.
- Standard library `log/slog` and `net/http`.
- **Not allowed:** LLM vendor SDKs, Google API SDKs (call the HTTP APIs
directly), goose, golang-migrate.
Frontend:
- React 19, TypeScript, Vite.
- React Router 7, TanStack Query 5.
- `vite-plugin-pwa`.
- `lucide-react` icons at stroke width 2.75.
- CSS Modules over the design tokens in
[tokens.css](design/MealQuest%20UI%20Designs/design_handoff_mealquest/tokens.css).
**No Tailwind, no Framer Motion, no hard-coded hex colours.**
- Self-hosted fonts: Bagel Fat One and Figtree.
- Tests: Vitest, Testing Library, jsdom.
## Quality floor
Every change must pass all of these before review (CI in
[.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) enforces them):
```sh
gofmt -l . # must print nothing
go vet ./...
go test -race ./...
cd web && npm run typecheck && npm test && npm run build
```
And the build entry points in the [Makefile](Makefile) and [Dockerfile](Dockerfile):
```sh
make test
make build
docker build .
```
Lint proves code parses, not that it works: where a change has runtime
behaviour, exercise it (hit the endpoint, render the page).
## Repository layout
| Path | Purpose |
| --- | --- |
| `cmd/server` | Entry point. Wiring only — no business logic. |
| `internal/config` | `MEALQUEST_*` environment config; secrets from `*_FILE`. |
| `internal/server` | HTTP server, routes, `/health`. |
| `internal/frontend` | `go:embed` of the built PWA with SPA fallback. |
| `internal/*` (planned) | `auth`, `db`, `store`, `secretbox`, `household`, `dinners`, `recipes`, `ai/llm`, `ai/harness`, `ai/skills`, `suggest`, `plan`, `shopping`, `ideas`, `gcal`, `notify`, `push`, `chat`, `jobs`, `scheduler`. |
| `migrations/` | SQL migrations (planned). |
| `web/` | React PWA source. |
| `deploy/` | Deployment files (planned). |
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md). |
| `design/` | Requirements and UI handoff. **Read-only reference** — do not edit. |
| `Dockerfile` | Multi-stage image build, at the repository root. |
| `Makefile` | `dev`, `test`, `build`, `docker`. |
| `.gitea/workflows/ci.yml` | CI quality floor. |
Design references:
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md)
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md)
- [design/MealQuest UI Designs/design_handoff_mealquest/tokens.css](design/MealQuest%20UI%20Designs/design_handoff_mealquest/tokens.css)
## Branches, pull requests and merges
- Integration branch is `dev`. `main` is the release branch.
- **Feature branches:** `feature/<feature-uuid>-<slug>`, branched from `dev`.
- **Task branches:** `task/<slug>_<task-uuid>`, branched from the feature branch.
- Task pull requests **squash-merge into the feature branch**.
- The feature pull request merges into `dev`.
- **Only `overseer` merges `dev` → `main`** (a release).
- Never push directly to `dev` or `main`.
- Authors never review, approve, merge or close their own work.
- Never store credentials in the repository; environment or ignored files only.
## Where context lives and where work is tracked
- **Context:** CTXD project `mealquest` — client guide at
<https://ctxd.cubecraftcreations.com>. Project documents: `CONTEXT.MD`
(architecture), `DECISIONS.MD` (decision log, e.g. D-021), `RUNBOOKS.MD`,
`PROMPTS.MD`, `GLOSSARY.MD`.
- **Work tracking:** Maestro at <https://maestro.cubecraftlabs.com>,
workspace **Maniacs-Mansion** → project **MealQuest**. Every change has a
task with a verifiable done-condition.
## Vocabulary (GLOSSARY)
Use these product terms in code names, UI copy, docs and commit messages. Do
not invent synonyms.
| Term | Meaning |
| --- | --- |
| **Regulars** | Dinners the household makes routinely. |
| **Side quest** | An AI-suggested new dinner to try. |
| **Quest log** | The to-try pool of accepted Side quests, scheduled later. |
| **Try it / Pass** | Accept (swipe right) / decline (swipe left) a suggestion. |
| **Rotation check** / **"Keep it in rotation?"** | The prompt after a Quest-log dinner is tried, asking whether it becomes a Regular. Promotion is always the household's choice, never automatic. |
| **Supply run** | The current week's shopping list, grouped by dinner. |
| **Supply archive** | The blog-style history of past shopping lists (by week, month, year). |
| **Draft board** | The mobile weekly plan layout the AI drafts for the household to adjust. |
| **Rebuild week** | Ask the AI to redraft the week's plan. |
| **Push to Calendar** | Push the plan one-way to Google Calendar. |
| **Quick dinner** | Cooked in a crock pot **or** 30 minutes or less from prep to table. |
| **Cook from what I have** | Ideas from a list of ingredients on hand. |
| **Kitchen reading mode** | Tablet recipe view: large text, ingredient checkboxes, step-by-step. |
| **"Why this?"** | The reason shown for a suggestion, referencing the household's Regulars. |
| **Our kitchen / Shared household** | The single shared household identity; no per-person profiles. |
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+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
+74
View File
@@ -0,0 +1,74 @@
# MealQuest
MealQuest is a mobile-first Progressive Web App for weekly dinner planning for
one household. The household lists the dinners it makes regularly, an AI
assistant suggests new dinners to try (swipe "Try it" or "Pass"), and the app
builds a weekly plan, pushes it to Google Calendar, and generates a shopping
list grouped by dinner, kept as a browsable weekly history. It ships as a
single Go service with the React PWA embedded in the binary.
> Status: this repository is the Feature 01 scaffold (Go service with
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Quick start
Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the
`engines` range of jsdom in the committed lockfile, the strictest of all the
web dependencies.
```sh
(cd web && npm ci)
mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret
chmod 700 ~/.mealquest-dev
head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key
chmod 600 ~/.mealquest-dev/secret_key
export MEALQUEST_HTTP_ADDR=:8080
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
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 except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE) and
trusted proxy list (every entry must be an IP or CIDR), and
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]`.
## Common commands
| Command | What it does |
| -------------- | --------------------------------------------------------------- |
| `make dev` | Go server plus Vite dev server. |
| `make test` | `gofmt` check, `go vet`, `go test -race`, web typecheck and tests. |
| `make build` | Build the PWA, embed it, build the static binary `bin/mealquest`. |
| `make docker` | Build the container image from the root `Dockerfile`. |
## Where to read next
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements.
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff.
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"bytes"
"context"
"io/fs"
"log/slog"
"net/url"
"strings"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"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 })
}
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 := config.Config{
HTTPAddr: "127.0.0.1:0",
PublicURL: &url.URL{Scheme: "http", Host: "localhost"},
DataDir: t.TempDir(),
ShutdownTimeout: time.Second,
}
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())
}
}
+51 -2
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
@@ -12,9 +13,13 @@ 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/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>".
@@ -27,7 +32,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,7 +43,33 @@ 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())
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)
}
}()
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, frontend.Assets())
if err != nil {
return err
}
@@ -73,12 +104,30 @@ func run(cfg config.Config, logger *slog.Logger) error {
return 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
}
// 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,
)
+244
View File
@@ -0,0 +1,244 @@
# MealQuest architecture
This mirrors the architecture section of `CONTEXT.MD` in the CTXD project
`mealquest`, which is the source of record; if the two disagree, CTXD wins and
this file should be updated. It describes the **planned** application. The
repository today is the Feature 01 scaffold: `cmd/server`, `internal/config`,
`internal/server` (`/health`), `internal/frontend` (embedded PWA with SPA
fallback), the `web/` skeleton, `Makefile`, `Dockerfile` and CI.
Decision numbers (D-NNN) refer to `DECISIONS.MD` in CTXD.
## Topology
```
PWA (phone / tablet / desktop)
│ HTTPS
▼
Traefik v3.6 — 10.60.1.33
file route: mealquest-ccl.yml
│
▼
docker compose project "mealquest" — 10.60.1.14
├── mealquest Go service + embedded PWA, host 8090 → container 8080
├── searxng sidecar for recipe search; internal compose network
│ only, no published port
└── Litestream SQLite replication → Synology NAS 10.60.1.50
```
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 |
| --- | --- |
| `cmd/server` | Entry point; wiring only. |
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
| `internal/server` | HTTP server, router, middleware. |
| `internal/frontend` | Embedded PWA, SPA fallback. |
| `internal/auth` | Authentik OIDC, sessions, CSRF. |
| `internal/db`, `internal/store` | SQLite connections, sqlc queries. |
| `internal/secretbox` | AES-256-GCM encryption for `_ct` columns. |
| `internal/household`, `internal/dinners`, `internal/recipes` | Domain. |
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
| `migrations/` | SQL migrations (no goose or golang-migrate). |
| `web/` | React PWA. |
| `deploy/` | Deployment files. |
| `docs/` | Documentation. |
| `design/` | Requirements and UI handoff (read-only reference). |
## Backend stack and conventions
- Go 1.25, `CGO_ENABLED=0`, a single static binary.
- Router: `go-chi/chi/v5`. Logging: `log/slog` JSON.
- SQLite via `modernc.org/sqlite`, with `journal_mode=WAL`,
`foreign_keys=ON` and `busy_timeout`. **One writer connection plus a read
pool.** Queries generated by `sqlc`.
- IDs are ULIDs (`oklog/ulid/v2`).
- Timestamps are stored in UTC; local calendar dates are `YYYY-MM-DD` strings
in the household timezone.
- Configuration is environment-only (`MEALQUEST_*`). Secrets come only from
files named by `*_FILE` variables. Missing or invalid config stops startup.
- Error responses always have the body
`{"error":{"code":"…","message":"…","retryable":false}}`.
## Authentication
- Authentik OIDC; the backend is a **confidential client**
(`coreos/go-oidc/v3`, `golang.org/x/oauth2`).
- Session cookie `__Host-mealquest_session` (HttpOnly, Secure,
SameSite=Lax). Sessions expire after 30 days idle or 180 days absolute.
- Non-GET requests require the CSRF header `X-MealQuest-CSRF`.
- Access requires membership of the Authentik group `mealquest-admin`.
There is a **single role**: one shared household, no per-person profiles.
## Data model
Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| Table | Holds |
| --- | --- |
| `household_settings` | One row: timezone, dinner_time (17:30), reminder_time (16:30), review_time (09:00), plan_draft day/time (Fri 09:00), suggestions day/time (Sun 08:00), chat_autosend day/time (Sat 08:00), onboarding_completed_at, updates_seen_at. |
| `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes, yield, raw_jsonld, fetched_at. |
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position, text. |
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url, api_key_ct. |
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
| `chat_settings` | One row: webhook_url_ct, space_label, last_test_at, last_test_status. |
| `notification_prefs` | event (`plan_ready` / `list_ready` / `day_of_reminder` / `new_suggestions` / `rotation_review`), push, chat. |
| `push_subscriptions` | endpoint (unique), p256dh, auth, user_agent, failure_count, last_success_at. |
| `notification_outbox` | event_key + channel (unique), payload, status, attempts, next_attempt_at, last_error. |
| `jobs` | kind, status, payload, result (progressive), error, timestamps. |
| `schedule_runs` | schedule, period_key (unique). |
| `ai_runs` | skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in/out, error, redacted request/response (30-day retention). |
| `sessions` | id_hash, subject, email, name, groups, csrf_token, created/last_seen/expires. |
`recipes` holds a fetched recipe page; `dinners` holds the household's meal
record and its lifecycle, optionally linked to a recipe and a photo.
## Dinner lifecycle
```
suggested (deck) ──Try it──▶ quest_log ──planned date passes──▶ tried
│ ▲ │
│ └──────── explicit decision ─────┤
│ ├──▶ regular
│ └──▶ archived
└──Pass──▶ archived (name kept for dedupe)
```
From `tried`, an explicit decision moves the dinner to `regular`, to
`archived`, or back to `quest_log`. Promotion to `regular` is **never
automatic** (D-021).
## API surface
- JSON API under `/api/v1`, using the error body above for every failure.
It requires the session cookie and, for non-GET requests, the
`X-MealQuest-CSRF` header.
- `/auth/*` — the OIDC flow.
- `/media/*` — cached photos.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
## AI layer
- **Provider interface** (`ai/llm`) with adapters for OpenAI-compatible,
Anthropic and Ollama endpoints, called over plain HTTP — no vendor SDKs.
- **Skill harness** (`ai/harness`): prompt layering, validation of the
structured output, and **one repair retry**; if the repair also fails the
run fails — no silent fallback. Every call is recorded in `ai_runs`.
## Recipe grounding
- Dinners are grounded in real recipe pages found through recipe search
(`searxng` or `brave`, per `recipe_search_settings`).
- Fetching is **SSRF-safe**.
- Recipe data is parsed from schema.org `Recipe` JSON-LD.
- Photos are cached locally (`photos`) and processed with
`golang.org/x/image`.
## Scheduler
Times are in the household timezone. The times of `day_of_reminder`
(`reminder_time`) and `rotation_check` (`review_time`), and the day and time
of `suggestions_weekly`, `plan_draft` and `shopping_chat_autosend`, are
household settings in `household_settings` (`dinner_time` is also stored
there); the table shows their defaults. `week_rollover` and the `ai_runs`
purge are fixed and not configurable.
| Schedule | When | Does |
| --- | --- | --- |
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. |
| `plan_draft` | Friday 09:00 | AI drafts next week's plan. |
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
| `ai_runs` purge | Daily (fixed) | Deletes `ai_runs` rows past 30-day retention. |
Runs are recorded in `schedule_runs` and executed through `jobs`.
## Notifications
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
`rotation_review`; each can go to Web Push and/or Google Chat per
`notification_prefs`.
- Delivery goes through `notification_outbox`.
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
webhook.
## Google Calendar
- One-way push: the app is the source of truth; edits in Google Calendar do
not come back.
- Connected once with Google OAuth in Settings; one event per planned
dinner.
- **Deterministic event ids**; each plan day records its
`calendar_event_id`.
## Frontend
- React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
`vite-plugin-pwa`, `lucide-react` (stroke 2.75).
- CSS Modules over `tokens.css`; no Tailwind, no Framer Motion, no
hard-coded hex. Self-hosted Bagel Fat One and Figtree.
- Tests: Vitest, Testing Library, jsdom.
- Offline cache for the current plan, shopping list and recipes.
| Route | Screen |
| --- | --- |
| `/` | Home |
| `/onboarding` | Add regular dinners |
| `/plan` | Weekly plan (Draft board) |
| `/plan/shopping` | Shopping list (Supply run) |
| `/plan/shopping/archive` | Supply archive |
| `/discover` | Swipe suggestions (Side quests) |
| `/quick` | Quick dinners |
| `/cook-from-pantry` | Cook from what I have |
| `/dinner/:id` | Recipe / dinner detail (Kitchen reading mode on tablet) |
| `/settings` | Settings home |
| `/settings/household` | Household settings |
| `/settings/regulars` | Regular dinners |
| `/settings/calendar` | Google Calendar connection |
| `/settings/notifications` | Notifications and Google Chat |
| `/settings/ai` | Admin · AI assistant |
`/settings/household`, `/settings/regulars` and `/settings/calendar` come from
CTXD; the other routes are defined in the UI handoff.
## Boundaries and gotchas
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
webhook stay server-side, encrypted at rest.
- **The app is the source of truth for the plan**; Google Calendar is a
one-way mirror.
- **Never auto-promote to Regular** (D-021).
- **Quick** means cooked in a crock pot **or** ≤ 30 minutes prep to table.
- **Weeks run Monday–Sunday** in the household timezone.
- **No silent fallbacks** — fail fast and report.
- **iOS Web Push requires the PWA to be installed to the Home Screen**; show
an install hint.
+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")
}
}
+428
View File
@@ -0,0 +1,428 @@
// 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{}, fmt.Errorf("verify ID token: %w", 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")
}
// 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)
}
}
}
+120 -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,23 @@ 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"
)
// 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 +49,22 @@ 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
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -47,10 +83,23 @@ 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),
}
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 +163,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 +239,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 +285,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)
+214 -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 {
@@ -70,6 +83,7 @@ func TestLoad(t *testing.T) {
env: map[string]string{},
badVars: []string{
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
},
},
{
@@ -139,6 +153,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 +201,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 +380,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 +395,21 @@ 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",
}
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
@@ -253,6 +431,40 @@ 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)
}
}
func TestSecretRedacted(t *testing.T) {
@@ -260,7 +472,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
}
+309
View File
@@ -0,0 +1,309 @@
// 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
}
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
}
+65
View File
@@ -0,0 +1,65 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
)
type Querier interface {
// An unknown id returns sql.ErrNoRows.
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, 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
// 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)
// 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)
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, 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
}
+428
View File
@@ -0,0 +1,428 @@
// 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 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,
)
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,
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_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
`
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
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.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,
)
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])
}
}
+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)
})
}
}
+166
View File
@@ -0,0 +1,166 @@
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.
{"POST with right header", http.MethodPost, "/api/v1/me", []string{testSession.CSRFToken}, http.StatusMethodNotAllowed, methodNotAllowedJSON},
{"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusNotFound, notFoundJSON},
{"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)
}
}
+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,
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{},
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)
}
}
}
+30 -5
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
)
@@ -16,9 +17,18 @@ import (
// 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; 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, assets fs.FS) (http.Handler, error) {
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -28,16 +38,31 @@ 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))
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
+38 -3
View File
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"testing/fstest"
"github.com/go-chi/chi/v5"
)
const (
@@ -25,13 +27,39 @@ func newTestRouter(t *testing.T, version string) http.Handler {
"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{}, 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)
}
}
func TestHealth(t *testing.T) {
t.Parallel()
@@ -98,8 +126,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 +139,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)
}
}
+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()
}
+263
View File
@@ -0,0 +1,263 @@
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" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init", 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 0001_init.sql alongside the unchanged bootstrap.
if err := Migrate(ctx, s.Writer, fstest.MapFS{"0000_bootstrap.sql": {Data: bootstrap}}); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got)
}
if !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)
}
}
+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"] = "pending"
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", "queued"},
{"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")
}
}
+119
View File
@@ -0,0 +1,119 @@
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
}
// 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.
func (d LocalDate) Value() (driver.Value, error) {
if d.IsZero() {
return nil, errors.New("store: zero LocalDate")
}
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
}
+126
View File
@@ -0,0 +1,126 @@
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 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")
}
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
);
+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 *;
+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 = ?;
+128
View File
@@ -0,0 +1,128 @@
-- 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,
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_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