Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2fe767471 | ||
|
|
52664ad64b | ||
|
|
60c3538967 | ||
|
|
1dfba7b9db | ||
|
|
99c8f1625f | ||
|
|
19ea8d52d8 | ||
|
|
c1a868c48b | ||
|
|
17a69f4de7 |
@@ -5,7 +5,9 @@
|
||||
# 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 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]
|
||||
@@ -28,5 +30,10 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
# make test gofmt check, go vet, go test -race, npm typecheck and test.
|
||||
# make build Build the PWA, embed it, build the static binary bin/mealquest.
|
||||
# make docker Build the container image from ./Dockerfile.
|
||||
# make sqlc Regenerate internal/db from sqlc.yaml with the pinned sqlc.
|
||||
# make sqlc-check Regenerate; fail if internal/db differs from the commit.
|
||||
# make sqlc-install Install the pinned sqlc into the Go bin directory.
|
||||
|
||||
FRONTEND_DIST := internal/frontend/dist
|
||||
# Dirty means a tracked change outside the generated embed output, which
|
||||
@@ -14,7 +17,13 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)$(if $(G
|
||||
IMAGE ?= mealquest:$(VERSION)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
|
||||
.PHONY: dev test go-test web-test build web embed check-embed-clean docker
|
||||
# sqlc is a dev tool only, never a go.mod dependency. Generated code records
|
||||
# the version that wrote it, so every run must use exactly this one.
|
||||
SQLC_VERSION := v1.31.1
|
||||
SQLC ?= sqlc
|
||||
SQLC_OUT := internal/db
|
||||
|
||||
.PHONY: dev test go-test web-test build web embed check-embed-clean docker sqlc sqlc-check sqlc-install
|
||||
|
||||
# Run both servers; Ctrl-C (or either exiting) stops both. The binary is built
|
||||
# first and run directly because `go run` would orphan its child on kill.
|
||||
@@ -58,3 +67,22 @@ check-embed-clean:
|
||||
|
||||
docker:
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) .
|
||||
|
||||
# sqlc $(SQLC_VERSION) needs Go 1.26 to build; GOTOOLCHAIN=auto lets go fetch
|
||||
# it for this install only, even where the environment pins the local Go.
|
||||
sqlc-install:
|
||||
GOTOOLCHAIN=auto go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION)
|
||||
|
||||
# Previously generated files are removed first so a dropped query file does
|
||||
# not leave its stale output behind; hand-written files are kept.
|
||||
sqlc:
|
||||
@v="$$($(SQLC) version)"; \
|
||||
if [ "$$v" != "$(SQLC_VERSION)" ]; then echo "sqlc $(SQLC_VERSION) required, $(SQLC) is $$v"; exit 1; fi
|
||||
find $(SQLC_OUT) -maxdepth 1 -name '*.go' -exec grep -l -x '// Code generated by sqlc. DO NOT EDIT.' {} + | xargs -r rm --
|
||||
$(SQLC) generate
|
||||
|
||||
# Fails on any difference from the committed generated code, including new
|
||||
# or deleted files.
|
||||
sqlc-check: sqlc
|
||||
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \
|
||||
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
|
||||
|
||||
@@ -30,14 +30,27 @@ 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
|
||||
make dev
|
||||
```
|
||||
|
||||
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
|
||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required, and secrets are read only from files named
|
||||
by `*_FILE` variables.
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE), and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
|
||||
## Common commands
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// useBootMigrations swaps bootMigrations for the test and restores it after.
|
||||
// Tests that call it must not run in parallel.
|
||||
func useBootMigrations(t *testing.T, m fstest.MapFS) {
|
||||
t.Helper()
|
||||
saved := bootMigrations
|
||||
bootMigrations = m
|
||||
t.Cleanup(func() { bootMigrations = saved })
|
||||
}
|
||||
|
||||
var brokenMigrations = fstest.MapFS{
|
||||
"0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")},
|
||||
"0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")},
|
||||
}
|
||||
|
||||
func TestOpenStoreMigrationFailureStopsBoot(t *testing.T) {
|
||||
useBootMigrations(t, brokenMigrations)
|
||||
|
||||
db, err := openStore(context.Background(), t.TempDir())
|
||||
if err == nil {
|
||||
db.Close()
|
||||
t.Fatal("openStore succeeded with a broken migration")
|
||||
}
|
||||
if db != nil {
|
||||
t.Error("openStore returned a store alongside the error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "0001_broken.sql") {
|
||||
t.Errorf("error does not name the broken migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenStoreAppliesEmbeddedMigrations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := openStore(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
var n int
|
||||
if err := db.Reader.QueryRowContext(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := fs.Glob(migrations.FS, "*.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != len(files) {
|
||||
t.Errorf("schema_migrations has %d rows, want %d", n, len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
||||
useBootMigrations(t, brokenMigrations)
|
||||
|
||||
var logs bytes.Buffer
|
||||
cfg := config.Config{
|
||||
HTTPAddr: "127.0.0.1:0",
|
||||
PublicURL: &url.URL{Scheme: "http", Host: "localhost"},
|
||||
DataDir: t.TempDir(),
|
||||
ShutdownTimeout: time.Second,
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
if err == nil || !strings.Contains(err.Error(), "0001_broken.sql") {
|
||||
t.Fatalf("run error = %v, want the broken migration's error", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("run kept going after a migration failure")
|
||||
}
|
||||
if strings.Contains(logs.String(), `"listening"`) {
|
||||
t.Errorf("run started listening despite the migration failure: %s", logs.String())
|
||||
}
|
||||
}
|
||||
+34
-3
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
@@ -40,7 +42,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := store.Open(ctx, cfg.DataDir)
|
||||
db, err := openStore(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -49,11 +51,23 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
logger.Error("close database", "error", err)
|
||||
}
|
||||
}()
|
||||
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
|
||||
|
||||
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()),
|
||||
}, sessions, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, authFlow, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,6 +102,23 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// bootMigrations is the migration set applied at boot. Tests replace it to
|
||||
// prove a failing migration stops the boot.
|
||||
var bootMigrations fs.FS = migrations.FS
|
||||
|
||||
// openStore opens the database in dataDir and applies bootMigrations. On a
|
||||
// migration failure it closes the database and returns the error.
|
||||
func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
||||
db, err := store.Open(ctx, dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.Migrate(ctx, db.Writer, bootMigrations); err != nil {
|
||||
return nil, errors.Join(err, db.Close())
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
@@ -3,13 +3,16 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
|
||||
go 1.25.0
|
||||
|
||||
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
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -19,6 +23,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// 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"
|
||||
"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
|
||||
}
|
||||
|
||||
// Auth serves the login and callback routes.
|
||||
type Auth struct {
|
||||
oauth oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
client *http.Client
|
||||
cookie loginCookie
|
||||
sessions Sessions
|
||||
// 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")
|
||||
}
|
||||
|
||||
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
|
||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
}
|
||||
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},
|
||||
} {
|
||||
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},
|
||||
}),
|
||||
client: client,
|
||||
cookie: newLoginCookie(cfg.SecretKey),
|
||||
sessions: sessions,
|
||||
endSessionURL: discovered.EndSessionURI,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 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.
|
||||
func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) {
|
||||
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
|
||||
oauth2.VerifierOption(state.Verifier))
|
||||
if err != nil {
|
||||
return Claims{}, exchangeError(err)
|
||||
}
|
||||
raw, ok := token.Extra("id_token").(string)
|
||||
if !ok || raw == "" {
|
||||
return Claims{}, errors.New("token response has no id_token")
|
||||
}
|
||||
idToken, err := a.verifier.Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("verify ID token: %w", err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
|
||||
return Claims{}, errors.New("ID token nonce mismatch")
|
||||
}
|
||||
var claims Claims
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return Claims{}, fmt.Errorf("decode ID token claims: %w", err)
|
||||
}
|
||||
if claims.Subject == "" {
|
||||
return Claims{}, errors.New("ID token has no subject")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
testClientID = "mealquest"
|
||||
testClientSecret = "client-s3cret"
|
||||
testKID = "test-key"
|
||||
)
|
||||
|
||||
var testPublicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
||||
|
||||
// fakeProvider is an OIDC provider serving discovery, JWKS and a token
|
||||
// endpoint. Each issued code maps to the ID token the token endpoint returns.
|
||||
type fakeProvider struct {
|
||||
t *testing.T
|
||||
srv *httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
|
||||
mu sync.Mutex
|
||||
codes map[string]issuedCode
|
||||
// discoveryOverrides replace fields of the discovery document.
|
||||
discoveryOverrides map[string]any
|
||||
// tokenReply, when set, is returned by the token endpoint instead of a
|
||||
// token.
|
||||
tokenReply *tokenReply
|
||||
}
|
||||
|
||||
type tokenReply struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
type issuedCode struct {
|
||||
challenge string
|
||||
idToken string
|
||||
}
|
||||
|
||||
func newFakeProvider(t *testing.T) *fakeProvider {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &fakeProvider{t: t, key: key, codes: map[string]issuedCode{}}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /.well-known/openid-configuration", p.discovery)
|
||||
mux.HandleFunc("GET /jwks", p.jwks)
|
||||
mux.HandleFunc("POST /token", p.token)
|
||||
p.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(p.srv.Close)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *fakeProvider) issuer() string { return p.srv.URL }
|
||||
|
||||
func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
||||
doc := map[string]any{
|
||||
"issuer": p.issuer(),
|
||||
"authorization_endpoint": p.issuer() + "/authorize",
|
||||
"token_endpoint": p.issuer() + "/token",
|
||||
"jwks_uri": p.issuer() + "/jwks",
|
||||
"end_session_endpoint": p.issuer() + "/end-session/",
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "HS256"},
|
||||
}
|
||||
p.mu.Lock()
|
||||
maps.Copy(doc, p.discoveryOverrides)
|
||||
p.mu.Unlock()
|
||||
writeJSON(w, doc)
|
||||
}
|
||||
|
||||
func (p *fakeProvider) jwks(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]any{"keys": []map[string]string{{
|
||||
"kty": "RSA",
|
||||
"kid": testKID,
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"n": b64(p.key.N.Bytes()),
|
||||
"e": b64(big.NewInt(int64(p.key.E)).Bytes()),
|
||||
}}})
|
||||
}
|
||||
|
||||
// token checks client authentication, the code and the PKCE verifier before
|
||||
// returning the code's ID token.
|
||||
func (p *fakeProvider) token(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.Lock()
|
||||
reply := p.tokenReply
|
||||
p.mu.Unlock()
|
||||
if reply != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(reply.status)
|
||||
io.WriteString(w, reply.body)
|
||||
return
|
||||
}
|
||||
id, secret, ok := r.BasicAuth()
|
||||
if !ok || id != testClientID || secret != testClientSecret {
|
||||
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
issued, ok := p.codes[r.PostForm.Get("code")]
|
||||
delete(p.codes, r.PostForm.Get("code"))
|
||||
p.mu.Unlock()
|
||||
if r.PostForm.Get("grant_type") != "authorization_code" || !ok ||
|
||||
challengeS256(r.PostForm.Get("code_verifier")) != issued.challenge {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
io.WriteString(w, `{"error":"invalid_grant"}`)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"access_token": "access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"id_token": issued.idToken,
|
||||
})
|
||||
}
|
||||
|
||||
// authorize stands in for the user signing in at the provider: it records a
|
||||
// code bound to the PKCE challenge and returns it.
|
||||
func (p *fakeProvider) authorize(challenge, idToken string) string {
|
||||
code := rand.Text()
|
||||
p.mu.Lock()
|
||||
p.codes[code] = issuedCode{challenge: challenge, idToken: idToken}
|
||||
p.mu.Unlock()
|
||||
return code
|
||||
}
|
||||
|
||||
// claims returns valid ID token claims for nonce.
|
||||
func (p *fakeProvider) claims(nonce string) map[string]any {
|
||||
now := time.Now()
|
||||
return map[string]any{
|
||||
"iss": p.issuer(),
|
||||
"sub": "user-123",
|
||||
"aud": testClientID,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(5 * time.Minute).Unix(),
|
||||
"nonce": nonce,
|
||||
"email": "cook@example.com",
|
||||
"email_verified": true,
|
||||
"name": "Home Cook",
|
||||
"preferred_username": "cook",
|
||||
"groups": []string{"mealquest-admin"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fakeProvider) signRS256(claims map[string]any) string {
|
||||
return signRS256(p.t, p.key, claims)
|
||||
}
|
||||
|
||||
func signRS256(t *testing.T, key *rsa.PrivateKey, claims map[string]any) string {
|
||||
t.Helper()
|
||||
signing := jwtSigningInput(t, "RS256", claims)
|
||||
digest := sha256.Sum256([]byte(signing))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signing + "." + b64(sig)
|
||||
}
|
||||
|
||||
// signHS256 signs with the client secret, as in an algorithm-confusion attack.
|
||||
func (p *fakeProvider) signHS256(claims map[string]any) string {
|
||||
signing := jwtSigningInput(p.t, "HS256", claims)
|
||||
mac := hmac.New(sha256.New, []byte(testClientSecret))
|
||||
mac.Write([]byte(signing))
|
||||
return signing + "." + b64(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func jwtSigningInput(t *testing.T, alg string, claims map[string]any) string {
|
||||
t.Helper()
|
||||
header, err := json.Marshal(map[string]string{"alg": alg, "typ": "JWT", "kid": testKID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b64(header) + "." + b64(body)
|
||||
}
|
||||
|
||||
func challengeS256(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return b64(sum[:])
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// memorySessions is an in-memory Sessions that records the claims it was
|
||||
// handed and how many sessions were ended.
|
||||
type memorySessions struct {
|
||||
mu sync.Mutex
|
||||
claims []Claims
|
||||
ended int
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, claims Claims) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.claims = append(m.claims, claims)
|
||||
http.SetCookie(w, &http.Cookie{Name: "test_session", Value: claims.Subject})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memorySessions) EndSession(http.ResponseWriter, *http.Request) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.ended++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memorySessions) started() []Claims {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return slices.Clone(m.claims)
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
provider *fakeProvider
|
||||
sessions *memorySessions
|
||||
router http.Handler
|
||||
logs *syncBuffer
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer safe for concurrent log writes.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
p := newFakeProvider(t)
|
||||
sessions := &memorySessions{}
|
||||
logs := &syncBuffer{}
|
||||
a, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
a.Mount(r)
|
||||
return &harness{provider: p, sessions: sessions, router: r, logs: logs}
|
||||
}
|
||||
|
||||
func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.router.ServeHTTP(rec, req)
|
||||
return rec.Result()
|
||||
}
|
||||
|
||||
// login is a started login: the authorize URL's query and the login cookie.
|
||||
type login struct {
|
||||
query url.Values
|
||||
cookie *http.Cookie
|
||||
}
|
||||
|
||||
func (h *harness) login(t *testing.T, target string) login {
|
||||
t.Helper()
|
||||
resp := h.get(target)
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("GET %s status = %d, want 302", target, resp.StatusCode)
|
||||
}
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := loc.Scheme+"://"+loc.Host+loc.Path, h.provider.issuer()+"/authorize"; got != want {
|
||||
t.Fatalf("redirect to %s, want %s", got, want)
|
||||
}
|
||||
var cookie *http.Cookie
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == loginCookieName {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
if cookie == nil {
|
||||
t.Fatalf("login set no %s cookie", loginCookieName)
|
||||
}
|
||||
return login{query: loc.Query(), cookie: cookie}
|
||||
}
|
||||
|
||||
// callback completes l at the provider with idToken and calls the callback.
|
||||
func (h *harness) callback(l login, state, idToken string) *http.Response {
|
||||
code := h.provider.authorize(l.query.Get("code_challenge"), idToken)
|
||||
q := url.Values{"code": {code}, "state": {state}}
|
||||
return h.get(CallbackPath+"?"+q.Encode(), l.cookie)
|
||||
}
|
||||
|
||||
func TestLoginRedirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath+"?return=/plan")
|
||||
|
||||
want := map[string]string{
|
||||
"client_id": testClientID,
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email groups offline_access",
|
||||
"redirect_uri": "https://meals.example.com/auth/callback",
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got := l.query.Get(k); got != v {
|
||||
t.Errorf("authorize %s = %q, want %q", k, got, v)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"state", "nonce", "code_challenge"} {
|
||||
if l.query.Get(k) == "" {
|
||||
t.Errorf("authorize URL has no %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
c := l.cookie
|
||||
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" {
|
||||
t.Errorf("login cookie HttpOnly=%v Secure=%v SameSite=%v Path=%q, want HttpOnly, Secure, Lax, /",
|
||||
c.HttpOnly, c.Secure, c.SameSite, c.Path)
|
||||
}
|
||||
if c.MaxAge <= 0 || c.MaxAge > 600 {
|
||||
t.Errorf("login cookie MaxAge = %d, want 1-600 seconds", c.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginStateIsFreshEachTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
a, b := h.login(t, LoginPath), h.login(t, LoginPath)
|
||||
for _, k := range []string{"state", "nonce", "code_challenge"} {
|
||||
if a.query.Get(k) == b.query.Get(k) {
|
||||
t.Errorf("%s repeated across logins", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath+"?return="+url.QueryEscape("/plan?week=2026-W40"))
|
||||
|
||||
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)
|
||||
}
|
||||
if got := resp.Header.Get("Location"); got != "/plan?week=2026-W40" {
|
||||
t.Errorf("callback redirect = %q, want the return path", got)
|
||||
}
|
||||
started := h.sessions.started()
|
||||
want := Claims{
|
||||
Subject: "user-123",
|
||||
Email: "cook@example.com",
|
||||
EmailVerified: true,
|
||||
Name: "Home Cook",
|
||||
PreferredUsername: "cook",
|
||||
Groups: []string{"mealquest-admin"},
|
||||
}
|
||||
if len(started) != 1 || started[0].Subject != want.Subject || started[0].Email != want.Email ||
|
||||
started[0].Name != want.Name || started[0].PreferredUsername != want.PreferredUsername ||
|
||||
!started[0].EmailVerified || !slices.Equal(started[0].Groups, want.Groups) {
|
||||
t.Errorf("session started with %+v, want [%+v]", started, want)
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
}
|
||||
|
||||
func TestCallbackDefaultReturnPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
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 || resp.Header.Get("Location") != "/" {
|
||||
t.Errorf("callback = %d to %q, want 303 to /", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// state returns the state sent to the callback.
|
||||
state func(l login) string
|
||||
// token returns the ID token the provider issues.
|
||||
token func(p *fakeProvider, l login) string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "mismatched state",
|
||||
state: func(login) string { return "attacker-state" },
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "wrong nonce",
|
||||
token: func(p *fakeProvider, _ login) string {
|
||||
return p.signRS256(p.claims("other-nonce"))
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "missing nonce",
|
||||
token: func(p *fakeProvider, _ login) string {
|
||||
c := p.claims("")
|
||||
delete(c, "nonce")
|
||||
return p.signRS256(c)
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "HS256-signed token",
|
||||
token: func(p *fakeProvider, l login) string {
|
||||
return p.signHS256(p.claims(l.query.Get("nonce")))
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "expired token",
|
||||
token: func(p *fakeProvider, l login) string {
|
||||
c := p.claims(l.query.Get("nonce"))
|
||||
c["iat"] = time.Now().Add(-2 * time.Hour).Unix()
|
||||
c["exp"] = time.Now().Add(-time.Hour).Unix()
|
||||
return p.signRS256(c)
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "wrong issuer",
|
||||
token: func(p *fakeProvider, l login) string {
|
||||
c := p.claims(l.query.Get("nonce"))
|
||||
c["iss"] = "https://evil.example.com"
|
||||
return p.signRS256(c)
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "wrong audience",
|
||||
token: func(p *fakeProvider, l login) string {
|
||||
c := p.claims(l.query.Get("nonce"))
|
||||
c["aud"] = "another-client"
|
||||
return p.signRS256(c)
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "signed by an unknown key",
|
||||
token: func(p *fakeProvider, l login) string {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
p.t.Fatal(err)
|
||||
}
|
||||
return signRS256(p.t, key, p.claims(l.query.Get("nonce")))
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
state := l.query.Get("state")
|
||||
if tc.state != nil {
|
||||
state = tc.state(l)
|
||||
}
|
||||
token := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
|
||||
if tc.token != nil {
|
||||
token = tc.token(h.provider, l)
|
||||
}
|
||||
|
||||
resp := h.callback(l, state, token)
|
||||
|
||||
if resp.StatusCode != tc.wantStatus {
|
||||
t.Errorf("callback status = %d, want %d", resp.StatusCode, tc.wantStatus)
|
||||
}
|
||||
if n := len(h.sessions.started()); n != 0 {
|
||||
t.Errorf("%d sessions started, want none", n)
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackWithoutLoginCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
l.cookie = &http.Cookie{Name: "unrelated", Value: "x"}
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.sessions.started()); n != 0 {
|
||||
t.Errorf("%d sessions started, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackTamperedLoginCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
payload, sig, _ := strings.Cut(l.cookie.Value, ".")
|
||||
data, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var s loginState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Return = "/somewhere-else"
|
||||
data, err = json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.cookie = &http.Cookie{Name: loginCookieName, Value: b64(data) + "." + sig}
|
||||
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The provider's error and error_description are untrusted text and never
|
||||
// reach the logs; only the fixed category does.
|
||||
func TestCallbackProviderError(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
errorMarker = "provider-error-marker"
|
||||
descriptionMarker = "description-marker-" + testClientSecret
|
||||
)
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
q := url.Values{
|
||||
"error": {errorMarker},
|
||||
"error_description": {descriptionMarker},
|
||||
"state": {l.query.Get("state")},
|
||||
}
|
||||
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
if n := len(h.sessions.started()); n != 0 {
|
||||
t.Errorf("started %d sessions, want 0", n)
|
||||
}
|
||||
logs := h.logs.String()
|
||||
for _, marker := range []string{errorMarker, descriptionMarker} {
|
||||
if strings.Contains(logs, marker) {
|
||||
t.Errorf("logs contain provider text %q: %s", marker, logs)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs, "identity provider returned an authorization error") {
|
||||
t.Errorf("logs = %q, want the authorization error category", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackSessionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.sessions.err = errors.New("database down")
|
||||
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.StatusInternalServerError {
|
||||
t.Errorf("callback status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed code exchange is logged by category and status only: the token
|
||||
// endpoint's body and error_description may echo credentials.
|
||||
func TestCallbackExchangeFailureDoesNotLogProviderResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
const leaked = "leaked-" + testClientSecret
|
||||
tests := []struct {
|
||||
name string
|
||||
reply tokenReply
|
||||
wantLogs string
|
||||
}{
|
||||
{
|
||||
name: "error response",
|
||||
reply: tokenReply{http.StatusBadRequest, `{"error":"invalid_grant","error_description":"` + leaked + `"}`},
|
||||
wantLogs: "HTTP 400",
|
||||
},
|
||||
{
|
||||
name: "unparseable error response",
|
||||
reply: tokenReply{http.StatusInternalServerError, leaked},
|
||||
wantLogs: "HTTP 500",
|
||||
},
|
||||
{
|
||||
name: "success without access token",
|
||||
reply: tokenReply{http.StatusOK, `{"id_token":"` + leaked + `"}`},
|
||||
wantLogs: "unusable response",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.mu.Lock()
|
||||
h.provider.tokenReply = &tc.reply
|
||||
h.provider.mu.Unlock()
|
||||
l := h.login(t, LoginPath)
|
||||
|
||||
resp := h.callback(l, l.query.Get("state"), "unused")
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
logs := h.logs.String()
|
||||
if strings.Contains(logs, leaked) {
|
||||
t.Errorf("logs contain the provider response: %s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, tc.wantLogs) {
|
||||
t.Errorf("logs = %q, want %q", logs, tc.wantLogs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCookieExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newLoginCookie([]byte("server-secret-key"))
|
||||
now := time.Now()
|
||||
rec := httptest.NewRecorder()
|
||||
if err := c.set(rec, loginState{State: "s", Nonce: "n", Verifier: "v", Return: "/"}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, CallbackPath, nil)
|
||||
req.AddCookie(rec.Result().Cookies()[0])
|
||||
|
||||
if _, err := c.read(req, now.Add(loginCookieTTL-time.Second)); err != nil {
|
||||
t.Errorf("read before expiry: %v", err)
|
||||
}
|
||||
if _, err := c.read(req, now.Add(loginCookieTTL)); err == nil {
|
||||
t.Error("read at expiry succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsOffOriginReturn(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
for _, ret := range []string{
|
||||
"https://evil.example.com/",
|
||||
"http://meals.example.com/plan",
|
||||
"//evil.example.com/",
|
||||
"/\\evil.example.com",
|
||||
"\\\\evil.example.com",
|
||||
"javascript:alert(1)",
|
||||
"plan",
|
||||
"/plan\r\nSet-Cookie: x=y",
|
||||
} {
|
||||
resp := h.get(LoginPath + "?return=" + url.QueryEscape(ret))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("return=%q: status = %d, want 400", ret, resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
t.Errorf("return=%q: redirected to %q", ret, loc)
|
||||
}
|
||||
if len(resp.Cookies()) != 0 {
|
||||
t.Errorf("return=%q: set cookies %v", ret, resp.Cookies())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFailsWhenDiscoveryFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
t.Cleanup(srv.Close)
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: srv.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil {
|
||||
t.Fatal("New succeeded without a discovery document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFailsOnIssuerMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
// Discovery trims the trailing slash, so this fetches the provider's
|
||||
// document, which reports the issuer without it.
|
||||
_, 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 {
|
||||
t.Fatal("New accepted a provider reporting a different issuer")
|
||||
}
|
||||
}
|
||||
|
||||
func assertLoginCookieCleared(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == loginCookieName && c.MaxAge < 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("callback did not clear the %s cookie", loginCookieName)
|
||||
}
|
||||
|
||||
func TestNewRejectsRemoteHTTPIssuer(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, issuer := range []string{"http://auth.example.com/application/o/mealquest/", "http://192.0.2.10"} {
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: issuer,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "issuer") {
|
||||
t.Errorf("New(issuer %q) error = %v, want issuer rejected", issuer, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
field, name string
|
||||
}{
|
||||
{"authorization_endpoint", "authorization endpoint"},
|
||||
{"token_endpoint", "token endpoint"},
|
||||
{"jwks_uri", "JWKS endpoint"},
|
||||
{"end_session_endpoint", "end session endpoint"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.field, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
p.discoveryOverrides = map[string]any{tc.field: "http://auth.example.com/" + tc.field}
|
||||
_, 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(), tc.name) {
|
||||
t.Errorf("New error = %v, want %s rejected", err, tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A token endpoint on https:// that redirects the code exchange to plain
|
||||
// http:// must not receive a follow-up request: Go would resend the client
|
||||
// secret in the Authorization header. The plaintext target is loopback, so
|
||||
// only the downgrade rule stops it.
|
||||
func TestCallbackRefusesTokenRedirectToHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var plaintextHits atomic.Int32
|
||||
plaintext := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
plaintextHits.Add(1)
|
||||
http.Error(w, "unreachable", http.StatusTeapot)
|
||||
}))
|
||||
t.Cleanup(plaintext.Close)
|
||||
var tlsHits atomic.Int32
|
||||
tlsToken := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tlsHits.Add(1)
|
||||
http.Redirect(w, r, plaintext.URL+"/token", status)
|
||||
}))
|
||||
t.Cleanup(tlsToken.Close)
|
||||
|
||||
p := newFakeProvider(t)
|
||||
p.discoveryOverrides = map[string]any{"token_endpoint": tlsToken.URL + "/token"}
|
||||
sessions := &memorySessions{}
|
||||
logs := &syncBuffer{}
|
||||
a, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Trust the test server's certificate; the redirect policy stays.
|
||||
a.client.Transport = tlsToken.Client().Transport
|
||||
r := chi.NewRouter()
|
||||
a.Mount(r)
|
||||
h := &harness{provider: p, sessions: sessions, router: r, logs: logs}
|
||||
l := h.login(t, LoginPath)
|
||||
|
||||
resp := h.callback(l, l.query.Get("state"), "unused")
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
if n := tlsHits.Load(); n != 1 {
|
||||
t.Errorf("https token endpoint hit %d times, want 1", n)
|
||||
}
|
||||
if n := plaintextHits.Load(); n != 0 {
|
||||
t.Errorf("plaintext endpoint hit %d times, want 0", n)
|
||||
}
|
||||
if n := len(sessions.started()); n != 0 {
|
||||
t.Errorf("started %d sessions, want 0", n)
|
||||
}
|
||||
if got := logs.String(); !strings.Contains(got, "downgrade") {
|
||||
t.Errorf("logs = %q, want the downgrade refusal", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRedirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := func(raw string) *http.Request {
|
||||
req, err := http.NewRequest(http.MethodPost, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
tests := []struct {
|
||||
from, to string
|
||||
ok bool
|
||||
}{
|
||||
{"https://auth.example.com/token", "https://auth2.example.com/token", true},
|
||||
{"http://127.0.0.1:9000/token", "http://localhost:9001/token", true},
|
||||
{"http://127.0.0.1:9000/token", "https://auth.example.com/token", true},
|
||||
{"https://auth.example.com/token", "http://auth.example.com/token", false},
|
||||
{"https://127.0.0.1:9000/token", "http://127.0.0.1:9001/token", false},
|
||||
{"http://127.0.0.1:9000/token", "http://auth.example.com/token", false},
|
||||
{"https://auth.example.com/token", "ftp://auth.example.com/token", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
err := checkRedirect(request(tc.to), []*http.Request{request(tc.from)})
|
||||
if (err == nil) != tc.ok {
|
||||
t.Errorf("redirect %s -> %s: error = %v, want ok=%v", tc.from, tc.to, err, tc.ok)
|
||||
}
|
||||
}
|
||||
via := make([]*http.Request, maxRedirects)
|
||||
for i := range via {
|
||||
via[i] = request("https://auth.example.com/token")
|
||||
}
|
||||
if err := checkRedirect(request("https://auth.example.com/token"), via); err == nil {
|
||||
t.Errorf("redirect after %d hops: error = nil, want limit", maxRedirects)
|
||||
}
|
||||
}
|
||||
@@ -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,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,521 @@
|
||||
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"),
|
||||
}, 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"),
|
||||
}, &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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||
)
|
||||
|
||||
// Environment variable names.
|
||||
@@ -20,6 +22,21 @@ const (
|
||||
EnvDataDir = "MEALQUEST_DATA_DIR"
|
||||
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
|
||||
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
|
||||
|
||||
EnvOIDCIssuer = "MEALQUEST_OIDC_ISSUER"
|
||||
EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID"
|
||||
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
|
||||
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
|
||||
|
||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
// stays signed in.
|
||||
const (
|
||||
DefaultSessionIdle = 720 * time.Hour // 30 days
|
||||
DefaultSessionMax = 4320 * time.Hour // 180 days
|
||||
)
|
||||
|
||||
// Config is the validated service configuration.
|
||||
@@ -29,6 +46,18 @@ type Config struct {
|
||||
DataDir string
|
||||
ShutdownTimeout time.Duration
|
||||
SecretKey Secret
|
||||
|
||||
// OIDCIssuer is the Authentik issuer URL used for discovery.
|
||||
OIDCIssuer *url.URL
|
||||
OIDCClientID string
|
||||
OIDCClientSecret Secret
|
||||
// OIDCAllowedGroups lists the groups whose members may sign in.
|
||||
OIDCAllowedGroups []string
|
||||
|
||||
// SessionIdle ends a session left unused this long.
|
||||
SessionIdle time.Duration
|
||||
// SessionMax ends every session this long after sign-in, used or not.
|
||||
SessionMax time.Duration
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -47,10 +76,21 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
r := reader{lookup: lookup}
|
||||
cfg := Config{
|
||||
HTTPAddr: r.listenAddr(EnvHTTPAddr),
|
||||
PublicURL: r.httpURL(EnvPublicURL),
|
||||
PublicURL: r.secureURL(EnvPublicURL),
|
||||
DataDir: r.dir(EnvDataDir),
|
||||
ShutdownTimeout: r.duration(EnvShutdownTimeout),
|
||||
SecretKey: r.secretFile(EnvSecretKeyFile),
|
||||
|
||||
OIDCIssuer: r.secureURL(EnvOIDCIssuer),
|
||||
OIDCClientID: r.str(EnvOIDCClientID),
|
||||
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
|
||||
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
|
||||
|
||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
}
|
||||
if len(r.problems) > 0 {
|
||||
return Config{}, &Error{Problems: r.problems}
|
||||
@@ -114,6 +154,29 @@ func (r *reader) required(name string) (string, bool) {
|
||||
return v, true
|
||||
}
|
||||
|
||||
func (r *reader) str(name string) string {
|
||||
v, _ := r.required(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// list parses a comma-separated list. Every entry must be non-empty after
|
||||
// trimming spaces.
|
||||
func (r *reader) list(name string) []string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items := strings.Split(v, ",")
|
||||
for i, item := range items {
|
||||
items[i] = strings.TrimSpace(item)
|
||||
if items[i] == "" {
|
||||
r.fail(name, "empty entry in comma-separated list %q", v)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (r *reader) listenAddr(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
@@ -155,15 +218,21 @@ func validHostname(h string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *reader) httpURL(name string) *url.URL {
|
||||
// secureURL reads an absolute URL that must pass secureurl.Check: https://,
|
||||
// or http:// to a loopback host only.
|
||||
func (r *reader) secureURL(name string) *url.URL {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// The value is never echoed: it may carry credentials.
|
||||
u, err := url.Parse(v)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
|
||||
r.fail(name, "invalid URL (want absolute http:// or https:// URL)")
|
||||
if err != nil {
|
||||
r.fail(name, "invalid URL (want absolute https:// URL)")
|
||||
return nil
|
||||
}
|
||||
if err := secureurl.Check(u); err != nil {
|
||||
r.fail(name, "invalid URL: %v", err)
|
||||
return nil
|
||||
}
|
||||
if u.User != nil {
|
||||
@@ -195,6 +264,32 @@ func (r *reader) duration(name string) time.Duration {
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return r.parseDuration(name, v)
|
||||
}
|
||||
|
||||
// optionalDuration returns def when name is unset. A set value, even an empty
|
||||
// one, must be a valid positive duration.
|
||||
func (r *reader) optionalDuration(name string, def time.Duration) time.Duration {
|
||||
v, ok := r.lookup(name)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return r.parseDuration(name, v)
|
||||
}
|
||||
|
||||
// sessionDuration is optionalDuration restricted to whole milliseconds:
|
||||
// session expiry is stored as a store.Timestamp, which keeps millisecond
|
||||
// precision, so a finer duration could not be honoured.
|
||||
func (r *reader) sessionDuration(name string, def time.Duration) time.Duration {
|
||||
d := r.optionalDuration(name, def)
|
||||
if d%time.Millisecond != 0 {
|
||||
r.fail(name, "duration must be a whole number of milliseconds, got %s", d)
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (r *reader) parseDuration(name, v string) time.Duration {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -21,6 +22,10 @@ func TestLoad(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unreadablePath := filepath.Join(dir, "missing_secret")
|
||||
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
|
||||
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emptyPath := filepath.Join(dir, "empty_secret")
|
||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -33,6 +38,11 @@ func TestLoad(t *testing.T) {
|
||||
EnvDataDir: dir,
|
||||
EnvShutdownTimeout: "15s",
|
||||
EnvSecretKeyFile: secretPath,
|
||||
|
||||
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
|
||||
EnvOIDCClientID: "mealquest",
|
||||
EnvOIDCClientSecretFile: clientSecretPath,
|
||||
EnvOIDCAllowedGroups: "mealquest-admin",
|
||||
}
|
||||
}
|
||||
with := func(changes map[string]string, unset ...string) map[string]string {
|
||||
@@ -70,6 +80,7 @@ func TestLoad(t *testing.T) {
|
||||
env: map[string]string{},
|
||||
badVars: []string{
|
||||
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
||||
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -139,6 +150,24 @@ func TestLoad(t *testing.T) {
|
||||
env: with(map[string]string{EnvPublicURL: "http://:8080"}),
|
||||
badVars: []string{EnvPublicURL},
|
||||
},
|
||||
{
|
||||
name: "plain HTTP public URL on a remote host",
|
||||
env: with(map[string]string{EnvPublicURL: "http://meals.example.com"}),
|
||||
badVars: []string{EnvPublicURL},
|
||||
},
|
||||
{
|
||||
name: "plain HTTP public URL on a non-loopback IP",
|
||||
env: with(map[string]string{EnvPublicURL: "http://192.168.1.20:8080"}),
|
||||
badVars: []string{EnvPublicURL},
|
||||
},
|
||||
{
|
||||
name: "plain HTTP public URL on 127.0.0.1",
|
||||
env: with(map[string]string{EnvPublicURL: "http://127.0.0.1:8080"}),
|
||||
},
|
||||
{
|
||||
name: "plain HTTP public URL on [::1]",
|
||||
env: with(map[string]string{EnvPublicURL: "http://[::1]:8080"}),
|
||||
},
|
||||
{
|
||||
name: "relative public URL",
|
||||
env: with(map[string]string{EnvPublicURL: "meals.example.com"}),
|
||||
@@ -169,6 +198,101 @@ func TestLoad(t *testing.T) {
|
||||
env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}),
|
||||
badVars: []string{"MEALQUEST_SECRET_KEY"},
|
||||
},
|
||||
{
|
||||
name: "inline OIDC client secret rejected",
|
||||
env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}),
|
||||
badVars: []string{"MEALQUEST_OIDC_CLIENT_SECRET"},
|
||||
},
|
||||
{
|
||||
name: "unreadable OIDC client secret file",
|
||||
env: with(map[string]string{EnvOIDCClientSecretFile: unreadablePath}),
|
||||
badVars: []string{EnvOIDCClientSecretFile},
|
||||
},
|
||||
{
|
||||
name: "relative OIDC issuer",
|
||||
env: with(map[string]string{EnvOIDCIssuer: "auth.example.com"}),
|
||||
badVars: []string{EnvOIDCIssuer},
|
||||
},
|
||||
{
|
||||
name: "non-http OIDC issuer",
|
||||
env: with(map[string]string{EnvOIDCIssuer: "ftp://auth.example.com"}),
|
||||
badVars: []string{EnvOIDCIssuer},
|
||||
},
|
||||
{
|
||||
name: "plain HTTP OIDC issuer on a remote host",
|
||||
env: with(map[string]string{EnvOIDCIssuer: "http://auth.example.com/application/o/mealquest/"}),
|
||||
badVars: []string{EnvOIDCIssuer},
|
||||
},
|
||||
{
|
||||
name: "plain HTTP OIDC issuer on localhost",
|
||||
env: with(map[string]string{EnvOIDCIssuer: "http://localhost:9000/application/o/mealquest/"}),
|
||||
},
|
||||
{
|
||||
name: "several allowed groups",
|
||||
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin, family"}),
|
||||
},
|
||||
{
|
||||
name: "empty allowed group entry",
|
||||
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,,family"}),
|
||||
badVars: []string{EnvOIDCAllowedGroups},
|
||||
},
|
||||
{
|
||||
name: "trailing comma in allowed groups",
|
||||
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,"}),
|
||||
badVars: []string{EnvOIDCAllowedGroups},
|
||||
},
|
||||
{
|
||||
name: "blank OIDC client ID",
|
||||
env: with(map[string]string{EnvOIDCClientID: " "}),
|
||||
badVars: []string{EnvOIDCClientID},
|
||||
},
|
||||
{
|
||||
name: "session lifetimes set",
|
||||
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "48h"}),
|
||||
},
|
||||
{
|
||||
name: "session max equal to idle",
|
||||
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "24h"}),
|
||||
},
|
||||
{
|
||||
name: "invalid session idle",
|
||||
env: with(map[string]string{EnvSessionIdle: "a month"}),
|
||||
badVars: []string{EnvSessionIdle},
|
||||
},
|
||||
{
|
||||
name: "empty session idle",
|
||||
env: with(map[string]string{EnvSessionIdle: ""}),
|
||||
badVars: []string{EnvSessionIdle},
|
||||
},
|
||||
{
|
||||
name: "non-positive session max",
|
||||
env: with(map[string]string{EnvSessionMax: "-1h"}),
|
||||
badVars: []string{EnvSessionMax},
|
||||
},
|
||||
{
|
||||
name: "whole-millisecond session lifetimes",
|
||||
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "2ms"}),
|
||||
},
|
||||
{
|
||||
name: "sub-millisecond session idle",
|
||||
env: with(map[string]string{EnvSessionIdle: "1500us", EnvSessionMax: "2ms"}),
|
||||
badVars: []string{EnvSessionIdle},
|
||||
},
|
||||
{
|
||||
name: "sub-millisecond session max",
|
||||
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "1ms500us"}),
|
||||
badVars: []string{EnvSessionMax},
|
||||
},
|
||||
{
|
||||
name: "session max below idle",
|
||||
env: with(map[string]string{EnvSessionIdle: "48h", EnvSessionMax: "24h"}),
|
||||
badVars: []string{EnvSessionMax},
|
||||
},
|
||||
{
|
||||
name: "session max below default idle",
|
||||
env: with(map[string]string{EnvSessionMax: "719h"}),
|
||||
badVars: []string{EnvSessionMax},
|
||||
},
|
||||
{
|
||||
name: "several invalid values are all named",
|
||||
env: with(map[string]string{
|
||||
@@ -211,7 +335,7 @@ func TestLoad(t *testing.T) {
|
||||
t.Errorf("error message %q does not name %s", err.Error(), v)
|
||||
}
|
||||
}
|
||||
if cfg != (Config{}) {
|
||||
if !reflect.ValueOf(cfg).IsZero() {
|
||||
t.Errorf("load() returned non-zero Config alongside error: %+v", cfg)
|
||||
}
|
||||
})
|
||||
@@ -226,12 +350,21 @@ func TestLoadValues(t *testing.T) {
|
||||
if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
|
||||
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{
|
||||
EnvHTTPAddr: "127.0.0.1:9000",
|
||||
EnvPublicURL: "http://localhost:9000",
|
||||
EnvDataDir: dir,
|
||||
EnvShutdownTimeout: "1m30s",
|
||||
EnvSecretKeyFile: secretPath,
|
||||
|
||||
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
|
||||
EnvOIDCClientID: "mealquest",
|
||||
EnvOIDCClientSecretFile: clientSecretPath,
|
||||
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
||||
}
|
||||
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
@@ -253,6 +386,31 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.SecretKey.Reveal() != "s3cret-value" {
|
||||
t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal())
|
||||
}
|
||||
if got, want := cfg.OIDCIssuer.String(), "https://auth.example.com/application/o/mealquest/"; got != want {
|
||||
t.Errorf("OIDCIssuer = %q, want %q", got, want)
|
||||
}
|
||||
if cfg.OIDCClientID != "mealquest" {
|
||||
t.Errorf("OIDCClientID = %q", cfg.OIDCClientID)
|
||||
}
|
||||
if cfg.OIDCClientSecret.Reveal() != "client-s3cret" {
|
||||
t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal())
|
||||
}
|
||||
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
||||
}
|
||||
if cfg.SessionIdle != 720*time.Hour || cfg.SessionMax != 4320*time.Hour {
|
||||
t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax)
|
||||
}
|
||||
|
||||
env[EnvSessionIdle] = "12h"
|
||||
env[EnvSessionMax] = "36h"
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
@@ -260,7 +418,7 @@ func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
s := Secret{value: "s3cret-value"}
|
||||
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
||||
cfg := Config{SecretKey: s}
|
||||
cfg := Config{SecretKey: s, OIDCClientSecret: s}
|
||||
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
||||
if strings.Contains(out, "s3cret-value") {
|
||||
t.Errorf("format %s leaked secret: %s", format, out)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const (
|
||||
created = "2026-10-03T12:00:00.000Z"
|
||||
updated = "2026-10-04T12:00:00.000Z"
|
||||
)
|
||||
|
||||
// openQueries returns queries on the writer of a freshly migrated database
|
||||
// in a temp directory.
|
||||
func openQueries(t *testing.T) (*store.Store, *db.Queries) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
s, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := s.Close(); err != nil {
|
||||
t.Errorf("Close: %v", err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
return s, db.New(s.Writer)
|
||||
}
|
||||
|
||||
func str(s string) sql.NullString { return sql.NullString{String: s, Valid: true} }
|
||||
func num(n int64) sql.NullInt64 { return sql.NullInt64{Int64: n, Valid: true} }
|
||||
|
||||
func mustNoRows(t *testing.T, what string, err error) {
|
||||
t.Helper()
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("%s: got %v, want sql.ErrNoRows", what, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createPhoto(t *testing.T, q *db.Queries, sha string) db.Photo {
|
||||
t.Helper()
|
||||
p, err := q.CreatePhoto(context.Background(), db.CreatePhotoParams{
|
||||
ID: store.NewID(),
|
||||
SourceUrl: "https://example.com/" + sha + ".jpg",
|
||||
LocalPath: "photos/" + sha + ".jpg",
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Sha256: sha,
|
||||
FetchedAt: created,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePhoto: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func upsertRecipe(t *testing.T, q *db.Queries, id, url, title, at string) db.Recipe {
|
||||
t.Helper()
|
||||
r, err := q.UpsertRecipe(context.Background(), db.UpsertRecipeParams{
|
||||
ID: id,
|
||||
SourceUrl: url,
|
||||
SiteName: str("Example"),
|
||||
Title: title,
|
||||
TotalMinutes: num(30),
|
||||
PrepMinutes: num(10),
|
||||
CookMinutes: num(20),
|
||||
Yield: str("4 servings"),
|
||||
RawJsonld: `{"@type":"Recipe"}`,
|
||||
FetchedAt: at,
|
||||
CreatedAt: at,
|
||||
UpdatedAt: at,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertRecipe: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestDinners(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, q := openQueries(t)
|
||||
recipe := upsertRecipe(t, q, store.NewID(), "https://example.com/tacos", "Tacos", created)
|
||||
photo := createPhoto(t, q, "aa11")
|
||||
|
||||
create := db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: "Taco Tuesday",
|
||||
NormalizedName: "taco tuesday",
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
Method: str("stovetop"),
|
||||
MinutesTotal: num(30),
|
||||
Difficulty: str("easy"),
|
||||
Cuisine: str("Mexican"),
|
||||
IsFavorite: 0,
|
||||
RecipeID: str(recipe.ID),
|
||||
PhotoID: str(photo.ID),
|
||||
GroundingStatus: "grounded",
|
||||
UpdatedBy: str("alex@example.com"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
want := db.Dinner(create)
|
||||
got, err := q.CreateDinner(ctx, create)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDinner: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("CreateDinner returned %+v, want %+v", got, want)
|
||||
}
|
||||
if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want {
|
||||
t.Fatalf("GetDinner = %+v, %v; want %+v", got, err, want)
|
||||
}
|
||||
_, err = q.GetDinner(ctx, "missing")
|
||||
mustNoRows(t, "GetDinner missing", err)
|
||||
|
||||
// Second dinner sorts before the first by normalized name, in a
|
||||
// different status so the status filter is exercised.
|
||||
other, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: "Apple Pork Chops",
|
||||
NormalizedName: "apple pork chops",
|
||||
Status: "quest_log",
|
||||
Origin: "side_quest",
|
||||
GroundingStatus: "pending",
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDinner other: %v", err)
|
||||
}
|
||||
third, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: "Beef Stew",
|
||||
NormalizedName: "beef stew",
|
||||
Status: "regular",
|
||||
Origin: "manual",
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDinner third: %v", err)
|
||||
}
|
||||
listIDs := func(status string) []string {
|
||||
t.Helper()
|
||||
ds, err := q.ListDinnersByStatus(ctx, status)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDinnersByStatus(%s): %v", status, err)
|
||||
}
|
||||
ids := []string{}
|
||||
for _, d := range ds {
|
||||
ids = append(ids, d.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
assertIDs(t, "regular", listIDs("regular"), third.ID, create.ID)
|
||||
assertIDs(t, "quest_log", listIDs("quest_log"), other.ID)
|
||||
assertIDs(t, "tried", listIDs("tried"))
|
||||
|
||||
update := db.UpdateDinnerParams{
|
||||
ID: create.ID,
|
||||
Name: "Fish Tacos",
|
||||
NormalizedName: "fish tacos",
|
||||
Status: "tried",
|
||||
Origin: "side_quest",
|
||||
Method: str("grill"),
|
||||
MinutesTotal: num(25),
|
||||
Difficulty: str("medium"),
|
||||
Cuisine: sql.NullString{},
|
||||
RecipeID: sql.NullString{},
|
||||
PhotoID: str(photo.ID),
|
||||
GroundingStatus: "failed",
|
||||
UpdatedBy: str("sam@example.com"),
|
||||
UpdatedAt: updated,
|
||||
}
|
||||
got, err = q.UpdateDinner(ctx, update)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDinner: %v", err)
|
||||
}
|
||||
want = db.Dinner{
|
||||
ID: create.ID, Name: update.Name, NormalizedName: update.NormalizedName,
|
||||
Status: update.Status, Origin: update.Origin, Method: update.Method,
|
||||
MinutesTotal: update.MinutesTotal, Difficulty: update.Difficulty,
|
||||
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
|
||||
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
|
||||
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("UpdateDinner returned %+v, want %+v", got, want)
|
||||
}
|
||||
if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want {
|
||||
t.Fatalf("GetDinner after update = %+v, %v; want %+v", got, err, want)
|
||||
}
|
||||
update.ID = "missing"
|
||||
_, err = q.UpdateDinner(ctx, update)
|
||||
mustNoRows(t, "UpdateDinner missing", err)
|
||||
|
||||
got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{
|
||||
ID: create.ID, IsFavorite: 1, UpdatedBy: str("alex@example.com"), UpdatedAt: updated,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetDinnerFavorite: %v", err)
|
||||
}
|
||||
want.IsFavorite, want.UpdatedBy = 1, str("alex@example.com")
|
||||
if got != want {
|
||||
t.Fatalf("SetDinnerFavorite returned %+v, want %+v", got, want)
|
||||
}
|
||||
got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{
|
||||
ID: create.ID, IsFavorite: 0, UpdatedBy: str("sam@example.com"), UpdatedAt: updated,
|
||||
})
|
||||
if err != nil || got.IsFavorite != 0 || got.UpdatedBy != str("sam@example.com") {
|
||||
t.Fatalf("SetDinnerFavorite off = %+v, %v", got, err)
|
||||
}
|
||||
_, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated})
|
||||
mustNoRows(t, "SetDinnerFavorite missing", err)
|
||||
|
||||
const archivedAt = "2026-10-05T12:00:00.000Z"
|
||||
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
||||
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ArchiveDinner: %v", err)
|
||||
}
|
||||
want.IsFavorite, want.Status, want.UpdatedBy, want.UpdatedAt = 0, "archived", str("alex@example.com"), archivedAt
|
||||
if got != want {
|
||||
t.Fatalf("ArchiveDinner returned %+v, want %+v", got, want)
|
||||
}
|
||||
assertIDs(t, "archived", listIDs("archived"), create.ID)
|
||||
assertIDs(t, "tried after archive", listIDs("tried"))
|
||||
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
|
||||
mustNoRows(t, "ArchiveDinner missing", err)
|
||||
}
|
||||
|
||||
func assertIDs(t *testing.T, what string, got []string, want ...string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s: got ids %v, want %v", what, got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s: got ids %v, want %v", what, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeUpsertWithIngredientsAndSteps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, q := openQueries(t)
|
||||
const url = "https://example.com/chili"
|
||||
|
||||
_, err := q.GetRecipeBySourceURL(ctx, url)
|
||||
mustNoRows(t, "GetRecipeBySourceURL before insert", err)
|
||||
|
||||
// saveRecipe is how a caller stores a fetched recipe: upsert by
|
||||
// source_url, then replace the children, all in one transaction.
|
||||
saveRecipe := func(title, at string, ingredients, steps []string) db.Recipe {
|
||||
t.Helper()
|
||||
var r db.Recipe
|
||||
err := s.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
qtx := q.WithTx(tx)
|
||||
var err error
|
||||
r, err = qtx.UpsertRecipe(ctx, db.UpsertRecipeParams{
|
||||
ID: store.NewID(), SourceUrl: url, SiteName: str("Example"), Title: title,
|
||||
TotalMinutes: num(60), RawJsonld: `{"name":"` + title + `"}`,
|
||||
FetchedAt: at, CreatedAt: at, UpdatedAt: at,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := qtx.DeleteRecipeIngredients(ctx, r.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := qtx.DeleteRecipeSteps(ctx, r.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, text := range ingredients {
|
||||
if err := qtx.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
|
||||
ID: store.NewID(), RecipeID: r.ID, Position: int64(i), RawText: text,
|
||||
Name: str(text), Qty: str("1"), Unit: str("cup"), Note: sql.NullString{},
|
||||
CreatedAt: at, UpdatedAt: at,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, text := range steps {
|
||||
if err := qtx.CreateRecipeStep(ctx, db.CreateRecipeStepParams{
|
||||
ID: store.NewID(), RecipeID: r.ID, Position: int64(i), Text: text,
|
||||
CreatedAt: at, UpdatedAt: at,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("save recipe %q: %v", title, err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
assertRecipe := func(id, title, createdAt, updatedAt string, ingredients, steps []string) {
|
||||
t.Helper()
|
||||
r, err := q.GetRecipe(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecipe: %v", err)
|
||||
}
|
||||
if r.Title != title || r.SourceUrl != url || r.CreatedAt != createdAt || r.UpdatedAt != updatedAt || r.FetchedAt != updatedAt {
|
||||
t.Fatalf("GetRecipe = %+v; want title %q, created %s, updated %s", r, title, createdAt, updatedAt)
|
||||
}
|
||||
byURL, err := q.GetRecipeBySourceURL(ctx, url)
|
||||
if err != nil || byURL != r {
|
||||
t.Fatalf("GetRecipeBySourceURL = %+v, %v; want %+v", byURL, err, r)
|
||||
}
|
||||
ings, err := q.ListRecipeIngredients(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRecipeIngredients: %v", err)
|
||||
}
|
||||
gotIngs := []string{}
|
||||
for i, ing := range ings {
|
||||
if ing.RecipeID != id || ing.Position != int64(i) || ing.Name != str(ing.RawText) {
|
||||
t.Fatalf("ingredient %d = %+v", i, ing)
|
||||
}
|
||||
gotIngs = append(gotIngs, ing.RawText)
|
||||
}
|
||||
assertIDs(t, "ingredients", gotIngs, ingredients...)
|
||||
sts, err := q.ListRecipeSteps(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRecipeSteps: %v", err)
|
||||
}
|
||||
gotSteps := []string{}
|
||||
for i, st := range sts {
|
||||
if st.RecipeID != id || st.Position != int64(i) {
|
||||
t.Fatalf("step %d = %+v", i, st)
|
||||
}
|
||||
gotSteps = append(gotSteps, st.Text)
|
||||
}
|
||||
assertIDs(t, "steps", gotSteps, steps...)
|
||||
}
|
||||
|
||||
first := saveRecipe("Chili", created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."})
|
||||
assertRecipe(first.ID, "Chili", created, created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."})
|
||||
|
||||
// Re-fetching the same URL keeps the id and created_at and replaces the
|
||||
// fields and children.
|
||||
second := saveRecipe("White Chili", updated, []string{"chicken", "white beans"}, []string{"Simmer."})
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("upsert changed id: %s -> %s", first.ID, second.ID)
|
||||
}
|
||||
assertRecipe(first.ID, "White Chili", created, updated, []string{"chicken", "white beans"}, []string{"Simmer."})
|
||||
|
||||
// A different recipe's children are not listed.
|
||||
upsertRecipe(t, q, store.NewID(), "https://example.com/other", "Other", created)
|
||||
var n int
|
||||
if err := s.Reader.QueryRowContext(ctx, "SELECT count(*) FROM recipes").Scan(&n); err != nil || n != 2 {
|
||||
t.Fatalf("recipes count = %d, %v; want 2", n, err)
|
||||
}
|
||||
_, err = q.GetRecipe(ctx, "missing")
|
||||
mustNoRows(t, "GetRecipe missing", err)
|
||||
}
|
||||
|
||||
func TestPhotos(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, q := openQueries(t)
|
||||
|
||||
_, err := q.GetPhotoBySHA256(ctx, "bb22")
|
||||
mustNoRows(t, "GetPhotoBySHA256 before insert", err)
|
||||
|
||||
first := createPhoto(t, q, "bb22")
|
||||
createPhoto(t, q, "cc33")
|
||||
if first.Sha256 != "bb22" || first.Width != 800 || first.Height != 600 || first.CreatedAt != created {
|
||||
t.Fatalf("CreatePhoto returned %+v", first)
|
||||
}
|
||||
got, err := q.GetPhotoBySHA256(ctx, "bb22")
|
||||
if err != nil || got != first {
|
||||
t.Fatalf("GetPhotoBySHA256 = %+v, %v; want %+v", got, err, first)
|
||||
}
|
||||
|
||||
// sha256 is not unique in the schema; the earliest photo is returned.
|
||||
createPhoto(t, q, "bb22")
|
||||
if got, err = q.GetPhotoBySHA256(ctx, "bb22"); err != nil || got != first {
|
||||
t.Fatalf("GetPhotoBySHA256 with duplicate = %+v, %v; want %+v", got, err, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseholdSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, q := openQueries(t)
|
||||
_, err := q.GetHouseholdSettings(ctx)
|
||||
mustNoRows(t, "GetHouseholdSettings before upsert", err)
|
||||
|
||||
p := db.UpsertHouseholdSettingsParams{
|
||||
Timezone: "America/New_York", DinnerTime: "17:30", ReminderTime: "16:30",
|
||||
ReviewTime: "09:00", PlanDraftDow: 5, PlanDraftTime: "09:00",
|
||||
SuggestionsDow: 0, SuggestionsTime: "08:00", ChatAutosendDow: 6,
|
||||
ChatAutosendTime: "08:00", CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
want := db.HouseholdSetting{
|
||||
ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime,
|
||||
ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime,
|
||||
SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime,
|
||||
ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime,
|
||||
CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
assertSettings(t, "insert", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want)
|
||||
|
||||
p = db.UpsertHouseholdSettingsParams{
|
||||
Timezone: "America/Chicago", DinnerTime: "18:00", ReminderTime: "17:00",
|
||||
ReviewTime: "10:00", PlanDraftDow: 4, PlanDraftTime: "20:00",
|
||||
SuggestionsDow: 1, SuggestionsTime: "07:00", ChatAutosendDow: 0,
|
||||
ChatAutosendTime: "09:30", OnboardingCompletedAt: str(updated),
|
||||
UpdatesSeenAt: str(updated), CreatedAt: updated, UpdatedAt: updated,
|
||||
}
|
||||
want = db.HouseholdSetting{
|
||||
ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime,
|
||||
ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime,
|
||||
SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime,
|
||||
ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime,
|
||||
OnboardingCompletedAt: p.OnboardingCompletedAt, UpdatesSeenAt: p.UpdatesSeenAt,
|
||||
CreatedAt: created, UpdatedAt: updated,
|
||||
}
|
||||
assertSettings(t, "update", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want)
|
||||
}
|
||||
|
||||
func TestAISettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, q := openQueries(t)
|
||||
_, err := q.GetAISettings(ctx)
|
||||
mustNoRows(t, "GetAISettings before upsert", err)
|
||||
|
||||
p := db.UpsertAISettingsParams{
|
||||
Provider: "ollama", BaseUrl: "http://ollama:11434", Model: "llama3",
|
||||
Temperature: 0.7, CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
want := db.AiSetting{
|
||||
ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model,
|
||||
Temperature: p.Temperature, CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
assertSettings(t, "insert", q.UpsertAISettings, q.GetAISettings, p, want)
|
||||
|
||||
p = db.UpsertAISettingsParams{
|
||||
Provider: "anthropic", BaseUrl: "https://api.anthropic.com", Model: "claude",
|
||||
Temperature: 0.2, SystemPrompt: str("Be brief."), ApiKeyCt: []byte{1, 2, 3},
|
||||
ApiKeyLast4: str("wxyz"), KeyVerifiedAt: str(updated), CreatedAt: updated, UpdatedAt: updated,
|
||||
}
|
||||
want = db.AiSetting{
|
||||
ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model,
|
||||
Temperature: p.Temperature, SystemPrompt: p.SystemPrompt, ApiKeyCt: p.ApiKeyCt,
|
||||
ApiKeyLast4: p.ApiKeyLast4, KeyVerifiedAt: p.KeyVerifiedAt,
|
||||
CreatedAt: created, UpdatedAt: updated,
|
||||
}
|
||||
assertSettings(t, "update", q.UpsertAISettings, q.GetAISettings, p, want)
|
||||
}
|
||||
|
||||
func TestRecipeSearchSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, q := openQueries(t)
|
||||
_, err := q.GetRecipeSearchSettings(ctx)
|
||||
mustNoRows(t, "GetRecipeSearchSettings before upsert", err)
|
||||
|
||||
p := db.UpsertRecipeSearchSettingsParams{
|
||||
Provider: "searxng", BaseUrl: str("http://searxng:8080"), CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
want := db.RecipeSearchSetting{
|
||||
ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
assertSettings(t, "insert", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want)
|
||||
|
||||
p = db.UpsertRecipeSearchSettingsParams{
|
||||
Provider: "brave", ApiKeyCt: []byte{9, 8, 7}, CreatedAt: updated, UpdatedAt: updated,
|
||||
}
|
||||
want = db.RecipeSearchSetting{
|
||||
ID: 1, Provider: p.Provider, ApiKeyCt: p.ApiKeyCt, CreatedAt: created, UpdatedAt: updated,
|
||||
}
|
||||
assertSettings(t, "update", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want)
|
||||
}
|
||||
|
||||
func TestIntegrationsGoogle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, q := openQueries(t)
|
||||
_, err := q.GetIntegrationsGoogle(ctx)
|
||||
mustNoRows(t, "GetIntegrationsGoogle before upsert", err)
|
||||
|
||||
p := db.UpsertIntegrationsGoogleParams{
|
||||
RefreshTokenCt: []byte{1}, ConnectedAt: created, CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
want := db.IntegrationsGoogle{
|
||||
ID: 1, RefreshTokenCt: p.RefreshTokenCt, ConnectedAt: created, CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
assertSettings(t, "insert", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want)
|
||||
|
||||
p = db.UpsertIntegrationsGoogleParams{
|
||||
RefreshTokenCt: []byte{2, 3}, CalendarID: str("family@group.calendar.google.com"),
|
||||
CalendarName: str("Family"), ConnectedAt: updated, LastSyncError: str("quota exceeded"),
|
||||
CreatedAt: updated, UpdatedAt: updated,
|
||||
}
|
||||
want = db.IntegrationsGoogle{
|
||||
ID: 1, RefreshTokenCt: p.RefreshTokenCt, CalendarID: p.CalendarID,
|
||||
CalendarName: p.CalendarName, ConnectedAt: updated, LastSyncError: p.LastSyncError,
|
||||
CreatedAt: created, UpdatedAt: updated,
|
||||
}
|
||||
assertSettings(t, "update", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want)
|
||||
}
|
||||
|
||||
func TestChatSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, q := openQueries(t)
|
||||
_, err := q.GetChatSettings(ctx)
|
||||
mustNoRows(t, "GetChatSettings before upsert", err)
|
||||
|
||||
p := db.UpsertChatSettingsParams{
|
||||
WebhookUrlCt: []byte{4, 5}, CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
want := db.ChatSetting{
|
||||
ID: 1, WebhookUrlCt: p.WebhookUrlCt, CreatedAt: created, UpdatedAt: created,
|
||||
}
|
||||
assertSettings(t, "insert", q.UpsertChatSettings, q.GetChatSettings, p, want)
|
||||
|
||||
p = db.UpsertChatSettingsParams{
|
||||
WebhookUrlCt: []byte{6}, SpaceLabel: str("Kitchen"), LastTestAt: str(updated),
|
||||
LastTestStatus: str("ok"), CreatedAt: updated, UpdatedAt: updated,
|
||||
}
|
||||
want = db.ChatSetting{
|
||||
ID: 1, WebhookUrlCt: p.WebhookUrlCt, SpaceLabel: p.SpaceLabel, LastTestAt: p.LastTestAt,
|
||||
LastTestStatus: p.LastTestStatus, CreatedAt: created, UpdatedAt: updated,
|
||||
}
|
||||
assertSettings(t, "update", q.UpsertChatSettings, q.GetChatSettings, p, want)
|
||||
}
|
||||
|
||||
// assertSettings upserts p and checks that both the returned row and a
|
||||
// fresh Get equal want. On update, want keeps the original created_at.
|
||||
func assertSettings[P, R any](
|
||||
t *testing.T, what string,
|
||||
upsert func(context.Context, P) (R, error), get func(context.Context) (R, error),
|
||||
p P, want R,
|
||||
) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
got, err := upsert(ctx, p)
|
||||
if err != nil {
|
||||
t.Fatalf("%s upsert: %v", what, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s upsert returned %+v, want %+v", what, got, want)
|
||||
}
|
||||
got, err = get(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("%s get: %v", what, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s get = %+v, want %+v", what, got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: dinners.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const archiveDinner = `-- name: ArchiveDinner :one
|
||||
UPDATE dinners
|
||||
SET status = 'archived',
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
`
|
||||
|
||||
type ArchiveDinnerParams struct {
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, archiveDinner, arg.UpdatedBy, arg.UpdatedAt, arg.ID)
|
||||
var i Dinner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createDinner = `-- name: CreateDinner :one
|
||||
INSERT INTO dinners (
|
||||
id, name, normalized_name, status, origin, method, minutes_total,
|
||||
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
|
||||
updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?
|
||||
)
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateDinnerParams struct {
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, createDinner,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.NormalizedName,
|
||||
arg.Status,
|
||||
arg.Origin,
|
||||
arg.Method,
|
||||
arg.MinutesTotal,
|
||||
arg.Difficulty,
|
||||
arg.Cuisine,
|
||||
arg.IsFavorite,
|
||||
arg.RecipeID,
|
||||
arg.PhotoID,
|
||||
arg.GroundingStatus,
|
||||
arg.UpdatedBy,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i Dinner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDinner = `-- name: GetDinner :one
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDinner, id)
|
||||
var i Dinner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
|
||||
WHERE status = ?
|
||||
ORDER BY normalized_name, id
|
||||
`
|
||||
|
||||
func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDinnersByStatus, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Dinner
|
||||
for rows.Next() {
|
||||
var i Dinner
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
|
||||
UPDATE dinners
|
||||
SET is_favorite = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
`
|
||||
|
||||
type SetDinnerFavoriteParams struct {
|
||||
IsFavorite int64
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, setDinnerFavorite,
|
||||
arg.IsFavorite,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
)
|
||||
var i Dinner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateDinner = `-- name: UpdateDinner :one
|
||||
UPDATE dinners
|
||||
SET name = ?,
|
||||
normalized_name = ?,
|
||||
status = ?,
|
||||
origin = ?,
|
||||
method = ?,
|
||||
minutes_total = ?,
|
||||
difficulty = ?,
|
||||
cuisine = ?,
|
||||
recipe_id = ?,
|
||||
photo_id = ?,
|
||||
grounding_status = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateDinnerParams struct {
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateDinner,
|
||||
arg.Name,
|
||||
arg.NormalizedName,
|
||||
arg.Status,
|
||||
arg.Origin,
|
||||
arg.Method,
|
||||
arg.MinutesTotal,
|
||||
arg.Difficulty,
|
||||
arg.Cuisine,
|
||||
arg.RecipeID,
|
||||
arg.PhotoID,
|
||||
arg.GroundingStatus,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
)
|
||||
var i Dinner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type AiRun struct {
|
||||
ID string
|
||||
Skill string
|
||||
SkillVersion string
|
||||
Provider string
|
||||
Model string
|
||||
Temperature float64
|
||||
Status string
|
||||
LatencyMs sql.NullInt64
|
||||
TokensIn sql.NullInt64
|
||||
TokensOut sql.NullInt64
|
||||
Error sql.NullString
|
||||
RequestRedacted string
|
||||
ResponseRedacted sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type AiSetting struct {
|
||||
ID int64
|
||||
Provider string
|
||||
BaseUrl string
|
||||
Model string
|
||||
Temperature float64
|
||||
SystemPrompt sql.NullString
|
||||
ApiKeyCt []byte
|
||||
ApiKeyLast4 sql.NullString
|
||||
KeyVerifiedAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type ChatSetting struct {
|
||||
ID int64
|
||||
WebhookUrlCt []byte
|
||||
SpaceLabel sql.NullString
|
||||
LastTestAt sql.NullString
|
||||
LastTestStatus sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type Dinner struct {
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type HouseholdSetting struct {
|
||||
ID int64
|
||||
Timezone string
|
||||
DinnerTime string
|
||||
ReminderTime string
|
||||
ReviewTime string
|
||||
PlanDraftDow int64
|
||||
PlanDraftTime string
|
||||
SuggestionsDow int64
|
||||
SuggestionsTime string
|
||||
ChatAutosendDow int64
|
||||
ChatAutosendTime string
|
||||
OnboardingCompletedAt sql.NullString
|
||||
UpdatesSeenAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type IntegrationsGoogle struct {
|
||||
ID int64
|
||||
RefreshTokenCt []byte
|
||||
CalendarID sql.NullString
|
||||
CalendarName sql.NullString
|
||||
ConnectedAt string
|
||||
LastSyncError sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID string
|
||||
Kind string
|
||||
Status string
|
||||
Payload string
|
||||
Result sql.NullString
|
||||
Error sql.NullString
|
||||
StartedAt sql.NullString
|
||||
FinishedAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type NotificationOutbox struct {
|
||||
ID string
|
||||
EventKey string
|
||||
Channel string
|
||||
Payload string
|
||||
Status string
|
||||
Attempts int64
|
||||
NextAttemptAt sql.NullString
|
||||
LastError sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type NotificationPref struct {
|
||||
Event string
|
||||
Push int64
|
||||
Chat int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type Photo struct {
|
||||
ID string
|
||||
SourceUrl string
|
||||
LocalPath string
|
||||
Width int64
|
||||
Height int64
|
||||
Sha256 string
|
||||
FetchedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type PlanDay struct {
|
||||
ID string
|
||||
PlanID string
|
||||
Date string
|
||||
DinnerID sql.NullString
|
||||
IsNew int64
|
||||
CalendarEventID sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type PushSubscription struct {
|
||||
ID string
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
UserAgent sql.NullString
|
||||
FailureCount int64
|
||||
LastSuccessAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type Recipe struct {
|
||||
ID string
|
||||
SourceUrl string
|
||||
SiteName sql.NullString
|
||||
Title string
|
||||
TotalMinutes sql.NullInt64
|
||||
PrepMinutes sql.NullInt64
|
||||
CookMinutes sql.NullInt64
|
||||
Yield sql.NullString
|
||||
RawJsonld string
|
||||
FetchedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type RecipeIngredient struct {
|
||||
ID string
|
||||
RecipeID string
|
||||
Position int64
|
||||
RawText string
|
||||
Name sql.NullString
|
||||
Qty sql.NullString
|
||||
Unit sql.NullString
|
||||
Note sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type RecipeSearchSetting struct {
|
||||
ID int64
|
||||
Provider string
|
||||
BaseUrl sql.NullString
|
||||
ApiKeyCt []byte
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type RecipeStep struct {
|
||||
ID string
|
||||
RecipeID string
|
||||
Position int64
|
||||
Text string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type RotationReview struct {
|
||||
ID string
|
||||
DinnerID string
|
||||
PlanDate string
|
||||
Status string
|
||||
Decision sql.NullString
|
||||
DecidedAt sql.NullString
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type ScheduleRun struct {
|
||||
ID string
|
||||
Schedule string
|
||||
PeriodKey string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
IDHash string
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
Groups string
|
||||
CsrfToken string
|
||||
CreatedAt string
|
||||
LastSeenAt string
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
type ShoppingGroup struct {
|
||||
ID string
|
||||
ListID string
|
||||
DinnerID string
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type ShoppingItem struct {
|
||||
ID string
|
||||
GroupID string
|
||||
Name string
|
||||
Qty sql.NullString
|
||||
Needed int64
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type ShoppingList struct {
|
||||
ID string
|
||||
WeekStart string
|
||||
PlanID string
|
||||
Title string
|
||||
Status string
|
||||
SentToChatAt sql.NullString
|
||||
FrozenAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type Suggestion struct {
|
||||
ID string
|
||||
DinnerID string
|
||||
BatchID string
|
||||
Reason string
|
||||
CitedRegularIds string
|
||||
Status string
|
||||
DecidedAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type SuggestionBatch struct {
|
||||
ID string
|
||||
Trigger string
|
||||
Status string
|
||||
AiRunID sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type WeekPlan struct {
|
||||
ID string
|
||||
WeekStart string
|
||||
Status string
|
||||
AiNote sql.NullString
|
||||
AiRunID sql.NullString
|
||||
CalendarSyncedAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: photos.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createPhoto = `-- name: CreatePhoto :one
|
||||
INSERT INTO photos (
|
||||
id, source_url, local_path, width, height, sha256, fetched_at,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?
|
||||
)
|
||||
RETURNING id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreatePhotoParams struct {
|
||||
ID string
|
||||
SourceUrl string
|
||||
LocalPath string
|
||||
Width int64
|
||||
Height int64
|
||||
Sha256 string
|
||||
FetchedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) {
|
||||
row := q.db.QueryRowContext(ctx, createPhoto,
|
||||
arg.ID,
|
||||
arg.SourceUrl,
|
||||
arg.LocalPath,
|
||||
arg.Width,
|
||||
arg.Height,
|
||||
arg.Sha256,
|
||||
arg.FetchedAt,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i Photo
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SourceUrl,
|
||||
&i.LocalPath,
|
||||
&i.Width,
|
||||
&i.Height,
|
||||
&i.Sha256,
|
||||
&i.FetchedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPhotoBySHA256 = `-- name: GetPhotoBySHA256 :one
|
||||
SELECT id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at FROM photos
|
||||
WHERE sha256 = ?
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// Finds stored image bytes by content hash. The schema does not make sha256
|
||||
// unique, so the earliest-created (lowest id) match is returned.
|
||||
func (q *Queries) GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error) {
|
||||
row := q.db.QueryRowContext(ctx, getPhotoBySHA256, sha256)
|
||||
var i Photo
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SourceUrl,
|
||||
&i.LocalPath,
|
||||
&i.Width,
|
||||
&i.Height,
|
||||
&i.Sha256,
|
||||
&i.FetchedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetChatSettings(ctx context.Context) (ChatSetting, error)
|
||||
GetDinner(ctx context.Context, id string) (Dinner, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
|
||||
// Finds stored image bytes by content hash. The schema does not make sha256
|
||||
// unique, so the earliest-created (lowest id) match is returned.
|
||||
GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error)
|
||||
GetRecipe(ctx context.Context, id string) (Recipe, error)
|
||||
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
|
||||
// A recipe is keyed by source_url: re-fetching the same URL updates the
|
||||
// existing row and keeps its id and created_at, which the returned row
|
||||
// carries. Replace its ingredients and steps in the same transaction with
|
||||
// DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries.
|
||||
UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -0,0 +1,298 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: recipes.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec
|
||||
INSERT INTO recipe_ingredients (
|
||||
id, recipe_id, position, raw_text, name, qty, unit, note,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?
|
||||
)
|
||||
`
|
||||
|
||||
type CreateRecipeIngredientParams struct {
|
||||
ID string
|
||||
RecipeID string
|
||||
Position int64
|
||||
RawText string
|
||||
Name sql.NullString
|
||||
Qty sql.NullString
|
||||
Unit sql.NullString
|
||||
Note sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createRecipeIngredient,
|
||||
arg.ID,
|
||||
arg.RecipeID,
|
||||
arg.Position,
|
||||
arg.RawText,
|
||||
arg.Name,
|
||||
arg.Qty,
|
||||
arg.Unit,
|
||||
arg.Note,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createRecipeStep = `-- name: CreateRecipeStep :exec
|
||||
INSERT INTO recipe_steps (
|
||||
id, recipe_id, position, text, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`
|
||||
|
||||
type CreateRecipeStepParams struct {
|
||||
ID string
|
||||
RecipeID string
|
||||
Position int64
|
||||
Text string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createRecipeStep,
|
||||
arg.ID,
|
||||
arg.RecipeID,
|
||||
arg.Position,
|
||||
arg.Text,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteRecipeIngredients = `-- name: DeleteRecipeIngredients :exec
|
||||
DELETE FROM recipe_ingredients
|
||||
WHERE recipe_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteRecipeIngredients(ctx context.Context, recipeID string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteRecipeIngredients, recipeID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteRecipeSteps = `-- name: DeleteRecipeSteps :exec
|
||||
DELETE FROM recipe_steps
|
||||
WHERE recipe_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteRecipeSteps(ctx context.Context, recipeID string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteRecipeSteps, recipeID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getRecipe = `-- name: GetRecipe :one
|
||||
SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetRecipe(ctx context.Context, id string) (Recipe, error) {
|
||||
row := q.db.QueryRowContext(ctx, getRecipe, id)
|
||||
var i Recipe
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SourceUrl,
|
||||
&i.SiteName,
|
||||
&i.Title,
|
||||
&i.TotalMinutes,
|
||||
&i.PrepMinutes,
|
||||
&i.CookMinutes,
|
||||
&i.Yield,
|
||||
&i.RawJsonld,
|
||||
&i.FetchedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRecipeBySourceURL = `-- name: GetRecipeBySourceURL :one
|
||||
SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes
|
||||
WHERE source_url = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) {
|
||||
row := q.db.QueryRowContext(ctx, getRecipeBySourceURL, sourceUrl)
|
||||
var i Recipe
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SourceUrl,
|
||||
&i.SiteName,
|
||||
&i.Title,
|
||||
&i.TotalMinutes,
|
||||
&i.PrepMinutes,
|
||||
&i.CookMinutes,
|
||||
&i.Yield,
|
||||
&i.RawJsonld,
|
||||
&i.FetchedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listRecipeIngredients = `-- name: ListRecipeIngredients :many
|
||||
SELECT id, recipe_id, position, raw_text, name, qty, unit, note, created_at, updated_at FROM recipe_ingredients
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position, id
|
||||
`
|
||||
|
||||
func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRecipeIngredients, recipeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []RecipeIngredient
|
||||
for rows.Next() {
|
||||
var i RecipeIngredient
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.RecipeID,
|
||||
&i.Position,
|
||||
&i.RawText,
|
||||
&i.Name,
|
||||
&i.Qty,
|
||||
&i.Unit,
|
||||
&i.Note,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRecipeSteps = `-- name: ListRecipeSteps :many
|
||||
SELECT id, recipe_id, position, text, created_at, updated_at FROM recipe_steps
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position, id
|
||||
`
|
||||
|
||||
func (q *Queries) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRecipeSteps, recipeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []RecipeStep
|
||||
for rows.Next() {
|
||||
var i RecipeStep
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.RecipeID,
|
||||
&i.Position,
|
||||
&i.Text,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertRecipe = `-- name: UpsertRecipe :one
|
||||
INSERT INTO recipes (
|
||||
id, source_url, site_name, title, total_minutes, prep_minutes,
|
||||
cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (source_url) DO UPDATE SET
|
||||
site_name = excluded.site_name,
|
||||
title = excluded.title,
|
||||
total_minutes = excluded.total_minutes,
|
||||
prep_minutes = excluded.prep_minutes,
|
||||
cook_minutes = excluded.cook_minutes,
|
||||
yield = excluded.yield,
|
||||
raw_jsonld = excluded.raw_jsonld,
|
||||
fetched_at = excluded.fetched_at,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertRecipeParams struct {
|
||||
ID string
|
||||
SourceUrl string
|
||||
SiteName sql.NullString
|
||||
Title string
|
||||
TotalMinutes sql.NullInt64
|
||||
PrepMinutes sql.NullInt64
|
||||
CookMinutes sql.NullInt64
|
||||
Yield sql.NullString
|
||||
RawJsonld string
|
||||
FetchedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// A recipe is keyed by source_url: re-fetching the same URL updates the
|
||||
// existing row and keeps its id and created_at, which the returned row
|
||||
// carries. Replace its ingredients and steps in the same transaction with
|
||||
// DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries.
|
||||
func (q *Queries) UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertRecipe,
|
||||
arg.ID,
|
||||
arg.SourceUrl,
|
||||
arg.SiteName,
|
||||
arg.Title,
|
||||
arg.TotalMinutes,
|
||||
arg.PrepMinutes,
|
||||
arg.CookMinutes,
|
||||
arg.Yield,
|
||||
arg.RawJsonld,
|
||||
arg.FetchedAt,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i Recipe
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SourceUrl,
|
||||
&i.SiteName,
|
||||
&i.Title,
|
||||
&i.TotalMinutes,
|
||||
&i.PrepMinutes,
|
||||
&i.CookMinutes,
|
||||
&i.Yield,
|
||||
&i.RawJsonld,
|
||||
&i.FetchedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: settings.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const getAISettings = `-- name: GetAISettings :one
|
||||
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings
|
||||
WHERE id = 1
|
||||
`
|
||||
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
func (q *Queries) GetAISettings(ctx context.Context) (AiSetting, error) {
|
||||
row := q.db.QueryRowContext(ctx, getAISettings)
|
||||
var i AiSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Provider,
|
||||
&i.BaseUrl,
|
||||
&i.Model,
|
||||
&i.Temperature,
|
||||
&i.SystemPrompt,
|
||||
&i.ApiKeyCt,
|
||||
&i.ApiKeyLast4,
|
||||
&i.KeyVerifiedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getChatSettings = `-- name: GetChatSettings :one
|
||||
SELECT id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at FROM chat_settings
|
||||
WHERE id = 1
|
||||
`
|
||||
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) {
|
||||
row := q.db.QueryRowContext(ctx, getChatSettings)
|
||||
var i ChatSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WebhookUrlCt,
|
||||
&i.SpaceLabel,
|
||||
&i.LastTestAt,
|
||||
&i.LastTestStatus,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getHouseholdSettings = `-- name: GetHouseholdSettings :one
|
||||
SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at FROM household_settings
|
||||
WHERE id = 1
|
||||
`
|
||||
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) {
|
||||
row := q.db.QueryRowContext(ctx, getHouseholdSettings)
|
||||
var i HouseholdSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Timezone,
|
||||
&i.DinnerTime,
|
||||
&i.ReminderTime,
|
||||
&i.ReviewTime,
|
||||
&i.PlanDraftDow,
|
||||
&i.PlanDraftTime,
|
||||
&i.SuggestionsDow,
|
||||
&i.SuggestionsTime,
|
||||
&i.ChatAutosendDow,
|
||||
&i.ChatAutosendTime,
|
||||
&i.OnboardingCompletedAt,
|
||||
&i.UpdatesSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getIntegrationsGoogle = `-- name: GetIntegrationsGoogle :one
|
||||
SELECT id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at FROM integrations_google
|
||||
WHERE id = 1
|
||||
`
|
||||
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
func (q *Queries) GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) {
|
||||
row := q.db.QueryRowContext(ctx, getIntegrationsGoogle)
|
||||
var i IntegrationsGoogle
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.RefreshTokenCt,
|
||||
&i.CalendarID,
|
||||
&i.CalendarName,
|
||||
&i.ConnectedAt,
|
||||
&i.LastSyncError,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRecipeSearchSettings = `-- name: GetRecipeSearchSettings :one
|
||||
SELECT id, provider, base_url, api_key_ct, created_at, updated_at FROM recipe_search_settings
|
||||
WHERE id = 1
|
||||
`
|
||||
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) {
|
||||
row := q.db.QueryRowContext(ctx, getRecipeSearchSettings)
|
||||
var i RecipeSearchSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Provider,
|
||||
&i.BaseUrl,
|
||||
&i.ApiKeyCt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertAISettings = `-- name: UpsertAISettings :one
|
||||
INSERT INTO ai_settings (
|
||||
id, provider, base_url, model, temperature, system_prompt, api_key_ct,
|
||||
api_key_last4, key_verified_at, created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
base_url = excluded.base_url,
|
||||
model = excluded.model,
|
||||
temperature = excluded.temperature,
|
||||
system_prompt = excluded.system_prompt,
|
||||
api_key_ct = excluded.api_key_ct,
|
||||
api_key_last4 = excluded.api_key_last4,
|
||||
key_verified_at = excluded.key_verified_at,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertAISettingsParams struct {
|
||||
Provider string
|
||||
BaseUrl string
|
||||
Model string
|
||||
Temperature float64
|
||||
SystemPrompt sql.NullString
|
||||
ApiKeyCt []byte
|
||||
ApiKeyLast4 sql.NullString
|
||||
KeyVerifiedAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
func (q *Queries) UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertAISettings,
|
||||
arg.Provider,
|
||||
arg.BaseUrl,
|
||||
arg.Model,
|
||||
arg.Temperature,
|
||||
arg.SystemPrompt,
|
||||
arg.ApiKeyCt,
|
||||
arg.ApiKeyLast4,
|
||||
arg.KeyVerifiedAt,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i AiSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Provider,
|
||||
&i.BaseUrl,
|
||||
&i.Model,
|
||||
&i.Temperature,
|
||||
&i.SystemPrompt,
|
||||
&i.ApiKeyCt,
|
||||
&i.ApiKeyLast4,
|
||||
&i.KeyVerifiedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertChatSettings = `-- name: UpsertChatSettings :one
|
||||
INSERT INTO chat_settings (
|
||||
id, webhook_url_ct, space_label, last_test_at, last_test_status,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?,
|
||||
?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
webhook_url_ct = excluded.webhook_url_ct,
|
||||
space_label = excluded.space_label,
|
||||
last_test_at = excluded.last_test_at,
|
||||
last_test_status = excluded.last_test_status,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertChatSettingsParams struct {
|
||||
WebhookUrlCt []byte
|
||||
SpaceLabel sql.NullString
|
||||
LastTestAt sql.NullString
|
||||
LastTestStatus sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
func (q *Queries) UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertChatSettings,
|
||||
arg.WebhookUrlCt,
|
||||
arg.SpaceLabel,
|
||||
arg.LastTestAt,
|
||||
arg.LastTestStatus,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i ChatSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WebhookUrlCt,
|
||||
&i.SpaceLabel,
|
||||
&i.LastTestAt,
|
||||
&i.LastTestStatus,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertHouseholdSettings = `-- name: UpsertHouseholdSettings :one
|
||||
INSERT INTO household_settings (
|
||||
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
|
||||
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
||||
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
timezone = excluded.timezone,
|
||||
dinner_time = excluded.dinner_time,
|
||||
reminder_time = excluded.reminder_time,
|
||||
review_time = excluded.review_time,
|
||||
plan_draft_dow = excluded.plan_draft_dow,
|
||||
plan_draft_time = excluded.plan_draft_time,
|
||||
suggestions_dow = excluded.suggestions_dow,
|
||||
suggestions_time = excluded.suggestions_time,
|
||||
chat_autosend_dow = excluded.chat_autosend_dow,
|
||||
chat_autosend_time = excluded.chat_autosend_time,
|
||||
onboarding_completed_at = excluded.onboarding_completed_at,
|
||||
updates_seen_at = excluded.updates_seen_at,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertHouseholdSettingsParams struct {
|
||||
Timezone string
|
||||
DinnerTime string
|
||||
ReminderTime string
|
||||
ReviewTime string
|
||||
PlanDraftDow int64
|
||||
PlanDraftTime string
|
||||
SuggestionsDow int64
|
||||
SuggestionsTime string
|
||||
ChatAutosendDow int64
|
||||
ChatAutosendTime string
|
||||
OnboardingCompletedAt sql.NullString
|
||||
UpdatesSeenAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertHouseholdSettings,
|
||||
arg.Timezone,
|
||||
arg.DinnerTime,
|
||||
arg.ReminderTime,
|
||||
arg.ReviewTime,
|
||||
arg.PlanDraftDow,
|
||||
arg.PlanDraftTime,
|
||||
arg.SuggestionsDow,
|
||||
arg.SuggestionsTime,
|
||||
arg.ChatAutosendDow,
|
||||
arg.ChatAutosendTime,
|
||||
arg.OnboardingCompletedAt,
|
||||
arg.UpdatesSeenAt,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i HouseholdSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Timezone,
|
||||
&i.DinnerTime,
|
||||
&i.ReminderTime,
|
||||
&i.ReviewTime,
|
||||
&i.PlanDraftDow,
|
||||
&i.PlanDraftTime,
|
||||
&i.SuggestionsDow,
|
||||
&i.SuggestionsTime,
|
||||
&i.ChatAutosendDow,
|
||||
&i.ChatAutosendTime,
|
||||
&i.OnboardingCompletedAt,
|
||||
&i.UpdatesSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertIntegrationsGoogle = `-- name: UpsertIntegrationsGoogle :one
|
||||
INSERT INTO integrations_google (
|
||||
id, refresh_token_ct, calendar_id, calendar_name, connected_at,
|
||||
last_sync_error, created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?,
|
||||
?, ?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
refresh_token_ct = excluded.refresh_token_ct,
|
||||
calendar_id = excluded.calendar_id,
|
||||
calendar_name = excluded.calendar_name,
|
||||
connected_at = excluded.connected_at,
|
||||
last_sync_error = excluded.last_sync_error,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertIntegrationsGoogleParams struct {
|
||||
RefreshTokenCt []byte
|
||||
CalendarID sql.NullString
|
||||
CalendarName sql.NullString
|
||||
ConnectedAt string
|
||||
LastSyncError sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
func (q *Queries) UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertIntegrationsGoogle,
|
||||
arg.RefreshTokenCt,
|
||||
arg.CalendarID,
|
||||
arg.CalendarName,
|
||||
arg.ConnectedAt,
|
||||
arg.LastSyncError,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i IntegrationsGoogle
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.RefreshTokenCt,
|
||||
&i.CalendarID,
|
||||
&i.CalendarName,
|
||||
&i.ConnectedAt,
|
||||
&i.LastSyncError,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertRecipeSearchSettings = `-- name: UpsertRecipeSearchSettings :one
|
||||
INSERT INTO recipe_search_settings (
|
||||
id, provider, base_url, api_key_ct, created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
base_url = excluded.base_url,
|
||||
api_key_ct = excluded.api_key_ct,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id, provider, base_url, api_key_ct, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertRecipeSearchSettingsParams struct {
|
||||
Provider string
|
||||
BaseUrl sql.NullString
|
||||
ApiKeyCt []byte
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
func (q *Queries) UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertRecipeSearchSettings,
|
||||
arg.Provider,
|
||||
arg.BaseUrl,
|
||||
arg.ApiKeyCt,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i RecipeSearchSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Provider,
|
||||
&i.BaseUrl,
|
||||
&i.ApiKeyCt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Package secureurl holds the one transport rule for URLs that carry
|
||||
// credentials or session cookies: HTTPS, or plain HTTP to a loopback host for
|
||||
// local development and tests.
|
||||
package secureurl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// loopbackHosts are the only hosts plain HTTP is allowed for, compared
|
||||
// case-insensitively. url.URL's Hostname strips the brackets from "[::1]".
|
||||
var loopbackHosts = map[string]bool{"localhost": true, "127.0.0.1": true, "::1": true}
|
||||
|
||||
// Check reports an error unless u is an absolute https:// URL, or an http://
|
||||
// URL whose host is localhost, 127.0.0.1 or ::1.
|
||||
func Check(u *url.URL) error {
|
||||
switch {
|
||||
case u.Hostname() == "":
|
||||
return errors.New("URL has no host")
|
||||
case u.Scheme == "https":
|
||||
return nil
|
||||
case u.Scheme == "http" && loopbackHosts[strings.ToLower(u.Hostname())]:
|
||||
return nil
|
||||
case u.Scheme == "http":
|
||||
return errors.New("plain http:// is allowed only for localhost, 127.0.0.1 or [::1]; use https://")
|
||||
default:
|
||||
return fmt.Errorf("unsupported scheme %q (want https://)", u.Scheme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package secureurl
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
raw string
|
||||
ok bool
|
||||
}{
|
||||
{"https://meals.example.com", true},
|
||||
{"https://auth.example.com/application/o/mealquest/", true},
|
||||
{"https://192.0.2.10:8443", true},
|
||||
{"http://localhost:8080", true},
|
||||
{"http://LOCALHOST:8080", true},
|
||||
{"http://127.0.0.1:8080/path", true},
|
||||
{"http://[::1]:8080", true},
|
||||
{"http://[::1]", true},
|
||||
{"http://meals.example.com", false},
|
||||
{"http://192.0.2.10", false},
|
||||
{"http://127.0.0.2", false},
|
||||
{"http://localhost.example.com", false},
|
||||
{"http://0.0.0.0:8080", false},
|
||||
{"ftp://localhost", false},
|
||||
{"https://", false},
|
||||
{"meals.example.com", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
u, err := url.Parse(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Check(u); (err == nil) != tc.ok {
|
||||
t.Errorf("Check(%q) error = %v, want ok=%v", tc.raw, err, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,15 @@ import (
|
||||
// SPA's index.html.
|
||||
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
|
||||
|
||||
// RouteMounter registers its own routes on the router.
|
||||
type RouteMounter interface {
|
||||
Mount(r chi.Router)
|
||||
}
|
||||
|
||||
// NewRouter returns the HTTP handler for the service. version is reported by
|
||||
// GET /health; assets is the web build served for every other GET.
|
||||
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
|
||||
// GET /health; auth mounts the /auth routes; assets is the web build served
|
||||
// for every other GET.
|
||||
func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs.FS) (http.Handler, error) {
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
@@ -32,12 +38,14 @@ func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler,
|
||||
r.NotFound(notFound)
|
||||
|
||||
r.Get("/health", health(logger, version))
|
||||
auth.Mount(r)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
})
|
||||
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
|
||||
// handler passes them to notFound rather than the SPA fallback.
|
||||
// Catch-all for the PWA. Unknown /auth paths and /media (no routes yet)
|
||||
// reach the frontend handler, which passes them to notFound rather than
|
||||
// the SPA fallback.
|
||||
r.Method(http.MethodGet, "/*", web)
|
||||
r.Method(http.MethodHead, "/*", web)
|
||||
return r, nil
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,13 +27,39 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, assets)
|
||||
r, err := NewRouter(logger, version, stubAuth{}, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// stubAuth mounts a fixed GET /auth/login in place of the real auth flow.
|
||||
type stubAuth struct{}
|
||||
|
||||
func (stubAuth) Mount(r chi.Router) {
|
||||
r.Get("/auth/login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
io.WriteString(w, "stub login")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthRoutesMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "stub login" {
|
||||
t.Errorf("GET /auth/login = %d %q, want the mounted handler", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("POST /auth/login status = %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -99,7 +127,7 @@ func TestReservedPathsNotFound(t *testing.T) {
|
||||
{http.MethodGet, "/api"},
|
||||
{http.MethodGet, "/api/unknown"},
|
||||
{http.MethodPost, "/api/unknown"},
|
||||
{http.MethodGet, "/auth/callback"},
|
||||
{http.MethodGet, "/auth/unknown"},
|
||||
{http.MethodGet, "/media/photo.jpg"},
|
||||
{http.MethodGet, "/health/deep"},
|
||||
{http.MethodGet, "/auth/../quests/42"},
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
@@ -201,3 +202,62 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
t.Error("table from 0001_init was not created")
|
||||
}
|
||||
}
|
||||
|
||||
// schemaSQL returns every schema object's definition, so a test can prove a
|
||||
// later Migrate created or altered nothing.
|
||||
func schemaSQL(t *testing.T, s *Store) []string {
|
||||
t.Helper()
|
||||
rows, err := s.Reader.Query("SELECT type || ' ' || name || ': ' || coalesce(sql, '') FROM sqlite_schema ORDER BY type, name")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var def string
|
||||
if err := rows.Scan(&def); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, def)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
first, err := Open(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(ctx, first.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, beforeSchema := migrationRows(t, first), schemaSQL(t, first)
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := Open(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := second.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := Migrate(ctx, second.Writer, migrations.FS); err != nil {
|
||||
t.Fatalf("migrate after reopen: %v", err)
|
||||
}
|
||||
if after := migrationRows(t, second); !slices.Equal(after, before) {
|
||||
t.Errorf("schema_migrations changed on reopen:\n got %v\nwant %v", after, before)
|
||||
}
|
||||
if afterSchema := schemaSQL(t, second); !slices.Equal(afterSchema, beforeSchema) {
|
||||
t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tables below have no public sqlc queries yet (they arrive with the
|
||||
// planning, suggestion and Supply run features), so these tests drive them
|
||||
// with raw SQL to prove the schema stores, returns and updates their rows.
|
||||
|
||||
const repoTime = "2026-10-03T12:00:00Z"
|
||||
|
||||
type repoRow struct {
|
||||
table string
|
||||
cols map[string]any
|
||||
}
|
||||
|
||||
// repoParents are the rows the tables under test reference, in insert order.
|
||||
var repoParents = map[string]repoRow{
|
||||
"dinners": {"dinners", map[string]any{
|
||||
"id": "dinner-1", "name": "Chili", "normalized_name": "chili",
|
||||
"status": "regular", "origin": "regular", "is_favorite": int64(0),
|
||||
"grounding_status": "manual", "created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
"suggestion_batches": {"suggestion_batches", map[string]any{
|
||||
"id": "batch-1", "trigger": "manual", "status": "done",
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
"week_plans": {"week_plans", map[string]any{
|
||||
"id": "plan-1", "week_start": "2026-10-05", "status": "draft",
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
"shopping_lists": {"shopping_lists", map[string]any{
|
||||
"id": "list-1", "week_start": "2026-10-05", "plan_id": "plan-1",
|
||||
"title": "Supply run", "status": "current",
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
"shopping_groups": {"shopping_groups", map[string]any{
|
||||
"id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1",
|
||||
"position": int64(0), "created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
}
|
||||
|
||||
// readRepoRow reads cols of the row with id through the reader.
|
||||
func readRepoRow(t *testing.T, s *Store, table, id string, cols []string) map[string]any {
|
||||
t.Helper()
|
||||
quoted := make([]string, len(cols))
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i, c := range cols {
|
||||
quoted[i] = `"` + c + `"`
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
q := fmt.Sprintf(`SELECT %s FROM "%s" WHERE id = ?`, strings.Join(quoted, ", "), table)
|
||||
if err := s.Reader.QueryRowContext(context.Background(), q, id).Scan(ptrs...); err != nil {
|
||||
t.Fatalf("read %s %s: %v", table, id, err)
|
||||
}
|
||||
got := make(map[string]any, len(cols))
|
||||
for i, c := range cols {
|
||||
got[c] = vals[i]
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestRepositoryInsertReadUpdate(t *testing.T) {
|
||||
cases := []struct {
|
||||
parents []string
|
||||
row repoRow
|
||||
update map[string]any
|
||||
}{
|
||||
{
|
||||
row: repoRow{"week_plans", map[string]any{
|
||||
"id": "plan-2", "week_start": "2026-10-12", "status": "draft",
|
||||
"ai_note": "Two Side quests this week.", "ai_run_id": nil, "calendar_synced_at": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"status": "synced", "calendar_synced_at": "2026-10-04T09:00:00Z"},
|
||||
},
|
||||
{
|
||||
parents: []string{"dinners", "week_plans"},
|
||||
row: repoRow{"plan_days", map[string]any{
|
||||
"id": "day-1", "plan_id": "plan-1", "date": "2026-10-06", "dinner_id": "dinner-1",
|
||||
"is_new": int64(0), "calendar_event_id": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"dinner_id": nil, "calendar_event_id": "evt-123"},
|
||||
},
|
||||
{
|
||||
parents: []string{"dinners", "suggestion_batches"},
|
||||
row: repoRow{"suggestions", map[string]any{
|
||||
"id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1",
|
||||
"reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
|
||||
"status": "pending", "decided_at": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"status": "accepted", "decided_at": "2026-10-04T10:00:00Z"},
|
||||
},
|
||||
{
|
||||
parents: []string{"week_plans"},
|
||||
row: repoRow{"shopping_lists", map[string]any{
|
||||
"id": "list-2", "week_start": "2026-10-05", "plan_id": "plan-1",
|
||||
"title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"status": "archived", "frozen_at": "2026-10-12T00:00:00Z"},
|
||||
},
|
||||
{
|
||||
parents: []string{"dinners", "week_plans", "shopping_lists"},
|
||||
row: repoRow{"shopping_groups", map[string]any{
|
||||
"id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "position": int64(0),
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"position": int64(3)},
|
||||
},
|
||||
{
|
||||
parents: []string{"dinners", "week_plans", "shopping_lists", "shopping_groups"},
|
||||
row: repoRow{"shopping_items", map[string]any{
|
||||
"id": "item-1", "group_id": "group-1", "name": "Onion", "qty": "2",
|
||||
"needed": int64(1), "position": int64(0),
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"needed": int64(0), "qty": "3"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.row.table, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := migratedStore(t)
|
||||
for _, p := range tc.parents {
|
||||
parent := repoParents[p]
|
||||
if err := insertSchemaRow(s.Writer, parent.table, parent.cols); err != nil {
|
||||
t.Fatalf("seed %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
table, id := tc.row.table, tc.row.cols["id"].(string)
|
||||
cols := slices.Sorted(maps.Keys(tc.row.cols))
|
||||
|
||||
if err := insertSchemaRow(s.Writer, table, tc.row.cols); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, tc.row.cols) {
|
||||
t.Fatalf("read after insert:\n got %v\nwant %v", got, tc.row.cols)
|
||||
}
|
||||
|
||||
want := maps.Clone(tc.row.cols)
|
||||
maps.Copy(want, tc.update)
|
||||
want["updated_at"] = "2026-10-04T12:00:00Z"
|
||||
setCols := slices.Sorted(maps.Keys(tc.update))
|
||||
sets := make([]string, 0, len(setCols)+1)
|
||||
args := make([]any, 0, len(setCols)+2)
|
||||
for _, c := range append(setCols, "updated_at") {
|
||||
sets = append(sets, `"`+c+`" = ?`)
|
||||
args = append(args, want[c])
|
||||
}
|
||||
args = append(args, id)
|
||||
q := fmt.Sprintf(`UPDATE "%s" SET %s WHERE id = ?`, table, strings.Join(sets, ", "))
|
||||
res, err := s.Writer.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil || n != 1 {
|
||||
t.Fatalf("update affected %d rows (err %v), want 1", n, err)
|
||||
}
|
||||
if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("read after update:\n got %v\nwant %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
-- name: CreateDinner :one
|
||||
INSERT INTO dinners (
|
||||
id, name, normalized_name, status, origin, method, minutes_total,
|
||||
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
|
||||
updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetDinner :one
|
||||
SELECT * FROM dinners
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: ListDinnersByStatus :many
|
||||
SELECT * FROM dinners
|
||||
WHERE status = ?
|
||||
ORDER BY normalized_name, id;
|
||||
|
||||
-- name: UpdateDinner :one
|
||||
-- An unknown id returns sql.ErrNoRows.
|
||||
UPDATE dinners
|
||||
SET name = ?,
|
||||
normalized_name = ?,
|
||||
status = ?,
|
||||
origin = ?,
|
||||
method = ?,
|
||||
minutes_total = ?,
|
||||
difficulty = ?,
|
||||
cuisine = ?,
|
||||
recipe_id = ?,
|
||||
photo_id = ?,
|
||||
grounding_status = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: ArchiveDinner :one
|
||||
-- An unknown id returns sql.ErrNoRows.
|
||||
UPDATE dinners
|
||||
SET status = 'archived',
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: SetDinnerFavorite :one
|
||||
-- An unknown id returns sql.ErrNoRows.
|
||||
UPDATE dinners
|
||||
SET is_favorite = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- name: CreatePhoto :one
|
||||
INSERT INTO photos (
|
||||
id, source_url, local_path, width, height, sha256, fetched_at,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetPhotoBySHA256 :one
|
||||
-- Finds stored image bytes by content hash. The schema does not make sha256
|
||||
-- unique, so the earliest-created (lowest id) match is returned.
|
||||
SELECT * FROM photos
|
||||
WHERE sha256 = ?
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,65 @@
|
||||
-- name: UpsertRecipe :one
|
||||
-- A recipe is keyed by source_url: re-fetching the same URL updates the
|
||||
-- existing row and keeps its id and created_at, which the returned row
|
||||
-- carries. Replace its ingredients and steps in the same transaction with
|
||||
-- DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries.
|
||||
INSERT INTO recipes (
|
||||
id, source_url, site_name, title, total_minutes, prep_minutes,
|
||||
cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (source_url) DO UPDATE SET
|
||||
site_name = excluded.site_name,
|
||||
title = excluded.title,
|
||||
total_minutes = excluded.total_minutes,
|
||||
prep_minutes = excluded.prep_minutes,
|
||||
cook_minutes = excluded.cook_minutes,
|
||||
yield = excluded.yield,
|
||||
raw_jsonld = excluded.raw_jsonld,
|
||||
fetched_at = excluded.fetched_at,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetRecipe :one
|
||||
SELECT * FROM recipes
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: GetRecipeBySourceURL :one
|
||||
SELECT * FROM recipes
|
||||
WHERE source_url = ?;
|
||||
|
||||
-- name: CreateRecipeIngredient :exec
|
||||
INSERT INTO recipe_ingredients (
|
||||
id, recipe_id, position, raw_text, name, qty, unit, note,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?
|
||||
);
|
||||
|
||||
-- name: ListRecipeIngredients :many
|
||||
SELECT * FROM recipe_ingredients
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position, id;
|
||||
|
||||
-- name: DeleteRecipeIngredients :exec
|
||||
DELETE FROM recipe_ingredients
|
||||
WHERE recipe_id = ?;
|
||||
|
||||
-- name: CreateRecipeStep :exec
|
||||
INSERT INTO recipe_steps (
|
||||
id, recipe_id, position, text, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?
|
||||
);
|
||||
|
||||
-- name: ListRecipeSteps :many
|
||||
SELECT * FROM recipe_steps
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position, id;
|
||||
|
||||
-- name: DeleteRecipeSteps :exec
|
||||
DELETE FROM recipe_steps
|
||||
WHERE recipe_id = ?;
|
||||
@@ -0,0 +1,128 @@
|
||||
-- name: GetHouseholdSettings :one
|
||||
-- Returns sql.ErrNoRows until the row exists.
|
||||
SELECT * FROM household_settings
|
||||
WHERE id = 1;
|
||||
|
||||
-- name: UpsertHouseholdSettings :one
|
||||
-- Creates the single row (id 1) or replaces every column except id and
|
||||
-- created_at.
|
||||
INSERT INTO household_settings (
|
||||
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
|
||||
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
||||
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
timezone = excluded.timezone,
|
||||
dinner_time = excluded.dinner_time,
|
||||
reminder_time = excluded.reminder_time,
|
||||
review_time = excluded.review_time,
|
||||
plan_draft_dow = excluded.plan_draft_dow,
|
||||
plan_draft_time = excluded.plan_draft_time,
|
||||
suggestions_dow = excluded.suggestions_dow,
|
||||
suggestions_time = excluded.suggestions_time,
|
||||
chat_autosend_dow = excluded.chat_autosend_dow,
|
||||
chat_autosend_time = excluded.chat_autosend_time,
|
||||
onboarding_completed_at = excluded.onboarding_completed_at,
|
||||
updates_seen_at = excluded.updates_seen_at,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetAISettings :one
|
||||
-- Returns sql.ErrNoRows until the row exists.
|
||||
SELECT * FROM ai_settings
|
||||
WHERE id = 1;
|
||||
|
||||
-- name: UpsertAISettings :one
|
||||
-- Creates the single row (id 1) or replaces every column except id and
|
||||
-- created_at.
|
||||
INSERT INTO ai_settings (
|
||||
id, provider, base_url, model, temperature, system_prompt, api_key_ct,
|
||||
api_key_last4, key_verified_at, created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
base_url = excluded.base_url,
|
||||
model = excluded.model,
|
||||
temperature = excluded.temperature,
|
||||
system_prompt = excluded.system_prompt,
|
||||
api_key_ct = excluded.api_key_ct,
|
||||
api_key_last4 = excluded.api_key_last4,
|
||||
key_verified_at = excluded.key_verified_at,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetRecipeSearchSettings :one
|
||||
-- Returns sql.ErrNoRows until the row exists.
|
||||
SELECT * FROM recipe_search_settings
|
||||
WHERE id = 1;
|
||||
|
||||
-- name: UpsertRecipeSearchSettings :one
|
||||
-- Creates the single row (id 1) or replaces every column except id and
|
||||
-- created_at.
|
||||
INSERT INTO recipe_search_settings (
|
||||
id, provider, base_url, api_key_ct, created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
base_url = excluded.base_url,
|
||||
api_key_ct = excluded.api_key_ct,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetIntegrationsGoogle :one
|
||||
-- Returns sql.ErrNoRows until the row exists.
|
||||
SELECT * FROM integrations_google
|
||||
WHERE id = 1;
|
||||
|
||||
-- name: UpsertIntegrationsGoogle :one
|
||||
-- Creates the single row (id 1) or replaces every column except id and
|
||||
-- created_at.
|
||||
INSERT INTO integrations_google (
|
||||
id, refresh_token_ct, calendar_id, calendar_name, connected_at,
|
||||
last_sync_error, created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?,
|
||||
?, ?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
refresh_token_ct = excluded.refresh_token_ct,
|
||||
calendar_id = excluded.calendar_id,
|
||||
calendar_name = excluded.calendar_name,
|
||||
connected_at = excluded.connected_at,
|
||||
last_sync_error = excluded.last_sync_error,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetChatSettings :one
|
||||
-- Returns sql.ErrNoRows until the row exists.
|
||||
SELECT * FROM chat_settings
|
||||
WHERE id = 1;
|
||||
|
||||
-- name: UpsertChatSettings :one
|
||||
-- Creates the single row (id 1) or replaces every column except id and
|
||||
-- created_at.
|
||||
INSERT INTO chat_settings (
|
||||
id, webhook_url_ct, space_label, last_test_at, last_test_status,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?,
|
||||
?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
webhook_url_ct = excluded.webhook_url_ct,
|
||||
space_label = excluded.space_label,
|
||||
last_test_at = excluded.last_test_at,
|
||||
last_test_status = excluded.last_test_status,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
@@ -0,0 +1,15 @@
|
||||
# sqlc code generation. Regenerate with `make sqlc` (pinned sqlc version in the
|
||||
# Makefile); `make sqlc-check` fails when internal/db is stale. sqlc reads only
|
||||
# the top-level *.sql files of the schema directory, so migrations/queries/ is
|
||||
# not treated as schema.
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: sqlite
|
||||
schema: migrations
|
||||
queries: migrations/queries
|
||||
gen:
|
||||
go:
|
||||
package: db
|
||||
out: internal/db
|
||||
sql_package: database/sql
|
||||
emit_interface: true
|
||||
Reference in New Issue
Block a user