Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e73ebe2f2f | ||
|
|
5833a73541 | ||
|
|
36b975ff5b | ||
|
|
b2201c5da1 | ||
|
|
f0bdd0ef40 | ||
|
|
99c8f1625f | ||
|
|
19ea8d52d8 | ||
|
|
c1a868c48b | ||
|
|
17a69f4de7 | ||
|
|
fa7c6b7218 | ||
|
|
b06d1680a3 | ||
|
|
e9ee4e768a | ||
|
|
65b27a7689 | ||
|
|
645b3c00b2 | ||
|
|
9f4537d0cb | ||
|
|
ec13423b6f | ||
|
|
c28dff18cd |
@@ -0,0 +1,26 @@
|
|||||||
|
# Keep the build context small and free of secrets and local state.
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
.remember/
|
||||||
|
.claude/
|
||||||
|
design/
|
||||||
|
**/.DS_Store
|
||||||
|
node_modules
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
# Generated embed output; the Go stage takes the PWA from the web stage.
|
||||||
|
internal/frontend/dist/
|
||||||
|
bin/
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
.env*
|
||||||
|
**/.env*
|
||||||
|
deploy/secrets/
|
||||||
|
**/*.pem
|
||||||
|
**/*.key
|
||||||
|
**/*.p12
|
||||||
|
**/*.pfx
|
||||||
|
**/*.jks
|
||||||
|
**/*.keystore
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: make test
|
||||||
|
run: make test
|
||||||
|
|
||||||
|
- name: make sqlc-check
|
||||||
|
run: |
|
||||||
|
make sqlc-install
|
||||||
|
make sqlc-check SQLC="$(go env GOPATH)/bin/sqlc"
|
||||||
|
|
||||||
|
- name: make build
|
||||||
|
run: make build
|
||||||
@@ -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. |
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Base images are pinned by digest (multi-arch index); tags are for readability.
|
||||||
|
|
||||||
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS web
|
||||||
|
WORKDIR /src/web
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM golang:1.25-alpine@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59 AS server
|
||||||
|
ARG VERSION=dev
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
# Replace the placeholder with the built PWA; recreate the directory so no
|
||||||
|
# stale file survives into the embed.
|
||||||
|
RUN rm -rf internal/frontend/dist
|
||||||
|
COPY --from=web /src/web/dist/ internal/frontend/dist/
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
|
||||||
|
-o /out/mealquest ./cmd/server
|
||||||
|
|
||||||
|
# Alpine ships the CA bundle and busybox wget for the health check.
|
||||||
|
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
|
||||||
|
RUN addgroup -S -g 10001 mealquest \
|
||||||
|
&& adduser -S -D -H -u 10001 -G mealquest mealquest \
|
||||||
|
&& mkdir /data && chown mealquest:mealquest /data
|
||||||
|
COPY --from=server /out/mealquest /usr/local/bin/mealquest
|
||||||
|
ENV MEALQUEST_HTTP_ADDR=:8080 \
|
||||||
|
MEALQUEST_DATA_DIR=/data
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 8080
|
||||||
|
USER 10001:10001
|
||||||
|
# Probe the listener from MEALQUEST_HTTP_ADDR: host is everything before the
|
||||||
|
# last colon (IPv6 keeps its brackets). Wildcard hosts map to loopback, as
|
||||||
|
# does localhost: Go listens on its IPv4 address but wget would try ::1.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD host="${MEALQUEST_HTTP_ADDR%:*}"; \
|
||||||
|
case "$host" in ""|0.0.0.0|"[::]"|localhost) host=127.0.0.1 ;; esac; \
|
||||||
|
wget -q -O /dev/null "http://${host}:${MEALQUEST_HTTP_ADDR##*:}/health" || exit 1
|
||||||
|
ENTRYPOINT ["/usr/local/bin/mealquest"]
|
||||||
@@ -1,11 +1,53 @@
|
|||||||
# Build entry point: build the PWA, embed it, then build the server binary.
|
# MealQuest build entry points.
|
||||||
|
#
|
||||||
|
# make dev Go server on :8080 plus Vite dev server on :5173 (proxies
|
||||||
|
# /api, /auth, /media to :8080). Needs the MEALQUEST_* env set.
|
||||||
|
# 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
|
FRONTEND_DIST := internal/frontend/dist
|
||||||
|
# Dirty means a tracked change outside the generated embed output, which
|
||||||
|
# `make embed` rewrites; plain `--dirty` would flag every rebuild.
|
||||||
|
GIT_DIRTY := $(shell git status --porcelain --untracked-files=no -- . ':(exclude)$(FRONTEND_DIST)' 2>/dev/null)
|
||||||
|
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)$(if $(GIT_DIRTY),-dirty)
|
||||||
|
IMAGE ?= mealquest:$(VERSION)
|
||||||
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
|
|
||||||
.PHONY: build web embed check-embed-clean
|
# 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.
|
||||||
|
dev: SHELL := /bin/bash
|
||||||
|
dev:
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o bin/mealquest-dev ./cmd/server
|
||||||
|
@trap 'kill $$(jobs -p) 2>/dev/null; wait' EXIT; \
|
||||||
|
bin/mealquest-dev & \
|
||||||
|
(cd web && exec npm run dev) & \
|
||||||
|
wait -n
|
||||||
|
|
||||||
|
test: go-test web-test
|
||||||
|
|
||||||
|
go-test:
|
||||||
|
@unformatted="$$(gofmt -l .)"; \
|
||||||
|
if [ -n "$$unformatted" ]; then echo "gofmt needed:"; echo "$$unformatted"; exit 1; fi
|
||||||
|
go vet ./...
|
||||||
|
go test -race ./...
|
||||||
|
|
||||||
|
web-test:
|
||||||
|
cd web && npm ci && npm run typecheck && npm test
|
||||||
|
|
||||||
build: embed
|
build: embed
|
||||||
go build -o bin/mealquest ./cmd/server
|
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/mealquest ./cmd/server
|
||||||
|
|
||||||
web:
|
web:
|
||||||
cd web && npm ci && npm run build
|
cd web && npm ci && npm run build
|
||||||
@@ -22,3 +64,25 @@ check-embed-clean:
|
|||||||
touch $(FRONTEND_DIST)/.stale-embed-check
|
touch $(FRONTEND_DIST)/.stale-embed-check
|
||||||
$(MAKE) embed
|
$(MAKE) embed
|
||||||
test ! -e $(FRONTEND_DIST)/.stale-embed-check
|
test ! -e $(FRONTEND_DIST)/.stale-embed-check
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,9 +13,13 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"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/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/frontend"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"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>".
|
// version is set at build time with -ldflags "-X main.version=<version>".
|
||||||
@@ -27,7 +32,7 @@ func main() {
|
|||||||
os.Exit(1)
|
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 {
|
if err := run(cfg, logger); err != nil {
|
||||||
logger.Error("server stopped", "error", err)
|
logger.Error("server stopped", "error", err)
|
||||||
os.Exit(1)
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -73,12 +104,30 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return nil
|
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
|
// logListening logs the startup line. Only the public URL's origin is logged
|
||||||
// so no credential-bearing URL component can reach the log.
|
// so no credential-bearing URL component can reach the log.
|
||||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||||
logger.Info("listening",
|
logger.Info("listening",
|
||||||
"addr", cfg.HTTPAddr,
|
"addr", cfg.HTTPAddr,
|
||||||
"public_origin", cfg.PublicOrigin(),
|
"public_origin", cfg.PublicOrigin(),
|
||||||
|
"trusted_proxy", cfg.TrustedProxy,
|
||||||
"data_dir", cfg.DataDir,
|
"data_dir", cfg.DataDir,
|
||||||
"version", version,
|
"version", version,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -11,6 +11,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Environment variable names.
|
// Environment variable names.
|
||||||
@@ -20,6 +23,23 @@ const (
|
|||||||
EnvDataDir = "MEALQUEST_DATA_DIR"
|
EnvDataDir = "MEALQUEST_DATA_DIR"
|
||||||
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
|
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
|
||||||
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
|
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.
|
// Config is the validated service configuration.
|
||||||
@@ -29,6 +49,22 @@ type Config struct {
|
|||||||
DataDir string
|
DataDir string
|
||||||
ShutdownTimeout time.Duration
|
ShutdownTimeout time.Duration
|
||||||
SecretKey Secret
|
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
|
// 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}
|
r := reader{lookup: lookup}
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
HTTPAddr: r.listenAddr(EnvHTTPAddr),
|
HTTPAddr: r.listenAddr(EnvHTTPAddr),
|
||||||
PublicURL: r.httpURL(EnvPublicURL),
|
PublicURL: r.secureURL(EnvPublicURL),
|
||||||
DataDir: r.dir(EnvDataDir),
|
DataDir: r.dir(EnvDataDir),
|
||||||
ShutdownTimeout: r.duration(EnvShutdownTimeout),
|
ShutdownTimeout: r.duration(EnvShutdownTimeout),
|
||||||
SecretKey: r.secretFile(EnvSecretKeyFile),
|
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 {
|
if len(r.problems) > 0 {
|
||||||
return Config{}, &Error{Problems: r.problems}
|
return Config{}, &Error{Problems: r.problems}
|
||||||
@@ -114,6 +163,41 @@ func (r *reader) required(name string) (string, bool) {
|
|||||||
return v, true
|
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 {
|
func (r *reader) listenAddr(name string) string {
|
||||||
v, ok := r.required(name)
|
v, ok := r.required(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -155,15 +239,21 @@ func validHostname(h string) bool {
|
|||||||
return true
|
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)
|
v, ok := r.required(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// The value is never echoed: it may carry credentials.
|
// The value is never echoed: it may carry credentials.
|
||||||
u, err := url.Parse(v)
|
u, err := url.Parse(v)
|
||||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
|
if err != nil {
|
||||||
r.fail(name, "invalid URL (want absolute http:// or https:// URL)")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
if u.User != nil {
|
if u.User != nil {
|
||||||
@@ -195,6 +285,32 @@ func (r *reader) duration(name string) time.Duration {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return 0
|
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)
|
d, err := time.ParseDuration(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
|
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoad(t *testing.T) {
|
func TestLoad(t *testing.T) {
|
||||||
@@ -21,6 +25,10 @@ func TestLoad(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
unreadablePath := filepath.Join(dir, "missing_secret")
|
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")
|
emptyPath := filepath.Join(dir, "empty_secret")
|
||||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -33,6 +41,11 @@ func TestLoad(t *testing.T) {
|
|||||||
EnvDataDir: dir,
|
EnvDataDir: dir,
|
||||||
EnvShutdownTimeout: "15s",
|
EnvShutdownTimeout: "15s",
|
||||||
EnvSecretKeyFile: secretPath,
|
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 {
|
with := func(changes map[string]string, unset ...string) map[string]string {
|
||||||
@@ -70,6 +83,7 @@ func TestLoad(t *testing.T) {
|
|||||||
env: map[string]string{},
|
env: map[string]string{},
|
||||||
badVars: []string{
|
badVars: []string{
|
||||||
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
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"}),
|
env: with(map[string]string{EnvPublicURL: "http://:8080"}),
|
||||||
badVars: []string{EnvPublicURL},
|
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",
|
name: "relative public URL",
|
||||||
env: with(map[string]string{EnvPublicURL: "meals.example.com"}),
|
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"}),
|
env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}),
|
||||||
badVars: []string{"MEALQUEST_SECRET_KEY"},
|
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",
|
name: "several invalid values are all named",
|
||||||
env: with(map[string]string{
|
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)
|
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)
|
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 {
|
if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
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{
|
env := map[string]string{
|
||||||
EnvHTTPAddr: "127.0.0.1:9000",
|
EnvHTTPAddr: "127.0.0.1:9000",
|
||||||
EnvPublicURL: "http://localhost:9000",
|
EnvPublicURL: "http://localhost:9000",
|
||||||
EnvDataDir: dir,
|
EnvDataDir: dir,
|
||||||
EnvShutdownTimeout: "1m30s",
|
EnvShutdownTimeout: "1m30s",
|
||||||
EnvSecretKeyFile: secretPath,
|
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 })
|
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -253,6 +431,40 @@ func TestLoadValues(t *testing.T) {
|
|||||||
if cfg.SecretKey.Reveal() != "s3cret-value" {
|
if cfg.SecretKey.Reveal() != "s3cret-value" {
|
||||||
t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal())
|
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) {
|
func TestSecretRedacted(t *testing.T) {
|
||||||
@@ -260,7 +472,7 @@ func TestSecretRedacted(t *testing.T) {
|
|||||||
|
|
||||||
s := Secret{value: "s3cret-value"}
|
s := Secret{value: "s3cret-value"}
|
||||||
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
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)} {
|
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
||||||
if strings.Contains(out, "s3cret-value") {
|
if strings.Contains(out, "s3cret-value") {
|
||||||
t.Errorf("format %s leaked secret: %s", format, out)
|
t.Errorf("format %s leaked secret: %s", format, out)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,9 +17,18 @@ import (
|
|||||||
// SPA's index.html.
|
// SPA's index.html.
|
||||||
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
|
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
|
||||||
|
|
||||||
// NewRouter returns the HTTP handler for the service. version is reported by
|
// RouteMounter registers its own routes on the router.
|
||||||
// GET /health; assets is the web build served for every other GET.
|
type RouteMounter interface {
|
||||||
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
|
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")
|
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
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 := chi.NewRouter()
|
||||||
|
r.Use(resolveOrigin(logger, trusted))
|
||||||
r.MethodNotAllowed(methodNotAllowed)
|
r.MethodNotAllowed(methodNotAllowed)
|
||||||
r.NotFound(notFound)
|
r.NotFound(notFound)
|
||||||
|
|
||||||
r.Get("/health", health(logger, version))
|
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.Route("/api", func(r chi.Router) {
|
||||||
r.MethodNotAllowed(methodNotAllowed)
|
r.MethodNotAllowed(methodNotAllowed)
|
||||||
r.NotFound(notFound)
|
r.NotFound(notFound)
|
||||||
|
r.Mount("/v1", signedIn(csrf(apiV1)))
|
||||||
})
|
})
|
||||||
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
|
r.Mount("/media", signedIn(media))
|
||||||
// handler passes them to notFound rather than the SPA fallback.
|
// 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.MethodGet, "/*", web)
|
||||||
r.Method(http.MethodHead, "/*", web)
|
r.Method(http.MethodHead, "/*", web)
|
||||||
return r, nil
|
return r, nil
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -25,13 +27,39 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
|||||||
"index.html": {Data: []byte(fixtureIndex)},
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
"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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return r
|
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) {
|
func TestHealth(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -98,8 +126,10 @@ func TestReservedPathsNotFound(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{http.MethodGet, "/api"},
|
{http.MethodGet, "/api"},
|
||||||
{http.MethodGet, "/api/unknown"},
|
{http.MethodGet, "/api/unknown"},
|
||||||
|
{http.MethodGet, "/api/v1/unknown"},
|
||||||
|
{http.MethodPost, "/api/v1/unknown"},
|
||||||
{http.MethodPost, "/api/unknown"},
|
{http.MethodPost, "/api/unknown"},
|
||||||
{http.MethodGet, "/auth/callback"},
|
{http.MethodGet, "/auth/unknown"},
|
||||||
{http.MethodGet, "/media/photo.jpg"},
|
{http.MethodGet, "/media/photo.jpg"},
|
||||||
{http.MethodGet, "/health/deep"},
|
{http.MethodGet, "/health/deep"},
|
||||||
{http.MethodGet, "/auth/../quests/42"},
|
{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.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||||
t.Parallel()
|
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()
|
rec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("status = %d, want 404", rec.Code)
|
t.Errorf("status = %d, want 404", rec.Code)
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
-- 0001_init: the initial v1 schema (CTXD CONTEXT.MD data model).
|
||||||
|
--
|
||||||
|
-- Conventions:
|
||||||
|
-- * Row ids are ULIDs stored as TEXT NOT NULL PRIMARY KEY (SQLite would
|
||||||
|
-- otherwise accept NULL in a TEXT primary key). Single-row settings tables
|
||||||
|
-- use id INTEGER PRIMARY KEY CHECK (id = 1) so a second row is refused.
|
||||||
|
-- * Timestamps are UTC RFC 3339 TEXT. Household-local dates (week_start,
|
||||||
|
-- plan day) are YYYY-MM-DD TEXT; household-local times are HH:MM TEXT.
|
||||||
|
-- * Columns ending in _ct only ever hold secretbox ciphertext.
|
||||||
|
-- * Every enum column has a CHECK. Booleans are INTEGER CHECK (x IN (0, 1)).
|
||||||
|
-- Days of the week are INTEGER IN (0, 1, 2, 3, 4, 5, 6), Sunday = 0 (Go
|
||||||
|
-- time.Weekday); IN rather than BETWEEN so fractional values are refused.
|
||||||
|
-- * Foreign keys use the default NO ACTION, so deleting a parent that still
|
||||||
|
-- has children is refused. This includes ai_run_id: the 30-day ai_runs
|
||||||
|
-- purge must clear those references explicitly before deleting.
|
||||||
|
-- * Defaults exist only where CTXD names one (household_settings times and
|
||||||
|
-- shopping_items.needed).
|
||||||
|
--
|
||||||
|
-- Enum value sets not spelled out in CONTEXT.MD, chosen here:
|
||||||
|
-- ai_settings.provider, ai_runs.provider: openai_compatible, anthropic, ollama
|
||||||
|
-- ai_runs.status: running, ok, error
|
||||||
|
-- chat_settings.last_test_status: ok, failed
|
||||||
|
-- jobs.status, suggestion_batches.status: pending, running, done, failed
|
||||||
|
-- notification_outbox.status: pending, sent, failed
|
||||||
|
-- schedule_runs.schedule: suggestions_weekly, plan_draft,
|
||||||
|
-- shopping_chat_autosend, day_of_reminder, rotation_check, week_rollover,
|
||||||
|
-- ai_runs_purge
|
||||||
|
-- jobs.kind and ai_runs.skill are open identifiers that grow with features,
|
||||||
|
-- not enums, so they carry no CHECK.
|
||||||
|
|
||||||
|
CREATE TABLE household_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
timezone TEXT NOT NULL,
|
||||||
|
dinner_time TEXT NOT NULL DEFAULT '17:30',
|
||||||
|
reminder_time TEXT NOT NULL DEFAULT '16:30',
|
||||||
|
review_time TEXT NOT NULL DEFAULT '09:00',
|
||||||
|
plan_draft_dow INTEGER NOT NULL DEFAULT 5 CHECK (plan_draft_dow IN (0, 1, 2, 3, 4, 5, 6)),
|
||||||
|
plan_draft_time TEXT NOT NULL DEFAULT '09:00',
|
||||||
|
suggestions_dow INTEGER NOT NULL DEFAULT 0 CHECK (suggestions_dow IN (0, 1, 2, 3, 4, 5, 6)),
|
||||||
|
suggestions_time TEXT NOT NULL DEFAULT '08:00',
|
||||||
|
chat_autosend_dow INTEGER NOT NULL DEFAULT 6 CHECK (chat_autosend_dow IN (0, 1, 2, 3, 4, 5, 6)),
|
||||||
|
chat_autosend_time TEXT NOT NULL DEFAULT '08:00',
|
||||||
|
onboarding_completed_at TEXT,
|
||||||
|
updates_seen_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ai_runs (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
skill TEXT NOT NULL,
|
||||||
|
skill_version TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama')),
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
temperature REAL NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')),
|
||||||
|
latency_ms INTEGER,
|
||||||
|
tokens_in INTEGER,
|
||||||
|
tokens_out INTEGER,
|
||||||
|
error TEXT,
|
||||||
|
request_redacted TEXT NOT NULL,
|
||||||
|
response_redacted TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE recipes (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
source_url TEXT NOT NULL UNIQUE,
|
||||||
|
site_name TEXT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
total_minutes INTEGER,
|
||||||
|
prep_minutes INTEGER,
|
||||||
|
cook_minutes INTEGER,
|
||||||
|
yield TEXT,
|
||||||
|
raw_jsonld TEXT NOT NULL,
|
||||||
|
fetched_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE recipe_ingredients (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
recipe_id TEXT NOT NULL REFERENCES recipes (id),
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
raw_text TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
qty TEXT,
|
||||||
|
unit TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE recipe_steps (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
recipe_id TEXT NOT NULL REFERENCES recipes (id),
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE photos (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
local_path TEXT NOT NULL,
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
fetched_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE dinners (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
normalized_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('suggested', 'quest_log', 'tried', 'regular', 'archived')),
|
||||||
|
origin TEXT NOT NULL CHECK (origin IN ('regular', 'side_quest', 'ingredients', 'quick', 'manual')),
|
||||||
|
method TEXT CHECK (method IN ('stovetop', 'oven', 'crockpot', 'grill', 'other')),
|
||||||
|
minutes_total INTEGER,
|
||||||
|
difficulty TEXT CHECK (difficulty IN ('easy', 'medium', 'hard')),
|
||||||
|
cuisine TEXT,
|
||||||
|
is_favorite INTEGER NOT NULL CHECK (is_favorite IN (0, 1)),
|
||||||
|
recipe_id TEXT REFERENCES recipes (id),
|
||||||
|
photo_id TEXT REFERENCES photos (id),
|
||||||
|
grounding_status TEXT NOT NULL CHECK (grounding_status IN ('pending', 'grounded', 'failed', 'manual')),
|
||||||
|
updated_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE suggestion_batches (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
trigger TEXT NOT NULL CHECK (trigger IN ('scheduled', 'manual', 'replenish')),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')),
|
||||||
|
ai_run_id TEXT REFERENCES ai_runs (id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE suggestions (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
dinner_id TEXT NOT NULL REFERENCES dinners (id),
|
||||||
|
batch_id TEXT NOT NULL REFERENCES suggestion_batches (id),
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||||
|
decided_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE week_plans (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
week_start TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('draft', 'synced', 'changed')),
|
||||||
|
ai_note TEXT,
|
||||||
|
ai_run_id TEXT REFERENCES ai_runs (id),
|
||||||
|
calendar_synced_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE plan_days (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES week_plans (id),
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
dinner_id TEXT REFERENCES dinners (id),
|
||||||
|
is_new INTEGER NOT NULL CHECK (is_new IN (0, 1)),
|
||||||
|
calendar_event_id TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (plan_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE rotation_reviews (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
dinner_id TEXT NOT NULL REFERENCES dinners (id),
|
||||||
|
plan_date TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'decided')),
|
||||||
|
decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')),
|
||||||
|
decided_at TEXT,
|
||||||
|
updated_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (dinner_id, plan_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE shopping_lists (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
week_start TEXT NOT NULL UNIQUE,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES week_plans (id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('current', 'archived')),
|
||||||
|
sent_to_chat_at TEXT,
|
||||||
|
frozen_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE shopping_groups (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
list_id TEXT NOT NULL REFERENCES shopping_lists (id),
|
||||||
|
dinner_id TEXT NOT NULL REFERENCES dinners (id),
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE shopping_items (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
group_id TEXT NOT NULL REFERENCES shopping_groups (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
qty TEXT,
|
||||||
|
needed INTEGER NOT NULL DEFAULT 1 CHECK (needed IN (0, 1)),
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ai_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama')),
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
temperature REAL NOT NULL,
|
||||||
|
system_prompt TEXT,
|
||||||
|
api_key_ct BLOB,
|
||||||
|
api_key_last4 TEXT,
|
||||||
|
key_verified_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE recipe_search_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('searxng', 'brave')),
|
||||||
|
base_url TEXT,
|
||||||
|
api_key_ct BLOB,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE integrations_google (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
refresh_token_ct BLOB NOT NULL,
|
||||||
|
calendar_id TEXT,
|
||||||
|
calendar_name TEXT,
|
||||||
|
connected_at TEXT NOT NULL,
|
||||||
|
last_sync_error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE chat_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
webhook_url_ct BLOB NOT NULL,
|
||||||
|
space_label TEXT,
|
||||||
|
last_test_at TEXT,
|
||||||
|
last_test_status TEXT CHECK (last_test_status IN ('ok', 'failed')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE notification_prefs (
|
||||||
|
event TEXT NOT NULL PRIMARY KEY CHECK (event IN ('plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review')),
|
||||||
|
push INTEGER NOT NULL CHECK (push IN (0, 1)),
|
||||||
|
chat INTEGER NOT NULL CHECK (chat IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE push_subscriptions (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
failure_count INTEGER NOT NULL,
|
||||||
|
last_success_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE notification_outbox (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
event_key TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL CHECK (channel IN ('push', 'chat')),
|
||||||
|
payload TEXT NOT NULL, -- JSON
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'sent', 'failed')),
|
||||||
|
attempts INTEGER NOT NULL,
|
||||||
|
next_attempt_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (event_key, channel)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')),
|
||||||
|
payload TEXT NOT NULL, -- JSON
|
||||||
|
result TEXT, -- JSON, written progressively
|
||||||
|
error TEXT,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE schedule_runs (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
schedule TEXT NOT NULL CHECK (schedule IN ('suggestions_weekly', 'plan_draft', 'shopping_chat_autosend', 'day_of_reminder', 'rotation_check', 'week_rollover', 'ai_runs_purge')),
|
||||||
|
period_key TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (schedule, period_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id_hash TEXT NOT NULL PRIMARY KEY,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
groups TEXT NOT NULL, -- JSON array
|
||||||
|
csrf_token TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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 *;
|
||||||
@@ -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;
|
||||||
@@ -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 = ?;
|
||||||
@@ -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 *;
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user