Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e105cdb9c5 | ||
|
|
9ace3aba9f | ||
|
|
32ab0c0b6d | ||
|
|
15886f2c55 | ||
|
|
acd29c8221 | ||
|
|
72042516e2 | ||
|
|
7cc96121e4 | ||
|
|
bba9033ac5 | ||
|
|
e050736178 | ||
|
|
3ba78d47bc | ||
|
|
8be5c56f6f | ||
|
|
ea4047a852 | ||
|
|
41ff2ab114 | ||
|
|
df692ef214 | ||
|
|
88f7d0a5fa | ||
|
|
5833a73541 | ||
|
|
36b975ff5b | ||
|
|
b2201c5da1 | ||
|
|
f0bdd0ef40 | ||
|
|
5e78465d34 | ||
|
|
99c8f1625f | ||
|
|
6d5c14f778 | ||
|
|
19ea8d52d8 | ||
|
|
c1a868c48b | ||
|
|
17a69f4de7 | ||
|
|
fa7c6b7218 | ||
|
|
b06d1680a3 | ||
|
|
e9ee4e768a | ||
|
|
65b27a7689 | ||
|
|
645b3c00b2 | ||
|
|
9f4537d0cb | ||
|
|
ec13423b6f | ||
|
|
c28dff18cd | ||
|
|
85569b451f | ||
|
|
f0b2696059 |
@@ -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
|
||||||
@@ -8,6 +8,11 @@ deploy/secrets/
|
|||||||
# Build artifacts and dependencies
|
# Build artifacts and dependencies
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
bin/
|
||||||
|
# Placeholder so the Go embed compiles on a fresh clone; built assets stay ignored.
|
||||||
|
!internal/frontend/dist/
|
||||||
|
internal/frontend/dist/*
|
||||||
|
!internal/frontend/dist/index.html
|
||||||
|
|
||||||
# Local databases
|
# Local databases
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# 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
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/mealquest ./cmd/server
|
||||||
|
|
||||||
|
web:
|
||||||
|
cd web && npm ci && npm run build
|
||||||
|
|
||||||
|
# Replace the committed placeholder with the built PWA so go:embed picks it up.
|
||||||
|
# Recreate the whole directory: a glob would skip dotfiles, which all:dist embeds.
|
||||||
|
embed: web
|
||||||
|
rm -rf $(FRONTEND_DIST)
|
||||||
|
mkdir -p $(FRONTEND_DIST)
|
||||||
|
cp -R web/dist/. $(FRONTEND_DIST)/
|
||||||
|
|
||||||
|
# Regression check: a stale hidden file must not survive an embed.
|
||||||
|
check-embed-clean:
|
||||||
|
touch $(FRONTEND_DIST)/.stale-embed-check
|
||||||
|
$(MAKE) embed
|
||||||
|
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,80 @@
|
|||||||
|
# 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
|
||||||
|
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
|
||||||
|
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||||
|
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||||
|
every variable above is required except the commented-out session
|
||||||
|
lifetimes (when set they must be positive, with MAX at least IDLE),
|
||||||
|
trusted proxy list (every entry must be an IP or CIDR) and
|
||||||
|
`MEALQUEST_DEFAULT_TZ`, and
|
||||||
|
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||||
|
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||||
|
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||||
|
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
|
||||||
|
with on first boot: until those settings exist, boot fails unless it is a
|
||||||
|
valid IANA zone. Later boots ignore it, set or not (change the zone in
|
||||||
|
household settings instead).
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
| 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,370 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// useBootMigrations swaps bootMigrations for the test and restores it after.
|
||||||
|
// Tests that call it must not run in parallel.
|
||||||
|
func useBootMigrations(t *testing.T, m fstest.MapFS) {
|
||||||
|
t.Helper()
|
||||||
|
saved := bootMigrations
|
||||||
|
bootMigrations = m
|
||||||
|
t.Cleanup(func() { bootMigrations = saved })
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig loads a configuration through config.Load whose secret key file
|
||||||
|
// holds keyFile. Tests that call it must not run in parallel.
|
||||||
|
func loadConfig(t *testing.T, keyFile string) config.Config {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
keyPath := filepath.Join(dir, "secret_key")
|
||||||
|
if err := os.WriteFile(keyPath, []byte(keyFile), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
|
||||||
|
if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0")
|
||||||
|
t.Setenv(config.EnvPublicURL, "http://localhost")
|
||||||
|
t.Setenv(config.EnvDataDir, t.TempDir())
|
||||||
|
t.Setenv(config.EnvShutdownTimeout, "1s")
|
||||||
|
t.Setenv(config.EnvSecretKeyFile, keyPath)
|
||||||
|
t.Setenv(config.EnvOIDCIssuer, "https://auth.example.com/application/o/mealquest/")
|
||||||
|
t.Setenv(config.EnvOIDCClientID, "mealquest")
|
||||||
|
t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath)
|
||||||
|
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||||
|
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
||||||
|
t.Setenv(config.EnvDefaultTZ, "")
|
||||||
|
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// validKeyFile returns secret key file contents as written by the README:
|
||||||
|
// base64 of 32 random bytes followed by a newline.
|
||||||
|
func validKeyFile(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
key := make([]byte, secretbox.KeySize)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(key) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
var brokenMigrations = fstest.MapFS{
|
||||||
|
"0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")},
|
||||||
|
"0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenStoreMigrationFailureStopsBoot(t *testing.T) {
|
||||||
|
useBootMigrations(t, brokenMigrations)
|
||||||
|
|
||||||
|
db, err := openStore(context.Background(), t.TempDir())
|
||||||
|
if err == nil {
|
||||||
|
db.Close()
|
||||||
|
t.Fatal("openStore succeeded with a broken migration")
|
||||||
|
}
|
||||||
|
if db != nil {
|
||||||
|
t.Error("openStore returned a store alongside the error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "0001_broken.sql") {
|
||||||
|
t.Errorf("error does not name the broken migration: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenStoreAppliesEmbeddedMigrations(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := openStore(ctx, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
var n int
|
||||||
|
if err := db.Reader.QueryRowContext(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
files, err := fs.Glob(migrations.FS, "*.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != len(files) {
|
||||||
|
t.Errorf("schema_migrations has %d rows, want %d", n, len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
||||||
|
useBootMigrations(t, brokenMigrations)
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
cfg := loadConfig(t, validKeyFile(t))
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errc:
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "0001_broken.sql") {
|
||||||
|
t.Fatalf("run error = %v, want the broken migration's error", err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("run kept going after a migration failure")
|
||||||
|
}
|
||||||
|
if strings.Contains(logs.String(), `"listening"`) {
|
||||||
|
t.Errorf("run started listening despite the migration failure: %s", logs.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenSecretBoxAcceptsReadmeKey(t *testing.T) {
|
||||||
|
cfg := loadConfig(t, validKeyFile(t))
|
||||||
|
box, err := openSecretBox(cfg.SecretKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token, err := box.Encrypt([]byte("x"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := box.Decrypt(token); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBadSecretKeyStopsBoot(t *testing.T) {
|
||||||
|
short := base64.StdEncoding.EncodeToString(make([]byte, 31))
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
keyFile string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"not base64", "this is not a base64 key!\n", secretbox.ErrKeyEncoding},
|
||||||
|
{"wrong length", short + "\n", secretbox.ErrKeyLength},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := loadConfig(t, tt.keyFile)
|
||||||
|
var logs bytes.Buffer
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
select {
|
||||||
|
case err = <-errc:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("run kept going with a bad secret key")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("run error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
|
||||||
|
t.Errorf("error does not name %s: %v", config.EnvSecretKeyFile, err)
|
||||||
|
}
|
||||||
|
if key := strings.TrimSpace(tt.keyFile); strings.Contains(err.Error(), key) {
|
||||||
|
t.Errorf("error leaks the key: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(logs.String(), `"listening"`) {
|
||||||
|
t.Errorf("run started listening despite the bad key: %s", logs.String())
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(cfg.DataDir, store.FileName)); !errors.Is(statErr, fs.ErrNotExist) {
|
||||||
|
t.Errorf("run opened the database before rejecting the key (stat: %v)", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) {
|
||||||
|
loadConfig(t, validKeyFile(t)) // sets every other variable
|
||||||
|
t.Setenv(config.EnvSecretKeyFile, filepath.Join(t.TempDir(), "missing"))
|
||||||
|
_, err := config.Load()
|
||||||
|
var cfgErr *config.Error
|
||||||
|
if !errors.As(err, &cfgErr) || !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
|
||||||
|
t.Fatalf("Load error = %v, want a config error naming %s", err, config.EnvSecretKeyFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithoutValidDefaultZoneOnFirstBootStops(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
defaultTZ *string // nil leaves MEALQUEST_DEFAULT_TZ unset
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"unset", nil, household.ErrNoDefaultZone},
|
||||||
|
{"not an IANA zone", ptr("Mars/Olympus_Mons"), household.ErrInvalidDefaultZone},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
loadConfig(t, validKeyFile(t))
|
||||||
|
if tt.defaultTZ != nil {
|
||||||
|
t.Setenv(config.EnvDefaultTZ, *tt.defaultTZ)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var logs bytes.Buffer
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err = <-errc:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("run kept going with no household settings and no valid default zone")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tt.want) || !strings.Contains(err.Error(), config.EnvDefaultTZ) {
|
||||||
|
t.Fatalf("run error = %v, want %v naming %s", err, tt.want, config.EnvDefaultTZ)
|
||||||
|
}
|
||||||
|
if strings.Contains(logs.String(), `"listening"`) {
|
||||||
|
t.Errorf("run started listening without household settings: %s", logs.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reboot over existing household settings must not depend on
|
||||||
|
// MEALQUEST_DEFAULT_TZ: config.Load accepts an invalid value and seeding
|
||||||
|
// ignores it, keeping the stored zone.
|
||||||
|
func TestRebootWithSettingsIgnoresInvalidDefaultZone(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cfg := loadConfig(t, validKeyFile(t))
|
||||||
|
|
||||||
|
db, err := openStore(ctx, cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := seedSettings(ctx, db, "America/Denver"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(config.EnvDefaultTZ, "Mars/Olympus_Mons")
|
||||||
|
cfg, err = config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load with an invalid %s and existing settings: %v", config.EnvDefaultTZ, err)
|
||||||
|
}
|
||||||
|
db, err = openStore(ctx, cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil {
|
||||||
|
t.Fatalf("reboot with %s=%q: %v", config.EnvDefaultTZ, cfg.DefaultTZ, err)
|
||||||
|
}
|
||||||
|
got, err := household.NewSettingsService(db).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Timezone != "America/Denver" {
|
||||||
|
t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
func TestSeedSettingsAtBoot(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// First boot seeds from the default zone.
|
||||||
|
db, err := openStore(ctx, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := seedSettings(ctx, db, "America/Denver"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later boot needs no default zone and keeps the stored one.
|
||||||
|
db, err = openStore(ctx, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := seedSettings(ctx, db, ""); err != nil {
|
||||||
|
t.Fatalf("reboot without %s: %v", config.EnvDefaultTZ, err)
|
||||||
|
}
|
||||||
|
got, err := household.NewSettingsService(db).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Timezone != "America/Denver" {
|
||||||
|
t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedDefaultsAtBoot(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := openStore(ctx, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := seedDefaults(ctx, db, "America/Denver"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := household.NewSettingsService(db).Get(ctx); err != nil {
|
||||||
|
t.Errorf("household settings: %v", err)
|
||||||
|
}
|
||||||
|
var provider string
|
||||||
|
if err := db.Reader.QueryRowContext(ctx, `SELECT provider FROM ai_settings`).Scan(&provider); err != nil {
|
||||||
|
t.Errorf("AI settings: %v", err)
|
||||||
|
} else if provider != household.SeedAIProvider {
|
||||||
|
t.Errorf("AI provider = %q, want %q", provider, household.SeedAIProvider)
|
||||||
|
}
|
||||||
|
var prefs int
|
||||||
|
if err := db.Reader.QueryRowContext(ctx, `SELECT count(*) FROM notification_prefs`).Scan(&prefs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if prefs != 5 {
|
||||||
|
t.Errorf("%d notification preferences, want 5", prefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second boot seeds nothing new and succeeds.
|
||||||
|
if err := seedDefaults(ctx, db, ""); err != nil {
|
||||||
|
t.Fatalf("reboot: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewJobRunnerRunsNoop proves the runner run builds can execute a job:
|
||||||
|
// a noop job enqueued before Start, as at boot, succeeds with its result.
|
||||||
|
func TestNewJobRunnerRunsNoop(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := openStore(ctx, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
runner, kinds, err := newJobRunner(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id, err := kinds.builtins.Noop.Enqueue(ctx, jobs.NoopPayload{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||||
|
if err := runner.Start(jobsCtx); err != nil {
|
||||||
|
stopJobs()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stopJobs()
|
||||||
|
runner.Wait()
|
||||||
|
})
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
job, err := runner.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||||
|
if job.Kind != jobs.KindNoop || job.Status != jobs.StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
|
||||||
|
t.Fatalf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("noop job never finished; last %+v", job)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
-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,13 +13,24 @@ 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/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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>".
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
// jobWorkers is the number of background jobs run at once.
|
||||||
|
const jobWorkers = 2
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -26,7 +38,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)
|
||||||
@@ -37,9 +49,64 @@ 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()
|
||||||
|
|
||||||
|
if _, err := openSecretBox(cfg.SecretKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := openStore(ctx, cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
logger.Error("close database", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := seedDefaults(ctx, db, cfg.DefaultTZ); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings := household.NewSettingsService(db)
|
||||||
|
runner, _, err := newJobRunner(db, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authFlow, err := auth.New(ctx, auth.Config{
|
||||||
|
Issuer: cfg.OIDCIssuer.String(),
|
||||||
|
ClientID: cfg.OIDCClientID,
|
||||||
|
ClientSecret: cfg.OIDCClientSecret.Reveal(),
|
||||||
|
PublicURL: cfg.PublicURL,
|
||||||
|
SecretKey: []byte(cfg.SecretKey.Reveal()),
|
||||||
|
AllowedGroups: cfg.OIDCAllowedGroups,
|
||||||
|
}, sessions, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, runner, frontend.Assets())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start fails the jobs a previous process left running before any
|
||||||
|
// worker runs. The workers stop, and are waited for, before the
|
||||||
|
// database closes.
|
||||||
|
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||||
|
defer func() {
|
||||||
|
stopJobs()
|
||||||
|
runner.Wait()
|
||||||
|
}()
|
||||||
|
if err := runner.Start(jobsCtx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: server.NewRouter(logger, version),
|
Handler: router,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
||||||
}
|
}
|
||||||
@@ -68,12 +135,92 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openSecretBox builds the secret box from the configured key, naming the
|
||||||
|
// variable whose file holds a bad key. The key itself is never reported.
|
||||||
|
func openSecretBox(key config.Secret) (*secretbox.Box, error) {
|
||||||
|
box, err := secretbox.New(key.Reveal())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", config.EnvSecretKeyFile, err)
|
||||||
|
}
|
||||||
|
return box, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootMigrations is the migration set applied at boot. Tests replace it to
|
||||||
|
// prove a failing migration stops the boot.
|
||||||
|
var bootMigrations fs.FS = migrations.FS
|
||||||
|
|
||||||
|
// openStore opens the database in dataDir and applies bootMigrations. On a
|
||||||
|
// migration failure it closes the database and returns the error.
|
||||||
|
func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
||||||
|
db, err := store.Open(ctx, dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := store.Migrate(ctx, db.Writer, bootMigrations); err != nil {
|
||||||
|
return nil, errors.Join(err, db.Close())
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSettings creates the household settings on first boot, naming the
|
||||||
|
// variable that supplies the zone when it is missing or invalid. Once the
|
||||||
|
// settings exist the variable is ignored.
|
||||||
|
func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error {
|
||||||
|
err := household.SeedSettings(ctx, db, defaultTZ)
|
||||||
|
if errors.Is(err, household.ErrNoDefaultZone) || errors.Is(err, household.ErrInvalidDefaultZone) {
|
||||||
|
return fmt.Errorf("%s: a valid IANA zone is required on first boot to create the household settings: %w", config.EnvDefaultTZ, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedDefaults creates the household settings, AI settings and notification
|
||||||
|
// preferences that do not exist yet. Existing rows are never changed.
|
||||||
|
func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error {
|
||||||
|
if err := seedSettings(ctx, db, defaultTZ); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := household.SeedAISettings(ctx, db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return household.SeedNotificationPrefs(ctx, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobKinds enqueues every job kind the server runs.
|
||||||
|
type jobKinds struct {
|
||||||
|
builtins *jobs.Builtins
|
||||||
|
}
|
||||||
|
|
||||||
|
// newJobRunner returns the job runner with every job kind registered, ready
|
||||||
|
// to Start.
|
||||||
|
func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds, error) {
|
||||||
|
runner, err := jobs.NewRunner(db, logger, jobWorkers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
kinds, err := registerJobKinds(runner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return runner, kinds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerJobKinds registers every job kind's handler on runner. Each
|
||||||
|
// feature's job kinds are registered here.
|
||||||
|
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||||
|
builtins, err := jobs.RegisterBuiltins(runner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &jobKinds{builtins: builtins}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// logListening logs the startup line. Only the public URL's origin is logged
|
// 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,560 @@
|
|||||||
|
// Package anthropic is the llm.Provider adapter for Anthropic's Messages
|
||||||
|
// API. It calls POST {base}/v1/messages and GET {base}/v1/models over plain
|
||||||
|
// net/http.
|
||||||
|
//
|
||||||
|
// The adapter never logs, and no error it returns contains the API key:
|
||||||
|
// errors do not quote caller- or vendor-supplied text, and every exported
|
||||||
|
// method passes its error through redact as a backstop.
|
||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIVersion is the Messages API version sent as the anthropic-version
|
||||||
|
// header.
|
||||||
|
const APIVersion = "2023-06-01"
|
||||||
|
|
||||||
|
// Config configures a Provider.
|
||||||
|
type Config struct {
|
||||||
|
// BaseURL is the API root the /v1 endpoint paths are appended to, e.g.
|
||||||
|
// "https://api.anthropic.com".
|
||||||
|
BaseURL string
|
||||||
|
// APIKey is sent as the x-api-key header. It must not be empty.
|
||||||
|
APIKey string
|
||||||
|
// Model is the model Generate requests. ListModels and Verify do not
|
||||||
|
// need it.
|
||||||
|
Model string
|
||||||
|
// MaxTokens is the max_tokens Generate sends when a Request sets none:
|
||||||
|
// the Messages API requires one. It must be positive.
|
||||||
|
MaxTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is an Anthropic Messages API llm.Provider.
|
||||||
|
type Provider struct {
|
||||||
|
base string
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
maxTokens int
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ llm.Provider = (*Provider)(nil)
|
||||||
|
|
||||||
|
// New validates cfg and returns a Provider.
|
||||||
|
func New(cfg Config) (*Provider, error) {
|
||||||
|
p, err := newFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, redact(err, cfg.APIKey)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFromConfig(cfg Config) (*Provider, error) {
|
||||||
|
// No error from New quotes caller-supplied text: any of it may equal or
|
||||||
|
// contain the API key. Base URL errors never echo the URL.
|
||||||
|
u, err := url.Parse(cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("anthropic: base URL does not parse")
|
||||||
|
}
|
||||||
|
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return nil, errors.New("anthropic: base URL must be an absolute http or https URL")
|
||||||
|
}
|
||||||
|
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||||
|
// endpoint path appended to it would land in the query or fragment.
|
||||||
|
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||||
|
return nil, errors.New("anthropic: base URL must not carry credentials, a query or a fragment")
|
||||||
|
}
|
||||||
|
if cfg.APIKey == "" {
|
||||||
|
return nil, errors.New("anthropic: no API key configured")
|
||||||
|
}
|
||||||
|
if !validHeaderValue(cfg.APIKey) {
|
||||||
|
return nil, errors.New("anthropic: API key has a control character")
|
||||||
|
}
|
||||||
|
if cfg.MaxTokens <= 0 {
|
||||||
|
return nil, errors.New("anthropic: max tokens must be positive")
|
||||||
|
}
|
||||||
|
return &Provider{
|
||||||
|
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
|
apiKey: cfg.APIKey,
|
||||||
|
model: cfg.Model,
|
||||||
|
maxTokens: cfg.MaxTokens,
|
||||||
|
client: noRedirectClient(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validHeaderValue reports whether value is an RFC 9110 field-value: no
|
||||||
|
// control characters other than horizontal tab, so no CR, LF or DEL.
|
||||||
|
func validHeaderValue(value string) bool {
|
||||||
|
for i := 0; i < len(value); i++ {
|
||||||
|
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolName is the pattern the Messages API requires of a tool name.
|
||||||
|
var toolName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
|
// maxResponseBody caps a successful response body; a larger one is an error.
|
||||||
|
const maxResponseBody = 16 << 20
|
||||||
|
|
||||||
|
// maxErrorBody caps how much of an error response body is read. It is far
|
||||||
|
// above llm.MaxBodyExcerpt, so redaction still sees every secret that could
|
||||||
|
// reach the excerpt.
|
||||||
|
const maxErrorBody = 64 << 10
|
||||||
|
|
||||||
|
type message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
InputSchema json.RawMessage `json:"input_schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolChoice struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type messagesRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
Messages []message `json:"messages"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
Tools []tool `json:"tools,omitempty"`
|
||||||
|
ToolChoice *toolChoice `json:"tool_choice,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type messagesResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Input json.RawMessage `json:"input"`
|
||||||
|
} `json:"content"`
|
||||||
|
StopReason string `json:"stop_reason"`
|
||||||
|
Usage struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sends req to {base}/v1/messages. A Request with a Schema is sent
|
||||||
|
// as one tool whose input_schema is the schema, with tool_choice forcing
|
||||||
|
// it; Response.JSON is then that tool's input.
|
||||||
|
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||||
|
resp, err := p.generate(ctx, req)
|
||||||
|
return resp, redact(err, p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
||||||
|
}
|
||||||
|
if p.model == "" {
|
||||||
|
return llm.Response{}, errors.New("anthropic: generate: no model configured")
|
||||||
|
}
|
||||||
|
body := messagesRequest{
|
||||||
|
Model: p.model,
|
||||||
|
MaxTokens: p.maxTokens,
|
||||||
|
System: req.System,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
}
|
||||||
|
if req.MaxTokens > 0 {
|
||||||
|
body.MaxTokens = req.MaxTokens
|
||||||
|
}
|
||||||
|
if req.Schema != nil {
|
||||||
|
if !toolName.MatchString(req.Schema.Name) {
|
||||||
|
return llm.Response{}, errors.New("anthropic: generate: schema name is not a valid tool name")
|
||||||
|
}
|
||||||
|
body.Tools = []tool{{Name: req.Schema.Name, InputSchema: req.Schema.Schema}}
|
||||||
|
body.ToolChoice = &toolChoice{Type: "tool", Name: req.Schema.Name}
|
||||||
|
}
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content})
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("anthropic: generate: encode request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
raw, err := p.do(ctx, http.MethodPost, "/v1/messages", payload)
|
||||||
|
if err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("anthropic: generate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mr messagesResponse
|
||||||
|
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("anthropic: generate: decode response: %w", err)
|
||||||
|
}
|
||||||
|
if mr.Type != "message" {
|
||||||
|
return llm.Response{}, errors.New("anthropic: generate: response is not a message")
|
||||||
|
}
|
||||||
|
resp := llm.Response{
|
||||||
|
Usage: llm.Usage{InputTokens: mr.Usage.InputTokens, OutputTokens: mr.Usage.OutputTokens},
|
||||||
|
Model: mr.Model,
|
||||||
|
FinishReason: mr.StopReason,
|
||||||
|
}
|
||||||
|
var text strings.Builder
|
||||||
|
for _, block := range mr.Content {
|
||||||
|
switch block.Type {
|
||||||
|
case "text":
|
||||||
|
text.WriteString(block.Text)
|
||||||
|
case "tool_use":
|
||||||
|
if req.Schema != nil && block.Name == req.Schema.Name && resp.JSON == nil {
|
||||||
|
resp.JSON = block.Input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.Text = text.String()
|
||||||
|
if req.Schema == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if !isJSONObject(resp.JSON) {
|
||||||
|
resp.JSON = nil
|
||||||
|
return resp, fmt.Errorf("anthropic: generate: reply holds no input for the forced tool: %w", llm.ErrBadOutput)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isJSONObject reports whether raw is exactly one JSON object.
|
||||||
|
func isJSONObject(raw json.RawMessage) bool {
|
||||||
|
s := bytes.TrimSpace(raw)
|
||||||
|
return bytes.HasPrefix(s, []byte("{")) && json.Valid(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelsResponse struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
} `json:"data"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
LastID string `json:"last_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsPageLimit is the largest page GET /v1/models serves.
|
||||||
|
const modelsPageLimit = 1000
|
||||||
|
|
||||||
|
// ListModels returns every model {base}/v1/models lists, following pages.
|
||||||
|
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||||
|
models, err := p.listModels(ctx)
|
||||||
|
return models, redact(err, p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) listModels(ctx context.Context) ([]llm.Model, error) {
|
||||||
|
models := []llm.Model{}
|
||||||
|
after := ""
|
||||||
|
for {
|
||||||
|
page, err := p.modelsPage(ctx, modelsPageLimit, after)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("anthropic: list models: %w", err)
|
||||||
|
}
|
||||||
|
for _, m := range page.Data {
|
||||||
|
if m.ID == "" {
|
||||||
|
return nil, fmt.Errorf("anthropic: list models: model %d has no id", len(models))
|
||||||
|
}
|
||||||
|
name := m.DisplayName
|
||||||
|
if name == "" {
|
||||||
|
name = m.ID
|
||||||
|
}
|
||||||
|
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
|
||||||
|
}
|
||||||
|
if !page.HasMore {
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
if page.LastID == "" || page.LastID == after {
|
||||||
|
return nil, errors.New("anthropic: list models: response has more pages but no new cursor")
|
||||||
|
}
|
||||||
|
after = page.LastID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsPage fetches one page of GET {base}/v1/models.
|
||||||
|
func (p *Provider) modelsPage(ctx context.Context, limit int, after string) (modelsResponse, error) {
|
||||||
|
q := url.Values{"limit": {strconv.Itoa(limit)}}
|
||||||
|
if after != "" {
|
||||||
|
q.Set("after_id", after)
|
||||||
|
}
|
||||||
|
raw, err := p.do(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return modelsResponse{}, err
|
||||||
|
}
|
||||||
|
var mr modelsResponse
|
||||||
|
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||||
|
return modelsResponse{}, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
if mr.Data == nil {
|
||||||
|
return modelsResponse{}, errors.New("response has no data list")
|
||||||
|
}
|
||||||
|
return mr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks the credentials with the cheapest authenticated call: one
|
||||||
|
// page of GET {base}/v1/models holding at most one model.
|
||||||
|
func (p *Provider) Verify(ctx context.Context) error {
|
||||||
|
_, err := p.modelsPage(ctx, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("anthropic: verify: %w", err)
|
||||||
|
}
|
||||||
|
return redact(err, p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// do sends one request and returns the body of a 2xx response. Any other
|
||||||
|
// status becomes the matching llm typed error.
|
||||||
|
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, error) {
|
||||||
|
var reqBody io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
reqBody = bytes.NewReader(payload)
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
// The parse error names the URL, whose path may hold the API key.
|
||||||
|
return nil, errors.New("build request: request URL is invalid")
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("x-api-key", p.apiKey)
|
||||||
|
httpReq.Header.Set("anthropic-version", APIVersion)
|
||||||
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
if payload != nil {
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, err := p.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.transportError(err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.transportError(err)
|
||||||
|
}
|
||||||
|
return nil, p.statusError(httpResp, body)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.transportError(err)
|
||||||
|
}
|
||||||
|
if len(body) > maxResponseBody {
|
||||||
|
return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusOverloaded is the status the Messages API returns when it is
|
||||||
|
// temporarily overloaded.
|
||||||
|
const statusOverloaded = 529
|
||||||
|
|
||||||
|
// statusError maps a non-2xx response to an llm typed error.
|
||||||
|
func (p *Provider) statusError(resp *http.Response, body []byte) error {
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrAuth)
|
||||||
|
case http.StatusTooManyRequests, statusOverloaded:
|
||||||
|
return llm.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
|
||||||
|
case http.StatusRequestTimeout:
|
||||||
|
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
|
||||||
|
default:
|
||||||
|
return llm.NewProviderError(resp.StatusCode, body, p.apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryAfter parses a Retry-After header, in delay-seconds or HTTP-date
|
||||||
|
// form. A missing, malformed or past value means the vendor gave no usable
|
||||||
|
// wait, reported as 0.
|
||||||
|
func retryAfter(value string, now time.Time) time.Duration {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if secs, err := strconv.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
|
||||||
|
if secs < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// time.Duration counts nanoseconds in an int64; a larger wait is
|
||||||
|
// capped to the most whole seconds it holds instead of overflowing.
|
||||||
|
if secs > maxRetryAfterSeconds {
|
||||||
|
secs = maxRetryAfterSeconds
|
||||||
|
}
|
||||||
|
return time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
if at, err := http.ParseTime(value); err == nil && at.After(now) {
|
||||||
|
return at.Sub(now).Round(time.Second)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxRetryAfterSeconds is the largest delay-seconds value retryAfter
|
||||||
|
// returns unchanged: the most whole seconds a time.Duration holds.
|
||||||
|
const maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
||||||
|
|
||||||
|
// noRedirectClient returns an HTTP client that never follows a redirect.
|
||||||
|
// A followed redirect would resend the credential headers, which net/http
|
||||||
|
// strips only for its own list of sensitive headers and only across hosts;
|
||||||
|
// instead the 3xx response is returned and becomes a provider error.
|
||||||
|
func noRedirectClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeout(err error) bool {
|
||||||
|
var ne net.Error
|
||||||
|
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportFailure is a failed HTTP exchange. It carries the cause's text
|
||||||
|
// with the request URL removed and the API key redacted, and unwraps only to
|
||||||
|
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
|
||||||
|
type transportFailure struct {
|
||||||
|
text string
|
||||||
|
kind error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *transportFailure) Error() string { return e.text }
|
||||||
|
|
||||||
|
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||||
|
func (e *transportFailure) GoString() string { return e.text }
|
||||||
|
|
||||||
|
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||||
|
|
||||||
|
// transportError reports a failure to complete the HTTP exchange. A
|
||||||
|
// *url.Error names the request URL, and a base URL path may hold the API
|
||||||
|
// key, so only the wrapped cause is kept, rendered with the key redacted.
|
||||||
|
func (p *Provider) transportError(err error) error {
|
||||||
|
var kind error
|
||||||
|
switch {
|
||||||
|
case isTimeout(err):
|
||||||
|
kind = llm.ErrTimeout
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
kind = context.Canceled
|
||||||
|
}
|
||||||
|
var ue *url.Error
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
err = ue.Err
|
||||||
|
}
|
||||||
|
text := "transport: " + err.Error()
|
||||||
|
if kind == llm.ErrTimeout {
|
||||||
|
text = fmt.Sprintf("%s: %s", llm.ErrTimeout, text)
|
||||||
|
}
|
||||||
|
return &transportFailure{text: llm.Redact(text, p.apiKey), kind: kind}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactedError stands in for one layer of an error chain that holds the
|
||||||
|
// API key. Its text is the layer's text with the key redacted, and Unwrap
|
||||||
|
// returns the layer's causes passed through redact, so no error reached by
|
||||||
|
// unwrapping has text holding the key. Is consults the original layer and
|
||||||
|
// its chain, so sentinels still match. As does too. The llm typed errors
|
||||||
|
// remove the key from their own complete text, fixed words included, so
|
||||||
|
// they always match; As refuses a match whose text, or whose %#v form,
|
||||||
|
// holds the key, so it never hands back an error that can print it.
|
||||||
|
type redactedError struct {
|
||||||
|
text string
|
||||||
|
err error
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *redactedError) Error() string { return e.text }
|
||||||
|
|
||||||
|
// GoString keeps %#v from printing the fields, which hold the key.
|
||||||
|
func (e *redactedError) GoString() string { return e.text }
|
||||||
|
|
||||||
|
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
|
||||||
|
|
||||||
|
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||||
|
|
||||||
|
func (e *redactedError) As(target any) bool {
|
||||||
|
if !errors.As(e.err, target) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
found := reflect.ValueOf(target).Elem()
|
||||||
|
matched, isErr := found.Interface().(error)
|
||||||
|
if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) {
|
||||||
|
found.SetZero()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactedJoin is redactedError for a layer with several causes, such as
|
||||||
|
// one built by errors.Join or by fmt.Errorf with more than one %w.
|
||||||
|
type redactedJoin struct {
|
||||||
|
redactedError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *redactedJoin) Unwrap() []error {
|
||||||
|
causes := e.err.(interface{ Unwrap() []error }).Unwrap()
|
||||||
|
safe := make([]error, len(causes))
|
||||||
|
for i, c := range causes {
|
||||||
|
safe[i] = redact(c, e.key)
|
||||||
|
}
|
||||||
|
return safe
|
||||||
|
}
|
||||||
|
|
||||||
|
// redact is the backstop every exported method applies to its error: with
|
||||||
|
// a non-empty key, it returns a stand-in whose text, and the text of every
|
||||||
|
// error it unwraps to, has the key redacted. Every non-nil err is wrapped,
|
||||||
|
// even one whose text lacks the key, because %#v of an unwrapped error
|
||||||
|
// prints its struct field names, and a key may spell one.
|
||||||
|
func redact(err error, key string) error {
|
||||||
|
if err == nil || key == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
||||||
|
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||||
|
return &redactedJoin{re}
|
||||||
|
}
|
||||||
|
return &re
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsKey reports whether the text of err or of any error in its chain
|
||||||
|
// contains key.
|
||||||
|
func holdsKey(err error, key string) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch u := err.(type) {
|
||||||
|
case interface{ Unwrap() error }:
|
||||||
|
return holdsKey(u.Unwrap(), key)
|
||||||
|
case interface{ Unwrap() []error }:
|
||||||
|
for _, c := range u.Unwrap() {
|
||||||
|
if holdsKey(c, key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Anthropic adapter fixtures
|
||||||
|
|
||||||
|
These are the Messages API response bodies the contract tests in
|
||||||
|
`anthropic_test.go` replay. They are **not live captures**. Product AI is
|
||||||
|
tested with fakes, and live vendor calls are opt-in and never use
|
||||||
|
credentials, so no request was sent to Anthropic to make them. Each fixture
|
||||||
|
is a sanitized adaptation of the response shapes in Anthropic's public API
|
||||||
|
documentation:
|
||||||
|
|
||||||
|
- Messages: <https://docs.anthropic.com/en/api/messages>
|
||||||
|
- Tool use, including `tool_choice` `{type: tool}`:
|
||||||
|
<https://docs.anthropic.com/en/docs/build-with-claude/tool-use>
|
||||||
|
- List models: <https://docs.anthropic.com/en/api/models-list>
|
||||||
|
- Errors: <https://docs.anthropic.com/en/api/errors>
|
||||||
|
|
||||||
|
Sanitizing:
|
||||||
|
|
||||||
|
- IDs, request IDs, timestamps and token counts are illustrative values in
|
||||||
|
the documented format, not values from a real account.
|
||||||
|
- Message content and tool input are replaced with MealQuest dinner text.
|
||||||
|
- No real credential appears anywhere. `{{KEY}}` is a placeholder the tests
|
||||||
|
replace with a fake key, to prove the adapter redacts a key echoed back in
|
||||||
|
a vendor body.
|
||||||
|
|
||||||
|
Below, *not in the public example* marks fields or values that were added
|
||||||
|
for a test case and do not come from the documented example.
|
||||||
|
|
||||||
|
| Fixture | Shape | Not in the public example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `messages_tool_use.json` | Message with one `tool_use` block, `stop_reason: tool_use`, `usage` with cache fields. | The tool name and input. |
|
||||||
|
| `messages_text.json` | Message with `text` blocks, `stop_reason: end_turn`. | A second text block, to test joining. |
|
||||||
|
| `messages_max_tokens.json` | Message with a `text` block, `stop_reason: max_tokens`. | — |
|
||||||
|
| `models_page1.json` | Models list page: `data`, `has_more: true`, `first_id`, `last_id`. | — |
|
||||||
|
| `models_page2.json` | Last models list page, `has_more: false`. | The second model has no `display_name`, to test the fallback to `id`. |
|
||||||
|
| `error_authentication.json` | Error, `authentication_error` (401). | The `{{KEY}}` text; added to test redaction. |
|
||||||
|
| `error_permission.json` | Error, `permission_error` (403). | — |
|
||||||
|
| `error_rate_limit.json` | Error, `rate_limit_error` (429). | The `{{KEY}}` text; added to test redaction. |
|
||||||
|
| `error_overloaded.json` | Error, `overloaded_error` (529). | — |
|
||||||
|
| `error_not_found.json` | Error, `not_found_error` (404). | — |
|
||||||
|
| `error_invalid_request.json` | Error, `invalid_request_error` (400). | — |
|
||||||
|
| `error_api.json` | Error, `api_error` (500). | The `x-api-key: {{KEY}}` text; added to test redaction. |
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "api_error", "message": "An unexpected error has occurred internal to Anthropic's systems. x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G4"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key: {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "invalid_request_error", "message": "max_tokens: Field required"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G3"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-nonexistent"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G2"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G1"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "permission_error", "message": "Your API key does not have permission to use the specified resource."}, "request_id": "req_011CSHoEeqs5C35K2UUqR7Fz"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"type": "error", "error": {"type": "rate_limit_error", "message": "Number of request tokens has exceeded your per-minute rate limit for key {{KEY}}"}, "request_id": "req_011CSHoEeqs5C35K2UUqR7G0"}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-sonnet-5",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Here are some Side quests for Our kitchen"}
|
||||||
|
],
|
||||||
|
"stop_reason": "max_tokens",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {"input_tokens": 472, "output_tokens": 400}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-sonnet-5",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Try a crock pot chili: "},
|
||||||
|
{"type": "text", "text": "it is a Quick dinner."}
|
||||||
|
],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 2095,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 0,
|
||||||
|
"output_tokens": 503
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "msg_01Aq9w938a90dw8q",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude-sonnet-5",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "toolu_01A09q90qw90lq917835lq9",
|
||||||
|
"name": "side_quests",
|
||||||
|
"input": {"dinners": [{"name": "Sheet-pan gnocchi", "why": "Like your Regular tray bake, but quicker."}]}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 472,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 0,
|
||||||
|
"output_tokens": 61
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"type": "model",
|
||||||
|
"id": "claude-sonnet-5",
|
||||||
|
"display_name": "Claude Sonnet 5",
|
||||||
|
"created_at": "2026-02-24T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"has_more": true,
|
||||||
|
"first_id": "claude-sonnet-5",
|
||||||
|
"last_id": "claude-sonnet-5"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"type": "model",
|
||||||
|
"id": "claude-haiku-4-5-20251001",
|
||||||
|
"display_name": "Claude Haiku 4.5",
|
||||||
|
"created_at": "2025-10-01T00:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "model",
|
||||||
|
"id": "claude-opus-5-5",
|
||||||
|
"created_at": "2026-05-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"has_more": false,
|
||||||
|
"first_id": "claude-haiku-4-5-20251001",
|
||||||
|
"last_id": "claude-opus-5-5"
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrAuth means the vendor rejected the credentials.
|
||||||
|
ErrAuth = errors.New("llm: authentication failed")
|
||||||
|
// ErrRateLimited means the vendor throttled the request. The concrete
|
||||||
|
// error is a *RateLimitedError carrying the requested wait.
|
||||||
|
ErrRateLimited = errors.New("llm: rate limited")
|
||||||
|
// ErrTimeout means the request did not finish within its timeout.
|
||||||
|
ErrTimeout = errors.New("llm: request timed out")
|
||||||
|
// ErrBadOutput means the model replied but the reply is unusable, e.g.
|
||||||
|
// structured output that does not parse.
|
||||||
|
ErrBadOutput = errors.New("llm: unusable model output")
|
||||||
|
// ErrProvider means the vendor returned an unexpected HTTP status. The
|
||||||
|
// concrete error is a *ProviderError carrying the status and a redacted
|
||||||
|
// body excerpt.
|
||||||
|
ErrProvider = errors.New("llm: provider error")
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimitedError is a throttled request. It matches ErrRateLimited.
|
||||||
|
// Adapters build it with NewRateLimitedError so its text never holds their
|
||||||
|
// credential.
|
||||||
|
type RateLimitedError struct {
|
||||||
|
// RetryAfter is how long the vendor asked callers to wait; 0 means it
|
||||||
|
// did not say.
|
||||||
|
RetryAfter time.Duration
|
||||||
|
// text is the redacted Error text set by NewRateLimitedError; "" means
|
||||||
|
// the error was built without a credential.
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimitedError returns the error for a throttled request. secret is
|
||||||
|
// the credential sent with the request, or "" when the request sent none.
|
||||||
|
// Every occurrence of a non-empty secret is removed from the error text
|
||||||
|
// when it is built, even where the fixed text happens to spell it, and the
|
||||||
|
// secret itself is not kept.
|
||||||
|
func NewRateLimitedError(retryAfter time.Duration, secret string) *RateLimitedError {
|
||||||
|
e := &RateLimitedError{RetryAfter: retryAfter}
|
||||||
|
e.text = Redact(e.Error(), secret)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RateLimitedError) Error() string {
|
||||||
|
if e.text != "" {
|
||||||
|
return e.text
|
||||||
|
}
|
||||||
|
if e.RetryAfter == 0 {
|
||||||
|
return ErrRateLimited.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: retry after %v", ErrRateLimited, e.RetryAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||||
|
func (e *RateLimitedError) GoString() string { return e.Error() }
|
||||||
|
|
||||||
|
// Is reports whether target is ErrRateLimited.
|
||||||
|
func (e *RateLimitedError) Is(target error) bool { return target == ErrRateLimited }
|
||||||
|
|
||||||
|
// MaxBodyExcerpt is the longest response-body excerpt, in bytes, a
|
||||||
|
// ProviderError keeps.
|
||||||
|
const MaxBodyExcerpt = 512
|
||||||
|
|
||||||
|
// redactionMarker replaces each secret in redacted text, unless the marker
|
||||||
|
// would itself hold the secret.
|
||||||
|
const redactionMarker = "[REDACTED]"
|
||||||
|
|
||||||
|
// Redact returns s with every occurrence of a non-empty secret removed. Each
|
||||||
|
// occurrence becomes "[REDACTED]" when the result then holds no secret;
|
||||||
|
// otherwise, because the marker contains the secret or joins with its
|
||||||
|
// neighbours to form it, occurrences are deleted until none remains. The
|
||||||
|
// result never contains secret.
|
||||||
|
func Redact(s, secret string) string {
|
||||||
|
if secret == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if out := strings.ReplaceAll(s, secret, redactionMarker); !strings.Contains(out, secret) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for strings.Contains(s, secret) {
|
||||||
|
s = strings.ReplaceAll(s, secret, "")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderError is an unexpected HTTP status from the vendor. It matches
|
||||||
|
// ErrProvider. Build it with NewProviderError, which redacts the body.
|
||||||
|
type ProviderError struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProviderError returns the error for an unexpected HTTP status. It
|
||||||
|
// always keeps a body excerpt of at most MaxBodyExcerpt bytes. secret is the
|
||||||
|
// credential sent with the request, or "" when the request sent none; every
|
||||||
|
// occurrence of a non-empty secret in body is removed by Redact before the
|
||||||
|
// body is cut, so no part of it survives, and again after the cut, so the
|
||||||
|
// truncation mark cannot complete it. It is removed from the complete error
|
||||||
|
// text too, even where the fixed text happens to spell it, and the secret
|
||||||
|
// itself is not kept.
|
||||||
|
func NewProviderError(status int, body []byte, secret string) *ProviderError {
|
||||||
|
s := strings.TrimSpace(Redact(string(body), secret))
|
||||||
|
if len(s) > MaxBodyExcerpt {
|
||||||
|
cut := MaxBodyExcerpt
|
||||||
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
s = Redact(s[:cut]+"…", secret)
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("%s: HTTP %d", ErrProvider, status)
|
||||||
|
if s != "" {
|
||||||
|
text += ": " + s
|
||||||
|
}
|
||||||
|
return &ProviderError{status: status, body: s, text: Redact(text, secret)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the HTTP status code.
|
||||||
|
func (e *ProviderError) Status() int { return e.status }
|
||||||
|
|
||||||
|
// Body returns the redacted, truncated response-body excerpt; it is empty
|
||||||
|
// when the vendor sent no body.
|
||||||
|
func (e *ProviderError) Body() string { return e.body }
|
||||||
|
|
||||||
|
func (e *ProviderError) Error() string { return e.text }
|
||||||
|
|
||||||
|
// GoString keeps %#v to the redacted Error text instead of the fields,
|
||||||
|
// whose names could spell the credential.
|
||||||
|
func (e *ProviderError) GoString() string { return e.text }
|
||||||
|
|
||||||
|
// Is reports whether target is ErrProvider.
|
||||||
|
func (e *ProviderError) Is(target error) bool { return target == ErrProvider }
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSentinelErrorsMatchWhenWrapped(t *testing.T) {
|
||||||
|
for _, sentinel := range []error{ErrAuth, ErrTimeout, ErrBadOutput} {
|
||||||
|
wrapped := fmt.Errorf("openai: generate: %w", sentinel)
|
||||||
|
if !errors.Is(wrapped, sentinel) {
|
||||||
|
t.Errorf("errors.Is(%v, %v) = false", wrapped, sentinel)
|
||||||
|
}
|
||||||
|
for _, other := range []error{ErrAuth, ErrTimeout, ErrBadOutput, ErrRateLimited, ErrProvider} {
|
||||||
|
if other != sentinel && errors.Is(wrapped, other) {
|
||||||
|
t.Errorf("errors.Is(%v, %v) = true", wrapped, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitedError(t *testing.T) {
|
||||||
|
err := fmt.Errorf("anthropic: %w", &RateLimitedError{RetryAfter: 30 * time.Second})
|
||||||
|
if !errors.Is(err, ErrRateLimited) {
|
||||||
|
t.Fatal("errors.Is(err, ErrRateLimited) = false")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrProvider) || errors.Is(err, ErrAuth) {
|
||||||
|
t.Fatal("rate-limited error matches an unrelated sentinel")
|
||||||
|
}
|
||||||
|
var rl *RateLimitedError
|
||||||
|
if !errors.As(err, &rl) {
|
||||||
|
t.Fatal("errors.As(err, *RateLimitedError) = false")
|
||||||
|
}
|
||||||
|
if rl.RetryAfter != 30*time.Second {
|
||||||
|
t.Fatalf("RetryAfter = %v, want 30s", rl.RetryAfter)
|
||||||
|
}
|
||||||
|
if got := err.Error(); got != "anthropic: llm: rate limited: retry after 30s" {
|
||||||
|
t.Fatalf("Error() = %q", got)
|
||||||
|
}
|
||||||
|
if got := (&RateLimitedError{}).Error(); got != "llm: rate limited" {
|
||||||
|
t.Fatalf("Error() without RetryAfter = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderError(t *testing.T) {
|
||||||
|
err := fmt.Errorf("gemini: %w", NewProviderError(503, []byte(` {"error":"overloaded"} `), "sk-test"))
|
||||||
|
if !errors.Is(err, ErrProvider) {
|
||||||
|
t.Fatal("errors.Is(err, ErrProvider) = false")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrAuth) {
|
||||||
|
t.Fatal("provider error matches an unrelated sentinel")
|
||||||
|
}
|
||||||
|
var pe *ProviderError
|
||||||
|
if !errors.As(err, &pe) {
|
||||||
|
t.Fatal("errors.As(err, *ProviderError) = false")
|
||||||
|
}
|
||||||
|
if pe.Status() != 503 || pe.Body() != `{"error":"overloaded"}` {
|
||||||
|
t.Fatalf("Status, Body = %d, %q", pe.Status(), pe.Body())
|
||||||
|
}
|
||||||
|
if got := err.Error(); got != `gemini: llm: provider error: HTTP 503: {"error":"overloaded"}` {
|
||||||
|
t.Fatalf("Error() = %q", got)
|
||||||
|
}
|
||||||
|
if got := NewProviderError(500, nil, "sk-test").Error(); got != "llm: provider error: HTTP 500" {
|
||||||
|
t.Fatalf("Error() without body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorRedactsSecrets(t *testing.T) {
|
||||||
|
const key = "sk-live-0123456789abcdef"
|
||||||
|
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef","echo":"Bearer sk-live-0123456789abcdef"}`)
|
||||||
|
pe := NewProviderError(400, body, key)
|
||||||
|
for _, s := range []string{pe.Error(), pe.Body()} {
|
||||||
|
if strings.Contains(s, key) {
|
||||||
|
t.Fatalf("secret leaked: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := `{"error":"invalid key [REDACTED]","echo":"Bearer [REDACTED]"}`
|
||||||
|
if pe.Body() != want {
|
||||||
|
t.Fatalf("Body() = %q, want %q", pe.Body(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorRedactsBeforeTruncating(t *testing.T) {
|
||||||
|
const key = "sk-secret-straddling-the-cut"
|
||||||
|
// The key starts just before the excerpt limit, so truncating first
|
||||||
|
// would leave its prefix in the excerpt.
|
||||||
|
body := strings.Repeat("x", MaxBodyExcerpt-5) + key
|
||||||
|
pe := NewProviderError(500, []byte(body), key)
|
||||||
|
if strings.Contains(pe.Body(), "sk-se") {
|
||||||
|
t.Fatalf("secret prefix leaked: %q", pe.Body())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorTruncatesOnRuneBoundary(t *testing.T) {
|
||||||
|
body := strings.Repeat("a", MaxBodyExcerpt-1) + "é" + strings.Repeat("b", 100)
|
||||||
|
pe := NewProviderError(500, []byte(body), "sk-test")
|
||||||
|
want := strings.Repeat("a", MaxBodyExcerpt-1) + "…"
|
||||||
|
if pe.Body() != want {
|
||||||
|
t.Fatalf("Body() has %d bytes, want %d", len(pe.Body()), len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorKeepsBoundedBodyWithoutSecret(t *testing.T) {
|
||||||
|
body := []byte(` {"error":"model \"llama9\" not found"} `)
|
||||||
|
pe := NewProviderError(404, body, "")
|
||||||
|
if pe.Body() != `{"error":"model \"llama9\" not found"}` {
|
||||||
|
t.Fatalf("Body() = %q", pe.Body())
|
||||||
|
}
|
||||||
|
if got := pe.Error(); got != `llm: provider error: HTTP 404: {"error":"model \"llama9\" not found"}` {
|
||||||
|
t.Fatalf("Error() = %q", got)
|
||||||
|
}
|
||||||
|
long := NewProviderError(500, []byte(strings.Repeat("x", 4*MaxBodyExcerpt)), "")
|
||||||
|
if want := strings.Repeat("x", MaxBodyExcerpt) + "…"; long.Body() != want {
|
||||||
|
t.Fatalf("Body() has %d bytes, want %d", len(long.Body()), len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactNeverLeavesSecret(t *testing.T) {
|
||||||
|
cases := []struct{ s, secret, want string }{
|
||||||
|
{"key sk-1 and Bearer sk-1", "sk-1", "key [REDACTED] and Bearer [REDACTED]"},
|
||||||
|
{"no secret here", "sk-1", "no secret here"},
|
||||||
|
{"x", "", "x"},
|
||||||
|
// The marker holds the secret, so occurrences are deleted.
|
||||||
|
{"rejected REDACTED", "REDACTED", "rejected "},
|
||||||
|
{"A then A", "A", " then "},
|
||||||
|
// The marker joins with a neighbour to form the secret.
|
||||||
|
{"]]aa", "]a", ""},
|
||||||
|
// The marker holds the secret and deleting once leaves a new one.
|
||||||
|
{"DDAA", "DA", ""},
|
||||||
|
{"aabb", "ab", "a[REDACTED]b"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := Redact(c.s, c.secret); got != c.want {
|
||||||
|
t.Errorf("Redact(%q, %q) = %q, want %q", c.s, c.secret, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorWithMarkerSecretNeverLeaksSecret(t *testing.T) {
|
||||||
|
const key = "REDACTED"
|
||||||
|
pe := NewProviderError(500, []byte(`{"error":"bad key REDACTED","echo":"Bearer REDACTED"}`), key)
|
||||||
|
for _, s := range []string{pe.Error(), pe.Body()} {
|
||||||
|
if strings.Contains(s, key) {
|
||||||
|
t.Fatalf("secret leaked: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want := `{"error":"bad key ","echo":"Bearer "}`; pe.Body() != want {
|
||||||
|
t.Fatalf("Body() = %q, want %q", pe.Body(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorTruncationMarkCannotCompleteSecret(t *testing.T) {
|
||||||
|
const key = "z…"
|
||||||
|
body := strings.Repeat("a", MaxBodyExcerpt-1) + "z" + strings.Repeat("b", 10)
|
||||||
|
if pe := NewProviderError(500, []byte(body), key); strings.Contains(pe.Body(), key) {
|
||||||
|
t.Fatalf("secret leaked: %q", pe.Body())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorWithFixedWordSecretNeverLeaksSecret(t *testing.T) {
|
||||||
|
const secret = "HTTP"
|
||||||
|
pe := NewProviderError(500, []byte(`{"error":"HTTP down"}`), secret)
|
||||||
|
if strings.Contains(pe.Error(), secret) || strings.Contains(pe.Body(), secret) {
|
||||||
|
t.Fatalf("secret leaked: Error %q, Body %q", pe.Error(), pe.Body())
|
||||||
|
}
|
||||||
|
if got := NewProviderError(500, nil, secret).Error(); got != "llm: provider error: [REDACTED] 500" {
|
||||||
|
t.Fatalf("Error() without body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitedErrorWithFixedWordSecretNeverLeaksSecret(t *testing.T) {
|
||||||
|
for _, secret := range []string{"rate", "retry after"} {
|
||||||
|
for _, wait := range []time.Duration{0, 30 * time.Second} {
|
||||||
|
rl := NewRateLimitedError(wait, secret)
|
||||||
|
if strings.Contains(rl.Error(), secret) {
|
||||||
|
t.Fatalf("secret %q leaked: %q", secret, rl.Error())
|
||||||
|
}
|
||||||
|
if rl.RetryAfter != wait || !errors.Is(rl, ErrRateLimited) {
|
||||||
|
t.Fatalf("NewRateLimitedError(%v) = %+v", wait, rl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypedErrorsDoNotKeepSecret(t *testing.T) {
|
||||||
|
const secret = "sk-live-0123456789abcdef"
|
||||||
|
for _, err := range []error{
|
||||||
|
NewProviderError(500, []byte("down "+secret), secret),
|
||||||
|
NewRateLimitedError(30*time.Second, secret),
|
||||||
|
} {
|
||||||
|
if got := fmt.Sprintf("%#v %+v", err, err); strings.Contains(got, secret) {
|
||||||
|
t.Fatalf("formatting %T leaks the secret: %q", err, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fake is a scripted Provider for tests. Each method returns its queued
|
||||||
|
// results in order, one per call, and records every Request Generate
|
||||||
|
// receives. A call with nothing left in its queue fails, so an unexpected
|
||||||
|
// call is never silently answered. It is safe for concurrent use.
|
||||||
|
type Fake struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
generate []GenerateResult
|
||||||
|
models []ListModelsResult
|
||||||
|
verify []error
|
||||||
|
requests []Request
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Provider = (*Fake)(nil)
|
||||||
|
|
||||||
|
// GenerateResult is one scripted Generate outcome.
|
||||||
|
type GenerateResult struct {
|
||||||
|
Response Response
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModelsResult is one scripted ListModels outcome.
|
||||||
|
type ListModelsResult struct {
|
||||||
|
Models []Model
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueGenerate appends results to the Generate script.
|
||||||
|
func (f *Fake) QueueGenerate(results ...GenerateResult) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.generate = append(f.generate, results...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueListModels appends results to the ListModels script.
|
||||||
|
func (f *Fake) QueueListModels(results ...ListModelsResult) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.models = append(f.models, results...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueVerify appends results to the Verify script; a nil entry is success.
|
||||||
|
func (f *Fake) QueueVerify(results ...error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.verify = append(f.verify, results...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate records req and returns the next scripted result.
|
||||||
|
func (f *Fake) Generate(_ context.Context, req Request) (Response, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.requests = append(f.requests, cloneRequest(req))
|
||||||
|
if len(f.generate) == 0 {
|
||||||
|
return Response{}, fmt.Errorf("llm: fake: unscripted Generate call %d", len(f.requests))
|
||||||
|
}
|
||||||
|
r := f.generate[0]
|
||||||
|
f.generate = f.generate[1:]
|
||||||
|
return r.Response, r.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels returns the next scripted result.
|
||||||
|
func (f *Fake) ListModels(context.Context) ([]Model, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if len(f.models) == 0 {
|
||||||
|
return nil, fmt.Errorf("llm: fake: unscripted ListModels call")
|
||||||
|
}
|
||||||
|
r := f.models[0]
|
||||||
|
f.models = f.models[1:]
|
||||||
|
return slices.Clone(r.Models), r.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify returns the next scripted result.
|
||||||
|
func (f *Fake) Verify(context.Context) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if len(f.verify) == 0 {
|
||||||
|
return fmt.Errorf("llm: fake: unscripted Verify call")
|
||||||
|
}
|
||||||
|
err := f.verify[0]
|
||||||
|
f.verify = f.verify[1:]
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requests returns a copy of every Request Generate received, in order.
|
||||||
|
func (f *Fake) Requests() []Request {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := make([]Request, len(f.requests))
|
||||||
|
for i, r := range f.requests {
|
||||||
|
out[i] = cloneRequest(r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining reports how many scripted results of each kind are unused, so a
|
||||||
|
// test can assert its script was consumed.
|
||||||
|
func (f *Fake) Remaining() (generate, listModels, verify int) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return len(f.generate), len(f.models), len(f.verify)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneRequest deep-copies r so a recorded Request is unaffected by later
|
||||||
|
// changes the caller makes to its slices and pointers.
|
||||||
|
func cloneRequest(r Request) Request {
|
||||||
|
r.Messages = slices.Clone(r.Messages)
|
||||||
|
if r.Temperature != nil {
|
||||||
|
t := *r.Temperature
|
||||||
|
r.Temperature = &t
|
||||||
|
}
|
||||||
|
if r.Schema != nil {
|
||||||
|
s := JSONSchema{Name: r.Schema.Name, Schema: json.RawMessage(slices.Clone([]byte(r.Schema.Schema)))}
|
||||||
|
r.Schema = &s
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFakeGenerateReturnsScriptInOrder(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var f Fake
|
||||||
|
f.QueueGenerate(
|
||||||
|
GenerateResult{Response: Response{Text: "first", Model: "m1"}},
|
||||||
|
GenerateResult{Err: &RateLimitedError{RetryAfter: time.Second}},
|
||||||
|
)
|
||||||
|
f.QueueGenerate(GenerateResult{Response: Response{JSON: json.RawMessage(`{"ok":true}`), FinishReason: "stop"}})
|
||||||
|
|
||||||
|
req := Request{Messages: []Message{{Role: RoleUser, Content: "one"}}}
|
||||||
|
resp, err := f.Generate(ctx, req)
|
||||||
|
if err != nil || resp.Text != "first" || resp.Model != "m1" {
|
||||||
|
t.Fatalf("call 1 = %+v, %v", resp, err)
|
||||||
|
}
|
||||||
|
if _, err := f.Generate(ctx, req); !errors.Is(err, ErrRateLimited) {
|
||||||
|
t.Fatalf("call 2 err = %v, want ErrRateLimited", err)
|
||||||
|
}
|
||||||
|
resp, err = f.Generate(ctx, req)
|
||||||
|
if err != nil || string(resp.JSON) != `{"ok":true}` || resp.FinishReason != "stop" {
|
||||||
|
t.Fatalf("call 3 = %+v, %v", resp, err)
|
||||||
|
}
|
||||||
|
if _, err := f.Generate(ctx, req); err == nil {
|
||||||
|
t.Fatal("unscripted call 4 succeeded")
|
||||||
|
}
|
||||||
|
if g, l, v := f.Remaining(); g != 0 || l != 0 || v != 0 {
|
||||||
|
t.Fatalf("Remaining() = %d, %d, %d", g, l, v)
|
||||||
|
}
|
||||||
|
if n := len(f.Requests()); n != 4 {
|
||||||
|
t.Fatalf("recorded %d requests, want 4 (unscripted calls are recorded too)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeRecordsRequests(t *testing.T) {
|
||||||
|
var f Fake
|
||||||
|
f.QueueGenerate(GenerateResult{}, GenerateResult{})
|
||||||
|
temp := 0.2
|
||||||
|
first := Request{
|
||||||
|
System: "be brief",
|
||||||
|
Messages: []Message{{Role: RoleUser, Content: "plan dinners"}},
|
||||||
|
Temperature: &temp,
|
||||||
|
MaxTokens: 100,
|
||||||
|
Schema: &JSONSchema{Name: "plan", Schema: json.RawMessage(`{"type":"object"}`)},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
if _, err := f.Generate(context.Background(), first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.Generate(context.Background(), Request{System: "second"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Later changes by the caller must not alter what was recorded.
|
||||||
|
first.Messages[0].Content = "changed"
|
||||||
|
temp = 1
|
||||||
|
first.Schema.Schema[2] = 'X'
|
||||||
|
|
||||||
|
got := f.Requests()
|
||||||
|
if len(got) != 2 || got[1].System != "second" {
|
||||||
|
t.Fatalf("Requests() = %+v", got)
|
||||||
|
}
|
||||||
|
r := got[0]
|
||||||
|
if r.System != "be brief" || r.Messages[0].Content != "plan dinners" || *r.Temperature != 0.2 ||
|
||||||
|
r.MaxTokens != 100 || r.Schema.Name != "plan" || string(r.Schema.Schema) != `{"type":"object"}` ||
|
||||||
|
r.Timeout != 5*time.Second {
|
||||||
|
t.Fatalf("recorded request = %+v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing a returned copy must not alter the record either.
|
||||||
|
got[0].Messages[0].Content = "mutated"
|
||||||
|
if f.Requests()[0].Messages[0].Content != "plan dinners" {
|
||||||
|
t.Fatal("Requests() returned shared state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeListModelsAndVerify(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var f Fake
|
||||||
|
f.QueueListModels(
|
||||||
|
ListModelsResult{Models: []Model{{ID: "a", DisplayName: "A"}, {ID: "b", DisplayName: "b"}}},
|
||||||
|
ListModelsResult{Err: ErrAuth},
|
||||||
|
)
|
||||||
|
f.QueueVerify(nil, fmt.Errorf("wrapped: %w", ErrAuth))
|
||||||
|
|
||||||
|
models, err := f.ListModels(ctx)
|
||||||
|
if err != nil || len(models) != 2 || models[0].ID != "a" {
|
||||||
|
t.Fatalf("ListModels 1 = %+v, %v", models, err)
|
||||||
|
}
|
||||||
|
if _, err := f.ListModels(ctx); !errors.Is(err, ErrAuth) {
|
||||||
|
t.Fatalf("ListModels 2 err = %v, want ErrAuth", err)
|
||||||
|
}
|
||||||
|
if _, err := f.ListModels(ctx); err == nil {
|
||||||
|
t.Fatal("unscripted ListModels succeeded")
|
||||||
|
}
|
||||||
|
if err := f.Verify(ctx); err != nil {
|
||||||
|
t.Fatalf("Verify 1 = %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Verify(ctx); !errors.Is(err, ErrAuth) {
|
||||||
|
t.Fatalf("Verify 2 = %v, want ErrAuth", err)
|
||||||
|
}
|
||||||
|
if err := f.Verify(ctx); err == nil {
|
||||||
|
t.Fatal("unscripted Verify succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeConcurrentUse(t *testing.T) {
|
||||||
|
const n = 50
|
||||||
|
var f Fake
|
||||||
|
for i := range n {
|
||||||
|
f.QueueGenerate(GenerateResult{Response: Response{Text: fmt.Sprint(i)}})
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range n {
|
||||||
|
wg.Go(func() {
|
||||||
|
if _, err := f.Generate(context.Background(), Request{}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if len(f.Requests()) != n {
|
||||||
|
t.Fatalf("recorded %d requests, want %d", len(f.Requests()), n)
|
||||||
|
}
|
||||||
|
if g, _, _ := f.Remaining(); g != 0 {
|
||||||
|
t.Fatalf("%d Generate results unused", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Package llm is the single interface MealQuest uses to talk to a language
|
||||||
|
// model. Every vendor adapter implements Provider over plain net/http (no
|
||||||
|
// vendor SDKs) and reports failures with this package's typed errors, so
|
||||||
|
// callers decide what to do from errors.Is and errors.As alone.
|
||||||
|
//
|
||||||
|
// Errors from this package and its adapters never contain an API key.
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider is a language-model vendor.
|
||||||
|
type Provider interface {
|
||||||
|
// Generate sends one request and returns the model's reply.
|
||||||
|
Generate(ctx context.Context, req Request) (Response, error)
|
||||||
|
// ListModels returns the models the configured credentials can use.
|
||||||
|
ListModels(ctx context.Context) ([]Model, error)
|
||||||
|
// Verify checks that the configured credentials are accepted.
|
||||||
|
Verify(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role is the author of a Message.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
// The roles a Message may have. The system prompt is Request.System, not a
|
||||||
|
// message.
|
||||||
|
const (
|
||||||
|
RoleUser Role = "user"
|
||||||
|
RoleAssistant Role = "assistant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is one turn of a conversation.
|
||||||
|
type Message struct {
|
||||||
|
Role Role
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONSchema is a named structured-output contract: when a Request carries
|
||||||
|
// one, the model must reply with JSON matching Schema.
|
||||||
|
type JSONSchema struct {
|
||||||
|
// Name identifies the contract to the vendor, e.g. "side_quests".
|
||||||
|
Name string
|
||||||
|
// Schema is a JSON Schema object.
|
||||||
|
Schema json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request is one generation request.
|
||||||
|
type Request struct {
|
||||||
|
// System is the system prompt; empty means none.
|
||||||
|
System string
|
||||||
|
// Messages is the conversation, oldest first. It must not be empty.
|
||||||
|
Messages []Message
|
||||||
|
// Temperature is the sampling temperature; nil means the vendor default.
|
||||||
|
Temperature *float64
|
||||||
|
// MaxTokens caps the reply length; 0 means the vendor default.
|
||||||
|
MaxTokens int
|
||||||
|
// Schema, when set, requests structured output; the reply is then in
|
||||||
|
// Response.JSON.
|
||||||
|
Schema *JSONSchema
|
||||||
|
// Timeout bounds the request; 0 means only ctx bounds it.
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reports whether r is well formed. Adapters call it before
|
||||||
|
// contacting the vendor. Its errors never quote the request's text, such as
|
||||||
|
// a role or schema name: any of it may equal the API key.
|
||||||
|
func (r Request) Validate() error {
|
||||||
|
if len(r.Messages) == 0 {
|
||||||
|
return errors.New("llm: request has no messages")
|
||||||
|
}
|
||||||
|
for i, m := range r.Messages {
|
||||||
|
if m.Role != RoleUser && m.Role != RoleAssistant {
|
||||||
|
return fmt.Errorf("llm: message %d has an unknown role", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Temperature != nil && *r.Temperature < 0 {
|
||||||
|
return fmt.Errorf("llm: temperature %v is negative", *r.Temperature)
|
||||||
|
}
|
||||||
|
if r.MaxTokens < 0 {
|
||||||
|
return fmt.Errorf("llm: max tokens %d is negative", r.MaxTokens)
|
||||||
|
}
|
||||||
|
if r.Timeout < 0 {
|
||||||
|
return fmt.Errorf("llm: timeout %v is negative", r.Timeout)
|
||||||
|
}
|
||||||
|
if r.Schema != nil {
|
||||||
|
if r.Schema.Name == "" {
|
||||||
|
return errors.New("llm: schema has no name")
|
||||||
|
}
|
||||||
|
var obj map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(r.Schema.Schema, &obj); err != nil || obj == nil {
|
||||||
|
return errors.New("llm: schema is not a JSON object")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage is the token count of one Generate call.
|
||||||
|
type Usage struct {
|
||||||
|
InputTokens int
|
||||||
|
OutputTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total returns InputTokens + OutputTokens.
|
||||||
|
func (u Usage) Total() int { return u.InputTokens + u.OutputTokens }
|
||||||
|
|
||||||
|
// Response is the model's reply to a Request.
|
||||||
|
type Response struct {
|
||||||
|
// Text is the reply text.
|
||||||
|
Text string
|
||||||
|
// JSON is the structured reply when the Request carried a Schema, and
|
||||||
|
// nil otherwise.
|
||||||
|
JSON json.RawMessage
|
||||||
|
// Usage is the tokens the call consumed.
|
||||||
|
Usage Usage
|
||||||
|
// Model is the model that answered, as the vendor reports it.
|
||||||
|
Model string
|
||||||
|
// FinishReason is why generation stopped, as the vendor reports it.
|
||||||
|
FinishReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is a model a Provider offers.
|
||||||
|
type Model struct {
|
||||||
|
// ID is the vendor's model identifier, used in requests.
|
||||||
|
ID string
|
||||||
|
// DisplayName is a human-readable name; it equals ID when the vendor
|
||||||
|
// gives none.
|
||||||
|
DisplayName string
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestValidate(t *testing.T) {
|
||||||
|
neg := -0.1
|
||||||
|
valid := func() Request {
|
||||||
|
return Request{
|
||||||
|
Messages: []Message{{Role: RoleUser, Content: "hi"}, {Role: RoleAssistant, Content: "hello"}},
|
||||||
|
Schema: &JSONSchema{Name: "plan", Schema: json.RawMessage(`{"type":"object"}`)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := valid().Validate(); err != nil {
|
||||||
|
t.Fatalf("valid request: %v", err)
|
||||||
|
}
|
||||||
|
cases := map[string]func(*Request){
|
||||||
|
"no messages": func(r *Request) { r.Messages = nil },
|
||||||
|
"unknown role": func(r *Request) { r.Messages[0].Role = "system" },
|
||||||
|
"negative temp": func(r *Request) { r.Temperature = &neg },
|
||||||
|
"negative max": func(r *Request) { r.MaxTokens = -1 },
|
||||||
|
"negative time": func(r *Request) { r.Timeout = -1 },
|
||||||
|
"unnamed schema": func(r *Request) { r.Schema.Name = "" },
|
||||||
|
"schema array": func(r *Request) { r.Schema.Schema = json.RawMessage(`[]`) },
|
||||||
|
"schema null": func(r *Request) { r.Schema.Schema = json.RawMessage(`null`) },
|
||||||
|
"schema invalid": func(r *Request) { r.Schema.Schema = json.RawMessage(`{`) },
|
||||||
|
"schema empty": func(r *Request) { r.Schema.Schema = nil },
|
||||||
|
}
|
||||||
|
for name, mutate := range cases {
|
||||||
|
r := valid()
|
||||||
|
mutate(&r)
|
||||||
|
if err := r.Validate(); err == nil {
|
||||||
|
t.Errorf("%s: Validate() = nil", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageTotal(t *testing.T) {
|
||||||
|
if got := (Usage{InputTokens: 12, OutputTokens: 30}).Total(); got != 42 {
|
||||||
|
t.Fatalf("Total() = %d, want 42", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
// Package ollama is the llm.Provider adapter for Ollama's native API. It
|
||||||
|
// calls POST {base}/api/chat and GET {base}/api/tags over plain net/http.
|
||||||
|
//
|
||||||
|
// Ollama takes no credentials: the adapter sends no Authorization header
|
||||||
|
// and never logs. The base URL may be a LAN address, and errors for an
|
||||||
|
// unreachable server name it, so New refuses a base URL that could carry a
|
||||||
|
// credential.
|
||||||
|
package ollama
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures a Provider.
|
||||||
|
type Config struct {
|
||||||
|
// BaseURL is the Ollama server root the /api endpoint paths are
|
||||||
|
// appended to, e.g. "http://192.168.1.20:11434" or
|
||||||
|
// "http://ollama:11434". It is required.
|
||||||
|
BaseURL string
|
||||||
|
// Model is the model Generate requests, e.g. "llama3.2". ListModels
|
||||||
|
// and Verify do not need it.
|
||||||
|
Model string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is an Ollama native API llm.Provider.
|
||||||
|
type Provider struct {
|
||||||
|
base string
|
||||||
|
model string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ llm.Provider = (*Provider)(nil)
|
||||||
|
|
||||||
|
// New validates cfg and returns a Provider.
|
||||||
|
func New(cfg Config) (*Provider, error) {
|
||||||
|
// Base URL errors never echo the URL: a rejected one may hold a
|
||||||
|
// credential.
|
||||||
|
u, err := url.Parse(cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("ollama: base URL does not parse")
|
||||||
|
}
|
||||||
|
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return nil, errors.New("ollama: base URL must be an absolute http or https URL")
|
||||||
|
}
|
||||||
|
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||||
|
// endpoint path appended to it would land in the query or fragment.
|
||||||
|
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||||
|
return nil, errors.New("ollama: base URL must not carry credentials, a query or a fragment")
|
||||||
|
}
|
||||||
|
return &Provider{
|
||||||
|
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
|
model: cfg.Model,
|
||||||
|
client: noRedirectClient(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxResponseBody caps a successful response body; a larger one is an error.
|
||||||
|
const maxResponseBody = 16 << 20
|
||||||
|
|
||||||
|
// maxErrorBody caps how much of an error response body is read; only an
|
||||||
|
// llm.MaxBodyExcerpt of it is kept.
|
||||||
|
const maxErrorBody = 64 << 10
|
||||||
|
|
||||||
|
type message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
NumPredict int `json:"num_predict,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []message `json:"messages"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
Format json.RawMessage `json:"format,omitempty"`
|
||||||
|
Options *options `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Message *struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
DoneReason string `json:"done_reason"`
|
||||||
|
PromptEvalCount int `json:"prompt_eval_count"`
|
||||||
|
EvalCount int `json:"eval_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sends req to {base}/api/chat without streaming. A Request with a
|
||||||
|
// Schema sends it as "format", so Ollama constrains the reply to it;
|
||||||
|
// Response.JSON is then the reply text, which must be a JSON object.
|
||||||
|
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("ollama: generate: %w", err)
|
||||||
|
}
|
||||||
|
if p.model == "" {
|
||||||
|
return llm.Response{}, errors.New("ollama: generate: no model configured")
|
||||||
|
}
|
||||||
|
body := chatRequest{Model: p.model, Stream: false}
|
||||||
|
if req.System != "" {
|
||||||
|
body.Messages = append(body.Messages, message{Role: "system", Content: req.System})
|
||||||
|
}
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content})
|
||||||
|
}
|
||||||
|
if req.Schema != nil {
|
||||||
|
body.Format = req.Schema.Schema
|
||||||
|
}
|
||||||
|
if req.Temperature != nil || req.MaxTokens > 0 {
|
||||||
|
body.Options = &options{Temperature: req.Temperature, NumPredict: req.MaxTokens}
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("ollama: generate: encode request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
raw, err := p.do(ctx, http.MethodPost, "/api/chat", payload)
|
||||||
|
if err != nil {
|
||||||
|
var pe *llm.ProviderError
|
||||||
|
if errors.As(err, &pe) && pe.Status() == http.StatusNotFound && p.modelNotFound(pe.Body()) {
|
||||||
|
// The body is not quoted: the model name is all the caller
|
||||||
|
// needs. Any other 404 keeps its body excerpt below.
|
||||||
|
return llm.Response{}, fmt.Errorf("ollama: generate: model %q not found: %w", p.model, llm.NewProviderError(pe.Status(), nil, ""))
|
||||||
|
}
|
||||||
|
return llm.Response{}, fmt.Errorf("ollama: generate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cr chatResponse
|
||||||
|
if err := json.Unmarshal(raw, &cr); err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("ollama: generate: decode response: %w", err)
|
||||||
|
}
|
||||||
|
if !cr.Done || cr.Message == nil {
|
||||||
|
return llm.Response{}, errors.New("ollama: generate: response is not a finished message")
|
||||||
|
}
|
||||||
|
resp := llm.Response{
|
||||||
|
Text: cr.Message.Content,
|
||||||
|
Usage: llm.Usage{InputTokens: cr.PromptEvalCount, OutputTokens: cr.EvalCount},
|
||||||
|
Model: cr.Model,
|
||||||
|
FinishReason: cr.DoneReason,
|
||||||
|
}
|
||||||
|
if req.Schema == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if text := strings.TrimSpace(resp.Text); isJSONObject(text) {
|
||||||
|
resp.JSON = json.RawMessage(text)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
return resp, fmt.Errorf("ollama: generate: reply is not a JSON object: %w", llm.ErrBadOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelNotFound reports whether body is Ollama's error for the configured
|
||||||
|
// model not being pulled: {"error": "model \"name\" not found, ..."}, or
|
||||||
|
// with the name in single quotes as newer servers send it. A 404 from
|
||||||
|
// anything else, such as a proxy without /api/chat, is not.
|
||||||
|
func (p *Provider) modelNotFound(body string) bool {
|
||||||
|
var e struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(body), &e); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(e.Error, fmt.Sprintf("model %q not found", p.model)) ||
|
||||||
|
strings.HasPrefix(e.Error, fmt.Sprintf("model '%s' not found", p.model))
|
||||||
|
}
|
||||||
|
|
||||||
|
// isJSONObject reports whether s is exactly one JSON object.
|
||||||
|
func isJSONObject(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "{") && json.Valid([]byte(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagsResponse struct {
|
||||||
|
Models []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels returns every model {base}/api/tags lists.
|
||||||
|
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||||
|
models, err := p.tags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ollama: list models: %w", err)
|
||||||
|
}
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks that the server answers GET {base}/api/tags with a model
|
||||||
|
// list.
|
||||||
|
func (p *Provider) Verify(ctx context.Context) error {
|
||||||
|
if _, err := p.tags(ctx); err != nil {
|
||||||
|
return fmt.Errorf("ollama: verify: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) tags(ctx context.Context) ([]llm.Model, error) {
|
||||||
|
raw, err := p.do(ctx, http.MethodGet, "/api/tags", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tr tagsResponse
|
||||||
|
if err := json.Unmarshal(raw, &tr); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
if tr.Models == nil {
|
||||||
|
return nil, errors.New("response has no models list")
|
||||||
|
}
|
||||||
|
models := make([]llm.Model, 0, len(tr.Models))
|
||||||
|
for i, m := range tr.Models {
|
||||||
|
if m.Name == "" {
|
||||||
|
return nil, fmt.Errorf("model %d has no name", i)
|
||||||
|
}
|
||||||
|
models = append(models, llm.Model{ID: m.Name, DisplayName: m.Name})
|
||||||
|
}
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do sends one request and returns the body of a 2xx response. Any other
|
||||||
|
// status, a 3xx included, is an *llm.ProviderError.
|
||||||
|
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, error) {
|
||||||
|
var reqBody io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
reqBody = bytes.NewReader(payload)
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
if payload != nil {
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, err := p.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.transportError(err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.transportError(err)
|
||||||
|
}
|
||||||
|
return nil, llm.NewProviderError(httpResp.StatusCode, body, "")
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.transportError(err)
|
||||||
|
}
|
||||||
|
if len(body) > maxResponseBody {
|
||||||
|
return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// noRedirectClient returns an HTTP client that never follows a redirect:
|
||||||
|
// the 3xx response is returned and becomes a provider error, so a request
|
||||||
|
// is only ever sent to the configured server.
|
||||||
|
func noRedirectClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeout(err error) bool {
|
||||||
|
var ne net.Error
|
||||||
|
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportFailure is a failed HTTP exchange. Its text names the base URL
|
||||||
|
// and the cause; it unwraps only to the cause's category, llm.ErrTimeout or
|
||||||
|
// context.Canceled.
|
||||||
|
type transportFailure struct {
|
||||||
|
text string
|
||||||
|
kind error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *transportFailure) Error() string { return e.text }
|
||||||
|
|
||||||
|
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||||
|
|
||||||
|
// transportError reports a failure to complete the HTTP exchange. A
|
||||||
|
// cancelled context is the caller's choice and stays context.Canceled.
|
||||||
|
// Every other failure, a refused connection or a timeout alike, means the
|
||||||
|
// server could not be reached in time: it matches llm.ErrTimeout and says
|
||||||
|
// "Ollama not reachable at {base}".
|
||||||
|
func (p *Provider) transportError(err error) error {
|
||||||
|
timedOut := isTimeout(err)
|
||||||
|
// The *url.Error repeats the request URL; the base is named once below.
|
||||||
|
var ue *url.Error
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
err = ue.Err
|
||||||
|
}
|
||||||
|
if !timedOut && errors.Is(err, context.Canceled) {
|
||||||
|
return &transportFailure{text: "request canceled: " + err.Error(), kind: context.Canceled}
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("Ollama not reachable at %s: %s", p.base, err)
|
||||||
|
if timedOut {
|
||||||
|
text = fmt.Sprintf("Ollama not reachable at %s: %s: %s", p.base, llm.ErrTimeout, err)
|
||||||
|
}
|
||||||
|
return &transportFailure{text: text, kind: llm.ErrTimeout}
|
||||||
|
}
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
package ollama
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recorded is one request the test server received.
|
||||||
|
type recorded struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
query url.Values
|
||||||
|
header http.Header
|
||||||
|
body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// reply is a response the test server replays.
|
||||||
|
type reply struct {
|
||||||
|
status int
|
||||||
|
fixture string // testdata file
|
||||||
|
body string // used when fixture is empty
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// ollamaServer replays its replies in order, repeating the last, and
|
||||||
|
// records every request.
|
||||||
|
type ollamaServer struct {
|
||||||
|
*httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
got []recorded
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOllamaServer(t *testing.T, replies ...reply) *ollamaServer {
|
||||||
|
t.Helper()
|
||||||
|
payloads := make([][]byte, len(replies))
|
||||||
|
for i, r := range replies {
|
||||||
|
payloads[i] = []byte(r.body)
|
||||||
|
if r.fixture != "" {
|
||||||
|
raw, err := os.ReadFile(filepath.Join("testdata", r.fixture))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payloads[i] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := &ollamaServer{}
|
||||||
|
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
body, _ := io.ReadAll(req.Body)
|
||||||
|
s.mu.Lock()
|
||||||
|
n := len(s.got)
|
||||||
|
s.got = append(s.got, recorded{req.Method, req.URL.Path, req.URL.Query(), req.Header.Clone(), body})
|
||||||
|
s.mu.Unlock()
|
||||||
|
i := min(n, len(replies)-1)
|
||||||
|
r := replies[i]
|
||||||
|
if r.delay > 0 {
|
||||||
|
select {
|
||||||
|
case <-time.After(r.delay):
|
||||||
|
case <-req.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(r.status)
|
||||||
|
w.Write(payloads[i])
|
||||||
|
}))
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ollamaServer) requests() []recorded {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append([]recorded(nil), s.got...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ollamaServer) only(t *testing.T) recorded {
|
||||||
|
t.Helper()
|
||||||
|
got := s.requests()
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("server got %d requests, want 1", len(got))
|
||||||
|
}
|
||||||
|
return got[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProvider(t *testing.T, base string) *Provider {
|
||||||
|
t.Helper()
|
||||||
|
p, err := New(Config{BaseURL: base, Model: "llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
var dinnersSchema = json.RawMessage(`{"type":"object","properties":{"dinners":{"type":"array"}},"required":["dinners"]}`)
|
||||||
|
|
||||||
|
func schemaRequest() llm.Request {
|
||||||
|
temp := 0.0
|
||||||
|
return llm.Request{
|
||||||
|
System: "You plan dinners for Our kitchen.",
|
||||||
|
Messages: []llm.Message{
|
||||||
|
{Role: llm.RoleUser, Content: "Suggest a Side quest."},
|
||||||
|
{Role: llm.RoleAssistant, Content: "Something quick?"},
|
||||||
|
{Role: llm.RoleUser, Content: "Yes, a Quick dinner."},
|
||||||
|
},
|
||||||
|
Temperature: &temp,
|
||||||
|
MaxTokens: 400,
|
||||||
|
Schema: &llm.JSONSchema{Name: "side_quests", Schema: dinnersSchema},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textRequest() llm.Request {
|
||||||
|
return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "What should we cook tonight?"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentBody decodes a request body into a generic map.
|
||||||
|
func sentBody(t *testing.T, r recorded) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(r.body, &m); err != nil {
|
||||||
|
t.Fatalf("request body %q: %v", r.body, err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonical re-encodes JSON so two encodings of one value compare equal.
|
||||||
|
func canonical(t *testing.T, raw []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
t.Fatalf("JSON %q: %v", raw, err)
|
||||||
|
}
|
||||||
|
out, _ := json.Marshal(v)
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoAuth(t *testing.T, r recorded) {
|
||||||
|
t.Helper()
|
||||||
|
if got := r.header.Values("Authorization"); len(got) != 0 {
|
||||||
|
t.Errorf("Authorization = %q, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateText(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"})
|
||||||
|
p := newProvider(t, s.URL+"/")
|
||||||
|
|
||||||
|
resp, err := p.Generate(context.Background(), textRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Text != "Try a crock pot chili: it is a Quick dinner." || resp.JSON != nil {
|
||||||
|
t.Errorf("Text, JSON = %q, %s", resp.Text, resp.JSON)
|
||||||
|
}
|
||||||
|
if resp.Usage != (llm.Usage{InputTokens: 26, OutputTokens: 298}) || resp.Model != "llama3.2" || resp.FinishReason != "stop" {
|
||||||
|
t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.only(t)
|
||||||
|
if got.method != http.MethodPost || got.path != "/api/chat" || len(got.query) != 0 {
|
||||||
|
t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode())
|
||||||
|
}
|
||||||
|
assertNoAuth(t, got)
|
||||||
|
body := sentBody(t, got)
|
||||||
|
if stream, ok := body["stream"]; !ok || stream != false {
|
||||||
|
t.Errorf("stream = %v (present %v), want false", stream, ok)
|
||||||
|
}
|
||||||
|
if body["model"] != "llama3.2" {
|
||||||
|
t.Errorf("model = %v", body["model"])
|
||||||
|
}
|
||||||
|
for _, field := range []string{"format", "options"} {
|
||||||
|
if v, ok := body[field]; ok {
|
||||||
|
t.Errorf("%s = %v, want it omitted", field, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, want := canonical(t, mustMarshal(t, body["messages"])), `[{"content":"What should we cook tonight?","role":"user"}]`; got != want {
|
||||||
|
t.Errorf("messages = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshal(t *testing.T, v any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSchemaOutput(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 200, fixture: "chat_schema.json"})
|
||||||
|
p := newProvider(t, s.URL)
|
||||||
|
|
||||||
|
resp, err := p.Generate(context.Background(), schemaRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := `{"dinners":[{"name":"Sheet-pan gnocchi","why":"Like your Regular tray bake, but quicker."}]}`
|
||||||
|
if got := canonical(t, resp.JSON); got != want {
|
||||||
|
t.Errorf("Response.JSON = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
if canonical(t, []byte(resp.Text)) != want {
|
||||||
|
t.Errorf("Text = %q, want the reply content", resp.Text)
|
||||||
|
}
|
||||||
|
if resp.Usage != (llm.Usage{InputTokens: 147, OutputTokens: 41}) || resp.FinishReason != "stop" {
|
||||||
|
t.Errorf("Usage, FinishReason = %+v, %q", resp.Usage, resp.FinishReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.only(t)
|
||||||
|
assertNoAuth(t, got)
|
||||||
|
body := sentBody(t, got)
|
||||||
|
if body["stream"] != false {
|
||||||
|
t.Errorf("stream = %v, want false", body["stream"])
|
||||||
|
}
|
||||||
|
if got, want := canonical(t, mustMarshal(t, body["format"])), canonical(t, dinnersSchema); got != want {
|
||||||
|
t.Errorf("format = %s, want the schema %s", got, want)
|
||||||
|
}
|
||||||
|
opts, _ := body["options"].(map[string]any)
|
||||||
|
if len(opts) != 2 || opts["temperature"] != 0.0 || opts["num_predict"] != 400.0 {
|
||||||
|
t.Errorf("options = %v, want temperature 0 and num_predict 400", body["options"])
|
||||||
|
}
|
||||||
|
wantMessages := `[{"content":"You plan dinners for Our kitchen.","role":"system"},` +
|
||||||
|
`{"content":"Suggest a Side quest.","role":"user"},` +
|
||||||
|
`{"content":"Something quick?","role":"assistant"},` +
|
||||||
|
`{"content":"Yes, a Quick dinner.","role":"user"}]`
|
||||||
|
if got := canonical(t, mustMarshal(t, body["messages"])); got != wantMessages {
|
||||||
|
t.Errorf("messages = %s, want %s", got, wantMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateTemperatureOnlyWhenSet(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"})
|
||||||
|
req := textRequest()
|
||||||
|
temp := 0.7
|
||||||
|
req.Temperature = &temp
|
||||||
|
if _, err := newProvider(t, s.URL).Generate(context.Background(), req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
opts, _ := sentBody(t, s.only(t))["options"].(map[string]any)
|
||||||
|
if len(opts) != 1 || opts["temperature"] != 0.7 {
|
||||||
|
t.Errorf("options = %v, want only temperature 0.7", opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateBadOutput(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"prose": `{"model":"m","message":{"role":"assistant","content":"Sure! Try chili."},"done":true,"done_reason":"stop"}`,
|
||||||
|
"array": `{"model":"m","message":{"role":"assistant","content":"[1,2]"},"done":true,"done_reason":"stop"}`,
|
||||||
|
"cut-off JSON": `{"model":"m","message":{"role":"assistant","content":"{\"dinners\": ["},"done":true,"done_reason":"length"}`,
|
||||||
|
}
|
||||||
|
for name, body := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 200, body: body})
|
||||||
|
resp, err := newProvider(t, s.URL).Generate(context.Background(), schemaRequest())
|
||||||
|
if !errors.Is(err, llm.ErrBadOutput) {
|
||||||
|
t.Fatalf("err = %v, want ErrBadOutput", err)
|
||||||
|
}
|
||||||
|
if resp.JSON != nil || resp.Model != "m" || resp.Text == "" {
|
||||||
|
t.Fatalf("Response = %+v, want no JSON and the reply kept", resp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRejectsMalformedResponses(t *testing.T) {
|
||||||
|
for name, body := range map[string]string{
|
||||||
|
"not JSON": `not json`,
|
||||||
|
"not finished": `{"model":"m","message":{"role":"assistant","content":"hi"},"done":false}`,
|
||||||
|
"no message": `{"model":"m","done":true}`,
|
||||||
|
} {
|
||||||
|
s := newOllamaServer(t, reply{status: 200, body: body})
|
||||||
|
_, err := newProvider(t, s.URL).Generate(context.Background(), textRequest())
|
||||||
|
if err == nil || errors.Is(err, llm.ErrBadOutput) || errors.Is(err, llm.ErrProvider) {
|
||||||
|
t.Errorf("%s: err = %v, want an untyped malformed-response error", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRejectsInvalidRequests(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"})
|
||||||
|
if _, err := newProvider(t, s.URL).Generate(context.Background(), llm.Request{}); err == nil {
|
||||||
|
t.Error("Generate accepted a request with no messages")
|
||||||
|
}
|
||||||
|
noModel, err := New(Config{BaseURL: s.URL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := noModel.Generate(context.Background(), textRequest()); err == nil {
|
||||||
|
t.Error("Generate ran with no model configured")
|
||||||
|
}
|
||||||
|
if n := len(s.requests()); n != 0 {
|
||||||
|
t.Errorf("server got %d requests, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateModelMissing(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 404, fixture: "error_model_not_found.json"})
|
||||||
|
p, err := New(Config{BaseURL: s.URL, Model: "llama9"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = p.Generate(context.Background(), textRequest())
|
||||||
|
var pe *llm.ProviderError
|
||||||
|
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusNotFound {
|
||||||
|
t.Fatalf("err = %v, want a ProviderError for HTTP 404", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, llm.ErrTimeout) || errors.Is(err, llm.ErrBadOutput) {
|
||||||
|
t.Fatalf("err = %v matches an unrelated sentinel", err)
|
||||||
|
}
|
||||||
|
if want := `ollama: generate: model "llama9" not found: llm: provider error: HTTP 404`; err.Error() != want {
|
||||||
|
t.Fatalf("Error() = %q, want %q", err, want)
|
||||||
|
}
|
||||||
|
if pe.Body() != "" {
|
||||||
|
t.Fatalf("Body() = %q, want the response body left out", pe.Body())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateNotFoundUnrelatedToModel(t *testing.T) {
|
||||||
|
for name, body := range map[string]string{
|
||||||
|
"proxy page": "404 page not found",
|
||||||
|
"other error": `{"error":"no route for /api/chat"}`,
|
||||||
|
"other model": `{"error":"model \"llama3\" not found, try pulling it first"}`,
|
||||||
|
"empty": "",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 404, body: body})
|
||||||
|
p, err := New(Config{BaseURL: s.URL, Model: "llama9"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = p.Generate(context.Background(), textRequest())
|
||||||
|
var pe *llm.ProviderError
|
||||||
|
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusNotFound {
|
||||||
|
t.Fatalf("err = %v, want a ProviderError for HTTP 404", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "not found") && !strings.Contains(body, "not found") {
|
||||||
|
t.Fatalf("Error() = %q claims something was not found", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "llama9") {
|
||||||
|
t.Fatalf("Error() = %q claims the model was not found", err)
|
||||||
|
}
|
||||||
|
if pe.Body() != strings.TrimSpace(body) {
|
||||||
|
t.Fatalf("Body() = %q, want the response body %q", pe.Body(), body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateModelMissingSingleQuoted(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 404, body: `{"error":"model 'llama9' not found"}`})
|
||||||
|
p, err := New(Config{BaseURL: s.URL, Model: "llama9"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = p.Generate(context.Background(), textRequest())
|
||||||
|
if want := `ollama: generate: model "llama9" not found: llm: provider error: HTTP 404`; err == nil || err.Error() != want {
|
||||||
|
t.Fatalf("err = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListModels(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 200, fixture: "tags.json"})
|
||||||
|
models, err := newProvider(t, s.URL).ListModels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []llm.Model{{ID: "llama3.2:latest", DisplayName: "llama3.2:latest"}, {ID: "qwen3:8b", DisplayName: "qwen3:8b"}}
|
||||||
|
if len(models) != len(want) || models[0] != want[0] || models[1] != want[1] {
|
||||||
|
t.Fatalf("ListModels() = %+v, want %+v", models, want)
|
||||||
|
}
|
||||||
|
got := s.only(t)
|
||||||
|
if got.method != http.MethodGet || got.path != "/api/tags" || len(got.query) != 0 {
|
||||||
|
t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode())
|
||||||
|
}
|
||||||
|
assertNoAuth(t, got)
|
||||||
|
|
||||||
|
empty := newOllamaServer(t, reply{status: 200, body: `{"models":[]}`})
|
||||||
|
if models, err := newProvider(t, empty.URL).ListModels(context.Background()); err != nil || models == nil || len(models) != 0 {
|
||||||
|
t.Fatalf("empty list: %v, %v", models, err)
|
||||||
|
}
|
||||||
|
for name, body := range map[string]string{
|
||||||
|
"no models list": `{}`,
|
||||||
|
"unnamed model": `{"models":[{"model":"x"}]}`,
|
||||||
|
"not JSON": `<html>`,
|
||||||
|
} {
|
||||||
|
bad := newOllamaServer(t, reply{status: 200, body: body})
|
||||||
|
if _, err := newProvider(t, bad.URL).ListModels(context.Background()); err == nil {
|
||||||
|
t.Errorf("%s: ListModels accepted it", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify(t *testing.T) {
|
||||||
|
ok := newOllamaServer(t, reply{status: 200, fixture: "tags.json"})
|
||||||
|
if err := newProvider(t, ok.URL).Verify(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Verify() = %v", err)
|
||||||
|
}
|
||||||
|
got := ok.only(t)
|
||||||
|
if got.method != http.MethodGet || got.path != "/api/tags" {
|
||||||
|
t.Errorf("Verify called %s %s", got.method, got.path)
|
||||||
|
}
|
||||||
|
assertNoAuth(t, got)
|
||||||
|
|
||||||
|
bad := newOllamaServer(t, reply{status: 500, body: `{"error":"something went wrong"}`})
|
||||||
|
err := newProvider(t, bad.URL).Verify(context.Background())
|
||||||
|
var pe *llm.ProviderError
|
||||||
|
if !errors.As(err, &pe) || pe.Status() != 500 || !strings.Contains(err.Error(), "something went wrong") {
|
||||||
|
t.Fatalf("Verify() = %v, want a ProviderError for HTTP 500", err)
|
||||||
|
}
|
||||||
|
malformed := newOllamaServer(t, reply{status: 200, body: `{}`})
|
||||||
|
if err := newProvider(t, malformed.URL).Verify(context.Background()); err == nil {
|
||||||
|
t.Error("Verify accepted a body with no models list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrors(t *testing.T) {
|
||||||
|
cases := map[string]reply{
|
||||||
|
"400": {status: 400, body: `{"error":"invalid format"}`},
|
||||||
|
"500": {status: 500, body: `{"error":"llama runner process has terminated"}`},
|
||||||
|
"503": {status: 503, body: `{"error":"server busy, please try again"}`},
|
||||||
|
"huge": {status: 500, body: strings.Repeat("x", 1<<20)},
|
||||||
|
}
|
||||||
|
for name, r := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, r)
|
||||||
|
p := newProvider(t, s.URL)
|
||||||
|
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||||
|
_, listErr := p.ListModels(context.Background())
|
||||||
|
for _, err := range []error{genErr, listErr} {
|
||||||
|
var pe *llm.ProviderError
|
||||||
|
if !errors.As(err, &pe) || pe.Status() != r.status {
|
||||||
|
t.Fatalf("err = %v, want a ProviderError for HTTP %d", err, r.status)
|
||||||
|
}
|
||||||
|
if errors.Is(err, llm.ErrTimeout) || strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Fatalf("err = %v, want only a provider error", err)
|
||||||
|
}
|
||||||
|
if len(err.Error()) > llm.MaxBodyExcerpt+200 {
|
||||||
|
t.Fatalf("error text has %d bytes, want a bounded excerpt", len(err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertUnreachable checks the error a request to an unreachable base
|
||||||
|
// returns: it matches llm.ErrTimeout and names the base.
|
||||||
|
func assertUnreachable(t *testing.T, err error, base string) {
|
||||||
|
t.Helper()
|
||||||
|
if !errors.Is(err, llm.ErrTimeout) {
|
||||||
|
t.Fatalf("err = %v, want ErrTimeout", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, llm.ErrProvider) || errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("err = %v matches an unrelated sentinel", err)
|
||||||
|
}
|
||||||
|
if want := "Ollama not reachable at " + base; !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("Error() = %q, want it to contain %q", err, want)
|
||||||
|
}
|
||||||
|
var ue *url.Error
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
t.Fatalf("error chain exposes the url.Error: %v", ue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnreachableHost(t *testing.T) {
|
||||||
|
closed := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
base := closed.URL
|
||||||
|
closed.Close()
|
||||||
|
|
||||||
|
p := newProvider(t, base+"/")
|
||||||
|
_, genErr := p.Generate(context.Background(), schemaRequest())
|
||||||
|
_, listErr := p.ListModels(context.Background())
|
||||||
|
verifyErr := p.Verify(context.Background())
|
||||||
|
for _, err := range []error{genErr, listErr, verifyErr} {
|
||||||
|
assertUnreachable(t, err, base)
|
||||||
|
if !strings.Contains(err.Error(), "connection refused") {
|
||||||
|
t.Errorf("Error() = %q, want the cause", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dropped := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
conn, _, err := w.(http.Hijacker).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}))
|
||||||
|
t.Cleanup(dropped.Close)
|
||||||
|
_, err := newProvider(t, dropped.URL).Generate(context.Background(), textRequest())
|
||||||
|
assertUnreachable(t, err, dropped.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeouts(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 200, fixture: "chat_schema.json", delay: 5 * time.Second})
|
||||||
|
p := newProvider(t, s.URL)
|
||||||
|
|
||||||
|
req := schemaRequest()
|
||||||
|
req.Timeout = 50 * time.Millisecond
|
||||||
|
start := time.Now()
|
||||||
|
_, err := p.Generate(context.Background(), req)
|
||||||
|
assertUnreachable(t, err, s.URL)
|
||||||
|
if !strings.Contains(err.Error(), llm.ErrTimeout.Error()) {
|
||||||
|
t.Errorf("Error() = %q, want it to say the request timed out", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||||
|
t.Fatalf("Request.Timeout not honoured: took %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, err = p.ListModels(ctx)
|
||||||
|
assertUnreachable(t, err, s.URL)
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
assertUnreachable(t, p.Verify(ctx), s.URL)
|
||||||
|
|
||||||
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
_, err = p.Generate(ctx, schemaRequest())
|
||||||
|
if !errors.Is(err, context.Canceled) || errors.Is(err, llm.ErrTimeout) || strings.Contains(err.Error(), "not reachable") {
|
||||||
|
t.Fatalf("cancelled context: err = %v, want context.Canceled only", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectIsNotFollowed(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var secondCalls int
|
||||||
|
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
secondCalls++
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
t.Cleanup(second.Close)
|
||||||
|
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
http.Redirect(w, req, second.URL+req.URL.Path, http.StatusFound)
|
||||||
|
}))
|
||||||
|
t.Cleanup(first.Close)
|
||||||
|
p := newProvider(t, first.URL)
|
||||||
|
|
||||||
|
_, genErr := p.Generate(context.Background(), textRequest())
|
||||||
|
verifyErr := p.Verify(context.Background())
|
||||||
|
for _, err := range []error{genErr, verifyErr} {
|
||||||
|
var pe *llm.ProviderError
|
||||||
|
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusFound {
|
||||||
|
t.Fatalf("err = %v, want a ProviderError for HTTP 302", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if secondCalls != 0 {
|
||||||
|
t.Fatalf("redirect target got %d requests, want 0", secondCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsBadConfig(t *testing.T) {
|
||||||
|
const secret = "hunter2-secret"
|
||||||
|
cases := map[string]string{
|
||||||
|
"empty base": "",
|
||||||
|
"relative base": "ollama:11434",
|
||||||
|
"host only": "192.168.1.20",
|
||||||
|
"ftp base": "ftp://192.168.1.20:11434",
|
||||||
|
"no host": "http://",
|
||||||
|
"credentials in URL": "http://user:" + secret + "@192.168.1.20:11434",
|
||||||
|
"user in URL": "http://" + secret + "@ollama:11434",
|
||||||
|
"secret in query": "http://ollama:11434/?token=" + secret,
|
||||||
|
"secret in fragment": "http://ollama:11434/#" + secret,
|
||||||
|
"unparseable": "http://ollama:11434/%zz?token=" + secret,
|
||||||
|
"empty query": "http://ollama:11434?",
|
||||||
|
"empty fragment": "http://ollama:11434#",
|
||||||
|
"empty both": "http://ollama:11434/?#",
|
||||||
|
}
|
||||||
|
for name, base := range cases {
|
||||||
|
_, err := New(Config{BaseURL: base, Model: "llama3.2"})
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s: New() = nil error", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Errorf("%s: error echoes the URL: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, base := range []string{
|
||||||
|
"http://192.168.1.20:11434",
|
||||||
|
"http://ollama:11434",
|
||||||
|
"http://localhost:11434/",
|
||||||
|
"https://ollama.lan/proxy/",
|
||||||
|
} {
|
||||||
|
if _, err := New(Config{BaseURL: base, Model: "llama3.2"}); err != nil {
|
||||||
|
t.Errorf("New(%q) = %v", base, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseWithPath(t *testing.T) {
|
||||||
|
s := newOllamaServer(t, reply{status: 200, fixture: "tags.json"})
|
||||||
|
if err := newProvider(t, s.URL+"/proxy/").Verify(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := s.only(t).path; got != "/proxy/api/tags" {
|
||||||
|
t.Fatalf("path = %s, want /proxy/api/tags", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# Ollama adapter fixtures
|
||||||
|
|
||||||
|
These are the native Ollama API response bodies the contract tests in
|
||||||
|
`ollama_test.go` replay. They are **not live captures**. Product AI is tested
|
||||||
|
with fakes and live vendor calls are opt-in, so no request was sent to an
|
||||||
|
Ollama server to make them. Each fixture is a sanitized adaptation of the
|
||||||
|
response shapes in Ollama's public API documentation
|
||||||
|
(<https://github.com/ollama/ollama/blob/main/docs/api.md>):
|
||||||
|
|
||||||
|
- Chat, non-streaming: `POST /api/chat` with `"stream": false`.
|
||||||
|
- Structured outputs: `POST /api/chat` with a JSON schema in `"format"`.
|
||||||
|
- List local models: `GET /api/tags`.
|
||||||
|
- Errors: a JSON `{"error": "..."}` body with a non-2xx status; a model
|
||||||
|
that is not pulled is HTTP 404.
|
||||||
|
|
||||||
|
Sanitizing:
|
||||||
|
|
||||||
|
- Timestamps, durations, sizes, digests and token counts are illustrative
|
||||||
|
values in the documented format, not values from a real server.
|
||||||
|
- Message content is replaced with MealQuest dinner text.
|
||||||
|
- Ollama takes no credentials, so none appear.
|
||||||
|
|
||||||
|
Below, *not in the public example* marks values that were added for a test
|
||||||
|
case and do not come from the documented example.
|
||||||
|
|
||||||
|
| Fixture | Case | Shape | Not in the public example |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `chat_text.json` | Success | Finished chat message, `done_reason: stop`, `prompt_eval_count` and `eval_count`. | — |
|
||||||
|
| `chat_schema.json` | Schema output | Finished chat message whose content is a JSON object string. | The dinner object. |
|
||||||
|
| `error_model_not_found.json` | Model missing | `{"error": "model \"…\" not found, try pulling it first"}`, served with HTTP 404. | — |
|
||||||
|
| `tags.json` | Model list | `{"models": [...]}` with `name`, `model`, `digest` and `details`. | — |
|
||||||
|
|
||||||
|
The unreachable-host case has no fixture: there is no response to record.
|
||||||
|
The tests close an `httptest` server and dial its address, so the
|
||||||
|
connection is refused, and they hold a reply past a short deadline for the
|
||||||
|
timeout.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"model": "llama3.2",
|
||||||
|
"created_at": "2026-10-04T12:20:11.009132Z",
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "{\"dinners\": [{\"name\": \"Sheet-pan gnocchi\", \"why\": \"Like your Regular tray bake, but quicker.\"}]}"
|
||||||
|
},
|
||||||
|
"done_reason": "stop",
|
||||||
|
"done": true,
|
||||||
|
"total_duration": 1416823291,
|
||||||
|
"load_duration": 12309083,
|
||||||
|
"prompt_eval_count": 147,
|
||||||
|
"prompt_eval_duration": 271802000,
|
||||||
|
"eval_count": 41,
|
||||||
|
"eval_duration": 1130948000
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"model": "llama3.2",
|
||||||
|
"created_at": "2026-10-04T12:13:43.416799Z",
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Try a crock pot chili: it is a Quick dinner."
|
||||||
|
},
|
||||||
|
"done": true,
|
||||||
|
"done_reason": "stop",
|
||||||
|
"total_duration": 5191566416,
|
||||||
|
"load_duration": 2154458,
|
||||||
|
"prompt_eval_count": 26,
|
||||||
|
"prompt_eval_duration": 383809000,
|
||||||
|
"eval_count": 298,
|
||||||
|
"eval_duration": 4799921000
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"error":"model \"llama9\" not found, try pulling it first"}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": "llama3.2:latest",
|
||||||
|
"model": "llama3.2:latest",
|
||||||
|
"modified_at": "2026-09-30T08:06:48.639712648-07:00",
|
||||||
|
"size": 2019393189,
|
||||||
|
"digest": "a80c4f17acd55265feec403c7aef86be0c25983ab279d83f3bcd3abbcb5b8b72",
|
||||||
|
"details": {
|
||||||
|
"parent_model": "",
|
||||||
|
"format": "gguf",
|
||||||
|
"family": "llama",
|
||||||
|
"families": ["llama"],
|
||||||
|
"parameter_size": "3.2B",
|
||||||
|
"quantization_level": "Q4_K_M"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "qwen3:8b",
|
||||||
|
"model": "qwen3:8b",
|
||||||
|
"modified_at": "2026-09-28T19:41:02.112408113-07:00",
|
||||||
|
"size": 5225388164,
|
||||||
|
"digest": "500a1f067a9f782620b40bee6f7b0c89e17ae61f686b92c24933e4ca4b2b8b41",
|
||||||
|
"details": {
|
||||||
|
"parent_model": "",
|
||||||
|
"format": "gguf",
|
||||||
|
"family": "qwen3",
|
||||||
|
"families": ["qwen3"],
|
||||||
|
"parameter_size": "8.2B",
|
||||||
|
"quantization_level": "Q4_K_M"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
// Package openai is the llm.Provider adapter for OpenAI-compatible chat
|
||||||
|
// APIs: OpenAI itself, OpenRouter and Ollama's /v1 endpoint. It calls
|
||||||
|
// POST {base}/chat/completions and GET {base}/models over plain net/http.
|
||||||
|
//
|
||||||
|
// The adapter never logs, and no error it returns contains the API key or
|
||||||
|
// the Authorization header value: errors do not quote caller- or
|
||||||
|
// vendor-supplied text, and every exported method passes its error through
|
||||||
|
// redact as a backstop.
|
||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StructuredOutput is how the adapter asks the vendor for JSON when a
|
||||||
|
// Request carries a Schema. Which mode a vendor and model support comes
|
||||||
|
// from its preset.
|
||||||
|
type StructuredOutput string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// JSONSchema sends response_format {type: json_schema, strict: true}
|
||||||
|
// with the Request's schema.
|
||||||
|
JSONSchema StructuredOutput = "json_schema"
|
||||||
|
// JSONObject sends response_format {type: json_object} and puts the
|
||||||
|
// schema in the system prompt.
|
||||||
|
JSONObject StructuredOutput = "json_object"
|
||||||
|
// PromptOnly sends no response_format, puts the schema in the system
|
||||||
|
// prompt and takes the first JSON object found in the reply.
|
||||||
|
PromptOnly StructuredOutput = "prompt_only"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures a Provider.
|
||||||
|
type Config struct {
|
||||||
|
// BaseURL is the API root the endpoint paths are appended to, e.g.
|
||||||
|
// "https://api.openai.com/v1", "https://openrouter.ai/api/v1" or
|
||||||
|
// "http://ollama:11434/v1".
|
||||||
|
BaseURL string
|
||||||
|
// APIKey is sent as "Authorization: Bearer <APIKey>". Empty sends no
|
||||||
|
// Authorization header, for endpoints such as Ollama that need none.
|
||||||
|
// It must not have a control character other than tab.
|
||||||
|
APIKey string
|
||||||
|
// Model is the model Generate requests. ListModels and Verify do not
|
||||||
|
// need it.
|
||||||
|
Model string
|
||||||
|
// Headers are extra request headers, e.g. OpenRouter's HTTP-Referer and
|
||||||
|
// X-Title. An Accept header replaces the default "application/json".
|
||||||
|
// Headers the adapter or net/http set themselves (see managedHeaders)
|
||||||
|
// are rejected.
|
||||||
|
Headers map[string]string
|
||||||
|
// StructuredOutput is how JSON is requested when a Request has a Schema.
|
||||||
|
StructuredOutput StructuredOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is an OpenAI-compatible llm.Provider.
|
||||||
|
type Provider struct {
|
||||||
|
base string
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
headers http.Header
|
||||||
|
mode StructuredOutput
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ llm.Provider = (*Provider)(nil)
|
||||||
|
|
||||||
|
// New validates cfg and returns a Provider.
|
||||||
|
func New(cfg Config) (*Provider, error) {
|
||||||
|
p, err := newFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, redact(err, cfg.APIKey)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFromConfig(cfg Config) (*Provider, error) {
|
||||||
|
// Base URL errors never echo the URL: a rejected one may carry a
|
||||||
|
// credential.
|
||||||
|
u, err := url.Parse(cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("openai: base URL does not parse")
|
||||||
|
}
|
||||||
|
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return nil, errors.New("openai: base URL must be an absolute http or https URL")
|
||||||
|
}
|
||||||
|
// A trailing "?" or "#" parses to an empty query or fragment, yet the
|
||||||
|
// endpoint path appended to it would land in the query or fragment.
|
||||||
|
if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") {
|
||||||
|
return nil, errors.New("openai: base URL must not carry credentials, a query or a fragment")
|
||||||
|
}
|
||||||
|
if !validHeaderValue(cfg.APIKey) {
|
||||||
|
return nil, errors.New("openai: API key has a control character")
|
||||||
|
}
|
||||||
|
// No error from New quotes caller-supplied text or a name derived from
|
||||||
|
// it: any such text may equal or contain the API key, and redaction
|
||||||
|
// cannot catch a fragment or a case-changed copy of it.
|
||||||
|
switch cfg.StructuredOutput {
|
||||||
|
case JSONSchema, JSONObject, PromptOnly:
|
||||||
|
default:
|
||||||
|
return nil, errors.New("openai: unknown structured output mode")
|
||||||
|
}
|
||||||
|
headers := make(http.Header, len(cfg.Headers)+1)
|
||||||
|
for name, value := range cfg.Headers {
|
||||||
|
if !validHeaderName(name) {
|
||||||
|
return nil, errors.New("openai: an extra header name is not a valid HTTP token")
|
||||||
|
}
|
||||||
|
canonical := textproto.CanonicalMIMEHeaderKey(name)
|
||||||
|
if managedHeaders[canonical] {
|
||||||
|
return nil, errors.New("openai: an extra header is set by the adapter")
|
||||||
|
}
|
||||||
|
if !validHeaderValue(value) {
|
||||||
|
return nil, errors.New("openai: an extra header value has a control character")
|
||||||
|
}
|
||||||
|
headers.Set(canonical, value)
|
||||||
|
}
|
||||||
|
if headers.Get("Accept") == "" {
|
||||||
|
headers.Set("Accept", "application/json")
|
||||||
|
}
|
||||||
|
return &Provider{
|
||||||
|
base: strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
|
apiKey: cfg.APIKey,
|
||||||
|
model: cfg.Model,
|
||||||
|
headers: headers,
|
||||||
|
mode: cfg.StructuredOutput,
|
||||||
|
client: noRedirectClient(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// managedHeaders are the request headers the adapter or net/http set; a
|
||||||
|
// configured value for one would be overwritten or ignored.
|
||||||
|
var managedHeaders = map[string]bool{
|
||||||
|
"Authorization": true,
|
||||||
|
"Content-Type": true,
|
||||||
|
"Content-Length": true,
|
||||||
|
"Transfer-Encoding": true,
|
||||||
|
"Host": true,
|
||||||
|
"Connection": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// validHeaderName reports whether name is an RFC 9110 field-name: a
|
||||||
|
// non-empty token of letters, digits and !#$%&'*+-.^_`|~.
|
||||||
|
func validHeaderName(name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(name); i++ {
|
||||||
|
c := name[i]
|
||||||
|
switch {
|
||||||
|
case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9':
|
||||||
|
case strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0:
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validHeaderValue reports whether value is an RFC 9110 field-value: no
|
||||||
|
// control characters other than horizontal tab, so no CR, LF or DEL.
|
||||||
|
func validHeaderValue(value string) bool {
|
||||||
|
for i := 0; i < len(value); i++ {
|
||||||
|
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxResponseBody caps a successful response body; a larger one is an error.
|
||||||
|
const maxResponseBody = 16 << 20
|
||||||
|
|
||||||
|
// maxErrorBody caps how much of an error response body is read. It is far
|
||||||
|
// above llm.MaxBodyExcerpt, so redaction still sees every secret that could
|
||||||
|
// reach the excerpt.
|
||||||
|
const maxErrorBody = 64 << 10
|
||||||
|
|
||||||
|
type chatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonSchemaFormat struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Schema json.RawMessage `json:"schema"`
|
||||||
|
Strict bool `json:"strict"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type responseFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
JSONSchema *jsonSchemaFormat `json:"json_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []chatMessage `json:"messages"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
ResponseFormat *responseFormat `json:"response_format,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content *string `json:"content"`
|
||||||
|
Refusal *string `json:"refusal"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
Error json.RawMessage `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaInstruction is appended to the system prompt in the json_object and
|
||||||
|
// prompt_only modes, where the schema is not sent in response_format.
|
||||||
|
const schemaInstruction = "Reply with only a JSON object, and no other text, that matches this JSON Schema:\n"
|
||||||
|
|
||||||
|
// Generate sends req to {base}/chat/completions.
|
||||||
|
func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||||
|
resp, err := p.generate(ctx, req)
|
||||||
|
return resp, redact(err, p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("openai: generate: %w", err)
|
||||||
|
}
|
||||||
|
if p.model == "" {
|
||||||
|
return llm.Response{}, errors.New("openai: generate: no model configured")
|
||||||
|
}
|
||||||
|
body := chatRequest{
|
||||||
|
Model: p.model,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
MaxTokens: req.MaxTokens,
|
||||||
|
}
|
||||||
|
system := req.System
|
||||||
|
if req.Schema != nil {
|
||||||
|
switch p.mode {
|
||||||
|
case JSONSchema:
|
||||||
|
body.ResponseFormat = &responseFormat{
|
||||||
|
Type: "json_schema",
|
||||||
|
JSONSchema: &jsonSchemaFormat{Name: req.Schema.Name, Schema: req.Schema.Schema, Strict: true},
|
||||||
|
}
|
||||||
|
case JSONObject:
|
||||||
|
body.ResponseFormat = &responseFormat{Type: "json_object"}
|
||||||
|
system = appendInstruction(system, req.Schema.Schema)
|
||||||
|
case PromptOnly:
|
||||||
|
system = appendInstruction(system, req.Schema.Schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if system != "" {
|
||||||
|
body.Messages = append(body.Messages, chatMessage{Role: "system", Content: system})
|
||||||
|
}
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
body.Messages = append(body.Messages, chatMessage{Role: string(m.Role), Content: m.Content})
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("openai: generate: encode request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
raw, status, err := p.do(ctx, http.MethodPost, "/chat/completions", payload)
|
||||||
|
if err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("openai: generate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cr chatResponse
|
||||||
|
if err := json.Unmarshal(raw, &cr); err != nil {
|
||||||
|
return llm.Response{}, fmt.Errorf("openai: generate: decode response: %w", err)
|
||||||
|
}
|
||||||
|
if len(cr.Error) > 0 && string(cr.Error) != "null" {
|
||||||
|
// OpenRouter can report an upstream failure in a 2xx body.
|
||||||
|
return llm.Response{}, fmt.Errorf("openai: generate: %w", llm.NewProviderError(status, raw, p.apiKey))
|
||||||
|
}
|
||||||
|
if len(cr.Choices) == 0 {
|
||||||
|
return llm.Response{}, errors.New("openai: generate: response has no choices")
|
||||||
|
}
|
||||||
|
choice := cr.Choices[0]
|
||||||
|
resp := llm.Response{
|
||||||
|
Usage: llm.Usage{InputTokens: cr.Usage.PromptTokens, OutputTokens: cr.Usage.CompletionTokens},
|
||||||
|
Model: cr.Model,
|
||||||
|
FinishReason: choice.FinishReason,
|
||||||
|
}
|
||||||
|
if choice.Message.Content != nil {
|
||||||
|
resp.Text = *choice.Message.Content
|
||||||
|
}
|
||||||
|
if req.Schema == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if choice.Message.Refusal != nil && *choice.Message.Refusal != "" {
|
||||||
|
return resp, fmt.Errorf("openai: generate: model refused the structured request: %w", llm.ErrBadOutput)
|
||||||
|
}
|
||||||
|
if p.mode == PromptOnly {
|
||||||
|
resp.JSON = firstJSONObject(resp.Text)
|
||||||
|
} else if text := strings.TrimSpace(resp.Text); isJSONObject(text) {
|
||||||
|
resp.JSON = json.RawMessage(text)
|
||||||
|
}
|
||||||
|
if resp.JSON == nil {
|
||||||
|
return resp, fmt.Errorf("openai: generate: reply holds no JSON object: %w", llm.ErrBadOutput)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendInstruction(system string, schema json.RawMessage) string {
|
||||||
|
instruction := schemaInstruction + string(schema)
|
||||||
|
if system == "" {
|
||||||
|
return instruction
|
||||||
|
}
|
||||||
|
return system + "\n\n" + instruction
|
||||||
|
}
|
||||||
|
|
||||||
|
// isJSONObject reports whether s is exactly one JSON object.
|
||||||
|
func isJSONObject(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "{") && json.Valid([]byte(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstJSONObject returns the first complete JSON object in s, trying each
|
||||||
|
// "{" in order, or nil when there is none. Text around it, such as prose or
|
||||||
|
// a Markdown code fence, is ignored.
|
||||||
|
func firstJSONObject(s string) json.RawMessage {
|
||||||
|
for i := strings.IndexByte(s, '{'); i >= 0; {
|
||||||
|
var obj json.RawMessage
|
||||||
|
if err := json.NewDecoder(strings.NewReader(s[i:])).Decode(&obj); err == nil {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
next := strings.IndexByte(s[i+1:], '{')
|
||||||
|
if next < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += 1 + next
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelsResponse struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels returns the models {base}/models lists.
|
||||||
|
func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||||
|
models, err := p.listModels(ctx)
|
||||||
|
return models, redact(err, p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) listModels(ctx context.Context) ([]llm.Model, error) {
|
||||||
|
raw, _, err := p.do(ctx, http.MethodGet, "/models", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("openai: list models: %w", err)
|
||||||
|
}
|
||||||
|
var mr modelsResponse
|
||||||
|
if err := json.Unmarshal(raw, &mr); err != nil {
|
||||||
|
return nil, fmt.Errorf("openai: list models: decode response: %w", err)
|
||||||
|
}
|
||||||
|
if mr.Data == nil {
|
||||||
|
return nil, errors.New("openai: list models: response has no data list")
|
||||||
|
}
|
||||||
|
models := make([]llm.Model, 0, len(mr.Data))
|
||||||
|
for i, m := range mr.Data {
|
||||||
|
if m.ID == "" {
|
||||||
|
return nil, fmt.Errorf("openai: list models: model %d has no id", i)
|
||||||
|
}
|
||||||
|
name := m.Name
|
||||||
|
if name == "" {
|
||||||
|
name = m.ID
|
||||||
|
}
|
||||||
|
models = append(models, llm.Model{ID: m.ID, DisplayName: name})
|
||||||
|
}
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks the credentials with an authenticated ListModels call.
|
||||||
|
func (p *Provider) Verify(ctx context.Context) error {
|
||||||
|
_, err := p.listModels(ctx)
|
||||||
|
return redact(err, p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// do sends one request and returns the body and status of a 2xx response.
|
||||||
|
// Any other status becomes the matching llm typed error.
|
||||||
|
func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, int, error) {
|
||||||
|
var reqBody io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
reqBody = bytes.NewReader(payload)
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
// The parse error names the URL, whose path may hold the API key.
|
||||||
|
return nil, 0, errors.New("build request: request URL is invalid")
|
||||||
|
}
|
||||||
|
for name, values := range p.headers {
|
||||||
|
httpReq.Header[name] = values
|
||||||
|
}
|
||||||
|
if payload != nil {
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if p.apiKey != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, err := p.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, p.transportError(err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, httpResp.StatusCode, p.transportError(err)
|
||||||
|
}
|
||||||
|
return nil, httpResp.StatusCode, p.statusError(httpResp, body)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, httpResp.StatusCode, p.transportError(err)
|
||||||
|
}
|
||||||
|
if len(body) > maxResponseBody {
|
||||||
|
return nil, httpResp.StatusCode, fmt.Errorf("response body exceeds %d bytes", maxResponseBody)
|
||||||
|
}
|
||||||
|
return body, httpResp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusError maps a non-2xx response to an llm typed error.
|
||||||
|
func (p *Provider) statusError(resp *http.Response, body []byte) error {
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrAuth)
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
return llm.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
|
||||||
|
case http.StatusRequestTimeout:
|
||||||
|
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
|
||||||
|
default:
|
||||||
|
return llm.NewProviderError(resp.StatusCode, body, p.apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryAfter parses a Retry-After header, in delay-seconds or HTTP-date
|
||||||
|
// form. A missing, malformed or past value means the vendor gave no usable
|
||||||
|
// wait, reported as 0.
|
||||||
|
func retryAfter(value string, now time.Time) time.Duration {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if secs, err := strconv.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
|
||||||
|
if secs < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// time.Duration counts nanoseconds in an int64; a larger wait is
|
||||||
|
// capped to the most whole seconds it holds instead of overflowing.
|
||||||
|
if secs > maxRetryAfterSeconds {
|
||||||
|
secs = maxRetryAfterSeconds
|
||||||
|
}
|
||||||
|
return time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
if at, err := http.ParseTime(value); err == nil && at.After(now) {
|
||||||
|
return at.Sub(now).Round(time.Second)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxRetryAfterSeconds is the largest delay-seconds value retryAfter
|
||||||
|
// returns unchanged: the most whole seconds a time.Duration holds.
|
||||||
|
const maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
||||||
|
|
||||||
|
// noRedirectClient returns an HTTP client that never follows a redirect.
|
||||||
|
// A followed redirect would resend the credential headers, which net/http
|
||||||
|
// strips only for its own list of sensitive headers and only across hosts;
|
||||||
|
// instead the 3xx response is returned and becomes a provider error.
|
||||||
|
func noRedirectClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeout(err error) bool {
|
||||||
|
var ne net.Error
|
||||||
|
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportFailure is a failed HTTP exchange. It carries the cause's text
|
||||||
|
// with the request URL removed and the API key redacted, and unwraps only to
|
||||||
|
// the cause's category: llm.ErrTimeout, context.Canceled or nothing.
|
||||||
|
type transportFailure struct {
|
||||||
|
text string
|
||||||
|
kind error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *transportFailure) Error() string { return e.text }
|
||||||
|
|
||||||
|
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||||
|
func (e *transportFailure) GoString() string { return e.text }
|
||||||
|
|
||||||
|
func (e *transportFailure) Unwrap() error { return e.kind }
|
||||||
|
|
||||||
|
// transportError reports a failure to complete the HTTP exchange. A
|
||||||
|
// *url.Error names the request URL, and a base URL path may hold the API
|
||||||
|
// key, so only the wrapped cause is kept, rendered with the key redacted.
|
||||||
|
func (p *Provider) transportError(err error) error {
|
||||||
|
var kind error
|
||||||
|
switch {
|
||||||
|
case isTimeout(err):
|
||||||
|
kind = llm.ErrTimeout
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
kind = context.Canceled
|
||||||
|
}
|
||||||
|
var ue *url.Error
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
err = ue.Err
|
||||||
|
}
|
||||||
|
text := "transport: " + err.Error()
|
||||||
|
if kind == llm.ErrTimeout {
|
||||||
|
text = fmt.Sprintf("%s: %s", llm.ErrTimeout, text)
|
||||||
|
}
|
||||||
|
return &transportFailure{text: llm.Redact(text, p.apiKey), kind: kind}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactedError stands in for one layer of an error chain that holds the
|
||||||
|
// API key. Its text is the layer's text with the key redacted, and Unwrap
|
||||||
|
// returns the layer's causes passed through redact, so no error reached by
|
||||||
|
// unwrapping has text holding the key. Is consults the original layer and
|
||||||
|
// its chain, so sentinels still match. As does too. The llm typed errors
|
||||||
|
// remove the key from their own complete text, fixed words included, so
|
||||||
|
// they always match; As refuses a match whose text, or whose %#v form,
|
||||||
|
// holds the key, so it never hands back an error that can print it.
|
||||||
|
type redactedError struct {
|
||||||
|
text string
|
||||||
|
err error
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *redactedError) Error() string { return e.text }
|
||||||
|
|
||||||
|
// GoString keeps %#v from printing the fields, which hold the key.
|
||||||
|
func (e *redactedError) GoString() string { return e.text }
|
||||||
|
|
||||||
|
func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) }
|
||||||
|
|
||||||
|
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||||
|
|
||||||
|
func (e *redactedError) As(target any) bool {
|
||||||
|
if !errors.As(e.err, target) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
found := reflect.ValueOf(target).Elem()
|
||||||
|
matched, isErr := found.Interface().(error)
|
||||||
|
if (isErr && holdsKey(matched, e.key)) || strings.Contains(fmt.Sprintf("%#v", found.Interface()), e.key) {
|
||||||
|
found.SetZero()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactedJoin is redactedError for a layer with several causes, such as
|
||||||
|
// one built by errors.Join or by fmt.Errorf with more than one %w.
|
||||||
|
type redactedJoin struct {
|
||||||
|
redactedError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *redactedJoin) Unwrap() []error {
|
||||||
|
causes := e.err.(interface{ Unwrap() []error }).Unwrap()
|
||||||
|
safe := make([]error, len(causes))
|
||||||
|
for i, c := range causes {
|
||||||
|
safe[i] = redact(c, e.key)
|
||||||
|
}
|
||||||
|
return safe
|
||||||
|
}
|
||||||
|
|
||||||
|
// redact is the backstop every exported method applies to its error: with
|
||||||
|
// a non-empty key, it returns a stand-in whose text, and the text of every
|
||||||
|
// error it unwraps to, has the key redacted. Every non-nil err is wrapped,
|
||||||
|
// even one whose text lacks the key, because %#v of an unwrapped error
|
||||||
|
// prints its struct field names, and a key may spell one.
|
||||||
|
func redact(err error, key string) error {
|
||||||
|
if err == nil || key == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
re := redactedError{text: llm.Redact(err.Error(), key), err: err, key: key}
|
||||||
|
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||||
|
return &redactedJoin{re}
|
||||||
|
}
|
||||||
|
return &re
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsKey reports whether the text of err or of any error in its chain
|
||||||
|
// contains key.
|
||||||
|
func holdsKey(err error, key string) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch u := err.(type) {
|
||||||
|
case interface{ Unwrap() error }:
|
||||||
|
return holdsKey(u.Unwrap(), key)
|
||||||
|
case interface{ Unwrap() []error }:
|
||||||
|
for _, c := range u.Unwrap() {
|
||||||
|
if holdsKey(c, key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# OpenAI-compatible adapter fixtures
|
||||||
|
|
||||||
|
These are the vendor response bodies the contract tests in `openai_test.go`
|
||||||
|
replay. They are **not live captures**. Product AI is tested with fakes, and
|
||||||
|
live vendor calls are opt-in and never use credentials, so no request was
|
||||||
|
sent to a vendor to make them. Each fixture is adapted from the response
|
||||||
|
shape in that vendor's public API documentation or open source, then
|
||||||
|
sanitized:
|
||||||
|
|
||||||
|
- IDs, timestamps, fingerprints and token counts are illustrative values in
|
||||||
|
the documented format, not values from a real account.
|
||||||
|
- Message content is replaced with MealQuest dinner text.
|
||||||
|
- No real credential appears anywhere. `{{KEY}}` is a placeholder the tests
|
||||||
|
replace with a fake key, to prove the adapter redacts a key echoed back in
|
||||||
|
a vendor body.
|
||||||
|
|
||||||
|
The tests check the adapter against these bodies with independent
|
||||||
|
assertions on both the request sent (path, headers, `response_format`,
|
||||||
|
messages) and the response decoded. The request side does not depend on the
|
||||||
|
fixtures.
|
||||||
|
|
||||||
|
Below, *not in the public example* marks fields or values that were added
|
||||||
|
for a test case and do not come from the vendor's documented example.
|
||||||
|
|
||||||
|
## OpenAI
|
||||||
|
|
||||||
|
Sources: API reference, "The chat completion object" and "List models"
|
||||||
|
(<https://platform.openai.com/docs/api-reference/chat/object>,
|
||||||
|
<https://platform.openai.com/docs/api-reference/models/list>); the
|
||||||
|
Structured Outputs guide, for `refusal`
|
||||||
|
(<https://platform.openai.com/docs/guides/structured-outputs>); and the error
|
||||||
|
codes guide (<https://platform.openai.com/docs/guides/error-codes>).
|
||||||
|
|
||||||
|
| Fixture | Shape | Not in the public example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `openai_chat_json_schema.json` | Chat completion object with `usage` details, `service_tier`, `system_fingerprint`. | The JSON reply content. |
|
||||||
|
| `openai_chat_refusal.json` | Structured Outputs refusal: `content: null`, `refusal` set. | — |
|
||||||
|
| `openai_models.json` | `{object: list, data: [model]}`. | — |
|
||||||
|
| `openai_error_invalid_key.json` | Error object, `code: invalid_api_key`, message echoing the key. | The full key in the message; OpenAI masks most of it. Kept whole to test redaction. |
|
||||||
|
| `openai_error_server.json` | Error object, `type: server_error`. | The `Authorization: Bearer {{KEY}}` text; added to test redaction. |
|
||||||
|
|
||||||
|
## OpenRouter
|
||||||
|
|
||||||
|
Sources: API reference, chat completion response and errors
|
||||||
|
(<https://openrouter.ai/docs/api-reference/overview>,
|
||||||
|
<https://openrouter.ai/docs/api-reference/errors>), and "List available
|
||||||
|
models" (<https://openrouter.ai/docs/api-reference/list-available-models>).
|
||||||
|
|
||||||
|
| Fixture | Shape | Not in the public example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `openrouter_chat_json_object.json` | Chat completion with `provider`, `native_finish_reason` and `message.reasoning`. | The JSON reply content. |
|
||||||
|
| `openrouter_chat_upstream_error.json` | An `error` object (`code`, `message`, `metadata.provider_name`, `metadata.raw`) in an HTTP 200 body, which OpenRouter documents for upstream failures. | `user_id`. |
|
||||||
|
| `openrouter_error_rate_limited.json` | Error object, `code: 429`, rate-limit `metadata`. | The `Key {{KEY}}` text; added to test redaction. |
|
||||||
|
| `openrouter_models.json` | `{data: [model]}` with `name`, `context_length`, `architecture`, `pricing`, `supported_parameters`. | The second model is a minimal entry, to test optional fields. |
|
||||||
|
|
||||||
|
## Ollama
|
||||||
|
|
||||||
|
Ollama's OpenAI compatibility page
|
||||||
|
(<https://github.com/ollama/ollama/blob/main/docs/openai.md>) documents
|
||||||
|
requests but shows no response bodies. The shapes come from its `openai`
|
||||||
|
middleware source (<https://github.com/ollama/ollama/blob/main/openai/openai.go>):
|
||||||
|
`chatcmpl-<n>` IDs, `system_fingerprint: fp_ollama`, `owned_by: library` on
|
||||||
|
models, and errors as `{error: {message, type: api_error, param, code}}`.
|
||||||
|
|
||||||
|
| Fixture | Shape | Not in the public example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ollama_chat_text.json` | Plain chat completion. | — |
|
||||||
|
| `ollama_chat_prompt_only.json` | Chat completion. | The reply text: prose and stray braces around a fenced JSON object, to test prompt-only extraction. |
|
||||||
|
| `ollama_chat_truncated.json` | Chat completion with `finish_reason: length`. | The cut-off JSON reply. |
|
||||||
|
| `ollama_models.json` | `{object: list, data: [model]}`. | — |
|
||||||
|
| `ollama_error_not_found.json` | Middleware error object for a missing model. | — |
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-417",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570322,
|
||||||
|
"model": "llama3.2",
|
||||||
|
"system_fingerprint": "fp_ollama",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Sure! Here is a dinner {idea} for you:\n```json\n{\"dinners\": [{\"name\": \"Crock pot chili\", \"why\": \"A Quick dinner like your {Regular} stew.\"}]}\n```\nEnjoy {\"not\": \"this one\"}!"
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 512, "completion_tokens": 58, "total_tokens": 570}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-419",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570340,
|
||||||
|
"model": "llama3.2",
|
||||||
|
"system_fingerprint": "fp_ollama",
|
||||||
|
"choices": [
|
||||||
|
{"index": 0, "message": {"role": "assistant", "content": "Try a lemon orzo bake tonight."}, "finish_reason": "stop"}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 40, "completion_tokens": 9, "total_tokens": 49}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-418",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570330,
|
||||||
|
"model": "llama3.2",
|
||||||
|
"system_fingerprint": "fp_ollama",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "{\"dinners\": [{\"name\": \"Crock pot ch"},
|
||||||
|
"finish_reason": "length"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 512, "completion_tokens": 16, "total_tokens": 528}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"error": {"message": "model \"llama9\" not found, try pulling it first", "type": "api_error", "param": null, "code": null}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{"id": "llama3.2:latest", "object": "model", "created": 1741000000, "owned_by": "library"},
|
||||||
|
{"id": "qwen2.5:7b", "object": "model", "created": 1740000000, "owned_by": "library"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-B9MHDbslfkBeAs8l4bebGdFOJ6PeG",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570283,
|
||||||
|
"model": "gpt-4o-2024-08-06",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "{\"dinners\":[{\"name\":\"Sheet-pan gnocchi\",\"why\":\"Like your Regular tray bake, but quicker.\"}]}",
|
||||||
|
"refusal": null,
|
||||||
|
"annotations": []
|
||||||
|
},
|
||||||
|
"logprobs": null,
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 1117,
|
||||||
|
"completion_tokens": 46,
|
||||||
|
"total_tokens": 1163,
|
||||||
|
"prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
|
||||||
|
"completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}
|
||||||
|
},
|
||||||
|
"service_tier": "default",
|
||||||
|
"system_fingerprint": "fp_fc9f1d7035"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-B9MHDbslfkBeAs8l4bebGdFOJ6PeH",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570290,
|
||||||
|
"model": "gpt-4o-2024-08-06",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": null, "refusal": "I'm sorry, I can't help with that request.", "annotations": []},
|
||||||
|
"logprobs": null,
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 210, "completion_tokens": 11, "total_tokens": 221},
|
||||||
|
"system_fingerprint": "fp_fc9f1d7035"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "Incorrect API key provided: {{KEY}}. You can find your API key at https://platform.openai.com/account/api-keys.",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"param": null,
|
||||||
|
"code": "invalid_api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"error": {"message": "The server had an error while processing your request. Authorization: Bearer {{KEY}}", "type": "server_error", "param": null, "code": null}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{"id": "gpt-4o-2024-08-06", "object": "model", "created": 1722814719, "owned_by": "system"},
|
||||||
|
{"id": "gpt-4o-mini", "object": "model", "created": 1721172741, "owned_by": "system"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "gen-1741570300-xQmT9vWb4kR2nP7sL3aE",
|
||||||
|
"provider": "OpenAI",
|
||||||
|
"model": "openai/gpt-4o-mini",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1741570300,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"logprobs": null,
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"native_finish_reason": "stop",
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "{\n \"dinners\": [{\"name\": \"Chicken tinga tacos\", \"why\": \"Close to your taco-night Regular.\"}]\n}",
|
||||||
|
"refusal": null,
|
||||||
|
"reasoning": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 842, "completion_tokens": 31, "total_tokens": 873}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"id": "gen-1741570311-aB3dE5fG7hJ9kL1mN2pQ",
|
||||||
|
"error": {"code": 502, "message": "Upstream error from provider", "metadata": {"provider_name": "Lambda", "raw": "upstream returned 502"}},
|
||||||
|
"user_id": "user_2abcDEF"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"error": {"code": 429, "message": "Rate limit exceeded: free-models-per-min. Key {{KEY}}", "metadata": {"headers": {"X-RateLimit-Limit": "20", "X-RateLimit-Remaining": "0"}}}}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "openai/gpt-4o-mini",
|
||||||
|
"canonical_slug": "openai/gpt-4o-mini",
|
||||||
|
"name": "OpenAI: GPT-4o-mini",
|
||||||
|
"created": 1721260800,
|
||||||
|
"description": "GPT-4o mini is OpenAI's newest model after GPT-4 Omni.",
|
||||||
|
"context_length": 128000,
|
||||||
|
"architecture": {"modality": "text+image->text", "input_modalities": ["text", "image"], "output_modalities": ["text"], "tokenizer": "GPT"},
|
||||||
|
"pricing": {"prompt": "0.00000015", "completion": "0.0000006"},
|
||||||
|
"supported_parameters": ["max_tokens", "temperature", "response_format", "structured_outputs"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "anthropic/claude-sonnet-5",
|
||||||
|
"name": "Anthropic: Claude Sonnet 5",
|
||||||
|
"created": 1767225600,
|
||||||
|
"context_length": 200000,
|
||||||
|
"supported_parameters": ["max_tokens", "temperature"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// Package presets holds what MealQuest knows about each language-model
|
||||||
|
// vendor — base URL, adapter, credentials, headers, structured output mode
|
||||||
|
// and model quirks — and FromSettings, which turns the household's
|
||||||
|
// llm.Settings into a ready llm.Provider.
|
||||||
|
//
|
||||||
|
// FromSettings lives here rather than in package llm because it builds the
|
||||||
|
// adapters, and every adapter imports llm.
|
||||||
|
//
|
||||||
|
// Nothing in this package logs, and no error it returns or unwraps to
|
||||||
|
// contains the API key, even where the key equals a setting's or request's
|
||||||
|
// value or text the error spells anyway.
|
||||||
|
package presets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/anthropic"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/ollama"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Adapter names the llm adapter package a preset builds.
|
||||||
|
type Adapter string
|
||||||
|
|
||||||
|
// The adapters.
|
||||||
|
const (
|
||||||
|
AdapterAnthropic Adapter = "anthropic"
|
||||||
|
AdapterOpenAI Adapter = "openai"
|
||||||
|
AdapterOllama Adapter = "ollama"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyPolicy is whether a preset sends an API key.
|
||||||
|
type KeyPolicy int
|
||||||
|
|
||||||
|
// The key policies.
|
||||||
|
const (
|
||||||
|
// KeyRequired presets fail without a key.
|
||||||
|
KeyRequired KeyPolicy = iota
|
||||||
|
// KeyOptional presets send a key when one is given.
|
||||||
|
KeyOptional
|
||||||
|
// KeyNone presets send no key and refuse one.
|
||||||
|
KeyNone
|
||||||
|
)
|
||||||
|
|
||||||
|
// The reasoning models that reject the temperature field are OpenAI's o1
|
||||||
|
// and o3 families: the bare family name or the name followed by a "-"
|
||||||
|
// variant (o1, o1-mini, o3-2025-04-16), never a longer word such as o123.
|
||||||
|
// OpenRouter names them with an "openai/" prefix and may add a ":" variant
|
||||||
|
// (openai/o1:online). A preset whose OmitTemperature holds a pattern never
|
||||||
|
// sends a temperature to a matching model.
|
||||||
|
var (
|
||||||
|
openAIReasoningModels = regexp.MustCompile(`^o[13](-.*)?$`)
|
||||||
|
openRouterReasoningModels = regexp.MustCompile(`^openai/o[13]([-:].*)?$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preset is one vendor's fixed configuration.
|
||||||
|
type Preset struct {
|
||||||
|
// Name is the Settings.Provider value that selects the preset.
|
||||||
|
Name string
|
||||||
|
// Adapter is the adapter the preset builds.
|
||||||
|
Adapter Adapter
|
||||||
|
// BaseURL is the API root the adapter appends its endpoint paths to.
|
||||||
|
// Empty means the household must supply it in Settings.BaseURL.
|
||||||
|
BaseURL string
|
||||||
|
// Key is whether the preset sends an API key.
|
||||||
|
Key KeyPolicy
|
||||||
|
// RequiresPublicURL means the preset needs Settings.PublicURL for its
|
||||||
|
// headers.
|
||||||
|
RequiresPublicURL bool
|
||||||
|
// Headers returns the extra request headers an openai-adapter preset
|
||||||
|
// sends; nil means none.
|
||||||
|
Headers func(llm.Settings) map[string]string
|
||||||
|
// StructuredOutput is the default mode of an openai-adapter preset.
|
||||||
|
StructuredOutput openai.StructuredOutput
|
||||||
|
// FallbackStructuredOutput is the mode the household can select when
|
||||||
|
// the default fails for its model; empty means none. FromSettings
|
||||||
|
// builds one mode and never retries a request in another.
|
||||||
|
FallbackStructuredOutput openai.StructuredOutput
|
||||||
|
// StructuredOutputs are the modes Settings.StructuredOutput may select.
|
||||||
|
StructuredOutputs []openai.StructuredOutput
|
||||||
|
// MaxTokens is the max_tokens the anthropic adapter sends when a
|
||||||
|
// Request sets none.
|
||||||
|
MaxTokens int
|
||||||
|
// OmitTemperature are model-id patterns the preset never sends a
|
||||||
|
// temperature to.
|
||||||
|
OmitTemperature []*regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
// openRouterHeaders identify MealQuest to OpenRouter: HTTP-Referer is
|
||||||
|
// MEALQUEST_PUBLIC_URL and X-Title is the app name.
|
||||||
|
func openRouterHeaders(s llm.Settings) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"HTTP-Referer": s.PublicURL,
|
||||||
|
"X-Title": "MealQuest",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// omitsTemperature reports whether model matches one of the preset's
|
||||||
|
// OmitTemperature patterns.
|
||||||
|
func (p Preset) omitsTemperature(model string) bool {
|
||||||
|
return slices.ContainsFunc(p.OmitTemperature, func(re *regexp.Regexp) bool {
|
||||||
|
return re.MatchString(model)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the preset Settings.Provider name selects.
|
||||||
|
func Lookup(name string) (Preset, bool) {
|
||||||
|
p, ok := all[name]
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// all is every preset, by Settings.Provider name.
|
||||||
|
var all = map[string]Preset{
|
||||||
|
"anthropic": {
|
||||||
|
Name: "anthropic",
|
||||||
|
Adapter: AdapterAnthropic,
|
||||||
|
BaseURL: "https://api.anthropic.com",
|
||||||
|
Key: KeyRequired,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
Name: "openai",
|
||||||
|
Adapter: AdapterOpenAI,
|
||||||
|
BaseURL: "https://api.openai.com/v1",
|
||||||
|
Key: KeyRequired,
|
||||||
|
StructuredOutput: openai.JSONSchema,
|
||||||
|
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema},
|
||||||
|
OmitTemperature: []*regexp.Regexp{openAIReasoningModels},
|
||||||
|
},
|
||||||
|
"openrouter": {
|
||||||
|
Name: "openrouter",
|
||||||
|
Adapter: AdapterOpenAI,
|
||||||
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
|
Key: KeyRequired,
|
||||||
|
RequiresPublicURL: true,
|
||||||
|
Headers: openRouterHeaders,
|
||||||
|
StructuredOutput: openai.JSONSchema,
|
||||||
|
FallbackStructuredOutput: openai.JSONObject,
|
||||||
|
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject},
|
||||||
|
OmitTemperature: []*regexp.Regexp{openRouterReasoningModels},
|
||||||
|
},
|
||||||
|
"xai": {
|
||||||
|
Name: "xai",
|
||||||
|
Adapter: AdapterOpenAI,
|
||||||
|
BaseURL: "https://api.x.ai/v1",
|
||||||
|
Key: KeyRequired,
|
||||||
|
StructuredOutput: openai.JSONSchema,
|
||||||
|
FallbackStructuredOutput: openai.JSONObject,
|
||||||
|
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject},
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
Name: "ollama",
|
||||||
|
Adapter: AdapterOllama,
|
||||||
|
Key: KeyNone,
|
||||||
|
},
|
||||||
|
"openai_compatible": {
|
||||||
|
Name: "openai_compatible",
|
||||||
|
Adapter: AdapterOpenAI,
|
||||||
|
Key: KeyOptional,
|
||||||
|
StructuredOutput: openai.JSONSchema,
|
||||||
|
StructuredOutputs: []openai.StructuredOutput{openai.JSONSchema, openai.JSONObject, openai.PromptOnly},
|
||||||
|
// The server may be OpenAI itself or a gateway that names models
|
||||||
|
// the OpenRouter way.
|
||||||
|
OmitTemperature: []*regexp.Regexp{openAIReasoningModels, openRouterReasoningModels},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromSettings returns the Provider s describes, using key as its API key.
|
||||||
|
// It fails with a configuration error from package llm — ErrUnknownProvider,
|
||||||
|
// ErrMissingKey, ErrUnexpectedKey, ErrInvalidKey, ErrMissingBaseURL, ErrMissingModel,
|
||||||
|
// ErrMissingPublicURL or ErrInvalidSetting — before contacting any vendor.
|
||||||
|
//
|
||||||
|
// The Provider sends s.Temperature with every request whose Temperature is
|
||||||
|
// nil; a request that sets one keeps it. Neither is sent to a model the
|
||||||
|
// preset's OmitTemperature matches.
|
||||||
|
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
|
||||||
|
cp, err := fromSettings(s, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, redact(err, key)
|
||||||
|
}
|
||||||
|
return cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromSettings(s llm.Settings, key string) (*configured, error) {
|
||||||
|
p, ok := Lookup(s.Provider)
|
||||||
|
if !ok {
|
||||||
|
return nil, llm.ErrUnknownProvider
|
||||||
|
}
|
||||||
|
if err := check(p, s, key); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", p.Name, err)
|
||||||
|
}
|
||||||
|
base := p.BaseURL
|
||||||
|
if base == "" {
|
||||||
|
base = s.BaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var inner llm.Provider
|
||||||
|
var err error
|
||||||
|
switch p.Adapter {
|
||||||
|
case AdapterAnthropic:
|
||||||
|
inner, err = anthropic.New(anthropic.Config{BaseURL: base, APIKey: key, Model: s.Model, MaxTokens: p.MaxTokens})
|
||||||
|
case AdapterOllama:
|
||||||
|
inner, err = ollama.New(ollama.Config{BaseURL: base, Model: s.Model})
|
||||||
|
case AdapterOpenAI:
|
||||||
|
mode := p.StructuredOutput
|
||||||
|
if s.StructuredOutput != "" {
|
||||||
|
mode = openai.StructuredOutput(s.StructuredOutput)
|
||||||
|
}
|
||||||
|
cfg := openai.Config{BaseURL: base, APIKey: key, Model: s.Model, StructuredOutput: mode}
|
||||||
|
if p.Headers != nil {
|
||||||
|
cfg.Headers = p.Headers(s)
|
||||||
|
}
|
||||||
|
inner, err = openai.New(cfg)
|
||||||
|
default:
|
||||||
|
panic("presets: preset " + p.Name + " has unknown adapter " + string(p.Adapter))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w: %w", p.Name, llm.ErrInvalidSetting, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cp := &configured{Provider: inner, key: key}
|
||||||
|
if !p.omitsTemperature(s.Model) {
|
||||||
|
t := s.Temperature
|
||||||
|
cp.temperature = &t
|
||||||
|
}
|
||||||
|
return cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// check validates s and key against p. Its errors never quote a value.
|
||||||
|
func check(p Preset, s llm.Settings, key string) error {
|
||||||
|
switch {
|
||||||
|
case p.Key == KeyRequired && key == "":
|
||||||
|
return llm.ErrMissingKey
|
||||||
|
case p.Key == KeyNone && key != "":
|
||||||
|
return llm.ErrUnexpectedKey
|
||||||
|
case !validHeaderValue(key):
|
||||||
|
return llm.ErrInvalidKey
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case p.BaseURL == "" && s.BaseURL == "":
|
||||||
|
return llm.ErrMissingBaseURL
|
||||||
|
case p.BaseURL != "" && s.BaseURL != "" && s.BaseURL != p.BaseURL:
|
||||||
|
return fmt.Errorf("%w: base URL is fixed by the preset", llm.ErrInvalidSetting)
|
||||||
|
}
|
||||||
|
if s.Model == "" {
|
||||||
|
return llm.ErrMissingModel
|
||||||
|
}
|
||||||
|
if p.RequiresPublicURL && s.PublicURL == "" {
|
||||||
|
return llm.ErrMissingPublicURL
|
||||||
|
}
|
||||||
|
if s.StructuredOutput != "" && !slices.Contains(p.StructuredOutputs, openai.StructuredOutput(s.StructuredOutput)) {
|
||||||
|
return fmt.Errorf("%w: structured output mode is not supported by the preset", llm.ErrInvalidSetting)
|
||||||
|
}
|
||||||
|
if math.IsNaN(s.Temperature) || math.IsInf(s.Temperature, 0) || s.Temperature < 0 {
|
||||||
|
return fmt.Errorf("%w: temperature must be a non-negative number", llm.ErrInvalidSetting)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validHeaderValue reports whether value has no control character other
|
||||||
|
// than tab, so it can be sent as an HTTP header value.
|
||||||
|
func validHeaderValue(value string) bool {
|
||||||
|
for i := 0; i < len(value); i++ {
|
||||||
|
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// configured applies the household temperature and the preset's
|
||||||
|
// temperature quirk to every Generate.
|
||||||
|
type configured struct {
|
||||||
|
llm.Provider
|
||||||
|
// key is the API key, redacted from validation errors; the adapter
|
||||||
|
// redacts its own.
|
||||||
|
key string
|
||||||
|
// temperature is sent when a request sets none; nil means the model
|
||||||
|
// rejects temperature, so none is ever sent.
|
||||||
|
temperature *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate validates req as sent by the caller before changing its
|
||||||
|
// temperature, so clearing it for a model that rejects temperature never
|
||||||
|
// hides an invalid one.
|
||||||
|
func (c *configured) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return llm.Response{}, redact(fmt.Errorf("presets: generate: %w", err), c.key)
|
||||||
|
}
|
||||||
|
if c.temperature == nil {
|
||||||
|
req.Temperature = nil
|
||||||
|
} else if req.Temperature == nil {
|
||||||
|
t := *c.temperature
|
||||||
|
req.Temperature = &t
|
||||||
|
}
|
||||||
|
return c.Provider.Generate(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactedError is an error with the API key removed from its text. It
|
||||||
|
// does not unwrap, because every layer below may hold the key; Is still
|
||||||
|
// matches the sentinels the original error wraps.
|
||||||
|
type redactedError struct {
|
||||||
|
text string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *redactedError) Error() string { return e.text }
|
||||||
|
|
||||||
|
// GoString keeps %#v to the redacted Error text instead of the fields.
|
||||||
|
func (e *redactedError) GoString() string { return e.text }
|
||||||
|
|
||||||
|
// Is reports whether the original error matches target.
|
||||||
|
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||||
|
|
||||||
|
// redact returns err with key removed from its text; a nil err or an empty
|
||||||
|
// key returns err unchanged.
|
||||||
|
func redact(err error, key string) error {
|
||||||
|
if err == nil || key == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return &redactedError{text: llm.Redact(err.Error(), key), err: err}
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
package presets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testKey = "sk-test-0123456789"
|
||||||
|
|
||||||
|
// captured is the HTTP request an adapter sent.
|
||||||
|
type captured struct {
|
||||||
|
url string
|
||||||
|
header http.Header
|
||||||
|
body map[string]json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
var errStop = errors.New("recorded")
|
||||||
|
|
||||||
|
// recordingTransport records each request and fails it, so no request
|
||||||
|
// leaves the process.
|
||||||
|
type recordingTransport struct{ reqs []captured }
|
||||||
|
|
||||||
|
func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
c := captured{url: r.URL.String(), header: r.Header.Clone()}
|
||||||
|
if r.Body != nil {
|
||||||
|
raw, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &c.body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rt.reqs = append(rt.reqs, c)
|
||||||
|
return nil, errStop
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate builds the provider s describes and returns the request its
|
||||||
|
// Generate sends for req. The adapters use http.DefaultTransport, which the
|
||||||
|
// test replaces, so the real preset base URLs are never contacted.
|
||||||
|
func generate(t *testing.T, s llm.Settings, key string, req llm.Request) captured {
|
||||||
|
t.Helper()
|
||||||
|
rt := &recordingTransport{}
|
||||||
|
orig := http.DefaultTransport
|
||||||
|
http.DefaultTransport = rt
|
||||||
|
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||||
|
|
||||||
|
p, err := FromSettings(s, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FromSettings: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := p.Generate(context.Background(), req); err == nil {
|
||||||
|
t.Fatal("Generate succeeded through the recording transport")
|
||||||
|
}
|
||||||
|
if len(rt.reqs) != 1 {
|
||||||
|
t.Fatalf("sent %d requests, want 1", len(rt.reqs))
|
||||||
|
}
|
||||||
|
return rt.reqs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func plainRequest() llm.Request {
|
||||||
|
return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "dinner?"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaRequest() llm.Request {
|
||||||
|
r := plainRequest()
|
||||||
|
r.Schema = &llm.JSONSchema{Name: "side_quests", Schema: json.RawMessage(`{"type":"object"}`)}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr(f float64) *float64 { return &f }
|
||||||
|
|
||||||
|
func responseFormatType(t *testing.T, c captured) string {
|
||||||
|
t.Helper()
|
||||||
|
raw, ok := c.body["response_format"]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var rf struct{ Type string }
|
||||||
|
if err := json.Unmarshal(raw, &rf); err != nil {
|
||||||
|
t.Fatalf("response_format: %v", err)
|
||||||
|
}
|
||||||
|
return rf.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
func temperature(t *testing.T, c captured) (float64, bool) {
|
||||||
|
t.Helper()
|
||||||
|
raw, ok := c.body["temperature"]
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var f float64
|
||||||
|
if err := json.Unmarshal(raw, &f); err != nil {
|
||||||
|
t.Fatalf("temperature: %v", err)
|
||||||
|
}
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicPreset(t *testing.T) {
|
||||||
|
c := generate(t, llm.Settings{Provider: "anthropic", Model: "claude-sonnet-5", Temperature: 0.7}, testKey, plainRequest())
|
||||||
|
if c.url != "https://api.anthropic.com/v1/messages" {
|
||||||
|
t.Fatalf("URL = %q", c.url)
|
||||||
|
}
|
||||||
|
if got := c.header.Get("X-Api-Key"); got != testKey {
|
||||||
|
t.Fatalf("x-api-key = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.header.Get("Authorization"); got != "" {
|
||||||
|
t.Fatalf("Authorization = %q, want none", got)
|
||||||
|
}
|
||||||
|
if got, ok := temperature(t, c); !ok || got != 0.7 {
|
||||||
|
t.Fatalf("temperature = %v, %v; want 0.7", got, ok)
|
||||||
|
}
|
||||||
|
if string(c.body["max_tokens"]) != "4096" {
|
||||||
|
t.Fatalf("max_tokens = %s", c.body["max_tokens"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicPresetAcceptsItsOwnBaseURL(t *testing.T) {
|
||||||
|
c := generate(t, llm.Settings{Provider: "anthropic", BaseURL: "https://api.anthropic.com", Model: "claude-sonnet-5"}, testKey, plainRequest())
|
||||||
|
if c.url != "https://api.anthropic.com/v1/messages" {
|
||||||
|
t.Fatalf("URL = %q", c.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIPreset(t *testing.T) {
|
||||||
|
c := generate(t, llm.Settings{Provider: "openai", Model: "gpt-4o", Temperature: 0.4}, testKey, schemaRequest())
|
||||||
|
if c.url != "https://api.openai.com/v1/chat/completions" {
|
||||||
|
t.Fatalf("URL = %q", c.url)
|
||||||
|
}
|
||||||
|
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||||
|
t.Fatalf("Authorization = %q", got)
|
||||||
|
}
|
||||||
|
if got := responseFormatType(t, c); got != "json_schema" {
|
||||||
|
t.Fatalf("response_format type = %q, want json_schema", got)
|
||||||
|
}
|
||||||
|
if c.header.Get("HTTP-Referer") != "" || c.header.Get("X-Title") != "" {
|
||||||
|
t.Fatal("openai sends OpenRouter headers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRouterPreset(t *testing.T) {
|
||||||
|
s := llm.Settings{Provider: "openrouter", Model: "openai/gpt-4o", PublicURL: "https://meals.example.com/"}
|
||||||
|
c := generate(t, s, testKey, schemaRequest())
|
||||||
|
if c.url != "https://openrouter.ai/api/v1/chat/completions" {
|
||||||
|
t.Fatalf("URL = %q", c.url)
|
||||||
|
}
|
||||||
|
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||||
|
t.Fatalf("Authorization = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.header.Get("HTTP-Referer"); got != "https://meals.example.com/" {
|
||||||
|
t.Fatalf("HTTP-Referer = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.header.Get("X-Title"); got != "MealQuest" {
|
||||||
|
t.Fatalf("X-Title = %q", got)
|
||||||
|
}
|
||||||
|
if got := responseFormatType(t, c); got != "json_schema" {
|
||||||
|
t.Fatalf("default response_format type = %q, want json_schema", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRouterFallbackModeIsJSONObject(t *testing.T) {
|
||||||
|
p, _ := Lookup("openrouter")
|
||||||
|
s := llm.Settings{
|
||||||
|
Provider: "openrouter",
|
||||||
|
Model: "meta-llama/llama-3.3-70b-instruct",
|
||||||
|
PublicURL: "https://meals.example.com",
|
||||||
|
StructuredOutput: string(p.FallbackStructuredOutput),
|
||||||
|
}
|
||||||
|
c := generate(t, s, testKey, schemaRequest())
|
||||||
|
if got := responseFormatType(t, c); got != "json_object" {
|
||||||
|
t.Fatalf("response_format type = %q, want json_object", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXAIPreset(t *testing.T) {
|
||||||
|
c := generate(t, llm.Settings{Provider: "xai", Model: "grok-4", Temperature: 0.6}, testKey, schemaRequest())
|
||||||
|
if c.url != "https://api.x.ai/v1/chat/completions" {
|
||||||
|
t.Fatalf("URL = %q", c.url)
|
||||||
|
}
|
||||||
|
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||||
|
t.Fatalf("Authorization = %q", got)
|
||||||
|
}
|
||||||
|
for _, h := range []string{"X-Api-Key", "HTTP-Referer", "X-Title"} {
|
||||||
|
if got := c.header.Get(h); got != "" {
|
||||||
|
t.Fatalf("%s = %q, want none", h, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := responseFormatType(t, c); got != "json_schema" {
|
||||||
|
t.Fatalf("default response_format type = %q, want json_schema", got)
|
||||||
|
}
|
||||||
|
if got, ok := temperature(t, c); !ok || got != 0.6 {
|
||||||
|
t.Fatalf("temperature = %v, %v; want 0.6", got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXAIFallbackModeIsJSONObject(t *testing.T) {
|
||||||
|
p, _ := Lookup("xai")
|
||||||
|
s := llm.Settings{Provider: "xai", Model: "grok-4", StructuredOutput: string(p.FallbackStructuredOutput)}
|
||||||
|
c := generate(t, s, testKey, schemaRequest())
|
||||||
|
if got := responseFormatType(t, c); got != "json_object" {
|
||||||
|
t.Fatalf("response_format type = %q, want json_object", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXAIPresetAcceptsItsOwnBaseURL(t *testing.T) {
|
||||||
|
c := generate(t, llm.Settings{Provider: "xai", BaseURL: "https://api.x.ai/v1", Model: "grok-4"}, testKey, plainRequest())
|
||||||
|
if c.url != "https://api.x.ai/v1/chat/completions" {
|
||||||
|
t.Fatalf("URL = %q", c.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOllamaPreset(t *testing.T) {
|
||||||
|
s := llm.Settings{Provider: "ollama", BaseURL: "http://ollama.lan:11434", Model: "llama3.2", Temperature: 0.5}
|
||||||
|
c := generate(t, s, "", plainRequest())
|
||||||
|
if c.url != "http://ollama.lan:11434/api/chat" {
|
||||||
|
t.Fatalf("URL = %q", c.url)
|
||||||
|
}
|
||||||
|
if got := c.header.Get("Authorization"); got != "" {
|
||||||
|
t.Fatalf("Authorization = %q, want none", got)
|
||||||
|
}
|
||||||
|
var opts struct{ Temperature *float64 }
|
||||||
|
if err := json.Unmarshal(c.body["options"], &opts); err != nil || opts.Temperature == nil || *opts.Temperature != 0.5 {
|
||||||
|
t.Fatalf("options = %s", c.body["options"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatiblePreset(t *testing.T) {
|
||||||
|
base := llm.Settings{Provider: "openai_compatible", BaseURL: "http://llm.lan:8000/v1", Model: "qwen2.5"}
|
||||||
|
|
||||||
|
c := generate(t, base, "", schemaRequest())
|
||||||
|
if c.url != "http://llm.lan:8000/v1/chat/completions" {
|
||||||
|
t.Fatalf("URL = %q", c.url)
|
||||||
|
}
|
||||||
|
if got := c.header.Get("Authorization"); got != "" {
|
||||||
|
t.Fatalf("Authorization without key = %q, want none", got)
|
||||||
|
}
|
||||||
|
if got := responseFormatType(t, c); got != "json_schema" {
|
||||||
|
t.Fatalf("default response_format type = %q, want json_schema", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
c = generate(t, base, testKey, plainRequest())
|
||||||
|
if got := c.header.Get("Authorization"); got != "Bearer "+testKey {
|
||||||
|
t.Fatalf("Authorization with key = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for mode, want := range map[string]string{"json_schema": "json_schema", "json_object": "json_object", "prompt_only": ""} {
|
||||||
|
s := base
|
||||||
|
s.StructuredOutput = mode
|
||||||
|
if got := responseFormatType(t, generate(t, s, "", schemaRequest())); got != want {
|
||||||
|
t.Errorf("mode %s: response_format type = %q, want %q", mode, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemperatureOmittedForRejectingModels(t *testing.T) {
|
||||||
|
cases := map[string][]string{
|
||||||
|
"openai": {"o1", "o1-mini", "o1-2024-12-17", "o3", "o3-mini"},
|
||||||
|
"openai_compatible": {"o1", "o1-mini", "o3-mini", "openai/o1"},
|
||||||
|
"openrouter": {"openai/o1", "openai/o1-mini", "openai/o3", "openai/o3-mini", "openai/o1:online"},
|
||||||
|
}
|
||||||
|
for provider, models := range cases {
|
||||||
|
for _, model := range models {
|
||||||
|
s := llm.Settings{Provider: provider, Model: model, Temperature: 0.8}
|
||||||
|
switch provider {
|
||||||
|
case "openai_compatible":
|
||||||
|
s.BaseURL = "http://llm.lan/v1"
|
||||||
|
case "openrouter":
|
||||||
|
s.PublicURL = "https://mealquest.example"
|
||||||
|
}
|
||||||
|
for _, req := range []llm.Request{plainRequest(), func() llm.Request { r := plainRequest(); r.Temperature = ptr(0.2); return r }()} {
|
||||||
|
if got, ok := temperature(t, generate(t, s, testKey, req)); ok {
|
||||||
|
t.Errorf("%s %s: temperature %v sent, want omitted", provider, model, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemperatureSentForOtherModels(t *testing.T) {
|
||||||
|
cases := map[string][]string{
|
||||||
|
"openai": {"gpt-4o", "gpt-4o-mini", "chatgpt-o1-like", "o123", "o3custom", "o1mini", "openai/o1"},
|
||||||
|
"openrouter": {"openai/gpt-4o", "openai/o123", "openai/o3custom", "acme/o1", "o1-mini"},
|
||||||
|
}
|
||||||
|
for provider, models := range cases {
|
||||||
|
for _, model := range models {
|
||||||
|
s := llm.Settings{Provider: provider, Model: model, Temperature: 0.8}
|
||||||
|
if provider == "openrouter" {
|
||||||
|
s.PublicURL = "https://mealquest.example"
|
||||||
|
}
|
||||||
|
if got, ok := temperature(t, generate(t, s, testKey, plainRequest())); !ok || got != 0.8 {
|
||||||
|
t.Errorf("%s %s: temperature = %v, %v; want 0.8", provider, model, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A request's own temperature wins over the household's.
|
||||||
|
req := plainRequest()
|
||||||
|
req.Temperature = ptr(0.1)
|
||||||
|
c := generate(t, llm.Settings{Provider: "openai", Model: "gpt-4o", Temperature: 0.8}, testKey, req)
|
||||||
|
if got, ok := temperature(t, c); !ok || got != 0.1 {
|
||||||
|
t.Fatalf("temperature = %v, %v; want 0.1", got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request is validated before the temperature quirk clears its
|
||||||
|
// temperature, so a negative one is an error and nothing is sent.
|
||||||
|
func TestNegativeTemperatureRejectedForRejectingModels(t *testing.T) {
|
||||||
|
rt := &recordingTransport{}
|
||||||
|
orig := http.DefaultTransport
|
||||||
|
http.DefaultTransport = rt
|
||||||
|
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||||
|
|
||||||
|
for _, s := range []llm.Settings{
|
||||||
|
{Provider: "openai", Model: "o1"},
|
||||||
|
{Provider: "openrouter", Model: "openai/o1", PublicURL: "https://mealquest.example"},
|
||||||
|
} {
|
||||||
|
p, err := FromSettings(s, testKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: FromSettings: %v", s.Model, err)
|
||||||
|
}
|
||||||
|
req := plainRequest()
|
||||||
|
req.Temperature = ptr(-0.5)
|
||||||
|
if _, err := p.Generate(context.Background(), req); err == nil || errors.Is(err, errStop) || !strings.Contains(err.Error(), "temperature") {
|
||||||
|
t.Errorf("%s: Generate = %v, want a temperature validation error", s.Model, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rt.reqs) != 0 {
|
||||||
|
t.Fatalf("sent %d requests, want 0", len(rt.reqs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemperatureQuirkPresets(t *testing.T) {
|
||||||
|
want := map[string]bool{"openai": true, "openrouter": true, "openai_compatible": true}
|
||||||
|
for name, p := range all {
|
||||||
|
if got := len(p.OmitTemperature) > 0; got != want[name] {
|
||||||
|
t.Errorf("%s: lists temperature quirk = %v, want %v", name, got, want[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPresetTable(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
adapter Adapter
|
||||||
|
baseURL string
|
||||||
|
key KeyPolicy
|
||||||
|
}{
|
||||||
|
{"anthropic", AdapterAnthropic, "https://api.anthropic.com", KeyRequired},
|
||||||
|
{"openai", AdapterOpenAI, "https://api.openai.com/v1", KeyRequired},
|
||||||
|
{"openrouter", AdapterOpenAI, "https://openrouter.ai/api/v1", KeyRequired},
|
||||||
|
{"xai", AdapterOpenAI, "https://api.x.ai/v1", KeyRequired},
|
||||||
|
{"ollama", AdapterOllama, "", KeyNone},
|
||||||
|
{"openai_compatible", AdapterOpenAI, "", KeyOptional},
|
||||||
|
}
|
||||||
|
if len(all) != len(cases) {
|
||||||
|
t.Fatalf("%d presets, want %d", len(all), len(cases))
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
p, ok := Lookup(c.name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no preset %q", c.name)
|
||||||
|
}
|
||||||
|
if p.Name != c.name || p.Adapter != c.adapter || p.BaseURL != c.baseURL || p.Key != c.key {
|
||||||
|
t.Errorf("%s = {%s %s %q %d}", c.name, p.Name, p.Adapter, p.BaseURL, p.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigErrors(t *testing.T) {
|
||||||
|
sentinels := []error{
|
||||||
|
llm.ErrUnknownProvider, llm.ErrMissingKey, llm.ErrUnexpectedKey, llm.ErrInvalidKey, llm.ErrMissingBaseURL,
|
||||||
|
llm.ErrMissingModel, llm.ErrMissingPublicURL, llm.ErrInvalidSetting,
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
s llm.Settings
|
||||||
|
key string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"unknown provider", llm.Settings{Provider: "gemini", Model: "m"}, testKey, llm.ErrUnknownProvider},
|
||||||
|
{"anthropic no key", llm.Settings{Provider: "anthropic", Model: "m"}, "", llm.ErrMissingKey},
|
||||||
|
{"openai no key", llm.Settings{Provider: "openai", Model: "m"}, "", llm.ErrMissingKey},
|
||||||
|
{"openrouter no key", llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, "", llm.ErrMissingKey},
|
||||||
|
{"xai no key", llm.Settings{Provider: "xai", Model: "grok-4"}, "", llm.ErrMissingKey},
|
||||||
|
{"ollama with key", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, testKey, llm.ErrUnexpectedKey},
|
||||||
|
{"anthropic key with newline", llm.Settings{Provider: "anthropic", Model: "m"}, testKey + "\n", llm.ErrInvalidKey},
|
||||||
|
{"openai key with newline", llm.Settings{Provider: "openai", Model: "m"}, testKey + "\n", llm.ErrInvalidKey},
|
||||||
|
{"openrouter key with CR LF", llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, testKey + "\r\nX-Injected: 1", llm.ErrInvalidKey},
|
||||||
|
{"xai key with newline", llm.Settings{Provider: "xai", Model: "grok-4"}, testKey + "\n", llm.ErrInvalidKey},
|
||||||
|
{"openai_compatible key with NUL", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, "\x00" + testKey, llm.ErrInvalidKey},
|
||||||
|
{"ollama no base URL", llm.Settings{Provider: "ollama", Model: "m"}, "", llm.ErrMissingBaseURL},
|
||||||
|
{"openai_compatible no base URL", llm.Settings{Provider: "openai_compatible", Model: "m"}, "", llm.ErrMissingBaseURL},
|
||||||
|
{"anthropic no model", llm.Settings{Provider: "anthropic"}, testKey, llm.ErrMissingModel},
|
||||||
|
{"openai no model", llm.Settings{Provider: "openai"}, testKey, llm.ErrMissingModel},
|
||||||
|
{"openrouter no model", llm.Settings{Provider: "openrouter", PublicURL: "https://x.example"}, testKey, llm.ErrMissingModel},
|
||||||
|
{"ollama no model", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434"}, "", llm.ErrMissingModel},
|
||||||
|
{"openai_compatible no model", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1"}, "", llm.ErrMissingModel},
|
||||||
|
{"xai no model", llm.Settings{Provider: "xai"}, testKey, llm.ErrMissingModel},
|
||||||
|
{"openrouter no public URL", llm.Settings{Provider: "openrouter", Model: "m"}, testKey, llm.ErrMissingPublicURL},
|
||||||
|
{"fixed base URL overridden", llm.Settings{Provider: "openai", BaseURL: "https://evil.example/v1", Model: "m"}, testKey, llm.ErrInvalidSetting},
|
||||||
|
{"xai base URL overridden", llm.Settings{Provider: "xai", BaseURL: "https://api.openai.com/v1", Model: "grok-4"}, testKey, llm.ErrInvalidSetting},
|
||||||
|
{"xai prompt_only", llm.Settings{Provider: "xai", Model: "grok-4", StructuredOutput: "prompt_only"}, testKey, llm.ErrInvalidSetting},
|
||||||
|
{"openai json_object", llm.Settings{Provider: "openai", Model: "m", StructuredOutput: "json_object"}, testKey, llm.ErrInvalidSetting},
|
||||||
|
{"anthropic mode", llm.Settings{Provider: "anthropic", Model: "m", StructuredOutput: "json_schema"}, testKey, llm.ErrInvalidSetting},
|
||||||
|
{"unknown mode", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m", StructuredOutput: "xml"}, "", llm.ErrInvalidSetting},
|
||||||
|
{"negative temperature", llm.Settings{Provider: "openai", Model: "m", Temperature: -0.1}, testKey, llm.ErrInvalidSetting},
|
||||||
|
{"bad base URL", llm.Settings{Provider: "ollama", BaseURL: "ftp://o", Model: "m"}, "", llm.ErrInvalidSetting},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
p, err := FromSettings(c.s, c.key)
|
||||||
|
if p != nil || !errors.Is(err, c.want) {
|
||||||
|
t.Fatalf("FromSettings = %v, %v; want %v", p, err, c.want)
|
||||||
|
}
|
||||||
|
for _, other := range sentinels {
|
||||||
|
if other != c.want && errors.Is(err, other) {
|
||||||
|
t.Fatalf("error %v also matches %v", err, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.key != "" && strings.Contains(err.Error(), c.key) {
|
||||||
|
t.Fatalf("error leaks the key: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadyProviderForEveryPreset(t *testing.T) {
|
||||||
|
for _, s := range []struct {
|
||||||
|
s llm.Settings
|
||||||
|
key string
|
||||||
|
}{
|
||||||
|
{llm.Settings{Provider: "anthropic", Model: "m"}, testKey},
|
||||||
|
{llm.Settings{Provider: "openai", Model: "m"}, testKey},
|
||||||
|
{llm.Settings{Provider: "openrouter", Model: "m", PublicURL: "https://x.example"}, testKey},
|
||||||
|
{llm.Settings{Provider: "xai", Model: "m"}, testKey},
|
||||||
|
{llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, ""},
|
||||||
|
{llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, ""},
|
||||||
|
} {
|
||||||
|
if p, err := FromSettings(s.s, s.key); err != nil || p == nil {
|
||||||
|
t.Errorf("%s: FromSettings = %v, %v", s.s.Provider, p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A key that matches a value the request carries, or text the error spells
|
||||||
|
// anyway, never appears in a validation error, and nothing is sent.
|
||||||
|
func TestGenerateValidationErrorsNeverLeakKey(t *testing.T) {
|
||||||
|
rt := &recordingTransport{}
|
||||||
|
orig := http.DefaultTransport
|
||||||
|
http.DefaultTransport = rt
|
||||||
|
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||||
|
|
||||||
|
for _, key := range []string{"-0.5", "temperature", "llm", "negative"} {
|
||||||
|
p, err := FromSettings(llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: FromSettings: %v", key, err)
|
||||||
|
}
|
||||||
|
req := plainRequest()
|
||||||
|
req.Temperature = ptr(-0.5)
|
||||||
|
_, err = p.Generate(context.Background(), req)
|
||||||
|
if err == nil || errors.Is(err, errStop) {
|
||||||
|
t.Fatalf("%s: Generate = %v, want a validation error", key, err)
|
||||||
|
}
|
||||||
|
for e := error(err); e != nil; e = errors.Unwrap(e) {
|
||||||
|
if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, key) {
|
||||||
|
t.Fatalf("%s: error leaks the key: %s", key, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rt.reqs) != 0 {
|
||||||
|
t.Fatalf("sent %d requests, want 0", len(rt.reqs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A configuration error keeps its sentinel but never holds the key, even
|
||||||
|
// when the key is also a setting's value or text the error spells anyway.
|
||||||
|
func TestConfigErrorsNeverLeakKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
s llm.Settings
|
||||||
|
key string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"unknown provider named by key", llm.Settings{Provider: "llm", Model: "m"}, "llm", llm.ErrUnknownProvider},
|
||||||
|
{"no base URL", llm.Settings{Provider: "openai_compatible", Model: "m"}, "base URL", llm.ErrMissingBaseURL},
|
||||||
|
{"no model", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1"}, "model", llm.ErrMissingModel},
|
||||||
|
{"key as model, no public URL", llm.Settings{Provider: "openrouter", Model: "missing"}, "missing", llm.ErrMissingPublicURL},
|
||||||
|
{"key as base URL", llm.Settings{Provider: "openai", BaseURL: "llm", Model: "m"}, "llm", llm.ErrInvalidSetting},
|
||||||
|
{"key as mode", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m", StructuredOutput: "xml"}, "xml", llm.ErrInvalidSetting},
|
||||||
|
{"key as temperature", llm.Settings{Provider: "openai", Model: "m", Temperature: -0.1}, "-0.1", llm.ErrInvalidSetting},
|
||||||
|
{"key in bad base URL", llm.Settings{Provider: "openai_compatible", BaseURL: "ftp://k3y", Model: "m"}, "k3y", llm.ErrInvalidSetting},
|
||||||
|
{"ollama with key", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, "key", llm.ErrUnexpectedKey},
|
||||||
|
{"key with newline", llm.Settings{Provider: "openai", Model: "m"}, "sk-test\n", llm.ErrInvalidKey},
|
||||||
|
{"xai key as preset name", llm.Settings{Provider: "xai"}, "xai", llm.ErrMissingModel},
|
||||||
|
{"xai key as missing-model text", llm.Settings{Provider: "xai"}, "model is required", llm.ErrMissingModel},
|
||||||
|
{"xai key as fixed-URL text", llm.Settings{Provider: "xai", BaseURL: "https://x.example/v1", Model: "m"}, "fixed", llm.ErrInvalidSetting},
|
||||||
|
{"xai key as structured-output text", llm.Settings{Provider: "xai", Model: "m", StructuredOutput: "prompt_only"}, "structured output", llm.ErrInvalidSetting},
|
||||||
|
{"key with newline as text", llm.Settings{Provider: "openai", Model: "m"}, "header\n", llm.ErrInvalidKey},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
p, err := FromSettings(c.s, c.key)
|
||||||
|
if p != nil || !errors.Is(err, c.want) {
|
||||||
|
t.Fatalf("FromSettings = %v, %v; want %v", p, err, c.want)
|
||||||
|
}
|
||||||
|
for e := error(err); e != nil; e = errors.Unwrap(e) {
|
||||||
|
if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, c.key) {
|
||||||
|
t.Fatalf("error leaks the key: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Settings is the household's AI configuration, the input to
|
||||||
|
// presets.FromSettings. The caller fills it from the db.AiSetting row and
|
||||||
|
// the service config; this package does not import internal/db.
|
||||||
|
//
|
||||||
|
// llm.Settings{
|
||||||
|
// Provider: row.Provider,
|
||||||
|
// BaseURL: row.BaseUrl,
|
||||||
|
// Model: row.Model,
|
||||||
|
// Temperature: row.Temperature,
|
||||||
|
// PublicURL: cfg.PublicURL.String(), // MEALQUEST_PUBLIC_URL
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Settings never holds the API key: the caller decrypts it and passes it
|
||||||
|
// to presets.FromSettings separately.
|
||||||
|
type Settings struct {
|
||||||
|
// Provider names the preset: "anthropic", "openai", "openrouter",
|
||||||
|
// "xai", "ollama" or "openai_compatible".
|
||||||
|
Provider string
|
||||||
|
// BaseURL is the API root. It is required for ollama and
|
||||||
|
// openai_compatible; the other presets fix their own, and a non-empty
|
||||||
|
// value must equal it.
|
||||||
|
BaseURL string
|
||||||
|
// Model is the model id Generate requests. It is required.
|
||||||
|
Model string
|
||||||
|
// Temperature is the household's sampling temperature. It is sent with
|
||||||
|
// every request that sets none, except to models that reject it.
|
||||||
|
Temperature float64
|
||||||
|
// PublicURL is MEALQUEST_PUBLIC_URL. OpenRouter requires it and
|
||||||
|
// receives it as the HTTP-Referer header; other presets ignore it.
|
||||||
|
PublicURL string
|
||||||
|
// StructuredOutput selects how an OpenAI-compatible preset asks for
|
||||||
|
// JSON: "json_schema", "json_object" or "prompt_only". Empty selects
|
||||||
|
// the preset's default, json_schema. Each preset allows only the modes
|
||||||
|
// it lists; anthropic and ollama allow none, so it must be empty.
|
||||||
|
StructuredOutput string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration errors from presets.FromSettings. Each is distinct and
|
||||||
|
// matches with errors.Is.
|
||||||
|
var (
|
||||||
|
// ErrUnknownProvider means Settings.Provider names no preset.
|
||||||
|
ErrUnknownProvider = errors.New("llm: unknown provider")
|
||||||
|
// ErrMissingKey means the preset requires an API key and none was
|
||||||
|
// given.
|
||||||
|
ErrMissingKey = errors.New("llm: API key is required")
|
||||||
|
// ErrUnexpectedKey means an API key was given to a preset that sends
|
||||||
|
// none.
|
||||||
|
ErrUnexpectedKey = errors.New("llm: provider takes no API key")
|
||||||
|
// ErrInvalidKey means the API key is not a valid HTTP header value:
|
||||||
|
// it has a control character such as CR or LF.
|
||||||
|
ErrInvalidKey = errors.New("llm: API key is not a valid header value")
|
||||||
|
// ErrMissingBaseURL means the preset requires a base URL and none was
|
||||||
|
// given.
|
||||||
|
ErrMissingBaseURL = errors.New("llm: base URL is required")
|
||||||
|
// ErrMissingModel means Settings.Model is empty.
|
||||||
|
ErrMissingModel = errors.New("llm: model is required")
|
||||||
|
// ErrMissingPublicURL means the preset requires Settings.PublicURL and
|
||||||
|
// it is empty.
|
||||||
|
ErrMissingPublicURL = errors.New("llm: public URL is required")
|
||||||
|
// ErrInvalidSetting means a setting has a value the preset does not
|
||||||
|
// accept, such as a base URL other than the preset's, an unsupported
|
||||||
|
// structured output mode or a negative temperature.
|
||||||
|
ErrInvalidSetting = errors.New("llm: invalid setting")
|
||||||
|
)
|
||||||
@@ -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,439 @@
|
|||||||
|
// Package auth signs the household in with Authentik over OpenID Connect.
|
||||||
|
// The service is a confidential client: the authorization code, tokens and
|
||||||
|
// client secret stay on the server and never reach JavaScript.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Routes owned by this package.
|
||||||
|
const (
|
||||||
|
LoginPath = "/auth/login"
|
||||||
|
CallbackPath = "/auth/callback"
|
||||||
|
LogoutPath = "/auth/logout"
|
||||||
|
)
|
||||||
|
|
||||||
|
// providerTimeout bounds every request to the identity provider.
|
||||||
|
const providerTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// scopes requested at login.
|
||||||
|
var scopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", oidc.ScopeOfflineAccess}
|
||||||
|
|
||||||
|
// Claims are the verified ID token claims handed to the session layer.
|
||||||
|
type Claims struct {
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PreferredUsername string `json:"preferred_username"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions is the session layer. StartSession is called once per successful
|
||||||
|
// login with claims from a verified ID token; it must set the session cookie
|
||||||
|
// on w. EndSession is called on logout; it must end the session r carries,
|
||||||
|
// if any, and clear the cookie on w. A returned error fails the request with
|
||||||
|
// 500.
|
||||||
|
type Sessions interface {
|
||||||
|
StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error
|
||||||
|
EndSession(w http.ResponseWriter, r *http.Request) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is what New needs to reach the identity provider.
|
||||||
|
type Config struct {
|
||||||
|
// Issuer is the exact issuer URL; discovery runs against it.
|
||||||
|
Issuer string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
// PublicURL is the service's public URL; its origin prefixes CallbackPath
|
||||||
|
// to form the redirect URI.
|
||||||
|
PublicURL *url.URL
|
||||||
|
// SecretKey is the server key the login cookie's signing key derives from.
|
||||||
|
SecretKey []byte
|
||||||
|
// AllowedGroups are the household's groups; a sign-in is refused unless
|
||||||
|
// the account is in at least one of them.
|
||||||
|
AllowedGroups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth serves the login and callback routes.
|
||||||
|
type Auth struct {
|
||||||
|
oauth oauth2.Config
|
||||||
|
verifier *oidc.IDTokenVerifier
|
||||||
|
provider *oidc.Provider
|
||||||
|
client *http.Client
|
||||||
|
cookie loginCookie
|
||||||
|
sessions Sessions
|
||||||
|
allowed []string
|
||||||
|
// endSessionURL is the provider's end_session_endpoint; logout ends
|
||||||
|
// there so the provider's own session ends too.
|
||||||
|
endSessionURL string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New discovers the provider at cfg.Issuer and returns the login flow. It
|
||||||
|
// fails if discovery fails or the issuer the provider reports differs.
|
||||||
|
func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger) (*Auth, error) {
|
||||||
|
switch {
|
||||||
|
case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "":
|
||||||
|
return nil, errors.New("auth: issuer, client ID and client secret are required")
|
||||||
|
case cfg.PublicURL == nil:
|
||||||
|
return nil, errors.New("auth: public URL is required")
|
||||||
|
case len(cfg.SecretKey) == 0:
|
||||||
|
return nil, errors.New("auth: secret key is required")
|
||||||
|
case sessions == nil:
|
||||||
|
return nil, errors.New("auth: session layer is required")
|
||||||
|
case len(cfg.AllowedGroups) == 0:
|
||||||
|
return nil, errors.New("auth: at least one allowed group is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
|
||||||
|
discovery := *client
|
||||||
|
recorder := &statusRecorder{next: http.DefaultTransport}
|
||||||
|
discovery.Transport = recorder
|
||||||
|
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, &discovery), cfg.Issuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, discoveryError(err, int(recorder.status.Load()))
|
||||||
|
}
|
||||||
|
endpoint := provider.Endpoint()
|
||||||
|
var discovered struct {
|
||||||
|
JWKSURI string `json:"jwks_uri"`
|
||||||
|
EndSessionURI string `json:"end_session_endpoint"`
|
||||||
|
}
|
||||||
|
if err := provider.Claims(&discovered); err != nil {
|
||||||
|
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||||
|
}
|
||||||
|
// The token endpoint receives the client secret and the key set decides
|
||||||
|
// which tokens verify, so every endpoint used must pass the same rule.
|
||||||
|
for _, e := range []struct{ name, raw string }{
|
||||||
|
{"authorization endpoint", endpoint.AuthURL},
|
||||||
|
{"token endpoint", endpoint.TokenURL},
|
||||||
|
{"JWKS endpoint", discovered.JWKSURI},
|
||||||
|
{"end session endpoint", discovered.EndSessionURI},
|
||||||
|
// Groups come from userinfo when the ID token carries none, and the
|
||||||
|
// access token is sent there.
|
||||||
|
{"userinfo endpoint", provider.UserInfoEndpoint()},
|
||||||
|
} {
|
||||||
|
if err := checkEndpoint(e.name, e.raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// client_secret_basic, the OIDC default; no auto-detection retries.
|
||||||
|
endpoint.AuthStyle = oauth2.AuthStyleInHeader
|
||||||
|
|
||||||
|
return &Auth{
|
||||||
|
oauth: oauth2.Config{
|
||||||
|
ClientID: cfg.ClientID,
|
||||||
|
ClientSecret: cfg.ClientSecret,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath,
|
||||||
|
Scopes: scopes,
|
||||||
|
},
|
||||||
|
verifier: provider.Verifier(&oidc.Config{
|
||||||
|
ClientID: cfg.ClientID,
|
||||||
|
SupportedSigningAlgs: []string{oidc.RS256},
|
||||||
|
}),
|
||||||
|
provider: provider,
|
||||||
|
client: client,
|
||||||
|
allowed: slices.Clone(cfg.AllowedGroups),
|
||||||
|
cookie: newLoginCookie(cfg.SecretKey),
|
||||||
|
sessions: sessions,
|
||||||
|
endSessionURL: discovered.EndSessionURI,
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusRecorder records the status of the last response it carries.
|
||||||
|
type statusRecorder struct {
|
||||||
|
next http.RoundTripper
|
||||||
|
status atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *statusRecorder) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := s.next.RoundTrip(req)
|
||||||
|
if err == nil {
|
||||||
|
s.status.Store(int32(resp.StatusCode))
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoveryError reports a failed discovery. go-oidc puts the provider's
|
||||||
|
// response body in its error for a non-200 discovery response, so that
|
||||||
|
// error is replaced by the status alone. Transport errors carry no response
|
||||||
|
// content and keep their cause.
|
||||||
|
func discoveryError(err error, status int) error {
|
||||||
|
var urlErr *url.Error
|
||||||
|
if !errors.As(err, &urlErr) && status != 0 && status != http.StatusOK {
|
||||||
|
return fmt.Errorf("auth: OIDC discovery: provider answered HTTP %d", status)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEndpoint applies secureurl.Check to the provider URL raw, naming the
|
||||||
|
// endpoint in the error.
|
||||||
|
func checkEndpoint(name, raw string) error {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth: OIDC %s %q is not a valid URL", name, raw)
|
||||||
|
}
|
||||||
|
if err := secureurl.Check(u); err != nil {
|
||||||
|
return fmt.Errorf("auth: OIDC %s %q: %w", name, raw, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxRedirects matches net/http's default redirect limit.
|
||||||
|
const maxRedirects = 10
|
||||||
|
|
||||||
|
// checkRedirect is the provider client's redirect policy. On a 307 or 308 to
|
||||||
|
// the same host Go resends the body and Authorization header, which carry the
|
||||||
|
// code and client secret, so every target must pass secureurl.Check and an
|
||||||
|
// https:// request never follows a redirect to plain http://, even loopback.
|
||||||
|
func checkRedirect(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= maxRedirects {
|
||||||
|
return fmt.Errorf("stopped after %d redirects", maxRedirects)
|
||||||
|
}
|
||||||
|
if err := secureurl.Check(req.URL); err != nil {
|
||||||
|
return fmt.Errorf("redirect refused: %w", err)
|
||||||
|
}
|
||||||
|
if via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme != "https" {
|
||||||
|
return errors.New("redirect refused: https:// to plain http:// downgrade")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount registers GET LoginPath, GET CallbackPath and POST LogoutPath on r.
|
||||||
|
func (a *Auth) Mount(r chi.Router) {
|
||||||
|
r.Get(LoginPath, a.login)
|
||||||
|
r.Get(CallbackPath, a.callback)
|
||||||
|
r.Post(LogoutPath, a.logout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndSessionURL is the provider's end_session_endpoint.
|
||||||
|
func (a *Auth) EndSessionURL() string { return a.endSessionURL }
|
||||||
|
|
||||||
|
// login starts the authorization code flow with PKCE (S256). The optional
|
||||||
|
// "return" query parameter must be a same-origin path; anything else fails
|
||||||
|
// the login with 400.
|
||||||
|
func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
|
returnTo, err := returnPath(r.URL.Query().Get("return"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid return path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state := loginState{
|
||||||
|
State: rand.Text(),
|
||||||
|
Nonce: rand.Text(),
|
||||||
|
Verifier: oauth2.GenerateVerifier(),
|
||||||
|
Return: returnTo,
|
||||||
|
}
|
||||||
|
if err := a.cookie.set(w, state, time.Now()); err != nil {
|
||||||
|
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.oauth.AuthCodeURL(state.State,
|
||||||
|
oidc.Nonce(state.Nonce),
|
||||||
|
oauth2.S256ChallengeOption(state.Verifier),
|
||||||
|
), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callback completes the flow: it checks state against the login cookie,
|
||||||
|
// exchanges the code, verifies the ID token and nonce, then starts a session
|
||||||
|
// if the account is in an allowed group. Membership is checked on every
|
||||||
|
// login, so an account removed from the group is refused at its next one.
|
||||||
|
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
ctx := r.Context()
|
||||||
|
// The login cookie is single-use.
|
||||||
|
a.cookie.clear(w)
|
||||||
|
|
||||||
|
state, err := a.cookie.read(r, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
a.logger.WarnContext(ctx, "login callback without a valid login cookie", "error", err)
|
||||||
|
http.Error(w, "sign-in expired; start again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 {
|
||||||
|
a.logger.WarnContext(ctx, "login callback state mismatch")
|
||||||
|
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q.Get("error") != "" {
|
||||||
|
// error and error_description are provider-supplied text; only the
|
||||||
|
// fixed category is logged.
|
||||||
|
a.logger.WarnContext(ctx, "identity provider returned an authorization error")
|
||||||
|
http.Error(w, "sign-in was refused", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := q.Get("code")
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "missing authorization code", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := a.verify(ctx, code, state)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.WarnContext(ctx, "sign-in failed", "error", err)
|
||||||
|
http.Error(w, "sign-in failed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.member(claims.Groups) {
|
||||||
|
if claims.Groups == nil {
|
||||||
|
a.logger.WarnContext(ctx, "sign-in refused: no groups claim in the ID token or userinfo", "subject", claims.Subject)
|
||||||
|
} else {
|
||||||
|
a.logger.WarnContext(ctx, "sign-in refused: not in an allowed group", "subject", claims.Subject)
|
||||||
|
}
|
||||||
|
// A session this browser already holds ends too, so a refused
|
||||||
|
// sign-in never leaves it signed in.
|
||||||
|
if err := a.sessions.EndSession(w, r); err != nil {
|
||||||
|
a.logger.ErrorContext(ctx, "end session", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.refuse(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.sessions.StartSession(w, r, claims); err != nil {
|
||||||
|
a.logger.ErrorContext(ctx, "start session", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, state.Return, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logout ends the session, clears the cookie and sends the browser to the
|
||||||
|
// provider's end_session_endpoint to end the provider session as well.
|
||||||
|
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if err := a.sessions.EndSession(w, r); err != nil {
|
||||||
|
a.logger.ErrorContext(r.Context(), "end session", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, a.endSessionURL, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify exchanges code for tokens and returns the verified ID token claims.
|
||||||
|
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
|
||||||
|
// here. When the ID token has no groups claim, Groups comes from userinfo;
|
||||||
|
// it stays nil when neither has one.
|
||||||
|
func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) {
|
||||||
|
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
|
||||||
|
oauth2.VerifierOption(state.Verifier))
|
||||||
|
if err != nil {
|
||||||
|
return Claims{}, exchangeError(err)
|
||||||
|
}
|
||||||
|
raw, ok := token.Extra("id_token").(string)
|
||||||
|
if !ok || raw == "" {
|
||||||
|
return Claims{}, errors.New("token response has no id_token")
|
||||||
|
}
|
||||||
|
idToken, err := a.verifier.Verify(ctx, raw)
|
||||||
|
if err != nil {
|
||||||
|
return Claims{}, verifyError(err)
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
|
||||||
|
return Claims{}, errors.New("ID token nonce mismatch")
|
||||||
|
}
|
||||||
|
var claims Claims
|
||||||
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
|
return Claims{}, fmt.Errorf("decode ID token claims: %w", err)
|
||||||
|
}
|
||||||
|
if claims.Subject == "" {
|
||||||
|
return Claims{}, errors.New("ID token has no subject")
|
||||||
|
}
|
||||||
|
if claims.Groups == nil {
|
||||||
|
groups, err := a.userinfoGroups(ctx, token, claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return Claims{}, err
|
||||||
|
}
|
||||||
|
claims.Groups = groups
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userinfoGroups returns the groups claim from the userinfo endpoint, nil if
|
||||||
|
// it has none. The userinfo subject must match the ID token's subject.
|
||||||
|
func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject string) ([]string, error) {
|
||||||
|
info, err := a.provider.UserInfo(oidc.ClientContext(ctx, a.client), oauth2.StaticTokenSource(token))
|
||||||
|
if err != nil {
|
||||||
|
return nil, userinfoError(err)
|
||||||
|
}
|
||||||
|
if info.Subject != subject {
|
||||||
|
return nil, errors.New("userinfo subject does not match the ID token")
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
if err := info.Claims(&claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode userinfo claims: %w", err)
|
||||||
|
}
|
||||||
|
return claims.Groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userinfoError reduces a failed userinfo request to a fixed category, like
|
||||||
|
// exchangeError: go-oidc's error text can carry the response body.
|
||||||
|
func userinfoError(err error) error {
|
||||||
|
var transport *url.Error
|
||||||
|
if errors.As(err, &transport) {
|
||||||
|
return fmt.Errorf("userinfo: request failed: %s: %w", transport.Op, transport.Err)
|
||||||
|
}
|
||||||
|
return errors.New("userinfo: endpoint returned an unusable response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyError reduces a failed ID token verification to a fixed category.
|
||||||
|
// go-oidc's error text can carry the JWKS endpoint's response body or
|
||||||
|
// token-supplied values, so only the expiry of an expired token is kept.
|
||||||
|
func verifyError(err error) error {
|
||||||
|
var expired *oidc.TokenExpiredError
|
||||||
|
if errors.As(err, &expired) {
|
||||||
|
return fmt.Errorf("verify ID token: token expired at %s", expired.Expiry.UTC().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
return errors.New("verify ID token: token or provider key set rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// member reports whether groups contains an allowed group.
|
||||||
|
func (a *Auth) member(groups []string) bool {
|
||||||
|
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeError reduces a failed code exchange to a fixed category and safe
|
||||||
|
// metadata. The oauth2 error text can carry the token endpoint's response
|
||||||
|
// body or error_description, which may echo credentials, so it is dropped.
|
||||||
|
func exchangeError(err error) error {
|
||||||
|
var retrieve *oauth2.RetrieveError
|
||||||
|
var transport *url.Error
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &retrieve):
|
||||||
|
return fmt.Errorf("exchange code: token endpoint rejected the request (HTTP %d)", retrieve.Response.StatusCode)
|
||||||
|
case errors.As(err, &transport):
|
||||||
|
// Request failures never carry the response. The URL is dropped: after
|
||||||
|
// a refused redirect it is the provider-supplied Location.
|
||||||
|
return fmt.Errorf("exchange code: token request failed: %s: %w", transport.Op, transport.Err)
|
||||||
|
default:
|
||||||
|
return errors.New("exchange code: token endpoint returned an unusable response")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+136
-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,25 @@ 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"
|
||||||
|
|
||||||
|
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||||
|
// stays signed in.
|
||||||
|
const (
|
||||||
|
DefaultSessionIdle = 720 * time.Hour // 30 days
|
||||||
|
DefaultSessionMax = 4320 * time.Hour // 180 days
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the validated service configuration.
|
// Config is the validated service configuration.
|
||||||
@@ -29,6 +51,28 @@ 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
|
||||||
|
|
||||||
|
// DefaultTZ is the IANA zone the household settings are created with on
|
||||||
|
// first boot. It is optional and not validated here: boot requires and
|
||||||
|
// validates it only while the settings do not exist yet, and ignores it
|
||||||
|
// once they do. Empty when unset.
|
||||||
|
DefaultTZ string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||||
@@ -47,10 +91,25 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
|||||||
r := reader{lookup: lookup}
|
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),
|
||||||
|
|
||||||
|
DefaultTZ: r.optional(EnvDefaultTZ),
|
||||||
|
}
|
||||||
|
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||||
|
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||||
}
|
}
|
||||||
if len(r.problems) > 0 {
|
if len(r.problems) > 0 {
|
||||||
return Config{}, &Error{Problems: r.problems}
|
return Config{}, &Error{Problems: r.problems}
|
||||||
@@ -114,6 +173,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 +249,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 +295,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)
|
||||||
@@ -207,6 +333,12 @@ func (r *reader) duration(name string) time.Duration {
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// optional returns the variable's value, or "" when it is unset.
|
||||||
|
func (r *reader) optional(name string) string {
|
||||||
|
v, _ := r.lookup(name)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
// secretFile reads a secret from the file named by name, which must end in
|
// secretFile reads a secret from the file named by name, which must end in
|
||||||
// _FILE. Setting the secret inline (name without _FILE) is rejected.
|
// _FILE. Setting the secret inline (name without _FILE) is rejected.
|
||||||
func (r *reader) secretFile(name string) Secret {
|
func (r *reader) secretFile(name string) Secret {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -55,6 +68,16 @@ func TestLoad(t *testing.T) {
|
|||||||
name: "all required vars present",
|
name: "all required vars present",
|
||||||
env: valid(),
|
env: valid(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "default time zone set",
|
||||||
|
env: with(map[string]string{EnvDefaultTZ: "America/New_York"}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Validated only when seeding the household settings, so a
|
||||||
|
// stale value cannot stop a boot that no longer uses it.
|
||||||
|
name: "default time zone not an IANA zone",
|
||||||
|
env: with(map[string]string{EnvDefaultTZ: "Mars/Olympus_Mons"}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "one missing",
|
name: "one missing",
|
||||||
env: with(nil, EnvHTTPAddr),
|
env: with(nil, EnvHTTPAddr),
|
||||||
@@ -70,6 +93,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 +163,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 +211,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 +390,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 +405,23 @@ 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",
|
||||||
|
|
||||||
|
EnvDefaultTZ: "Europe/London",
|
||||||
}
|
}
|
||||||
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 +443,43 @@ 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)
|
||||||
|
}
|
||||||
|
if cfg.DefaultTZ != "Europe/London" {
|
||||||
|
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecretRedacted(t *testing.T) {
|
func TestSecretRedacted(t *testing.T) {
|
||||||
@@ -260,7 +487,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,166 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: jobs.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const claimNextJob = `-- name: ClaimNextJob :one
|
||||||
|
UPDATE jobs
|
||||||
|
SET status = 'running', started_at = ?1, updated_at = ?1
|
||||||
|
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
|
||||||
|
RETURNING id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||||
|
// when no job is queued.
|
||||||
|
func (q *Queries) ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, claimNextJob, now)
|
||||||
|
var i Job
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Status,
|
||||||
|
&i.Payload,
|
||||||
|
&i.Result,
|
||||||
|
&i.Error,
|
||||||
|
&i.StartedAt,
|
||||||
|
&i.FinishedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createJob = `-- name: CreateJob :exec
|
||||||
|
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'queued', ?, ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateJobParams struct {
|
||||||
|
ID string
|
||||||
|
Kind string
|
||||||
|
Payload string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createJob,
|
||||||
|
arg.ID,
|
||||||
|
arg.Kind,
|
||||||
|
arg.Payload,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const failJob = `-- name: FailJob :execrows
|
||||||
|
UPDATE jobs
|
||||||
|
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
|
||||||
|
WHERE id = ?3 AND status = 'running'
|
||||||
|
`
|
||||||
|
|
||||||
|
type FailJobParams struct {
|
||||||
|
Error sql.NullString
|
||||||
|
Now sql.NullString
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, failJob, arg.Error, arg.Now, arg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const failRunningJobs = `-- name: FailRunningJobs :execrows
|
||||||
|
UPDATE jobs
|
||||||
|
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
|
||||||
|
WHERE status = 'running'
|
||||||
|
`
|
||||||
|
|
||||||
|
type FailRunningJobsParams struct {
|
||||||
|
Error sql.NullString
|
||||||
|
Now sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fails every job left running, at boot before any worker starts.
|
||||||
|
func (q *Queries) FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, failRunningJobs, arg.Error, arg.Now)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getJob = `-- name: GetJob :one
|
||||||
|
SELECT id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetJob(ctx context.Context, id string) (Job, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getJob, id)
|
||||||
|
var i Job
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Status,
|
||||||
|
&i.Payload,
|
||||||
|
&i.Result,
|
||||||
|
&i.Error,
|
||||||
|
&i.StartedAt,
|
||||||
|
&i.FinishedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishJobResult = `-- name: PublishJobResult :execrows
|
||||||
|
UPDATE jobs
|
||||||
|
SET result = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'running'
|
||||||
|
`
|
||||||
|
|
||||||
|
type PublishJobResultParams struct {
|
||||||
|
Result sql.NullString
|
||||||
|
UpdatedAt string
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores a partial result of a running job.
|
||||||
|
func (q *Queries) PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, publishJobResult, arg.Result, arg.UpdatedAt, arg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeedJob = `-- name: SucceedJob :execrows
|
||||||
|
UPDATE jobs
|
||||||
|
SET status = 'succeeded', result = ?1, finished_at = ?2, updated_at = ?2
|
||||||
|
WHERE id = ?3 AND status = 'running'
|
||||||
|
`
|
||||||
|
|
||||||
|
type SucceedJobParams struct {
|
||||||
|
Result sql.NullString
|
||||||
|
Now sql.NullString
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, succeedJob, arg.Result, arg.Now, arg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AiRun struct {
|
||||||
|
ID string
|
||||||
|
Skill string
|
||||||
|
SkillVersion string
|
||||||
|
Provider string
|
||||||
|
Model string
|
||||||
|
Temperature float64
|
||||||
|
Status string
|
||||||
|
LatencyMs sql.NullInt64
|
||||||
|
TokensIn sql.NullInt64
|
||||||
|
TokensOut sql.NullInt64
|
||||||
|
Error sql.NullString
|
||||||
|
RequestRedacted string
|
||||||
|
ResponseRedacted sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AiSetting struct {
|
||||||
|
ID int64
|
||||||
|
Provider string
|
||||||
|
BaseUrl string
|
||||||
|
Model string
|
||||||
|
Temperature float64
|
||||||
|
SystemPrompt sql.NullString
|
||||||
|
ApiKeyCt []byte
|
||||||
|
ApiKeyLast4 sql.NullString
|
||||||
|
KeyVerifiedAt sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatSetting struct {
|
||||||
|
ID int64
|
||||||
|
WebhookUrlCt []byte
|
||||||
|
SpaceLabel sql.NullString
|
||||||
|
LastTestAt sql.NullString
|
||||||
|
LastTestStatus sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dinner struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
NormalizedName string
|
||||||
|
Status string
|
||||||
|
Origin string
|
||||||
|
Method sql.NullString
|
||||||
|
MinutesTotal sql.NullInt64
|
||||||
|
Difficulty sql.NullString
|
||||||
|
Cuisine sql.NullString
|
||||||
|
IsFavorite int64
|
||||||
|
RecipeID sql.NullString
|
||||||
|
PhotoID sql.NullString
|
||||||
|
GroundingStatus string
|
||||||
|
UpdatedBy sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HouseholdSetting struct {
|
||||||
|
ID int64
|
||||||
|
Timezone string
|
||||||
|
DinnerTime string
|
||||||
|
ReminderTime string
|
||||||
|
ReviewTime string
|
||||||
|
PlanDraftDow int64
|
||||||
|
PlanDraftTime string
|
||||||
|
SuggestionsDow int64
|
||||||
|
SuggestionsTime string
|
||||||
|
ChatAutosendDow int64
|
||||||
|
ChatAutosendTime string
|
||||||
|
OnboardingCompletedAt sql.NullString
|
||||||
|
UpdatesSeenAt sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
UpdatedBy sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationsGoogle struct {
|
||||||
|
ID int64
|
||||||
|
RefreshTokenCt []byte
|
||||||
|
CalendarID sql.NullString
|
||||||
|
CalendarName sql.NullString
|
||||||
|
ConnectedAt string
|
||||||
|
LastSyncError sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
ID string
|
||||||
|
Kind string
|
||||||
|
Status string
|
||||||
|
Payload string
|
||||||
|
Result sql.NullString
|
||||||
|
Error sql.NullString
|
||||||
|
StartedAt sql.NullString
|
||||||
|
FinishedAt sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationOutbox struct {
|
||||||
|
ID string
|
||||||
|
EventKey string
|
||||||
|
Channel string
|
||||||
|
Payload string
|
||||||
|
Status string
|
||||||
|
Attempts int64
|
||||||
|
NextAttemptAt sql.NullString
|
||||||
|
LastError sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationPref struct {
|
||||||
|
Event string
|
||||||
|
Push int64
|
||||||
|
Chat int64
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Photo struct {
|
||||||
|
ID string
|
||||||
|
SourceUrl string
|
||||||
|
LocalPath string
|
||||||
|
Width int64
|
||||||
|
Height int64
|
||||||
|
Sha256 string
|
||||||
|
FetchedAt string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanDay struct {
|
||||||
|
ID string
|
||||||
|
PlanID string
|
||||||
|
Date string
|
||||||
|
DinnerID sql.NullString
|
||||||
|
IsNew int64
|
||||||
|
CalendarEventID sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushSubscription struct {
|
||||||
|
ID string
|
||||||
|
Endpoint string
|
||||||
|
P256dh string
|
||||||
|
Auth string
|
||||||
|
UserAgent sql.NullString
|
||||||
|
FailureCount int64
|
||||||
|
LastSuccessAt sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Recipe struct {
|
||||||
|
ID string
|
||||||
|
SourceUrl string
|
||||||
|
SiteName sql.NullString
|
||||||
|
Title string
|
||||||
|
TotalMinutes sql.NullInt64
|
||||||
|
PrepMinutes sql.NullInt64
|
||||||
|
CookMinutes sql.NullInt64
|
||||||
|
Yield sql.NullString
|
||||||
|
RawJsonld string
|
||||||
|
FetchedAt string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecipeIngredient struct {
|
||||||
|
ID string
|
||||||
|
RecipeID string
|
||||||
|
Position int64
|
||||||
|
RawText string
|
||||||
|
Name sql.NullString
|
||||||
|
Qty sql.NullString
|
||||||
|
Unit sql.NullString
|
||||||
|
Note sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecipeSearchSetting struct {
|
||||||
|
ID int64
|
||||||
|
Provider string
|
||||||
|
BaseUrl sql.NullString
|
||||||
|
ApiKeyCt []byte
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecipeStep struct {
|
||||||
|
ID string
|
||||||
|
RecipeID string
|
||||||
|
Position int64
|
||||||
|
Text string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RotationReview struct {
|
||||||
|
ID string
|
||||||
|
DinnerID string
|
||||||
|
PlanDate string
|
||||||
|
Status string
|
||||||
|
Decision sql.NullString
|
||||||
|
DecidedAt sql.NullString
|
||||||
|
UpdatedBy sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScheduleRun struct {
|
||||||
|
ID string
|
||||||
|
Schedule string
|
||||||
|
PeriodKey string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
IDHash string
|
||||||
|
Subject string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Groups string
|
||||||
|
CsrfToken string
|
||||||
|
CreatedAt string
|
||||||
|
LastSeenAt string
|
||||||
|
ExpiresAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShoppingGroup struct {
|
||||||
|
ID string
|
||||||
|
ListID string
|
||||||
|
DinnerID string
|
||||||
|
Position int64
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShoppingItem struct {
|
||||||
|
ID string
|
||||||
|
GroupID string
|
||||||
|
Name string
|
||||||
|
Qty sql.NullString
|
||||||
|
Needed int64
|
||||||
|
Position int64
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShoppingList struct {
|
||||||
|
ID string
|
||||||
|
WeekStart string
|
||||||
|
PlanID string
|
||||||
|
Title string
|
||||||
|
Status string
|
||||||
|
SentToChatAt sql.NullString
|
||||||
|
FrozenAt sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Suggestion struct {
|
||||||
|
ID string
|
||||||
|
DinnerID string
|
||||||
|
BatchID string
|
||||||
|
Reason string
|
||||||
|
CitedRegularIds string
|
||||||
|
Status string
|
||||||
|
DecidedAt sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SuggestionBatch struct {
|
||||||
|
ID string
|
||||||
|
Trigger string
|
||||||
|
Status string
|
||||||
|
AiRunID sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeekPlan struct {
|
||||||
|
ID string
|
||||||
|
WeekStart string
|
||||||
|
Status string
|
||||||
|
AiNote sql.NullString
|
||||||
|
AiRunID sql.NullString
|
||||||
|
CalendarSyncedAt sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
@@ -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,77 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Querier interface {
|
||||||
|
// An unknown id returns sql.ErrNoRows.
|
||||||
|
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
||||||
|
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||||
|
// when no job is queued.
|
||||||
|
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||||
|
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||||
|
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||||
|
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||||
|
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||||
|
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||||
|
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||||
|
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||||
|
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||||
|
// Fails every job left running, at boot before any worker starts.
|
||||||
|
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||||
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
|
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||||
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
|
GetChatSettings(ctx context.Context) (ChatSetting, error)
|
||||||
|
GetDinner(ctx context.Context, id string) (Dinner, error)
|
||||||
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
|
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
||||||
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
|
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
|
||||||
|
GetJob(ctx context.Context, id string) (Job, error)
|
||||||
|
// Finds stored image bytes by content hash. The schema does not make sha256
|
||||||
|
// unique, so the earliest-created (lowest id) match is returned.
|
||||||
|
GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error)
|
||||||
|
GetRecipe(ctx context.Context, id string) (Recipe, error)
|
||||||
|
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||||
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
|
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||||
|
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||||
|
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||||
|
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||||
|
// Stores a partial result of a running job.
|
||||||
|
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||||
|
// An unknown id returns sql.ErrNoRows.
|
||||||
|
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||||
|
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
||||||
|
// An unknown id returns sql.ErrNoRows.
|
||||||
|
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||||
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
|
// created_at.
|
||||||
|
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
|
||||||
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
|
// created_at.
|
||||||
|
UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error)
|
||||||
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
|
// created_at.
|
||||||
|
UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error)
|
||||||
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
|
// created_at.
|
||||||
|
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
|
||||||
|
// A recipe is keyed by source_url: re-fetching the same URL updates the
|
||||||
|
// existing row and keeps its id and created_at, which the returned row
|
||||||
|
// carries. Replace its ingredients and steps in the same transaction with
|
||||||
|
// DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries.
|
||||||
|
UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error)
|
||||||
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
|
// created_at.
|
||||||
|
UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Querier = (*Queries)(nil)
|
||||||
@@ -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,433 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: settings.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getAISettings = `-- name: GetAISettings :one
|
||||||
|
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings
|
||||||
|
WHERE id = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
|
func (q *Queries) GetAISettings(ctx context.Context) (AiSetting, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getAISettings)
|
||||||
|
var i AiSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Provider,
|
||||||
|
&i.BaseUrl,
|
||||||
|
&i.Model,
|
||||||
|
&i.Temperature,
|
||||||
|
&i.SystemPrompt,
|
||||||
|
&i.ApiKeyCt,
|
||||||
|
&i.ApiKeyLast4,
|
||||||
|
&i.KeyVerifiedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChatSettings = `-- name: GetChatSettings :one
|
||||||
|
SELECT id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at FROM chat_settings
|
||||||
|
WHERE id = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
|
func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getChatSettings)
|
||||||
|
var i ChatSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WebhookUrlCt,
|
||||||
|
&i.SpaceLabel,
|
||||||
|
&i.LastTestAt,
|
||||||
|
&i.LastTestStatus,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getHouseholdSettings = `-- name: GetHouseholdSettings :one
|
||||||
|
SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by FROM household_settings
|
||||||
|
WHERE id = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
|
func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getHouseholdSettings)
|
||||||
|
var i HouseholdSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.DinnerTime,
|
||||||
|
&i.ReminderTime,
|
||||||
|
&i.ReviewTime,
|
||||||
|
&i.PlanDraftDow,
|
||||||
|
&i.PlanDraftTime,
|
||||||
|
&i.SuggestionsDow,
|
||||||
|
&i.SuggestionsTime,
|
||||||
|
&i.ChatAutosendDow,
|
||||||
|
&i.ChatAutosendTime,
|
||||||
|
&i.OnboardingCompletedAt,
|
||||||
|
&i.UpdatesSeenAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.UpdatedBy,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIntegrationsGoogle = `-- name: GetIntegrationsGoogle :one
|
||||||
|
SELECT id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at FROM integrations_google
|
||||||
|
WHERE id = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
|
func (q *Queries) GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getIntegrationsGoogle)
|
||||||
|
var i IntegrationsGoogle
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.RefreshTokenCt,
|
||||||
|
&i.CalendarID,
|
||||||
|
&i.CalendarName,
|
||||||
|
&i.ConnectedAt,
|
||||||
|
&i.LastSyncError,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRecipeSearchSettings = `-- name: GetRecipeSearchSettings :one
|
||||||
|
SELECT id, provider, base_url, api_key_ct, created_at, updated_at FROM recipe_search_settings
|
||||||
|
WHERE id = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
|
func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getRecipeSearchSettings)
|
||||||
|
var i RecipeSearchSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Provider,
|
||||||
|
&i.BaseUrl,
|
||||||
|
&i.ApiKeyCt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertAISettings = `-- name: UpsertAISettings :one
|
||||||
|
INSERT INTO ai_settings (
|
||||||
|
id, provider, base_url, model, temperature, system_prompt, api_key_ct,
|
||||||
|
api_key_last4, key_verified_at, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
1, ?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
provider = excluded.provider,
|
||||||
|
base_url = excluded.base_url,
|
||||||
|
model = excluded.model,
|
||||||
|
temperature = excluded.temperature,
|
||||||
|
system_prompt = excluded.system_prompt,
|
||||||
|
api_key_ct = excluded.api_key_ct,
|
||||||
|
api_key_last4 = excluded.api_key_last4,
|
||||||
|
key_verified_at = excluded.key_verified_at,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
RETURNING id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertAISettingsParams struct {
|
||||||
|
Provider string
|
||||||
|
BaseUrl string
|
||||||
|
Model string
|
||||||
|
Temperature float64
|
||||||
|
SystemPrompt sql.NullString
|
||||||
|
ApiKeyCt []byte
|
||||||
|
ApiKeyLast4 sql.NullString
|
||||||
|
KeyVerifiedAt sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
|
// created_at.
|
||||||
|
func (q *Queries) UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, upsertAISettings,
|
||||||
|
arg.Provider,
|
||||||
|
arg.BaseUrl,
|
||||||
|
arg.Model,
|
||||||
|
arg.Temperature,
|
||||||
|
arg.SystemPrompt,
|
||||||
|
arg.ApiKeyCt,
|
||||||
|
arg.ApiKeyLast4,
|
||||||
|
arg.KeyVerifiedAt,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
var i AiSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Provider,
|
||||||
|
&i.BaseUrl,
|
||||||
|
&i.Model,
|
||||||
|
&i.Temperature,
|
||||||
|
&i.SystemPrompt,
|
||||||
|
&i.ApiKeyCt,
|
||||||
|
&i.ApiKeyLast4,
|
||||||
|
&i.KeyVerifiedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertChatSettings = `-- name: UpsertChatSettings :one
|
||||||
|
INSERT INTO chat_settings (
|
||||||
|
id, webhook_url_ct, space_label, last_test_at, last_test_status,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
1, ?, ?, ?, ?,
|
||||||
|
?, ?
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
webhook_url_ct = excluded.webhook_url_ct,
|
||||||
|
space_label = excluded.space_label,
|
||||||
|
last_test_at = excluded.last_test_at,
|
||||||
|
last_test_status = excluded.last_test_status,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
RETURNING id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertChatSettingsParams struct {
|
||||||
|
WebhookUrlCt []byte
|
||||||
|
SpaceLabel sql.NullString
|
||||||
|
LastTestAt sql.NullString
|
||||||
|
LastTestStatus sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
|
// created_at.
|
||||||
|
func (q *Queries) UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, upsertChatSettings,
|
||||||
|
arg.WebhookUrlCt,
|
||||||
|
arg.SpaceLabel,
|
||||||
|
arg.LastTestAt,
|
||||||
|
arg.LastTestStatus,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
var i ChatSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WebhookUrlCt,
|
||||||
|
&i.SpaceLabel,
|
||||||
|
&i.LastTestAt,
|
||||||
|
&i.LastTestStatus,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertHouseholdSettings = `-- name: UpsertHouseholdSettings :one
|
||||||
|
INSERT INTO household_settings (
|
||||||
|
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
|
||||||
|
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
||||||
|
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
||||||
|
updated_by, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
1, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?, ?,
|
||||||
|
?, ?, ?
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
timezone = excluded.timezone,
|
||||||
|
dinner_time = excluded.dinner_time,
|
||||||
|
reminder_time = excluded.reminder_time,
|
||||||
|
review_time = excluded.review_time,
|
||||||
|
plan_draft_dow = excluded.plan_draft_dow,
|
||||||
|
plan_draft_time = excluded.plan_draft_time,
|
||||||
|
suggestions_dow = excluded.suggestions_dow,
|
||||||
|
suggestions_time = excluded.suggestions_time,
|
||||||
|
chat_autosend_dow = excluded.chat_autosend_dow,
|
||||||
|
chat_autosend_time = excluded.chat_autosend_time,
|
||||||
|
onboarding_completed_at = excluded.onboarding_completed_at,
|
||||||
|
updates_seen_at = excluded.updates_seen_at,
|
||||||
|
updated_by = excluded.updated_by,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertHouseholdSettingsParams struct {
|
||||||
|
Timezone string
|
||||||
|
DinnerTime string
|
||||||
|
ReminderTime string
|
||||||
|
ReviewTime string
|
||||||
|
PlanDraftDow int64
|
||||||
|
PlanDraftTime string
|
||||||
|
SuggestionsDow int64
|
||||||
|
SuggestionsTime string
|
||||||
|
ChatAutosendDow int64
|
||||||
|
ChatAutosendTime string
|
||||||
|
OnboardingCompletedAt sql.NullString
|
||||||
|
UpdatesSeenAt sql.NullString
|
||||||
|
UpdatedBy sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
|
// created_at.
|
||||||
|
func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, upsertHouseholdSettings,
|
||||||
|
arg.Timezone,
|
||||||
|
arg.DinnerTime,
|
||||||
|
arg.ReminderTime,
|
||||||
|
arg.ReviewTime,
|
||||||
|
arg.PlanDraftDow,
|
||||||
|
arg.PlanDraftTime,
|
||||||
|
arg.SuggestionsDow,
|
||||||
|
arg.SuggestionsTime,
|
||||||
|
arg.ChatAutosendDow,
|
||||||
|
arg.ChatAutosendTime,
|
||||||
|
arg.OnboardingCompletedAt,
|
||||||
|
arg.UpdatesSeenAt,
|
||||||
|
arg.UpdatedBy,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
var i HouseholdSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.DinnerTime,
|
||||||
|
&i.ReminderTime,
|
||||||
|
&i.ReviewTime,
|
||||||
|
&i.PlanDraftDow,
|
||||||
|
&i.PlanDraftTime,
|
||||||
|
&i.SuggestionsDow,
|
||||||
|
&i.SuggestionsTime,
|
||||||
|
&i.ChatAutosendDow,
|
||||||
|
&i.ChatAutosendTime,
|
||||||
|
&i.OnboardingCompletedAt,
|
||||||
|
&i.UpdatesSeenAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.UpdatedBy,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertIntegrationsGoogle = `-- name: UpsertIntegrationsGoogle :one
|
||||||
|
INSERT INTO integrations_google (
|
||||||
|
id, refresh_token_ct, calendar_id, calendar_name, connected_at,
|
||||||
|
last_sync_error, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
1, ?, ?, ?, ?,
|
||||||
|
?, ?, ?
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
refresh_token_ct = excluded.refresh_token_ct,
|
||||||
|
calendar_id = excluded.calendar_id,
|
||||||
|
calendar_name = excluded.calendar_name,
|
||||||
|
connected_at = excluded.connected_at,
|
||||||
|
last_sync_error = excluded.last_sync_error,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
RETURNING id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertIntegrationsGoogleParams struct {
|
||||||
|
RefreshTokenCt []byte
|
||||||
|
CalendarID sql.NullString
|
||||||
|
CalendarName sql.NullString
|
||||||
|
ConnectedAt string
|
||||||
|
LastSyncError sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
|
// created_at.
|
||||||
|
func (q *Queries) UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, upsertIntegrationsGoogle,
|
||||||
|
arg.RefreshTokenCt,
|
||||||
|
arg.CalendarID,
|
||||||
|
arg.CalendarName,
|
||||||
|
arg.ConnectedAt,
|
||||||
|
arg.LastSyncError,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
var i IntegrationsGoogle
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.RefreshTokenCt,
|
||||||
|
&i.CalendarID,
|
||||||
|
&i.CalendarName,
|
||||||
|
&i.ConnectedAt,
|
||||||
|
&i.LastSyncError,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertRecipeSearchSettings = `-- name: UpsertRecipeSearchSettings :one
|
||||||
|
INSERT INTO recipe_search_settings (
|
||||||
|
id, provider, base_url, api_key_ct, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
1, ?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
provider = excluded.provider,
|
||||||
|
base_url = excluded.base_url,
|
||||||
|
api_key_ct = excluded.api_key_ct,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
RETURNING id, provider, base_url, api_key_ct, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertRecipeSearchSettingsParams struct {
|
||||||
|
Provider string
|
||||||
|
BaseUrl sql.NullString
|
||||||
|
ApiKeyCt []byte
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
|
// created_at.
|
||||||
|
func (q *Queries) UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, upsertRecipeSearchSettings,
|
||||||
|
arg.Provider,
|
||||||
|
arg.BaseUrl,
|
||||||
|
arg.ApiKeyCt,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
var i RecipeSearchSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Provider,
|
||||||
|
&i.BaseUrl,
|
||||||
|
&i.ApiKeyCt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>MealQuest</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Placeholder so the Go embed compiles. The build replaces this directory with web/dist. -->
|
||||||
|
<p>MealQuest web assets have not been built.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Package frontend serves the embedded PWA build with an SPA fallback.
|
||||||
|
package frontend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// embedded holds the web build. The build copies web/dist over the committed
|
||||||
|
// placeholder index.html.
|
||||||
|
//
|
||||||
|
//go:embed all:dist
|
||||||
|
var embedded embed.FS
|
||||||
|
|
||||||
|
const (
|
||||||
|
indexFile = "index.html"
|
||||||
|
|
||||||
|
cacheImmutable = "public, max-age=31536000, immutable"
|
||||||
|
cacheNoCache = "no-cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// noCacheFiles must be revalidated on every load: they name the hashed assets
|
||||||
|
// (or, for the service worker, control their caching), so a stale copy pins an
|
||||||
|
// old release.
|
||||||
|
var noCacheFiles = map[string]bool{
|
||||||
|
indexFile: true,
|
||||||
|
"sw.js": true,
|
||||||
|
"manifest.webmanifest": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets returns the embedded web build rooted at its dist directory.
|
||||||
|
func Assets() fs.FS {
|
||||||
|
sub, err := fs.Sub(embedded, "dist")
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // "dist" is a valid path, so Sub cannot fail
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler returns a handler that serves files from fsys. A GET or HEAD for a
|
||||||
|
// path that is not a file in fsys is answered with index.html so client-side
|
||||||
|
// routes load the app. Paths equal to or under a reserved prefix (for example
|
||||||
|
// "/api") are never answered with index.html, whether before or after the path
|
||||||
|
// is cleaned; they are passed to notFound.
|
||||||
|
// fsys must contain index.html.
|
||||||
|
func NewHandler(fsys fs.FS, logger *slog.Logger, notFound http.Handler, reserved ...string) (http.Handler, error) {
|
||||||
|
if _, err := fs.Stat(fsys, indexFile); err != nil {
|
||||||
|
return nil, fmt.Errorf("frontend assets: %w", err)
|
||||||
|
}
|
||||||
|
return &handler{fsys: fsys, logger: logger, notFound: notFound, reserved: reserved}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
fsys fs.FS
|
||||||
|
logger *slog.Logger
|
||||||
|
notFound http.Handler
|
||||||
|
reserved []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
urlPath := path.Clean("/" + r.URL.Path)
|
||||||
|
// Check the uncleaned path too, so dot segments cannot escape a reserved
|
||||||
|
// prefix into the fallback ("/auth/../quests/42" cleans to "/quests/42").
|
||||||
|
if h.isReserved(r.URL.Path) || h.isReserved(urlPath) {
|
||||||
|
h.notFound.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(urlPath, "/")
|
||||||
|
if name == "" || !h.isFile(name) {
|
||||||
|
name = indexFile
|
||||||
|
}
|
||||||
|
h.serveFile(w, r, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) isReserved(urlPath string) bool {
|
||||||
|
for _, prefix := range h.reserved {
|
||||||
|
if urlPath == prefix || strings.HasPrefix(urlPath, prefix+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) isFile(name string) bool {
|
||||||
|
info, err := fs.Stat(h.fsys, name)
|
||||||
|
return err == nil && info.Mode().IsRegular()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
f, err := h.fsys.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, r, name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
content, ok := f.(io.ReadSeeker)
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, name, errors.New("file does not implement io.Seeker"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if noCacheFiles[name] {
|
||||||
|
w.Header().Set("Cache-Control", cacheNoCache)
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", cacheImmutable)
|
||||||
|
}
|
||||||
|
if path.Ext(name) == ".webmanifest" {
|
||||||
|
w.Header().Set("Content-Type", "application/manifest+json")
|
||||||
|
}
|
||||||
|
// Embedded files carry no modification time; ServeContent omits
|
||||||
|
// Last-Modified for the zero time.
|
||||||
|
http.ServeContent(w, r, name, time.Time{}, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) fail(w http.ResponseWriter, r *http.Request, name string, err error) {
|
||||||
|
h.logger.ErrorContext(r.Context(), "serve frontend asset", "file", name, "error", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package frontend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
indexBody = "<!doctype html><title>fixture index</title>"
|
||||||
|
assetBody = "console.log('fixture asset')"
|
||||||
|
robotsBody = "User-agent: *"
|
||||||
|
swBody = "self.addEventListener('fetch', () => {})"
|
||||||
|
manifestBody = `{"name":"MealQuest"}`
|
||||||
|
notFoundBody = "reserved not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fixture() fstest.MapFS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"index.html": {Data: []byte(indexBody)},
|
||||||
|
"sw.js": {Data: []byte(swBody)},
|
||||||
|
"manifest.webmanifest": {Data: []byte(manifestBody)},
|
||||||
|
"assets/index-AbC123.js": {Data: []byte(assetBody)},
|
||||||
|
"robots.txt": {Data: []byte(robotsBody)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestHandler(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
notFound := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, notFoundBody, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
h, err := NewHandler(fixture(), logger, notFound, "/api", "/auth", "/media", "/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
wantBody string
|
||||||
|
wantCache string
|
||||||
|
}{
|
||||||
|
{"hashed asset", http.MethodGet, "/assets/index-AbC123.js", assetBody, cacheImmutable},
|
||||||
|
{"unhashed asset", http.MethodGet, "/robots.txt", robotsBody, cacheImmutable},
|
||||||
|
{"root", http.MethodGet, "/", indexBody, cacheNoCache},
|
||||||
|
{"index.html", http.MethodGet, "/index.html", indexBody, cacheNoCache},
|
||||||
|
{"service worker", http.MethodGet, "/sw.js", swBody, cacheNoCache},
|
||||||
|
{"manifest", http.MethodGet, "/manifest.webmanifest", manifestBody, cacheNoCache},
|
||||||
|
{"client route", http.MethodGet, "/quests/42", indexBody, cacheNoCache},
|
||||||
|
{"missing asset", http.MethodGet, "/assets/missing.js", indexBody, cacheNoCache},
|
||||||
|
{"directory", http.MethodGet, "/assets", indexBody, cacheNoCache},
|
||||||
|
{"dot segments", http.MethodGet, "/x/../../sw.js", swBody, cacheNoCache},
|
||||||
|
{"prefix lookalike", http.MethodGet, "/apiary", indexBody, cacheNoCache},
|
||||||
|
{"head", http.MethodHead, "/assets/index-AbC123.js", "", cacheImmutable},
|
||||||
|
}
|
||||||
|
h := newTestHandler(t)
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != tt.wantBody {
|
||||||
|
t.Errorf("body = %q, want %q", got, tt.wantBody)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != tt.wantCache {
|
||||||
|
t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTestHandler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil))
|
||||||
|
|
||||||
|
if got := rec.Header().Get("Content-Type"); got != "application/manifest+json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/manifest+json", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReservedPrefixesNotServed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := newTestHandler(t)
|
||||||
|
for _, path := range []string{"/api", "/api/unknown", "/auth/login", "/media/x.jpg", "/health", "/health/deep", "/x/../api/unknown", "/auth/../quests/42", "/api%2F..%2Fquests/42"} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != notFoundBody+"\n" {
|
||||||
|
t.Errorf("body = %q, want the notFound handler's body", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHandlerRequiresIndex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
fsys := fstest.MapFS{"sw.js": {Data: []byte(swBody)}}
|
||||||
|
if _, err := NewHandler(fsys, logger, http.NotFoundHandler()); err == nil {
|
||||||
|
t.Fatal("NewHandler without index.html: want error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssetsContainsIndex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if _, err := NewHandler(Assets(), logger, http.NotFoundHandler()); err != nil {
|
||||||
|
t.Fatalf("embedded assets: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package household
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4).
|
||||||
|
// SeedAISettings stores it on first boot and the Admin reset restores it.
|
||||||
|
const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners
|
||||||
|
similar to their regular rotation, nudging them to try something a little new each
|
||||||
|
week. Prefer weeknight-friendly recipes with common grocery-store ingredients.
|
||||||
|
Flag crock pot meals and anything 30 minutes or less as "quick". Keep explanations
|
||||||
|
short, warm and specific — mention the household's own regular dinners when you
|
||||||
|
explain why something fits. Never suggest a dinner they already make or have
|
||||||
|
declined.`
|
||||||
|
|
||||||
|
// Seed values for the AI settings (CTXD CONTEXT.MD). The base URL and model
|
||||||
|
// are empty until the household picks a provider preset; there is no key.
|
||||||
|
const (
|
||||||
|
SeedAIProvider = "anthropic"
|
||||||
|
SeedAIBaseURL = ""
|
||||||
|
SeedAIModel = ""
|
||||||
|
SeedAITemperature = 0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedNotificationPrefs are the notification preferences for a new
|
||||||
|
// household, one per event: push for every event, chat only for the plan
|
||||||
|
// and the shopping list.
|
||||||
|
var seedNotificationPrefs = []struct {
|
||||||
|
Event string
|
||||||
|
Push, Chat bool
|
||||||
|
}{
|
||||||
|
{Event: "plan_ready", Push: true, Chat: true},
|
||||||
|
{Event: "list_ready", Push: true, Chat: true},
|
||||||
|
{Event: "day_of_reminder", Push: true, Chat: false},
|
||||||
|
{Event: "new_suggestions", Push: true, Chat: false},
|
||||||
|
{Event: "rotation_review", Push: true, Chat: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedAISettings creates the AI settings row with the seed values and
|
||||||
|
// DefaultPersonaPrompt if it does not exist. An existing row is never
|
||||||
|
// changed.
|
||||||
|
func SeedAISettings(ctx context.Context, st *store.Store) error {
|
||||||
|
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
_, err := q.GetAISettings(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("household: read AI settings: %w", err)
|
||||||
|
}
|
||||||
|
now := store.Now().String()
|
||||||
|
_, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
||||||
|
Provider: SeedAIProvider,
|
||||||
|
BaseUrl: SeedAIBaseURL,
|
||||||
|
Model: SeedAIModel,
|
||||||
|
Temperature: SeedAITemperature,
|
||||||
|
SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true},
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("household: seed AI settings: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedNotificationPrefs creates the seed notification preference row of
|
||||||
|
// every event that has none. Existing rows are never changed.
|
||||||
|
func SeedNotificationPrefs(ctx context.Context, st *store.Store) error {
|
||||||
|
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
now := store.Now().String()
|
||||||
|
for _, p := range seedNotificationPrefs {
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO notification_prefs (event, push, chat, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (event) DO NOTHING`,
|
||||||
|
p.Event, p.Push, p.Chat, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("household: seed notification preference %s: %w", p.Event, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package household
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notificationPrefs returns the stored notification preferences by event as
|
||||||
|
// [push, chat].
|
||||||
|
func notificationPrefs(t *testing.T, st *store.Store) map[string][2]int64 {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := st.Reader.QueryContext(context.Background(), `SELECT event, push, chat FROM notification_prefs`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
got := make(map[string][2]int64)
|
||||||
|
for rows.Next() {
|
||||||
|
var event string
|
||||||
|
var push, chat int64
|
||||||
|
if err := rows.Scan(&event, &push, &chat); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got[event] = [2]int64{push, chat}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedAISettingsCreatesRow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
st := migratedStore(t, t.TempDir())
|
||||||
|
|
||||||
|
if err := SeedAISettings(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := db.New(st.Reader).GetAISettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := db.AiSetting{
|
||||||
|
ID: 1,
|
||||||
|
Provider: "anthropic",
|
||||||
|
BaseUrl: "",
|
||||||
|
Model: "",
|
||||||
|
Temperature: 0.7,
|
||||||
|
SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true},
|
||||||
|
CreatedAt: got.CreatedAt,
|
||||||
|
UpdatedAt: got.UpdatedAt,
|
||||||
|
}
|
||||||
|
if got.ID != want.ID || got.Provider != want.Provider || got.BaseUrl != want.BaseUrl ||
|
||||||
|
got.Model != want.Model || got.Temperature != want.Temperature || got.SystemPrompt != want.SystemPrompt ||
|
||||||
|
got.ApiKeyCt != nil || got.ApiKeyLast4.Valid || got.KeyVerifiedAt.Valid {
|
||||||
|
t.Errorf("seeded AI settings = %+v\nwant %+v", got, want)
|
||||||
|
}
|
||||||
|
var created store.Timestamp
|
||||||
|
if err := created.Scan(got.CreatedAt); err != nil {
|
||||||
|
t.Errorf("created_at: %v", err)
|
||||||
|
}
|
||||||
|
if got.UpdatedAt != got.CreatedAt {
|
||||||
|
t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPersonaPrompt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const want = "You are MealQuest, a dinner planning assistant for one household. Suggest dinners\n" +
|
||||||
|
"similar to their regular rotation, nudging them to try something a little new each\n" +
|
||||||
|
"week. Prefer weeknight-friendly recipes with common grocery-store ingredients.\n" +
|
||||||
|
"Flag crock pot meals and anything 30 minutes or less as \"quick\". Keep explanations\n" +
|
||||||
|
"short, warm and specific — mention the household's own regular dinners when you\n" +
|
||||||
|
"explain why something fits. Never suggest a dinner they already make or have\n" +
|
||||||
|
"declined."
|
||||||
|
if DefaultPersonaPrompt != want {
|
||||||
|
t.Errorf("DefaultPersonaPrompt = %q\nwant %q", DefaultPersonaPrompt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedAISettingsNeverOverwrites(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// First boot seeds; the household then configures a provider.
|
||||||
|
first := migratedStore(t, dir)
|
||||||
|
if err := SeedAISettings(ctx, first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seeded, err := db.New(first.Reader).GetAISettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
changed, err := db.New(first.Writer).UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
||||||
|
Provider: "ollama",
|
||||||
|
BaseUrl: "http://ollama:11434",
|
||||||
|
Model: "llama3",
|
||||||
|
Temperature: 0.2,
|
||||||
|
SystemPrompt: sql.NullString{String: "Be brief.", Valid: true},
|
||||||
|
ApiKeyCt: []byte{1, 2, 3},
|
||||||
|
ApiKeyLast4: sql.NullString{String: "wxyz", Valid: true},
|
||||||
|
KeyVerifiedAt: sql.NullString{String: store.Now().String(), Valid: true},
|
||||||
|
CreatedAt: seeded.CreatedAt,
|
||||||
|
UpdatedAt: store.Now().String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := first.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later boot reopens the same database and seeds again.
|
||||||
|
st := migratedStore(t, dir)
|
||||||
|
if err := SeedAISettings(ctx, st); err != nil {
|
||||||
|
t.Fatalf("reboot: %v", err)
|
||||||
|
}
|
||||||
|
got, err := db.New(st.Reader).GetAISettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Provider != changed.Provider || got.BaseUrl != changed.BaseUrl || got.Model != changed.Model ||
|
||||||
|
got.Temperature != changed.Temperature || got.SystemPrompt != changed.SystemPrompt ||
|
||||||
|
string(got.ApiKeyCt) != string(changed.ApiKeyCt) || got.ApiKeyLast4 != changed.ApiKeyLast4 ||
|
||||||
|
got.KeyVerifiedAt != changed.KeyVerifiedAt || got.CreatedAt != changed.CreatedAt || got.UpdatedAt != changed.UpdatedAt {
|
||||||
|
t.Errorf("reboot changed AI settings:\n got %+v\nwant %+v", got, changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedNotificationPrefsCreatesRows(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
st := migratedStore(t, t.TempDir())
|
||||||
|
|
||||||
|
if err := SeedNotificationPrefs(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string][2]int64{
|
||||||
|
"plan_ready": {1, 1},
|
||||||
|
"list_ready": {1, 1},
|
||||||
|
"day_of_reminder": {1, 0},
|
||||||
|
"new_suggestions": {1, 0},
|
||||||
|
"rotation_review": {1, 0},
|
||||||
|
}
|
||||||
|
got := notificationPrefs(t, st)
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("seeded %d notification preferences, want %d: %v", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for event, w := range want {
|
||||||
|
if g, ok := got[event]; !ok || g != w {
|
||||||
|
t.Errorf("%s [push, chat] = %v (present %t), want %v", event, g, ok, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var created, updated string
|
||||||
|
err := st.Reader.QueryRowContext(ctx, `SELECT created_at, updated_at FROM notification_prefs WHERE event = 'plan_ready'`).Scan(&created, &updated)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var ts store.Timestamp
|
||||||
|
if err := ts.Scan(created); err != nil {
|
||||||
|
t.Errorf("created_at: %v", err)
|
||||||
|
}
|
||||||
|
if updated != created {
|
||||||
|
t.Errorf("updated_at = %q, want created_at %q", updated, created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedNotificationPrefsNeverOverwrites(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// First boot seeds; the household then changes two preferences, and one
|
||||||
|
// event's row goes missing.
|
||||||
|
first := migratedStore(t, dir)
|
||||||
|
if err := SeedNotificationPrefs(ctx, first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`UPDATE notification_prefs SET push = 0, chat = 0 WHERE event = 'plan_ready'`,
|
||||||
|
`UPDATE notification_prefs SET chat = 1 WHERE event = 'rotation_review'`,
|
||||||
|
`DELETE FROM notification_prefs WHERE event = 'new_suggestions'`,
|
||||||
|
} {
|
||||||
|
if _, err := first.Writer.ExecContext(ctx, stmt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := first.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later boot keeps the changed rows and seeds only the missing one.
|
||||||
|
st := migratedStore(t, dir)
|
||||||
|
if err := SeedNotificationPrefs(ctx, st); err != nil {
|
||||||
|
t.Fatalf("reboot: %v", err)
|
||||||
|
}
|
||||||
|
want := map[string][2]int64{
|
||||||
|
"plan_ready": {0, 0},
|
||||||
|
"list_ready": {1, 1},
|
||||||
|
"day_of_reminder": {1, 0},
|
||||||
|
"new_suggestions": {1, 0},
|
||||||
|
"rotation_review": {1, 1},
|
||||||
|
}
|
||||||
|
got := notificationPrefs(t, st)
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("%d notification preferences after reboot, want %d: %v", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for event, w := range want {
|
||||||
|
if g, ok := got[event]; !ok || g != w {
|
||||||
|
t.Errorf("after reboot %s [push, chat] = %v (present %t), want %v", event, g, ok, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package household
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seed values for a new household (CTXD CONTEXT.MD). Days count from
|
||||||
|
// Sunday = 0, as time.Weekday does.
|
||||||
|
const (
|
||||||
|
SeedDinnerTime = "17:30"
|
||||||
|
SeedReminderTime = "16:30"
|
||||||
|
SeedReviewTime = "09:00" // rotation check
|
||||||
|
SeedPlanDraftDow = 5 // Friday
|
||||||
|
SeedPlanDraftTime = "09:00"
|
||||||
|
SeedSuggestionsDow = 0 // Sunday
|
||||||
|
SeedSuggestionsTime = "08:00"
|
||||||
|
SeedChatAutosendDow = 6 // Saturday
|
||||||
|
SeedChatAutosendTime = "08:00"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNoDefaultZone is returned by SeedSettings when the household settings
|
||||||
|
// do not exist yet and no default zone was configured to create them with.
|
||||||
|
var ErrNoDefaultZone = errors.New("household settings do not exist yet and no default time zone is configured")
|
||||||
|
|
||||||
|
// ErrInvalidDefaultZone is returned by SeedSettings when the household
|
||||||
|
// settings do not exist yet and the configured default zone cannot be loaded.
|
||||||
|
var ErrInvalidDefaultZone = errors.New("household settings do not exist yet and the default time zone is invalid")
|
||||||
|
|
||||||
|
// Settings are the shared household settings. Times are household-local
|
||||||
|
// 24-hour HH:MM in Timezone; days of the week count from Sunday = 0.
|
||||||
|
type Settings struct {
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
DinnerTime string `json:"dinner_time"`
|
||||||
|
ReminderTime string `json:"reminder_time"`
|
||||||
|
ReviewTime string `json:"review_time"`
|
||||||
|
PlanDraftDow int `json:"plan_draft_dow"`
|
||||||
|
PlanDraftTime string `json:"plan_draft_time"`
|
||||||
|
SuggestionsDow int `json:"suggestions_dow"`
|
||||||
|
SuggestionsTime string `json:"suggestions_time"`
|
||||||
|
ChatAutosendDow int `json:"chat_autosend_dow"`
|
||||||
|
ChatAutosendTime string `json:"chat_autosend_time"`
|
||||||
|
OnboardingCompletedAt *string `json:"onboarding_completed_at"`
|
||||||
|
UpdatesSeenAt *string `json:"updates_seen_at"`
|
||||||
|
UpdatedBy *string `json:"updated_by"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsPatch changes the household settings. Nil fields are left as they
|
||||||
|
// are.
|
||||||
|
type SettingsPatch struct {
|
||||||
|
Timezone *string
|
||||||
|
DinnerTime *string
|
||||||
|
ReminderTime *string
|
||||||
|
ReviewTime *string
|
||||||
|
PlanDraftDow *int
|
||||||
|
PlanDraftTime *string
|
||||||
|
SuggestionsDow *int
|
||||||
|
SuggestionsTime *string
|
||||||
|
ChatAutosendDow *int
|
||||||
|
ChatAutosendTime *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
||||||
|
type Problem struct {
|
||||||
|
Field string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationError lists every invalid field of a SettingsPatch.
|
||||||
|
type ValidationError struct {
|
||||||
|
Problems []Problem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string {
|
||||||
|
parts := make([]string, len(e.Problems))
|
||||||
|
for i, p := range e.Problems {
|
||||||
|
parts[i] = p.Field + ": " + p.Reason
|
||||||
|
}
|
||||||
|
return "invalid household settings: " + strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedSettings creates the household settings row with zone defaultTZ and the
|
||||||
|
// seed times and days if it does not exist. An existing row is never changed,
|
||||||
|
// and defaultTZ is then ignored, valid or not. With no row, an empty
|
||||||
|
// defaultTZ is ErrNoDefaultZone and an unloadable one ErrInvalidDefaultZone.
|
||||||
|
func SeedSettings(ctx context.Context, st *store.Store, defaultTZ string) error {
|
||||||
|
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
_, err := q.GetHouseholdSettings(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("household: read settings: %w", err)
|
||||||
|
}
|
||||||
|
if defaultTZ == "" {
|
||||||
|
return ErrNoDefaultZone
|
||||||
|
}
|
||||||
|
if _, err := LoadZone(defaultTZ); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrInvalidDefaultZone, err)
|
||||||
|
}
|
||||||
|
now := store.Now().String()
|
||||||
|
_, err = q.UpsertHouseholdSettings(ctx, db.UpsertHouseholdSettingsParams{
|
||||||
|
Timezone: defaultTZ,
|
||||||
|
DinnerTime: SeedDinnerTime,
|
||||||
|
ReminderTime: SeedReminderTime,
|
||||||
|
ReviewTime: SeedReviewTime,
|
||||||
|
PlanDraftDow: SeedPlanDraftDow,
|
||||||
|
PlanDraftTime: SeedPlanDraftTime,
|
||||||
|
SuggestionsDow: SeedSuggestionsDow,
|
||||||
|
SuggestionsTime: SeedSuggestionsTime,
|
||||||
|
ChatAutosendDow: SeedChatAutosendDow,
|
||||||
|
ChatAutosendTime: SeedChatAutosendTime,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("household: seed settings: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsService reads and changes the household settings, and tells
|
||||||
|
// in-process subscribers (the scheduler) about every change.
|
||||||
|
type SettingsService struct {
|
||||||
|
st *store.Store
|
||||||
|
|
||||||
|
// updateMu serialises Update with its publish, so subscribers see
|
||||||
|
// changes in commit order.
|
||||||
|
updateMu sync.Mutex
|
||||||
|
|
||||||
|
subsMu sync.Mutex
|
||||||
|
subs map[int]func(Settings)
|
||||||
|
nextID int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSettingsService returns a service over st. Call SeedSettings first.
|
||||||
|
func NewSettingsService(st *store.Store) *SettingsService {
|
||||||
|
return &SettingsService{st: st, subs: make(map[int]func(Settings))}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the household settings.
|
||||||
|
func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
|
||||||
|
row, err := db.New(s.st.Reader).GetHouseholdSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Settings{}, fmt.Errorf("household: read settings: %w", err)
|
||||||
|
}
|
||||||
|
return fromRow(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update validates patch, applies it, records updatedBy (the signed-in
|
||||||
|
// member's subject, which must not be empty) and publishes the new settings
|
||||||
|
// to every subscriber. An invalid patch is a *ValidationError and changes
|
||||||
|
// nothing.
|
||||||
|
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
|
||||||
|
if updatedBy == "" {
|
||||||
|
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
|
||||||
|
}
|
||||||
|
if err := patch.validate(); err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.updateMu.Lock()
|
||||||
|
defer s.updateMu.Unlock()
|
||||||
|
|
||||||
|
var row db.HouseholdSetting
|
||||||
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
cur, err := q.GetHouseholdSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("household: read settings: %w", err)
|
||||||
|
}
|
||||||
|
p := db.UpsertHouseholdSettingsParams{
|
||||||
|
Timezone: cur.Timezone,
|
||||||
|
DinnerTime: cur.DinnerTime,
|
||||||
|
ReminderTime: cur.ReminderTime,
|
||||||
|
ReviewTime: cur.ReviewTime,
|
||||||
|
PlanDraftDow: cur.PlanDraftDow,
|
||||||
|
PlanDraftTime: cur.PlanDraftTime,
|
||||||
|
SuggestionsDow: cur.SuggestionsDow,
|
||||||
|
SuggestionsTime: cur.SuggestionsTime,
|
||||||
|
ChatAutosendDow: cur.ChatAutosendDow,
|
||||||
|
ChatAutosendTime: cur.ChatAutosendTime,
|
||||||
|
OnboardingCompletedAt: cur.OnboardingCompletedAt,
|
||||||
|
UpdatesSeenAt: cur.UpdatesSeenAt,
|
||||||
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||||
|
CreatedAt: cur.CreatedAt,
|
||||||
|
UpdatedAt: store.Now().String(),
|
||||||
|
}
|
||||||
|
setString(&p.Timezone, patch.Timezone)
|
||||||
|
setString(&p.DinnerTime, patch.DinnerTime)
|
||||||
|
setString(&p.ReminderTime, patch.ReminderTime)
|
||||||
|
setString(&p.ReviewTime, patch.ReviewTime)
|
||||||
|
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
|
||||||
|
setString(&p.PlanDraftTime, patch.PlanDraftTime)
|
||||||
|
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
|
||||||
|
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
||||||
|
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||||
|
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||||
|
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("household: update settings: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := fromRow(row)
|
||||||
|
s.publish(settings)
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers fn to receive the new settings after every successful
|
||||||
|
// Update. fn runs synchronously on the updating goroutine, so it must return
|
||||||
|
// promptly and must not call Update. The returned function unsubscribes fn.
|
||||||
|
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
|
||||||
|
s.subsMu.Lock()
|
||||||
|
defer s.subsMu.Unlock()
|
||||||
|
id := s.nextID
|
||||||
|
s.nextID++
|
||||||
|
s.subs[id] = fn
|
||||||
|
return func() {
|
||||||
|
s.subsMu.Lock()
|
||||||
|
defer s.subsMu.Unlock()
|
||||||
|
delete(s.subs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) publish(settings Settings) {
|
||||||
|
s.subsMu.Lock()
|
||||||
|
fns := make([]func(Settings), 0, len(s.subs))
|
||||||
|
for _, fn := range s.subs {
|
||||||
|
fns = append(fns, fn)
|
||||||
|
}
|
||||||
|
s.subsMu.Unlock()
|
||||||
|
for _, fn := range fns {
|
||||||
|
fn(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p SettingsPatch) validate() error {
|
||||||
|
var problems []Problem
|
||||||
|
fail := func(field, reason string) {
|
||||||
|
problems = append(problems, Problem{Field: field, Reason: reason})
|
||||||
|
}
|
||||||
|
if p == (SettingsPatch{}) {
|
||||||
|
fail("settings", "no settings to change")
|
||||||
|
}
|
||||||
|
if p.Timezone != nil {
|
||||||
|
if _, err := LoadZone(*p.Timezone); err != nil {
|
||||||
|
fail("timezone", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range []struct {
|
||||||
|
field string
|
||||||
|
value *string
|
||||||
|
}{
|
||||||
|
{"dinner_time", p.DinnerTime},
|
||||||
|
{"reminder_time", p.ReminderTime},
|
||||||
|
{"review_time", p.ReviewTime},
|
||||||
|
{"plan_draft_time", p.PlanDraftTime},
|
||||||
|
{"suggestions_time", p.SuggestionsTime},
|
||||||
|
{"chat_autosend_time", p.ChatAutosendTime},
|
||||||
|
} {
|
||||||
|
if t.value != nil && !clockTime.MatchString(*t.value) {
|
||||||
|
fail(t.field, fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", *t.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range []struct {
|
||||||
|
field string
|
||||||
|
value *int
|
||||||
|
}{
|
||||||
|
{"plan_draft_dow", p.PlanDraftDow},
|
||||||
|
{"suggestions_dow", p.SuggestionsDow},
|
||||||
|
{"chat_autosend_dow", p.ChatAutosendDow},
|
||||||
|
} {
|
||||||
|
if d.value != nil && (*d.value < 0 || *d.value > 6) {
|
||||||
|
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(problems) > 0 {
|
||||||
|
return &ValidationError{Problems: problems}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clockTime matches a zero-padded 24-hour HH:MM time.
|
||||||
|
var clockTime = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||||
|
|
||||||
|
func setString(dst *string, v *string) {
|
||||||
|
if v != nil {
|
||||||
|
*dst = *v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDow(dst *int64, v *int) {
|
||||||
|
if v != nil {
|
||||||
|
*dst = int64(*v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromRow(r db.HouseholdSetting) Settings {
|
||||||
|
return Settings{
|
||||||
|
Timezone: r.Timezone,
|
||||||
|
DinnerTime: r.DinnerTime,
|
||||||
|
ReminderTime: r.ReminderTime,
|
||||||
|
ReviewTime: r.ReviewTime,
|
||||||
|
PlanDraftDow: int(r.PlanDraftDow),
|
||||||
|
PlanDraftTime: r.PlanDraftTime,
|
||||||
|
SuggestionsDow: int(r.SuggestionsDow),
|
||||||
|
SuggestionsTime: r.SuggestionsTime,
|
||||||
|
ChatAutosendDow: int(r.ChatAutosendDow),
|
||||||
|
ChatAutosendTime: r.ChatAutosendTime,
|
||||||
|
OnboardingCompletedAt: nullable(r.OnboardingCompletedAt),
|
||||||
|
UpdatesSeenAt: nullable(r.UpdatesSeenAt),
|
||||||
|
UpdatedBy: nullable(r.UpdatedBy),
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
UpdatedAt: r.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullable(s sql.NullString) *string {
|
||||||
|
if !s.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s.String
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package household
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migratedStore opens a temp store in dir migrated with the embedded
|
||||||
|
// migrations, closed when the test ends.
|
||||||
|
func migratedStore(t *testing.T, dir string) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
st, err := store.Open(ctx, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// seededService returns a settings service over a migrated temp database
|
||||||
|
// seeded with zone America/New_York.
|
||||||
|
func seededService(t *testing.T) *SettingsService {
|
||||||
|
t.Helper()
|
||||||
|
st := migratedStore(t, t.TempDir())
|
||||||
|
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return NewSettingsService(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
func TestSeedSettingsCreatesRow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
st := migratedStore(t, t.TempDir())
|
||||||
|
|
||||||
|
if err := SeedSettings(ctx, st, "America/Chicago"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := NewSettingsService(st).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := Settings{
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
DinnerTime: "17:30",
|
||||||
|
ReminderTime: "16:30",
|
||||||
|
ReviewTime: "09:00",
|
||||||
|
PlanDraftDow: 5,
|
||||||
|
PlanDraftTime: "09:00",
|
||||||
|
SuggestionsDow: 0,
|
||||||
|
SuggestionsTime: "08:00",
|
||||||
|
ChatAutosendDow: 6,
|
||||||
|
ChatAutosendTime: "08:00",
|
||||||
|
CreatedAt: got.CreatedAt,
|
||||||
|
UpdatedAt: got.UpdatedAt,
|
||||||
|
}
|
||||||
|
if !equalSettings(got, want) {
|
||||||
|
t.Errorf("seeded settings = %+v\nwant %+v", got, want)
|
||||||
|
}
|
||||||
|
var created store.Timestamp
|
||||||
|
if err := created.Scan(got.CreatedAt); err != nil {
|
||||||
|
t.Errorf("created_at: %v", err)
|
||||||
|
}
|
||||||
|
if got.UpdatedAt != got.CreatedAt {
|
||||||
|
t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedSettingsNeverOverwrites(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// First boot seeds; the household then changes a setting.
|
||||||
|
first := migratedStore(t, dir)
|
||||||
|
if err := SeedSettings(ctx, first, "America/Chicago"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
changed, err := NewSettingsService(first).Update(ctx, SettingsPatch{DinnerTime: ptr("18:15")}, "sam")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := first.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Later boots reopen the same database with a different default zone,
|
||||||
|
// an invalid one and none at all.
|
||||||
|
for _, defaultTZ := range []string{"Europe/London", "Mars/Olympus_Mons", ""} {
|
||||||
|
st := migratedStore(t, dir)
|
||||||
|
if err := SeedSettings(ctx, st, defaultTZ); err != nil {
|
||||||
|
t.Fatalf("reboot with default zone %q: %v", defaultTZ, err)
|
||||||
|
}
|
||||||
|
got, err := NewSettingsService(st).Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !equalSettings(got, changed) {
|
||||||
|
t.Errorf("reboot with default zone %q changed settings:\n got %+v\nwant %+v", defaultTZ, got, changed)
|
||||||
|
}
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedSettingsRequiresDefaultZone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
defaultTZ string
|
||||||
|
is error
|
||||||
|
}{
|
||||||
|
{"unset", "", ErrNoDefaultZone},
|
||||||
|
{"not an IANA zone", "Mars/Olympus_Mons", ErrInvalidDefaultZone},
|
||||||
|
{"Local", "Local", ErrInvalidDefaultZone},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t, t.TempDir())
|
||||||
|
err := SeedSettings(ctx, st, tt.defaultTZ)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("SeedSettings succeeded")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tt.is) {
|
||||||
|
t.Errorf("error = %v, want %v", err, tt.is)
|
||||||
|
}
|
||||||
|
if _, err := NewSettingsService(st).Get(ctx); err == nil {
|
||||||
|
t.Error("a settings row was created despite the error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := seededService(t)
|
||||||
|
before, err := svc.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := svc.Update(ctx, SettingsPatch{
|
||||||
|
Timezone: ptr("Europe/Paris"),
|
||||||
|
DinnerTime: ptr("19:00"),
|
||||||
|
ReminderTime: ptr("00:00"),
|
||||||
|
ReviewTime: ptr("23:59"),
|
||||||
|
PlanDraftDow: ptr(4),
|
||||||
|
PlanDraftTime: ptr("10:30"),
|
||||||
|
SuggestionsDow: ptr(1),
|
||||||
|
SuggestionsTime: ptr("07:45"),
|
||||||
|
ChatAutosendDow: ptr(0),
|
||||||
|
ChatAutosendTime: ptr("12:00"),
|
||||||
|
}, "sam")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := Settings{
|
||||||
|
Timezone: "Europe/Paris",
|
||||||
|
DinnerTime: "19:00",
|
||||||
|
ReminderTime: "00:00",
|
||||||
|
ReviewTime: "23:59",
|
||||||
|
PlanDraftDow: 4,
|
||||||
|
PlanDraftTime: "10:30",
|
||||||
|
SuggestionsDow: 1,
|
||||||
|
SuggestionsTime: "07:45",
|
||||||
|
ChatAutosendDow: 0,
|
||||||
|
ChatAutosendTime: "12:00",
|
||||||
|
UpdatedBy: ptr("sam"),
|
||||||
|
CreatedAt: before.CreatedAt,
|
||||||
|
UpdatedAt: got.UpdatedAt,
|
||||||
|
}
|
||||||
|
if !equalSettings(got, want) {
|
||||||
|
t.Errorf("Update = %+v\nwant %+v", got, want)
|
||||||
|
}
|
||||||
|
if got.UpdatedAt < before.UpdatedAt {
|
||||||
|
t.Errorf("updated_at went back: %s < %s", got.UpdatedAt, before.UpdatedAt)
|
||||||
|
}
|
||||||
|
stored, err := svc.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !equalSettings(stored, got) {
|
||||||
|
t.Errorf("stored = %+v\nwant %+v", stored, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A partial patch changes only its fields and records its own member.
|
||||||
|
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "alex")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want.DinnerTime = "18:00"
|
||||||
|
want.UpdatedBy = ptr("alex")
|
||||||
|
want.UpdatedAt = got.UpdatedAt
|
||||||
|
if !equalSettings(got, want) {
|
||||||
|
t.Errorf("partial Update = %+v\nwant %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRequiresMember(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := seededService(t)
|
||||||
|
notified := 0
|
||||||
|
svc.Subscribe(func(Settings) { notified++ })
|
||||||
|
|
||||||
|
if _, err := svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil {
|
||||||
|
t.Fatal("Update with no member succeeded")
|
||||||
|
}
|
||||||
|
got, err := svc.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.DinnerTime != SeedDinnerTime || got.UpdatedBy != nil || notified != 0 {
|
||||||
|
t.Errorf("Update with no member changed settings or notified: %+v, notified %d", got, notified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
patch SettingsPatch
|
||||||
|
fields []string
|
||||||
|
}{
|
||||||
|
{"empty patch", SettingsPatch{}, []string{"settings"}},
|
||||||
|
{"unknown zone", SettingsPatch{Timezone: ptr("Mars/Olympus_Mons")}, []string{"timezone"}},
|
||||||
|
{"empty zone", SettingsPatch{Timezone: ptr("")}, []string{"timezone"}},
|
||||||
|
{"Local zone", SettingsPatch{Timezone: ptr("Local")}, []string{"timezone"}},
|
||||||
|
{"zone path traversal", SettingsPatch{Timezone: ptr("../../etc/passwd")}, []string{"timezone"}},
|
||||||
|
{"hour 24", SettingsPatch{DinnerTime: ptr("24:00")}, []string{"dinner_time"}},
|
||||||
|
{"minute 60", SettingsPatch{ReminderTime: ptr("16:60")}, []string{"reminder_time"}},
|
||||||
|
{"unpadded hour", SettingsPatch{ReviewTime: ptr("9:00")}, []string{"review_time"}},
|
||||||
|
{"seconds", SettingsPatch{PlanDraftTime: ptr("09:00:00")}, []string{"plan_draft_time"}},
|
||||||
|
{"12-hour", SettingsPatch{SuggestionsTime: ptr("08:00 AM")}, []string{"suggestions_time"}},
|
||||||
|
{"empty time", SettingsPatch{ChatAutosendTime: ptr("")}, []string{"chat_autosend_time"}},
|
||||||
|
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
|
||||||
|
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
|
||||||
|
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
|
||||||
|
{
|
||||||
|
"every problem is reported",
|
||||||
|
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
|
||||||
|
[]string{"timezone", "dinner_time", "plan_draft_dow"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"valid fields do not mask an invalid one",
|
||||||
|
SettingsPatch{DinnerTime: ptr("18:00"), ChatAutosendDow: ptr(8)},
|
||||||
|
[]string{"chat_autosend_dow"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc := seededService(t)
|
||||||
|
before, err := svc.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
published := 0
|
||||||
|
svc.Subscribe(func(Settings) { published++ })
|
||||||
|
|
||||||
|
_, err = svc.Update(ctx, tt.patch, "sam")
|
||||||
|
var verr *ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Fatalf("Update error = %v, want *ValidationError", err)
|
||||||
|
}
|
||||||
|
var fields []string
|
||||||
|
for _, p := range verr.Problems {
|
||||||
|
fields = append(fields, p.Field)
|
||||||
|
}
|
||||||
|
if !slices.Equal(fields, tt.fields) {
|
||||||
|
t.Errorf("problem fields = %v, want %v", fields, tt.fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := svc.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !equalSettings(after, before) {
|
||||||
|
t.Errorf("invalid patch changed settings:\n got %+v\nwant %+v", after, before)
|
||||||
|
}
|
||||||
|
if published != 0 {
|
||||||
|
t.Errorf("invalid patch published %d times", published)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePublishesToSubscribers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := seededService(t)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var first, second []Settings
|
||||||
|
unsubscribe := svc.Subscribe(func(s Settings) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
first = append(first, s)
|
||||||
|
})
|
||||||
|
svc.Subscribe(func(s Settings) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
second = append(second, s)
|
||||||
|
})
|
||||||
|
|
||||||
|
updated, err := svc.Update(ctx, SettingsPatch{Timezone: ptr("Asia/Tokyo")}, "sam")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if len(first) != 1 || !equalSettings(first[0], updated) {
|
||||||
|
t.Errorf("first subscriber got %+v, want [%+v]", first, updated)
|
||||||
|
}
|
||||||
|
if len(second) != 1 || !equalSettings(second[0], updated) {
|
||||||
|
t.Errorf("second subscriber got %+v, want [%+v]", second, updated)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
unsubscribe()
|
||||||
|
updated, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:45")}, "sam")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(first) != 1 {
|
||||||
|
t.Errorf("unsubscribed subscriber got %d notifications, want 1", len(first))
|
||||||
|
}
|
||||||
|
if len(second) != 2 || !equalSettings(second[1], updated) {
|
||||||
|
t.Errorf("second subscriber got %+v, want a second notification %+v", second, updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateWithoutSeedFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc := NewSettingsService(migratedStore(t, t.TempDir()))
|
||||||
|
if _, err := svc.Update(context.Background(), SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil {
|
||||||
|
t.Fatal("Update succeeded with no settings row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// equalSettings compares settings by value, following the nullable pointers.
|
||||||
|
func equalSettings(a, b Settings) bool {
|
||||||
|
eq := func(x, y *string) bool { return (x == nil) == (y == nil) && (x == nil || *x == *y) }
|
||||||
|
if !eq(a.OnboardingCompletedAt, b.OnboardingCompletedAt) || !eq(a.UpdatesSeenAt, b.UpdatesSeenAt) || !eq(a.UpdatedBy, b.UpdatedBy) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.OnboardingCompletedAt, a.UpdatesSeenAt, a.UpdatedBy = nil, nil, nil
|
||||||
|
b.OnboardingCompletedAt, b.UpdatesSeenAt, b.UpdatedBy = nil, nil, nil
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Package household owns household-local calendar and clock logic. Every
|
||||||
|
// function works in the household's IANA zone, passed explicitly, and never
|
||||||
|
// in the process zone (time.Local).
|
||||||
|
//
|
||||||
|
// Weeks are ISO weeks: Monday to Sunday, numbered by ISO 8601, so the period
|
||||||
|
// key of 2026-12-28 is "2026-W53" and of 2027-01-04 is "2027-W01".
|
||||||
|
//
|
||||||
|
// Calendar arithmetic is done on UTC midnights, which have no DST, so a date
|
||||||
|
// plus seven days is always the same weekday a week later.
|
||||||
|
package household
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Today returns the household-local date at instant now in zone loc.
|
||||||
|
func Today(now time.Time, loc *time.Location) store.LocalDate {
|
||||||
|
return store.LocalDateOf(now.In(loc))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekStart returns the ISO Monday of the week containing d. It panics if d is
|
||||||
|
// the zero LocalDate.
|
||||||
|
func WeekStart(d store.LocalDate) store.LocalDate {
|
||||||
|
t := midnightUTC(d)
|
||||||
|
// Weekday counts from Sunday = 0; shift so Monday = 0 and Sunday = 6.
|
||||||
|
sinceMonday := (int(t.Weekday()) + 6) % 7
|
||||||
|
return store.LocalDateOf(t.AddDate(0, 0, -sinceMonday))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekDays returns the seven dates Monday to Sunday of the week starting on
|
||||||
|
// weekStart. It is an error if weekStart is not a Monday.
|
||||||
|
func WeekDays(weekStart store.LocalDate) ([7]store.LocalDate, error) {
|
||||||
|
t, err := monday(weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return [7]store.LocalDate{}, err
|
||||||
|
}
|
||||||
|
var days [7]store.LocalDate
|
||||||
|
for i := range days {
|
||||||
|
days[i] = store.LocalDateOf(t.AddDate(0, 0, i))
|
||||||
|
}
|
||||||
|
return days, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekLabel returns the display label of the week starting on weekStart, such
|
||||||
|
// as "Week 41 · Oct 5 – 11", or "Week 40 · Sep 28 – Oct 4" when the week
|
||||||
|
// spans two months. It is an error if weekStart is not a Monday.
|
||||||
|
func WeekLabel(weekStart store.LocalDate) (string, error) {
|
||||||
|
start, err := monday(weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, week := start.ISOWeek()
|
||||||
|
end := start.AddDate(0, 0, 6)
|
||||||
|
if end.Month() == start.Month() {
|
||||||
|
return fmt.Sprintf("Week %d · %s – %d", week, start.Format("Jan 2"), end.Day()), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Week %d · %s – %s", week, start.Format("Jan 2"), end.Format("Jan 2")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeriodKey returns the ISO week key of the week containing d, such as
|
||||||
|
// "2026-W41". The year is the ISO week-numbering year, which differs from the
|
||||||
|
// calendar year around New Year. It panics if d is the zero LocalDate.
|
||||||
|
func PeriodKey(d store.LocalDate) string {
|
||||||
|
year, week := midnightUTC(d).ISOWeek()
|
||||||
|
return fmt.Sprintf("%04d-W%02d", year, week)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalTime returns the instant at which household clocks in zone loc read
|
||||||
|
// hhmm (24-hour "HH:MM") on date d.
|
||||||
|
//
|
||||||
|
// DST rule: a wall time that does not exist (spring forward) or occurs twice
|
||||||
|
// (fall back) is read with the UTC offset in effect before the transition. A
|
||||||
|
// skipped time therefore moves forward by the length of the gap (02:30 on a
|
||||||
|
// day clocks jump 02:00→03:00 is 03:30), and a repeated time is its first
|
||||||
|
// occurrence. The rule assumes at most one offset change in the 48 hours
|
||||||
|
// around the wall time, which holds for every real zone.
|
||||||
|
func LocalTime(loc *time.Location, d store.LocalDate, hhmm string) (time.Time, error) {
|
||||||
|
if d.IsZero() {
|
||||||
|
return time.Time{}, errors.New("household: zero date")
|
||||||
|
}
|
||||||
|
clock, err := time.Parse("15:04", hhmm)
|
||||||
|
if err != nil || clock.Format("15:04") != hhmm {
|
||||||
|
return time.Time{}, fmt.Errorf("household: invalid time %q: want HH:MM", hhmm)
|
||||||
|
}
|
||||||
|
y, m, day := d.Date()
|
||||||
|
// wall is the wall-clock reading expressed as if it were UTC; the real
|
||||||
|
// instant is wall minus the zone's UTC offset at that instant.
|
||||||
|
wall := time.Date(y, m, day, clock.Hour(), clock.Minute(), 0, 0, time.UTC)
|
||||||
|
before := offset(wall.Add(-24*time.Hour), loc)
|
||||||
|
after := offset(wall.Add(24*time.Hour), loc)
|
||||||
|
early := wall.Add(-before)
|
||||||
|
late := wall.Add(-after)
|
||||||
|
// Use the post-transition offset only when the pre-transition reading is
|
||||||
|
// not a real wall time and the post-transition one is.
|
||||||
|
if offset(early, loc) != before && offset(late, loc) == after {
|
||||||
|
return late.In(loc), nil
|
||||||
|
}
|
||||||
|
return early.In(loc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// offset returns the UTC offset of zone loc at instant t.
|
||||||
|
func offset(t time.Time, loc *time.Location) time.Duration {
|
||||||
|
_, secs := t.In(loc).Zone()
|
||||||
|
return time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// midnightUTC returns d as midnight UTC. It panics if d is the zero LocalDate.
|
||||||
|
func midnightUTC(d store.LocalDate) time.Time {
|
||||||
|
if d.IsZero() {
|
||||||
|
panic("household: zero date")
|
||||||
|
}
|
||||||
|
y, m, day := d.Date()
|
||||||
|
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// monday returns weekStart as midnight UTC, or an error if it is not a Monday.
|
||||||
|
func monday(weekStart store.LocalDate) (time.Time, error) {
|
||||||
|
if weekStart.IsZero() {
|
||||||
|
return time.Time{}, errors.New("household: zero week start")
|
||||||
|
}
|
||||||
|
t := midnightUTC(weekStart)
|
||||||
|
if t.Weekday() != time.Monday {
|
||||||
|
return time.Time{}, fmt.Errorf("household: week start %s is a %s, want Monday", weekStart, t.Weekday())
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package household
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain runs the package with the process zone set to UTC, so any use of
|
||||||
|
// time.Local in place of the household zone shows up as a failure.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
time.Local = time.UTC
|
||||||
|
m.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadZone(t *testing.T, name string) *time.Location {
|
||||||
|
t.Helper()
|
||||||
|
loc, err := time.LoadLocation(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation(%q): %v", name, err)
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
func date(t *testing.T, s string) store.LocalDate {
|
||||||
|
t.Helper()
|
||||||
|
d, err := store.ParseLocalDate(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func utc(t *testing.T, s string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
ts, err := time.Parse(time.RFC3339, s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodayUsesHouseholdZone(t *testing.T) {
|
||||||
|
chicago := loadZone(t, "America/Chicago")
|
||||||
|
auckland := loadZone(t, "Pacific/Auckland")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
now string
|
||||||
|
loc *time.Location
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// 2026-10-05T04:30Z is Sunday 23:30 in Chicago (CDT, UTC−5) and
|
||||||
|
// Monday 17:30 in Auckland (NZDT, UTC+13).
|
||||||
|
{"2026-10-05T04:30:00Z", chicago, "2026-10-04"},
|
||||||
|
{"2026-10-05T04:30:00Z", auckland, "2026-10-05"},
|
||||||
|
// 2026-10-04T11:30Z is Sunday 06:30 in Chicago and already Monday
|
||||||
|
// 00:30 in Auckland.
|
||||||
|
{"2026-10-04T11:30:00Z", chicago, "2026-10-04"},
|
||||||
|
{"2026-10-04T11:30:00Z", auckland, "2026-10-05"},
|
||||||
|
} {
|
||||||
|
if got := Today(utc(t, tc.now), tc.loc).String(); got != tc.want {
|
||||||
|
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.loc, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSundayNightMondayMorningWeekAssignment(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
zone, now string
|
||||||
|
wantDay string
|
||||||
|
wantWeekStart string
|
||||||
|
wantPeriodKey string
|
||||||
|
wantWeekday time.Weekday
|
||||||
|
}{
|
||||||
|
// Sunday 23:59 local belongs to the week that started the previous
|
||||||
|
// Monday; one minute later, Monday 00:00 starts a new week.
|
||||||
|
{"America/Chicago", "2026-10-12T04:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
|
||||||
|
{"America/Chicago", "2026-10-12T05:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
|
||||||
|
{"Pacific/Auckland", "2026-10-11T10:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
|
||||||
|
{"Pacific/Auckland", "2026-10-11T11:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
|
||||||
|
} {
|
||||||
|
loc := loadZone(t, tc.zone)
|
||||||
|
now := utc(t, tc.now)
|
||||||
|
if wd := now.In(loc).Weekday(); wd != tc.wantWeekday {
|
||||||
|
t.Fatalf("%s in %s is %s, want %s", tc.now, tc.zone, wd, tc.wantWeekday)
|
||||||
|
}
|
||||||
|
today := Today(now, loc)
|
||||||
|
if today.String() != tc.wantDay {
|
||||||
|
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.zone, today, tc.wantDay)
|
||||||
|
}
|
||||||
|
if got := WeekStart(today).String(); got != tc.wantWeekStart {
|
||||||
|
t.Errorf("WeekStart(%s) = %s, want %s", today, got, tc.wantWeekStart)
|
||||||
|
}
|
||||||
|
if got := PeriodKey(today); got != tc.wantPeriodKey {
|
||||||
|
t.Errorf("PeriodKey(%s) = %s, want %s", today, got, tc.wantPeriodKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekStartEveryWeekday(t *testing.T) {
|
||||||
|
for _, d := range []string{
|
||||||
|
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08",
|
||||||
|
"2026-10-09", "2026-10-10", "2026-10-11",
|
||||||
|
} {
|
||||||
|
if got := WeekStart(date(t, d)).String(); got != "2026-10-05" {
|
||||||
|
t.Errorf("WeekStart(%s) = %s, want 2026-10-05", d, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := WeekStart(date(t, "2026-10-12")).String(); got != "2026-10-12" {
|
||||||
|
t.Errorf("WeekStart(2026-10-12) = %s, want 2026-10-12", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekDays(t *testing.T) {
|
||||||
|
days, err := WeekDays(date(t, "2026-09-28"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01",
|
||||||
|
"2026-10-02", "2026-10-03", "2026-10-04",
|
||||||
|
}
|
||||||
|
for i, d := range days {
|
||||||
|
if d.String() != want[i] {
|
||||||
|
t.Errorf("WeekDays[%d] = %s, want %s", i, d, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekDaysAcrossDSTChange(t *testing.T) {
|
||||||
|
// Chicago falls back on Sunday 2026-11-01 and Auckland springs forward on
|
||||||
|
// Sunday 2026-09-27; neither shifts the dates of those weeks.
|
||||||
|
for _, tc := range []struct{ start, last string }{
|
||||||
|
{"2026-10-26", "2026-11-01"},
|
||||||
|
{"2026-09-21", "2026-09-27"},
|
||||||
|
{"2026-03-02", "2026-03-08"},
|
||||||
|
} {
|
||||||
|
days, err := WeekDays(date(t, tc.start))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if days[0].String() != tc.start || days[6].String() != tc.last {
|
||||||
|
t.Errorf("WeekDays(%s) = %s … %s, want %s … %s", tc.start, days[0], days[6], tc.start, tc.last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestISOWeekYearBoundary(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ day, weekStart, key string }{
|
||||||
|
{"2026-12-27", "2026-12-21", "2026-W52"},
|
||||||
|
{"2026-12-28", "2026-12-28", "2026-W53"},
|
||||||
|
{"2026-12-31", "2026-12-28", "2026-W53"},
|
||||||
|
{"2027-01-01", "2026-12-28", "2026-W53"},
|
||||||
|
{"2027-01-03", "2026-12-28", "2026-W53"},
|
||||||
|
{"2027-01-04", "2027-01-04", "2027-W01"},
|
||||||
|
{"2027-01-10", "2027-01-04", "2027-W01"},
|
||||||
|
{"2026-01-01", "2025-12-29", "2026-W01"},
|
||||||
|
{"2026-10-07", "2026-10-05", "2026-W41"},
|
||||||
|
} {
|
||||||
|
d := date(t, tc.day)
|
||||||
|
if got := WeekStart(d).String(); got != tc.weekStart {
|
||||||
|
t.Errorf("WeekStart(%s) = %s, want %s", tc.day, got, tc.weekStart)
|
||||||
|
}
|
||||||
|
if got := PeriodKey(d); got != tc.key {
|
||||||
|
t.Errorf("PeriodKey(%s) = %s, want %s", tc.day, got, tc.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
days, err := WeekDays(date(t, "2026-12-28"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if days[6].String() != "2027-01-03" {
|
||||||
|
t.Errorf("W53 Sunday = %s, want 2027-01-03", days[6])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekLabel(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ start, want string }{
|
||||||
|
{"2026-10-05", "Week 41 · Oct 5 – 11"},
|
||||||
|
{"2026-09-28", "Week 40 · Sep 28 – Oct 4"},
|
||||||
|
{"2026-12-28", "Week 53 · Dec 28 – Jan 3"},
|
||||||
|
{"2027-01-04", "Week 1 · Jan 4 – 10"},
|
||||||
|
} {
|
||||||
|
got, err := WeekLabel(date(t, tc.start))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("WeekLabel(%s) = %q, want %q", tc.start, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekStartRequired(t *testing.T) {
|
||||||
|
for _, d := range []store.LocalDate{date(t, "2026-10-07"), date(t, "2026-10-11"), {}} {
|
||||||
|
if _, err := WeekDays(d); err == nil {
|
||||||
|
t.Errorf("WeekDays(%s) succeeded, want error", d)
|
||||||
|
}
|
||||||
|
if _, err := WeekLabel(d); err == nil {
|
||||||
|
t.Errorf("WeekLabel(%s) succeeded, want error", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroDatePanics(t *testing.T) {
|
||||||
|
for name, fn := range map[string]func(){
|
||||||
|
"WeekStart": func() { WeekStart(store.LocalDate{}) },
|
||||||
|
"PeriodKey": func() { PeriodKey(store.LocalDate{}) },
|
||||||
|
} {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Errorf("%s(zero) did not panic", name)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalTime(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
zone, day, hhmm string
|
||||||
|
want string // UTC instant
|
||||||
|
wantWall string // local wall clock of the result
|
||||||
|
}{
|
||||||
|
// Ordinary days.
|
||||||
|
{"America/Chicago", "2026-10-05", "18:30", "2026-10-05T23:30:00Z", "2026-10-05 18:30 CDT"},
|
||||||
|
{"America/Chicago", "2026-12-28", "00:00", "2026-12-28T06:00:00Z", "2026-12-28 00:00 CST"},
|
||||||
|
{"Pacific/Auckland", "2026-10-05", "18:30", "2026-10-05T05:30:00Z", "2026-10-05 18:30 NZDT"},
|
||||||
|
{"Pacific/Auckland", "2026-07-01", "23:59", "2026-07-01T11:59:00Z", "2026-07-01 23:59 NZST"},
|
||||||
|
|
||||||
|
// Chicago spring forward, Sunday 2026-03-08: 02:00 CST → 03:00 CDT.
|
||||||
|
{"America/Chicago", "2026-03-08", "01:59", "2026-03-08T07:59:00Z", "2026-03-08 01:59 CST"},
|
||||||
|
{"America/Chicago", "2026-03-08", "02:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
|
||||||
|
{"America/Chicago", "2026-03-08", "02:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
|
||||||
|
{"America/Chicago", "2026-03-08", "03:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
|
||||||
|
{"America/Chicago", "2026-03-08", "03:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
|
||||||
|
|
||||||
|
// Chicago fall back, Sunday 2026-11-01: 02:00 CDT → 01:00 CST, so
|
||||||
|
// 01:00–01:59 happens twice; the first (CDT) occurrence is chosen.
|
||||||
|
{"America/Chicago", "2026-11-01", "00:59", "2026-11-01T05:59:00Z", "2026-11-01 00:59 CDT"},
|
||||||
|
{"America/Chicago", "2026-11-01", "01:00", "2026-11-01T06:00:00Z", "2026-11-01 01:00 CDT"},
|
||||||
|
{"America/Chicago", "2026-11-01", "01:30", "2026-11-01T06:30:00Z", "2026-11-01 01:30 CDT"},
|
||||||
|
{"America/Chicago", "2026-11-01", "02:00", "2026-11-01T08:00:00Z", "2026-11-01 02:00 CST"},
|
||||||
|
|
||||||
|
// Auckland spring forward, Sunday 2026-09-27: 02:00 NZST → 03:00 NZDT.
|
||||||
|
{"Pacific/Auckland", "2026-09-27", "01:59", "2026-09-26T13:59:00Z", "2026-09-27 01:59 NZST"},
|
||||||
|
{"Pacific/Auckland", "2026-09-27", "02:30", "2026-09-26T14:30:00Z", "2026-09-27 03:30 NZDT"},
|
||||||
|
{"Pacific/Auckland", "2026-09-27", "03:00", "2026-09-26T14:00:00Z", "2026-09-27 03:00 NZDT"},
|
||||||
|
|
||||||
|
// Auckland fall back, Sunday 2026-04-05: 03:00 NZDT → 02:00 NZST, so
|
||||||
|
// 02:00–02:59 happens twice; the first (NZDT) occurrence is chosen.
|
||||||
|
{"Pacific/Auckland", "2026-04-05", "01:59", "2026-04-04T12:59:00Z", "2026-04-05 01:59 NZDT"},
|
||||||
|
{"Pacific/Auckland", "2026-04-05", "02:30", "2026-04-04T13:30:00Z", "2026-04-05 02:30 NZDT"},
|
||||||
|
{"Pacific/Auckland", "2026-04-05", "03:00", "2026-04-04T15:00:00Z", "2026-04-05 03:00 NZST"},
|
||||||
|
} {
|
||||||
|
loc := loadZone(t, tc.zone)
|
||||||
|
got, err := LocalTime(loc, date(t, tc.day), tc.hhmm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LocalTime(%s, %s, %s): %v", tc.zone, tc.day, tc.hhmm, err)
|
||||||
|
}
|
||||||
|
if !got.Equal(utc(t, tc.want)) {
|
||||||
|
t.Errorf("LocalTime(%s, %s, %s) = %s, want %s", tc.zone, tc.day, tc.hhmm, got.UTC().Format(time.RFC3339), tc.want)
|
||||||
|
}
|
||||||
|
if wall := got.Format("2006-01-02 15:04 MST"); wall != tc.wantWall {
|
||||||
|
t.Errorf("LocalTime(%s, %s, %s) wall = %s, want %s", tc.zone, tc.day, tc.hhmm, wall, tc.wantWall)
|
||||||
|
}
|
||||||
|
if got.Location() != loc {
|
||||||
|
t.Errorf("LocalTime(%s, …) location = %s, want %s", tc.zone, got.Location(), loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalTimeDeterministic(t *testing.T) {
|
||||||
|
// The same ambiguous or skipped input always resolves to the same instant.
|
||||||
|
chicago := loadZone(t, "America/Chicago")
|
||||||
|
for _, tc := range []struct{ day, hhmm string }{{"2026-03-08", "02:30"}, {"2026-11-01", "01:30"}} {
|
||||||
|
first, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for range 100 {
|
||||||
|
again, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !again.Equal(first) {
|
||||||
|
t.Fatalf("LocalTime(%s, %s) = %s then %s", tc.day, tc.hhmm, first, again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalTimeRejectsInvalid(t *testing.T) {
|
||||||
|
chicago := loadZone(t, "America/Chicago")
|
||||||
|
d := date(t, "2026-10-05")
|
||||||
|
for _, hhmm := range []string{
|
||||||
|
"", "9:30", "09:3", "24:00", "23:60", "09:30:00", "0930", " 09:30", "09:30 ", "9am", "-1:00",
|
||||||
|
} {
|
||||||
|
if got, err := LocalTime(chicago, d, hhmm); err == nil {
|
||||||
|
t.Errorf("LocalTime(%q) = %s, want error", hhmm, got)
|
||||||
|
} else if !strings.Contains(err.Error(), "HH:MM") {
|
||||||
|
t.Errorf("LocalTime(%q) error %q does not name the HH:MM form", hhmm, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := LocalTime(chicago, store.LocalDate{}, "09:30"); err == nil {
|
||||||
|
t.Error("LocalTime(zero date) succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package household
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
// Embeds the IANA zone database in the binary, so zones load the same
|
||||||
|
// way in a minimal image with no /usr/share/zoneinfo.
|
||||||
|
_ "time/tzdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadZone loads the IANA zone name, such as "America/New_York". The empty
|
||||||
|
// name and "Local", which time.LoadLocation maps to UTC and the process zone,
|
||||||
|
// are refused: the household zone is never implied.
|
||||||
|
func LoadZone(name string) (*time.Location, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("time zone is empty (want an IANA zone such as America/New_York)")
|
||||||
|
}
|
||||||
|
if name == "Local" {
|
||||||
|
return nil, errors.New(`time zone "Local" is not an IANA zone (want e.g. America/New_York)`)
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unknown time zone %q (want an IANA zone such as America/New_York)", name)
|
||||||
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package jobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KindNoop is the framework's built-in job kind: it does no work and
|
||||||
|
// succeeds with {"ok":true}. It is the runner's own trivial kind, as Feature
|
||||||
|
// 09's trivial test skill is the harness's, and the startup and readiness
|
||||||
|
// job: a noop job reaching succeeded proves the runner claims, runs and
|
||||||
|
// records jobs.
|
||||||
|
const KindNoop = "noop"
|
||||||
|
|
||||||
|
// NoopPayload is the payload of a noop job.
|
||||||
|
type NoopPayload struct{}
|
||||||
|
|
||||||
|
// NoopResult is the result of a noop job.
|
||||||
|
type NoopResult struct{}
|
||||||
|
|
||||||
|
// PublicJSON returns {"ok":true}.
|
||||||
|
func (NoopResult) PublicJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}{OK: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNoop(context.Context, NoopPayload, func(NoopResult) error) (NoopResult, error) {
|
||||||
|
return NoopResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builtins enqueues the framework's built-in job kinds.
|
||||||
|
type Builtins struct {
|
||||||
|
Noop *Kind[NoopPayload]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterBuiltins registers the framework's built-in job kinds on r.
|
||||||
|
func RegisterBuiltins(r *Runner) (*Builtins, error) {
|
||||||
|
noop, err := Register(r, KindNoop, runNoop)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Builtins{Noop: noop}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
// Package jobs runs long work in the background. A job is a row in the jobs
|
||||||
|
// table: Enqueue inserts it queued, and a fixed pool of workers claims queued
|
||||||
|
// jobs oldest first, so the table itself is the queue and a job enqueued
|
||||||
|
// before a restart still runs after it.
|
||||||
|
//
|
||||||
|
// Each job kind has one handler, registered before Start. A handler takes a
|
||||||
|
// typed payload and returns a result implementing Public; it may publish
|
||||||
|
// partial results while it runs. The runner stores only what a result's
|
||||||
|
// PublicJSON returns and, for a failed job, only a failure code: either a
|
||||||
|
// *Failure code the kind declared at registration or one of the runner's own
|
||||||
|
// codes. Handler error text, which may quote a URL, token or response body
|
||||||
|
// from a dependency, is never stored, returned by the API or logged.
|
||||||
|
package jobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is the state of a job.
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
// Job states. A job moves queued → running → succeeded or failed.
|
||||||
|
const (
|
||||||
|
StatusQueued Status = "queued"
|
||||||
|
StatusRunning Status = "running"
|
||||||
|
StatusSucceeded Status = "succeeded"
|
||||||
|
StatusFailed Status = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Failure codes the runner stores itself. A kind cannot declare them.
|
||||||
|
const (
|
||||||
|
// CodeInterrupted is the error of a job a previous process left running.
|
||||||
|
// It is fixed framework text, not a snake-case code, so no kind can
|
||||||
|
// declare it.
|
||||||
|
CodeInterrupted = "interrupted by restart"
|
||||||
|
// CodeInternal is the error of a job whose handler failed with anything
|
||||||
|
// but a *Failure with a code its kind declared, or whose payload or
|
||||||
|
// result could not be encoded. The underlying error is neither stored
|
||||||
|
// nor logged.
|
||||||
|
CodeInternal = "internal_error"
|
||||||
|
// CodeUnknownKind is the error of a job whose kind has no handler.
|
||||||
|
CodeUnknownKind = "unknown_kind"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claim retry backoff. A worker whose claim fails with a database error
|
||||||
|
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
|
||||||
|
// when the error clears is claimed without waiting for a wake token.
|
||||||
|
const (
|
||||||
|
claimRetryMin = 50 * time.Millisecond
|
||||||
|
claimRetryMax = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// codePattern is the form of every failure code: lower-case snake case.
|
||||||
|
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||||
|
|
||||||
|
// ErrNotFound is returned by Get for an unknown job id.
|
||||||
|
var ErrNotFound = errors.New("jobs: job not found")
|
||||||
|
|
||||||
|
// ErrUnknownKind is returned by Runner.Enqueue for a kind with no
|
||||||
|
// registered handler.
|
||||||
|
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
||||||
|
|
||||||
|
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
||||||
|
// not decode into the kind's payload type.
|
||||||
|
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
||||||
|
|
||||||
|
// Failure is a handler error reporting a failure code the household may
|
||||||
|
// see. Code must be one the job's kind declared at registration; the runner
|
||||||
|
// stores it as the job's error. Any other handler error, including a Failure
|
||||||
|
// with an undeclared code, is stored as CodeInternal.
|
||||||
|
type Failure struct {
|
||||||
|
Code string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Failure) Error() string { return "jobs: failure " + f.Code }
|
||||||
|
|
||||||
|
// Public is a job result. PublicJSON returns the JSON the runner stores and
|
||||||
|
// GET /api/v1/jobs/{id} returns, so a result type states exactly which of its
|
||||||
|
// data is public; nothing else of the value is ever encoded.
|
||||||
|
type Public interface {
|
||||||
|
PublicJSON() ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler runs one job with its payload. It may call publish any number of
|
||||||
|
// times with a partial result, which Get returns while the job runs; its
|
||||||
|
// return value is the final result. ctx is cancelled when the runner stops.
|
||||||
|
type Handler[P any, R Public] func(ctx context.Context, payload P, publish func(partial R) error) (R, error)
|
||||||
|
|
||||||
|
// Job is a job as reported by GET /api/v1/jobs/{id}. Result is the latest
|
||||||
|
// partial or final result, and is null until the handler publishes one.
|
||||||
|
// Error is set only on a failed job.
|
||||||
|
type Job struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Status Status `json:"status"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error *string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// runFunc is a registered Handler with its payload and results as JSON. It
|
||||||
|
// returns the failure code to store, or "" on success; the logged reason
|
||||||
|
// never quotes an error.
|
||||||
|
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||||
|
|
||||||
|
// handler is a registered kind: run runs a job, and check reports whether
|
||||||
|
// a payload decodes into the kind's payload type.
|
||||||
|
type handler struct {
|
||||||
|
run runFunc
|
||||||
|
check func(payload []byte) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner owns the job handlers and the worker pool.
|
||||||
|
type Runner struct {
|
||||||
|
st *store.Store
|
||||||
|
logger *slog.Logger
|
||||||
|
workers int
|
||||||
|
// wake holds up to one token per worker; Enqueue adds one so an idle
|
||||||
|
// worker looks for the new job.
|
||||||
|
wake chan struct{}
|
||||||
|
// claim claims the oldest queued job, marking it running at now.
|
||||||
|
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
handlers map[string]handler
|
||||||
|
started bool
|
||||||
|
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner returns a runner over st with workers concurrent workers.
|
||||||
|
func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, error) {
|
||||||
|
if st == nil {
|
||||||
|
return nil, errors.New("jobs: nil store")
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
return nil, errors.New("jobs: nil logger")
|
||||||
|
}
|
||||||
|
if workers < 1 {
|
||||||
|
return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers)
|
||||||
|
}
|
||||||
|
return &Runner{
|
||||||
|
st: st,
|
||||||
|
logger: logger,
|
||||||
|
workers: workers,
|
||||||
|
wake: make(chan struct{}, workers),
|
||||||
|
claim: db.New(st.Writer).ClaimNextJob,
|
||||||
|
handlers: make(map[string]handler),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind enqueues jobs of one registered kind.
|
||||||
|
type Kind[P any] struct {
|
||||||
|
r *Runner
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register makes h the handler for kind and returns the Kind that enqueues
|
||||||
|
// its jobs. failureCodes are the *Failure codes h may report. It must be
|
||||||
|
// called before Start, once per kind.
|
||||||
|
func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureCodes ...string) (*Kind[P], error) {
|
||||||
|
if kind == "" {
|
||||||
|
return nil, errors.New("jobs: register: empty kind")
|
||||||
|
}
|
||||||
|
if h == nil {
|
||||||
|
return nil, fmt.Errorf("jobs: register %q: nil handler", kind)
|
||||||
|
}
|
||||||
|
declared := make(map[string]bool, len(failureCodes))
|
||||||
|
for _, code := range failureCodes {
|
||||||
|
if !codePattern.MatchString(code) {
|
||||||
|
return nil, fmt.Errorf("jobs: register %q: failure code %q is not lower-case snake case", kind, code)
|
||||||
|
}
|
||||||
|
if code == CodeInternal || code == CodeUnknownKind {
|
||||||
|
return nil, fmt.Errorf("jobs: register %q: failure code %q is reserved for the runner", kind, code)
|
||||||
|
}
|
||||||
|
declared[code] = true
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.started {
|
||||||
|
return nil, fmt.Errorf("jobs: register %q: runner already started", kind)
|
||||||
|
}
|
||||||
|
if _, ok := r.handlers[kind]; ok {
|
||||||
|
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
|
||||||
|
}
|
||||||
|
run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
|
||||||
|
p, err := decodePayload[P]([]byte(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, CodeInternal, "payload does not decode"
|
||||||
|
}
|
||||||
|
result, err := h(ctx, p, func(partial R) error {
|
||||||
|
data, err := publicJSON(partial)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return publish(data)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
return nil, CodeInternal, "handler error"
|
||||||
|
}
|
||||||
|
if !declared[failure.Code] {
|
||||||
|
return nil, CodeInternal, "undeclared failure code"
|
||||||
|
}
|
||||||
|
return nil, failure.Code, "handler failure"
|
||||||
|
}
|
||||||
|
data, err := publicJSON(result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, CodeInternal, "result does not encode"
|
||||||
|
}
|
||||||
|
return data, "", ""
|
||||||
|
}
|
||||||
|
check := func(payload []byte) bool {
|
||||||
|
_, err := decodePayload[P](payload)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
r.handlers[kind] = handler{run: run, check: check}
|
||||||
|
return &Kind[P]{r: r, name: kind}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodePayload decodes payload into P, refusing unknown fields.
|
||||||
|
func decodePayload[P any](payload []byte) (P, error) {
|
||||||
|
var p P
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(payload))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
err := dec.Decode(&p)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
|
||||||
|
// value. Its error never quotes PublicJSON's error.
|
||||||
|
func publicJSON(v Public) (json.RawMessage, error) {
|
||||||
|
data, err := v.PublicJSON()
|
||||||
|
if err != nil || !json.Valid(data) {
|
||||||
|
return nil, errors.New("jobs: result has no valid public JSON")
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue stores a queued job with payload and returns its id. A job
|
||||||
|
// enqueued before Start waits for it.
|
||||||
|
func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
||||||
|
}
|
||||||
|
return k.r.enqueue(ctx, k.name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||||
|
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||||
|
// ErrUnknownKind for a kind with no registered handler and
|
||||||
|
// ErrInvalidPayload for a payload that does not decode into the kind's
|
||||||
|
// payload type; neither stores a job.
|
||||||
|
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
h, ok := r.handlers[kind]
|
||||||
|
r.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return "", ErrUnknownKind
|
||||||
|
}
|
||||||
|
if !h.check(payload) {
|
||||||
|
return "", ErrInvalidPayload
|
||||||
|
}
|
||||||
|
return r.enqueue(ctx, kind, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
||||||
|
// worker.
|
||||||
|
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
||||||
|
id := store.NewID()
|
||||||
|
now := store.Now().String()
|
||||||
|
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
||||||
|
ID: id,
|
||||||
|
Kind: kind,
|
||||||
|
Payload: string(payload),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r.wake <- struct{}{}:
|
||||||
|
default: // every worker already has a token and will look again
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start fails every job a previous process left running with
|
||||||
|
// CodeInterrupted, then starts the workers. They stop when ctx is cancelled;
|
||||||
|
// Wait waits for them. A job whose handler returns an error after ctx is
|
||||||
|
// cancelled is left running, so the next Start fails it as interrupted.
|
||||||
|
func (r *Runner) Start(ctx context.Context) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.started {
|
||||||
|
return errors.New("jobs: runner already started")
|
||||||
|
}
|
||||||
|
now := store.Now().String()
|
||||||
|
n, err := db.New(r.st.Writer).FailRunningJobs(ctx, db.FailRunningJobsParams{
|
||||||
|
Error: sql.NullString{String: CodeInterrupted, Valid: true},
|
||||||
|
Now: sql.NullString{String: now, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("jobs: fail interrupted jobs: %w", err)
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
r.logger.WarnContext(ctx, "failed jobs interrupted by restart", "count", n)
|
||||||
|
}
|
||||||
|
r.started = true
|
||||||
|
for range r.workers {
|
||||||
|
r.wg.Add(1)
|
||||||
|
go r.work(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until every worker has stopped.
|
||||||
|
func (r *Runner) Wait() {
|
||||||
|
r.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the job with id, or ErrNotFound.
|
||||||
|
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||||
|
row, err := db.New(r.st.Reader).GetJob(ctx, id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Job{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Job{}, fmt.Errorf("jobs: read job: %w", err)
|
||||||
|
}
|
||||||
|
job := Job{ID: row.ID, Kind: row.Kind, Status: Status(row.Status)}
|
||||||
|
if row.Result.Valid {
|
||||||
|
job.Result = json.RawMessage(row.Result.String)
|
||||||
|
}
|
||||||
|
if row.Error.Valid {
|
||||||
|
job.Error = &row.Error.String
|
||||||
|
}
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// work claims and runs queued jobs until ctx is cancelled, waiting for a
|
||||||
|
// wake token whenever none is queued. A claim that fails with a database
|
||||||
|
// error is retried with backoff rather than waiting for a token, so a job
|
||||||
|
// already queued is not stranded by a transient error.
|
||||||
|
func (r *Runner) work(ctx context.Context) {
|
||||||
|
defer r.wg.Done()
|
||||||
|
backoff := claimRetryMin
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
backoff = claimRetryMin
|
||||||
|
r.run(ctx, job)
|
||||||
|
continue
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
backoff = claimRetryMin
|
||||||
|
select {
|
||||||
|
case <-r.wake:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case ctx.Err() != nil:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.logger.ErrorContext(ctx, "claim job failed; retrying", "reason", "database error", "retry_in", backoff)
|
||||||
|
timer := time.NewTimer(backoff)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
backoff = min(2*backoff, claimRetryMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run runs a claimed job to completion. The final write is made even when
|
||||||
|
// ctx has been cancelled, so a handler that finishes during shutdown is
|
||||||
|
// recorded.
|
||||||
|
func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||||
|
write := context.WithoutCancel(ctx)
|
||||||
|
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
|
||||||
|
q := db.New(r.st.Writer)
|
||||||
|
|
||||||
|
h, ok := r.handlers[job.Kind] // read-only once started
|
||||||
|
if !ok {
|
||||||
|
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
|
||||||
|
r.fail(write, logger, job.ID, CodeUnknownKind)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
publish := func(partial json.RawMessage) error {
|
||||||
|
n, err := q.PublishJobResult(ctx, db.PublishJobResultParams{
|
||||||
|
Result: sql.NullString{String: string(partial), Valid: true},
|
||||||
|
UpdatedAt: store.Now().String(),
|
||||||
|
ID: job.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("jobs: publish result: %w", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
return fmt.Errorf("jobs: publish result: job %s is not running", job.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, code, reason := h.run(ctx, job.Payload, publish)
|
||||||
|
if code != "" {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if code == CodeInternal {
|
||||||
|
level = slog.LevelError
|
||||||
|
}
|
||||||
|
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
|
||||||
|
r.fail(write, logger, job.ID, code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := q.SucceedJob(write, db.SucceedJobParams{
|
||||||
|
Result: sql.NullString{String: string(result), Valid: true},
|
||||||
|
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||||
|
ID: job.ID,
|
||||||
|
})
|
||||||
|
if err == nil && n != 1 {
|
||||||
|
err = errors.New("job is not running")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(write, "record job success", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail records the job as failed with code. The record errors it logs come
|
||||||
|
// from the database, never from a handler.
|
||||||
|
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
|
||||||
|
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
|
||||||
|
Error: sql.NullString{String: code, Valid: true},
|
||||||
|
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||||
|
ID: id,
|
||||||
|
})
|
||||||
|
if err == nil && n != 1 {
|
||||||
|
err = errors.New("job is not running")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(ctx, "record job failure", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
package jobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sentinelSecret stands in for a credential a handler holds while it runs.
|
||||||
|
const sentinelSecret = "sk-sentinel-5f2c9a"
|
||||||
|
|
||||||
|
type countPayload struct {
|
||||||
|
Steps int `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type countResult struct {
|
||||||
|
Done int `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c countResult) PublicJSON() ([]byte, error) { return json.Marshal(c) }
|
||||||
|
|
||||||
|
func migratedStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
st, err := store.Open(ctx, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunner(t *testing.T, st *store.Store, workers int) *Runner {
|
||||||
|
t.Helper()
|
||||||
|
return newRunnerLogging(t, st, workers, io.Discard)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunnerLogging(t *testing.T, st *store.Store, workers int, w io.Writer) *Runner {
|
||||||
|
t.Helper()
|
||||||
|
r, err := NewRunner(st, slog.New(slog.NewTextHandler(w, nil)), workers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// logBuffer collects log output written by concurrent workers.
|
||||||
|
type logBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *logBuffer) Write(p []byte) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *logBuffer) String() string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// start starts r and stops it when the test ends. The returned function
|
||||||
|
// stops it early and waits for its workers.
|
||||||
|
func start(t *testing.T, r *Runner) (stop func()) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if err := r.Start(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stop = func() {
|
||||||
|
cancel()
|
||||||
|
r.Wait()
|
||||||
|
}
|
||||||
|
t.Cleanup(stop)
|
||||||
|
return stop
|
||||||
|
}
|
||||||
|
|
||||||
|
func register[P any, R Public](t *testing.T, r *Runner, kind string, h Handler[P, R], failureCodes ...string) *Kind[P] {
|
||||||
|
t.Helper()
|
||||||
|
k, err := Register(r, kind, h, failureCodes...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue[P any](t *testing.T, k *Kind[P], payload P) string {
|
||||||
|
t.Helper()
|
||||||
|
id, err := k.Enqueue(context.Background(), payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor polls Get until done accepts the job, failing after five seconds.
|
||||||
|
func waitFor(t *testing.T, r *Runner, id string, done func(Job) bool) Job {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
job, err := r.Get(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if done(job) {
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("job %s never reached the expected state; last %+v (result %s)", id, job, job.Result)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finished(j Job) bool { return j.Status == StatusSucceeded || j.Status == StatusFailed }
|
||||||
|
|
||||||
|
// rawRow returns every column of the job's row joined, for leak checks.
|
||||||
|
func rawRow(t *testing.T, st *store.Store, id string) string {
|
||||||
|
t.Helper()
|
||||||
|
var cols [10]any
|
||||||
|
ptrs := make([]any, len(cols))
|
||||||
|
for i := range cols {
|
||||||
|
ptrs[i] = &cols[i]
|
||||||
|
}
|
||||||
|
if err := st.Reader.QueryRow("SELECT * FROM jobs WHERE id = ?", id).Scan(ptrs...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return fmt.Sprint(cols[:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t)
|
||||||
|
r := newRunner(t, st, 2)
|
||||||
|
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{Done: p.Steps}, nil
|
||||||
|
})
|
||||||
|
start(t, r)
|
||||||
|
|
||||||
|
id := enqueue(t, count, countPayload{Steps: 3})
|
||||||
|
job := waitFor(t, r, id, finished)
|
||||||
|
if job.ID != id || job.Kind != "count" || job.Status != StatusSucceeded || job.Error != nil {
|
||||||
|
t.Fatalf("job = %+v, want succeeded count job %s with no error", job, id)
|
||||||
|
}
|
||||||
|
if string(job.Result) != `{"done":3}` {
|
||||||
|
t.Errorf("result = %s, want {\"done\":3}", job.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
var startedAt, finishedAt, createdAt, updatedAt, payload string
|
||||||
|
if err := st.Reader.QueryRow("SELECT started_at, finished_at, created_at, updated_at, payload FROM jobs WHERE id = ?", id).
|
||||||
|
Scan(&startedAt, &finishedAt, &createdAt, &updatedAt, &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, v := range map[string]string{"started_at": startedAt, "finished_at": finishedAt, "created_at": createdAt, "updated_at": updatedAt} {
|
||||||
|
var ts store.Timestamp
|
||||||
|
if err := ts.Scan(v); err != nil {
|
||||||
|
t.Errorf("%s = %q: %v", name, v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if payload != `{"steps":3}` {
|
||||||
|
t.Errorf("payload = %s, want {\"steps\":3}", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobFailsWithFailureCode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t)
|
||||||
|
r := newRunner(t, st, 1)
|
||||||
|
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{}, fmt.Errorf("suggest: %w", &Failure{Code: "no_regulars"})
|
||||||
|
}, "no_regulars")
|
||||||
|
start(t, r)
|
||||||
|
|
||||||
|
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
|
||||||
|
if job.Status != StatusFailed || job.Error == nil || *job.Error != "no_regulars" {
|
||||||
|
t.Fatalf("job = %+v, want failed with code no_regulars", job)
|
||||||
|
}
|
||||||
|
if job.Result != nil {
|
||||||
|
t.Errorf("result = %s, want none", job.Result)
|
||||||
|
}
|
||||||
|
var stored string
|
||||||
|
if err := st.Reader.QueryRow("SELECT error FROM jobs WHERE id = ?", job.ID).Scan(&stored); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored != "no_regulars" {
|
||||||
|
t.Errorf("stored error = %q, want only the code", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUndeclaredFailureCodeIsInternal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := newRunner(t, migratedStore(t), 1)
|
||||||
|
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{}, &Failure{Code: "not_declared"}
|
||||||
|
}, "no_regulars")
|
||||||
|
start(t, r)
|
||||||
|
|
||||||
|
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
|
||||||
|
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInternal {
|
||||||
|
t.Fatalf("job = %+v, want failed with %q", job, CodeInternal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// credential holds a secret the way config.Secret does: unexported, so
|
||||||
|
// encoding/json cannot see it.
|
||||||
|
type credential struct{ value string }
|
||||||
|
|
||||||
|
func (credential) String() string { return "[REDACTED]" }
|
||||||
|
|
||||||
|
// leakyResult holds a secret in exported and unexported fields; only
|
||||||
|
// Dinners is public.
|
||||||
|
type leakyResult struct {
|
||||||
|
Dinners []string
|
||||||
|
Key credential
|
||||||
|
Token string
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l leakyResult) PublicJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Dinners []string `json:"dinners"`
|
||||||
|
}{l.Dinners})
|
||||||
|
}
|
||||||
|
|
||||||
|
// badResult has no valid public JSON; its encoding error quotes a secret.
|
||||||
|
type badResult struct{}
|
||||||
|
|
||||||
|
func (badResult) PublicJSON() ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("encode with key %s", sentinelSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecretsNeverStoredOrLogged proves a handler holding a secret cannot
|
||||||
|
// surface it in the jobs row, Get's result or the log through its result,
|
||||||
|
// partial results, *Failure, error text or result encoding error.
|
||||||
|
func TestSecretsNeverStoredOrLogged(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t)
|
||||||
|
var logs logBuffer
|
||||||
|
r := newRunnerLogging(t, st, 1, &logs)
|
||||||
|
secretResult := leakyResult{Dinners: []string{"Tacos"}, Key: credential{sentinelSecret}, Token: sentinelSecret, apiKey: sentinelSecret}
|
||||||
|
typed := register(t, r, "typed", func(_ context.Context, _ countPayload, publish func(leakyResult) error) (leakyResult, error) {
|
||||||
|
if err := publish(secretResult); err != nil {
|
||||||
|
return leakyResult{}, err
|
||||||
|
}
|
||||||
|
return secretResult, nil
|
||||||
|
})
|
||||||
|
leakyError := register(t, r, "leaky-error", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||||
|
return leakyResult{}, fmt.Errorf("GET https://api.example.com/v1?key=%s: 401 %s", sentinelSecret, sentinelSecret)
|
||||||
|
})
|
||||||
|
leakyFailure := register(t, r, "leaky-failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||||
|
return leakyResult{}, &Failure{Code: sentinelSecret}
|
||||||
|
}, "quota_exceeded")
|
||||||
|
failure := register(t, r, "failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||||
|
return leakyResult{}, fmt.Errorf("call with key %s: %w", sentinelSecret, &Failure{Code: "quota_exceeded"})
|
||||||
|
}, "quota_exceeded")
|
||||||
|
badEncoding := register(t, r, "bad-encoding", func(context.Context, countPayload, func(badResult) error) (badResult, error) {
|
||||||
|
return badResult{}, nil
|
||||||
|
})
|
||||||
|
stop := start(t, r)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
kind *Kind[countPayload]
|
||||||
|
wantStatus Status
|
||||||
|
wantResult string
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{typed, StatusSucceeded, `{"dinners":["Tacos"]}`, ""},
|
||||||
|
{leakyError, StatusFailed, "", CodeInternal},
|
||||||
|
{leakyFailure, StatusFailed, "", CodeInternal},
|
||||||
|
{failure, StatusFailed, "", "quota_exceeded"},
|
||||||
|
{badEncoding, StatusFailed, "", CodeInternal},
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for _, c := range cases {
|
||||||
|
job := waitFor(t, r, enqueue(t, c.kind, countPayload{}), finished)
|
||||||
|
ids = append(ids, job.ID)
|
||||||
|
gotError := ""
|
||||||
|
if job.Error != nil {
|
||||||
|
gotError = *job.Error
|
||||||
|
}
|
||||||
|
if job.Status != c.wantStatus || string(job.Result) != c.wantResult || gotError != c.wantError {
|
||||||
|
t.Errorf("%s job = %+v (result %s), want %s with result %q and error %q", job.Kind, job, job.Result, c.wantStatus, c.wantResult, c.wantError)
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(job)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), sentinelSecret) {
|
||||||
|
t.Errorf("%s job as served contains the secret: %s", job.Kind, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
for _, id := range ids {
|
||||||
|
if row := rawRow(t, st, id); strings.Contains(row, sentinelSecret) {
|
||||||
|
t.Errorf("job %s row contains the secret: %s", id, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(logs.String(), sentinelSecret) {
|
||||||
|
t.Errorf("log contains the secret:\n%s", logs.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs.String(), "code="+CodeInternal) {
|
||||||
|
t.Errorf("log does not record the internal_error code:\n%s", logs.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressiveResultsVisibleMidRun(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := newRunner(t, migratedStore(t), 1)
|
||||||
|
release := make(chan struct{})
|
||||||
|
k := register(t, r, "draft", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||||
|
for i := 1; i < p.Steps; i++ {
|
||||||
|
if err := publish(countResult{Done: i}); err != nil {
|
||||||
|
return countResult{}, err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-release:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return countResult{}, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return countResult{Done: p.Steps}, nil
|
||||||
|
})
|
||||||
|
start(t, r)
|
||||||
|
|
||||||
|
id := enqueue(t, k, countPayload{Steps: 3})
|
||||||
|
for step := 1; step < 3; step++ {
|
||||||
|
want := fmt.Sprintf(`{"done":%d}`, step)
|
||||||
|
job := waitFor(t, r, id, func(j Job) bool { return string(j.Result) == want })
|
||||||
|
if job.Status != StatusRunning || job.Error != nil {
|
||||||
|
t.Fatalf("mid-run job = %+v, want running with no error", job)
|
||||||
|
}
|
||||||
|
release <- struct{}{}
|
||||||
|
}
|
||||||
|
job := waitFor(t, r, id, finished)
|
||||||
|
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||||
|
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartFailsInterruptedJobsAndRunsQueuedOnes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t)
|
||||||
|
|
||||||
|
first := newRunner(t, st, 1)
|
||||||
|
started := make(chan struct{})
|
||||||
|
k := register(t, first, "slow", func(ctx context.Context, _ countPayload, publish func(countResult) error) (countResult, error) {
|
||||||
|
if err := publish(countResult{Done: 1}); err != nil {
|
||||||
|
return countResult{}, err
|
||||||
|
}
|
||||||
|
close(started)
|
||||||
|
<-ctx.Done()
|
||||||
|
return countResult{}, ctx.Err()
|
||||||
|
})
|
||||||
|
stop := start(t, first)
|
||||||
|
interrupted := enqueue(t, k, countPayload{})
|
||||||
|
<-started
|
||||||
|
waiting := enqueue(t, k, countPayload{}) // the only worker is busy
|
||||||
|
stop()
|
||||||
|
|
||||||
|
if job := waitFor(t, first, interrupted, func(Job) bool { return true }); job.Status != StatusRunning {
|
||||||
|
t.Fatalf("after shutdown job = %+v, want left running", job)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := newRunner(t, st, 1)
|
||||||
|
register(t, second, "slow", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{Done: 9}, nil
|
||||||
|
})
|
||||||
|
start(t, second)
|
||||||
|
|
||||||
|
job := waitFor(t, second, interrupted, finished)
|
||||||
|
if job.Status != StatusFailed || job.Error == nil || *job.Error != "interrupted by restart" {
|
||||||
|
t.Errorf("interrupted job = %+v, want failed with %q", job, "interrupted by restart")
|
||||||
|
}
|
||||||
|
if string(job.Result) != `{"done":1}` {
|
||||||
|
t.Errorf("interrupted job result = %s, want the last partial result kept", job.Result)
|
||||||
|
}
|
||||||
|
if job := waitFor(t, second, waiting, finished); job.Status != StatusSucceeded {
|
||||||
|
t.Errorf("queued job = %+v, want run by the new runner", job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerPoolIsBounded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := newRunner(t, migratedStore(t), 2)
|
||||||
|
var running, peak atomic.Int32
|
||||||
|
release := make(chan struct{})
|
||||||
|
k := register(t, r, "busy", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||||
|
n := running.Add(1)
|
||||||
|
for {
|
||||||
|
p := peak.Load()
|
||||||
|
if n <= p || peak.CompareAndSwap(p, n) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<-release
|
||||||
|
running.Add(-1)
|
||||||
|
return countResult{}, nil
|
||||||
|
})
|
||||||
|
start(t, r)
|
||||||
|
|
||||||
|
ids := make([]string, 5)
|
||||||
|
for i := range ids {
|
||||||
|
ids[i] = enqueue(t, k, countPayload{})
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for running.Load() < 2 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("two workers never ran at once")
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
for _, id := range ids {
|
||||||
|
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
|
||||||
|
t.Errorf("job = %+v, want succeeded", job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := peak.Load(); got != 2 {
|
||||||
|
t.Errorf("peak concurrent jobs = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobWithoutHandlerFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t)
|
||||||
|
old := newRunner(t, st, 1)
|
||||||
|
k := register(t, old, "retired", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{}, nil
|
||||||
|
})
|
||||||
|
id := enqueue(t, k, countPayload{}) // never started
|
||||||
|
|
||||||
|
r := newRunner(t, st, 1)
|
||||||
|
start(t, r)
|
||||||
|
job := waitFor(t, r, id, finished)
|
||||||
|
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeUnknownKind {
|
||||||
|
t.Errorf("job = %+v, want failed for a missing handler", job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUnknownID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := newRunner(t, migratedStore(t), 1)
|
||||||
|
if _, err := r.Get(context.Background(), store.NewID()); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("Get unknown id error = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRefusesBadRegistrations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := newRunner(t, migratedStore(t), 1)
|
||||||
|
h := func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{}, nil
|
||||||
|
}
|
||||||
|
register(t, r, "count", h)
|
||||||
|
|
||||||
|
if _, err := Register(r, "count", h); err == nil {
|
||||||
|
t.Error("duplicate kind registered")
|
||||||
|
}
|
||||||
|
if _, err := Register(r, "", h); err == nil {
|
||||||
|
t.Error("empty kind registered")
|
||||||
|
}
|
||||||
|
if _, err := Register[countPayload, countResult](r, "nil", nil); err == nil {
|
||||||
|
t.Error("nil handler registered")
|
||||||
|
}
|
||||||
|
for _, code := range []string{"", "Bad Code", "sk-sentinel", CodeInternal, CodeInterrupted, CodeUnknownKind} {
|
||||||
|
if _, err := Register(r, "code", h, code); err == nil {
|
||||||
|
t.Errorf("failure code %q accepted", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start(t, r)
|
||||||
|
if _, err := Register(r, "late", h); err == nil {
|
||||||
|
t.Error("kind registered after Start")
|
||||||
|
}
|
||||||
|
if err := r.Start(context.Background()); err == nil {
|
||||||
|
t.Error("second Start succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRunnerRefusesBadConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t)
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if _, err := NewRunner(st, logger, 0); err == nil {
|
||||||
|
t.Error("zero workers accepted")
|
||||||
|
}
|
||||||
|
if _, err := NewRunner(nil, logger, 1); err == nil {
|
||||||
|
t.Error("nil store accepted")
|
||||||
|
}
|
||||||
|
if _, err := NewRunner(st, nil, 1); err == nil {
|
||||||
|
t.Error("nil logger accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoopSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := newRunner(t, migratedStore(t), 1)
|
||||||
|
builtins, err := RegisterBuiltins(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
start(t, r)
|
||||||
|
|
||||||
|
job := waitFor(t, r, enqueue(t, builtins.Noop, NoopPayload{}), finished)
|
||||||
|
if job.Kind != KindNoop || job.Status != StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
|
||||||
|
t.Errorf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimErrorIsRetried(t *testing.T) {
|
||||||
|
st := migratedStore(t)
|
||||||
|
logs := &logBuffer{}
|
||||||
|
r := newRunnerLogging(t, st, 1, logs)
|
||||||
|
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{Done: p.Steps}, nil
|
||||||
|
})
|
||||||
|
id := enqueue(t, k, countPayload{Steps: 3})
|
||||||
|
// Drop the wake token, as for a job queued before a restart, so only
|
||||||
|
// the retry can find the job.
|
||||||
|
<-r.wake
|
||||||
|
|
||||||
|
var claims atomic.Int32
|
||||||
|
claim := r.claim
|
||||||
|
r.claim = func(ctx context.Context, now sql.NullString) (db.Job, error) {
|
||||||
|
if claims.Add(1) == 1 {
|
||||||
|
return db.Job{}, errors.New("database is locked " + sentinelSecret)
|
||||||
|
}
|
||||||
|
return claim(ctx, now)
|
||||||
|
}
|
||||||
|
start(t, r)
|
||||||
|
|
||||||
|
job := waitFor(t, r, id, finished)
|
||||||
|
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||||
|
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||||
|
}
|
||||||
|
if n := claims.Load(); n < 2 {
|
||||||
|
t.Fatalf("claims = %d, want a retry after the error", n)
|
||||||
|
}
|
||||||
|
out := logs.String()
|
||||||
|
if !strings.Contains(out, "claim job failed; retrying") {
|
||||||
|
t.Fatalf("claim error not logged: %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, sentinelSecret) {
|
||||||
|
t.Fatalf("claim error text logged: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerEnqueueByKindName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t)
|
||||||
|
r := newRunner(t, st, 1)
|
||||||
|
register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{Done: p.Steps}, nil
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := r.Enqueue(ctx, "no_such_kind", json.RawMessage(`{}`)); !errors.Is(err, ErrUnknownKind) {
|
||||||
|
t.Errorf("unknown kind error = %v, want ErrUnknownKind", err)
|
||||||
|
}
|
||||||
|
for _, payload := range []string{`{"steps":"three"}`, `{"other":1}`, `[]`, ``} {
|
||||||
|
if _, err := r.Enqueue(ctx, "count", json.RawMessage(payload)); !errors.Is(err, ErrInvalidPayload) {
|
||||||
|
t.Errorf("payload %q error = %v, want ErrInvalidPayload", payload, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("refused enqueues stored %d jobs, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
start(t, r)
|
||||||
|
job := waitFor(t, r, id, finished)
|
||||||
|
if job.Kind != "count" || job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||||
|
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Package secretbox encrypts secrets at rest with AES-256-GCM. It is the only
|
||||||
|
// owner of secret encryption: every _ct column holds a token produced by
|
||||||
|
// Box.Encrypt, laid out as nonce ‖ ciphertext.
|
||||||
|
//
|
||||||
|
// Errors from this package never contain plaintext, ciphertext or key
|
||||||
|
// material.
|
||||||
|
package secretbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeySize is the decoded key length in bytes (AES-256).
|
||||||
|
const KeySize = 32
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrKeyEncoding means the key is not valid standard base64.
|
||||||
|
ErrKeyEncoding = errors.New("secretbox: key is not valid standard base64")
|
||||||
|
// ErrKeyLength means the decoded key is not exactly KeySize bytes.
|
||||||
|
ErrKeyLength = errors.New("secretbox: key must decode to exactly 32 bytes")
|
||||||
|
// ErrMalformed means a token is too short to hold a nonce and tag.
|
||||||
|
ErrMalformed = errors.New("secretbox: token is too short")
|
||||||
|
// ErrDecrypt means a token failed authentication: it was corrupted or
|
||||||
|
// sealed under a different key.
|
||||||
|
ErrDecrypt = errors.New("secretbox: token failed authentication")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Box seals and opens secrets under one key. It is safe for concurrent use.
|
||||||
|
type Box struct {
|
||||||
|
aead cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Box for encodedKey, the standard-base64 encoding of a
|
||||||
|
// KeySize-byte key.
|
||||||
|
func New(encodedKey string) (*Box, error) {
|
||||||
|
key, err := base64.StdEncoding.Strict().DecodeString(encodedKey)
|
||||||
|
if err != nil {
|
||||||
|
// The decoder's error is not wrapped: it describes the key's content.
|
||||||
|
return nil, ErrKeyEncoding
|
||||||
|
}
|
||||||
|
if len(key) != KeySize {
|
||||||
|
return nil, fmt.Errorf("%w, got %d", ErrKeyLength, len(key))
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("secretbox: %w", err)
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("secretbox: %w", err)
|
||||||
|
}
|
||||||
|
return &Box{aead: aead}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt seals plaintext under a fresh random nonce and returns
|
||||||
|
// nonce ‖ ciphertext.
|
||||||
|
func (b *Box) Encrypt(plaintext []byte) ([]byte, error) {
|
||||||
|
nonce := make([]byte, b.aead.NonceSize(), b.aead.NonceSize()+len(plaintext)+b.aead.Overhead())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("secretbox: generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
return b.aead.Seal(nonce, nonce, plaintext, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt authenticates and opens a token produced by Encrypt.
|
||||||
|
func (b *Box) Decrypt(token []byte) ([]byte, error) {
|
||||||
|
n := b.aead.NonceSize()
|
||||||
|
if len(token) < n+b.aead.Overhead() {
|
||||||
|
return nil, ErrMalformed
|
||||||
|
}
|
||||||
|
plaintext, err := b.aead.Open(nil, token[:n], token[n:], nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrDecrypt
|
||||||
|
}
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package secretbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newKey returns a fresh random key and its standard-base64 encoding.
|
||||||
|
func newKey(t *testing.T) ([]byte, string) {
|
||||||
|
t.Helper()
|
||||||
|
key := make([]byte, KeySize)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return key, base64.StdEncoding.EncodeToString(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBox(t *testing.T) (*Box, []byte, string) {
|
||||||
|
t.Helper()
|
||||||
|
key, encoded := newKey(t)
|
||||||
|
box, err := New(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return box, key, encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoLeak fails if err's message contains any of the secrets, either
|
||||||
|
// raw or base64-encoded.
|
||||||
|
func assertNoLeak(t *testing.T, err error, secrets ...[]byte) {
|
||||||
|
t.Helper()
|
||||||
|
msg := err.Error()
|
||||||
|
for _, s := range secrets {
|
||||||
|
if len(s) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, form := range []string{string(s), base64.StdEncoding.EncodeToString(s), base64.RawStdEncoding.EncodeToString(s)} {
|
||||||
|
if strings.Contains(msg, form) {
|
||||||
|
t.Errorf("error %q leaks secret material %q", msg, form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
box, _, _ := newBox(t)
|
||||||
|
for _, plaintext := range [][]byte{
|
||||||
|
[]byte("ya29.oauth-refresh-token"),
|
||||||
|
{},
|
||||||
|
bytes.Repeat([]byte{0x00, 0xff}, 4096),
|
||||||
|
} {
|
||||||
|
token, err := box.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(plaintext) > 0 && bytes.Contains(token, plaintext) {
|
||||||
|
t.Errorf("token contains the plaintext")
|
||||||
|
}
|
||||||
|
got, err := box.Decrypt(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, plaintext) {
|
||||||
|
t.Errorf("Decrypt = %q, want %q", got, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenLayout(t *testing.T) {
|
||||||
|
box, _, _ := newBox(t)
|
||||||
|
plaintext := []byte("secret")
|
||||||
|
token, err := box.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// nonce (12) ‖ ciphertext (len(plaintext)) ‖ GCM tag (16).
|
||||||
|
if want := 12 + len(plaintext) + 16; len(token) != want {
|
||||||
|
t.Errorf("token length = %d, want %d", len(token), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptUsesFreshNonce(t *testing.T) {
|
||||||
|
box, _, _ := newBox(t)
|
||||||
|
plaintext := []byte("same plaintext")
|
||||||
|
a, err := box.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := box.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(a, b) {
|
||||||
|
t.Fatal("two encryptions of the same plaintext are identical")
|
||||||
|
}
|
||||||
|
if bytes.Equal(a[:12], b[:12]) {
|
||||||
|
t.Error("two encryptions reused the same nonce")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptRejectsFlippedByte(t *testing.T) {
|
||||||
|
box, key, encoded := newBox(t)
|
||||||
|
plaintext := []byte("do-not-leak-me")
|
||||||
|
token, err := box.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Flip one bit in every position: nonce, ciphertext and tag.
|
||||||
|
for i := range token {
|
||||||
|
tampered := bytes.Clone(token)
|
||||||
|
tampered[i] ^= 0x01
|
||||||
|
got, err := box.Decrypt(tampered)
|
||||||
|
if !errors.Is(err, ErrDecrypt) {
|
||||||
|
t.Fatalf("byte %d flipped: Decrypt = %q, %v; want ErrDecrypt", i, got, err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("byte %d flipped: Decrypt returned plaintext alongside the error", i)
|
||||||
|
}
|
||||||
|
assertNoLeak(t, err, plaintext, key, []byte(encoded), tampered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptRejectsWrongKey(t *testing.T) {
|
||||||
|
box, key, encoded := newBox(t)
|
||||||
|
other, otherKey, otherEncoded := newBox(t)
|
||||||
|
plaintext := []byte("do-not-leak-me")
|
||||||
|
token, err := box.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := other.Decrypt(token)
|
||||||
|
if !errors.Is(err, ErrDecrypt) {
|
||||||
|
t.Fatalf("Decrypt with wrong key = %q, %v; want ErrDecrypt", got, err)
|
||||||
|
}
|
||||||
|
assertNoLeak(t, err, plaintext, key, []byte(encoded), otherKey, []byte(otherEncoded), token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptRejectsShortToken(t *testing.T) {
|
||||||
|
box, _, _ := newBox(t)
|
||||||
|
for _, n := range []int{0, 1, 12, 12 + 15} {
|
||||||
|
token := bytes.Repeat([]byte{0xab}, n)
|
||||||
|
if _, err := box.Decrypt(token); !errors.Is(err, ErrMalformed) {
|
||||||
|
t.Errorf("Decrypt(%d bytes) error = %v, want ErrMalformed", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsBadKey(t *testing.T) {
|
||||||
|
key, _ := newKey(t)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
encoded string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"empty", "", ErrKeyLength},
|
||||||
|
{"not base64", "not*base64!" + strings.Repeat("A", 33), ErrKeyEncoding},
|
||||||
|
{"url-safe alphabet", strings.Repeat("_-", 22), ErrKeyEncoding},
|
||||||
|
{"unpadded", base64.RawStdEncoding.EncodeToString(key), ErrKeyEncoding},
|
||||||
|
{"raw key bytes", string(key), ErrKeyEncoding},
|
||||||
|
{"too short", base64.StdEncoding.EncodeToString(key[:31]), ErrKeyLength},
|
||||||
|
{"too long", base64.StdEncoding.EncodeToString(append(bytes.Clone(key), 0x00)), ErrKeyLength},
|
||||||
|
{"AES-128 length", base64.StdEncoding.EncodeToString(key[:16]), ErrKeyLength},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
box, err := New(tt.encoded)
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("New error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
if box != nil {
|
||||||
|
t.Error("New returned a Box alongside the error")
|
||||||
|
}
|
||||||
|
assertNoLeak(t, err, key, []byte(tt.encoded))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAcceptsBase64Key(t *testing.T) {
|
||||||
|
key, encoded := newKey(t)
|
||||||
|
box, err := New(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A Box built from the same key opens tokens sealed by the first.
|
||||||
|
token, err := box.Encrypt([]byte("x"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := New(base64.StdEncoding.EncodeToString(key))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := again.Decrypt(token); err != nil {
|
||||||
|
t.Errorf("same key failed to decrypt: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user