release: authentication moves to Authelia (#5)
Authelia now runs in front of Traefik, so the app was asking for a second
password at the same door. This removes its own authentication entirely
rather than layering the two.
## Breaking — the server needs both files in this deploy
`compose.yaml` and `.env` are not pulled from this repository. The new image
ignores `PASSWORD`, and the old image refuses to start without it, so the
image and the compose file have to move together or the container dies at
startup.
| Variable | Change |
|---|---|
| `PASSWORD` | **removed** — the app no longer reads it |
| `AUTH` | **new, required** — the Traefik middleware that authenticates the app, e.g. `authelia@docker` |
| `CERTRESOLVER` | **new, required** — the resolver issuing the certificate for `HOST` |
The `tls=true` label is replaced by `tls.certresolver=${CERTRESOLVER}`.
Naming a resolver implies TLS, so it stays one label rather than two — and
the resolver had been carried by hand on the server since the first deploy.
## What was removed
- `auth()` and `challenge()` — HTTP Basic over a single shared password
- `throttle.go` and its tests — the per-IP guess limiter and the
`X-Forwarded-For` handling that fed it
- `golang.org/x/time`, which existed only for that limiter
- Sixty `-u` flags from the smoke script
`routes()` returns the bare mux and `/healthz` is an ordinary route on it.
159 insertions against 446 deletions; nothing was written to replace what
went.
## What holds the app up now
Both invariants live in `compose.yaml`, next to comments saying why:
- **The router names the Authelia middleware through `AUTH`.** Traefik takes
a router out of service when its middleware does not resolve, so an unset
or misspelt value fails shut rather than serving the app open.
- **The container publishes no ports.** It is reachable only over the shared
proxy network. Publishing `8080` would now bypass authentication outright,
not merely TLS.
`/healthz` returns the version and nothing else, so it is safe to exempt in
Authelia if a monitor needs to reach it.
## Why this is stronger, not weaker
The layer being deleted was one shared secret with no sessions, no second
factor and no way to revoke access for one person. Authelia does all three,
configured once for every service on the host instead of reimplemented per
app. The weaker of the two prompts was the one being kept.
## Tests
`TestAuth` and `TestHealthzSkipsAuth` are replaced by a single test asserting
every route answers without credentials — a 401 from the app would now mean
authentication had crept back in. `make check` green; CI green on `dev`.
## Note on the commit list
Nine of the ten commits below are already in `main` via #4, squash-merged
under a different SHA. They contribute nothing to the diff, which is the
auth removal alone.
---------
Co-authored-by: Esa Kataja <[email protected]>
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
+8
-3
@@ -5,13 +5,18 @@
|
|||||||
REPO=registry.example.com/you/foodster
|
REPO=registry.example.com/you/foodster
|
||||||
TAG=latest
|
TAG=latest
|
||||||
|
|
||||||
# Shared household password. The app will not start without it.
|
|
||||||
PASSWORD=changeme
|
|
||||||
|
|
||||||
# Hostname Traefik routes to. Kept here rather than in compose.yaml so no
|
# Hostname Traefik routes to. Kept here rather than in compose.yaml so no
|
||||||
# infrastructure detail is committed.
|
# infrastructure detail is committed.
|
||||||
HOST=foodster.example.com
|
HOST=foodster.example.com
|
||||||
|
|
||||||
|
# The Traefik middleware that authenticates the app. The app itself has no
|
||||||
|
# login, so this is the whole of its access control — an unset or misspelt
|
||||||
|
# name takes the router out of service, which is the right way to fail.
|
||||||
|
AUTH=authelia@docker
|
||||||
|
|
||||||
|
# Traefik certificate resolver issuing the TLS certificate for HOST.
|
||||||
|
CERTRESOLVER=letsencrypt
|
||||||
|
|
||||||
# Anything other than prod is written into the browser tab title, so a dev
|
# Anything other than prod is written into the browser tab title, so a dev
|
||||||
# instance open beside the real one can be told apart.
|
# instance open beside the real one can be told apart.
|
||||||
ENV=prod
|
ENV=prod
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ COMPOSE ?= podman compose
|
|||||||
BIN := foodster
|
BIN := foodster
|
||||||
PKG := ./cmd/foodster
|
PKG := ./cmd/foodster
|
||||||
|
|
||||||
# Shared password and TZ live here. Gitignored.
|
# Registry coordinates, hostname, TZ. Gitignored.
|
||||||
ifneq (,$(wildcard .env))
|
ifneq (,$(wildcard .env))
|
||||||
include .env
|
include .env
|
||||||
export
|
export
|
||||||
@@ -28,7 +28,7 @@ build: generate ## Build ./foodster
|
|||||||
-ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG)
|
-ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG)
|
||||||
|
|
||||||
run: generate ## Run locally on :8080 (database in ./data)
|
run: generate ## Run locally on :8080 (database in ./data)
|
||||||
PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG)
|
ENV=dev go run $(PKG)
|
||||||
|
|
||||||
test: generate ## Run unit tests
|
test: generate ## Run unit tests
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ polished, it may be released as FOSS under MIT.
|
|||||||
- No grocery list generation (possible future add-on).
|
- No grocery list generation (possible future add-on).
|
||||||
- No per-recipe ingredient tracking — meals are just names.
|
- No per-recipe ingredient tracking — meals are just names.
|
||||||
- No calendar/scheduling with times, reminders, or calendar exports.
|
- No calendar/scheduling with times, reminders, or calendar exports.
|
||||||
- No user accounts, per-person profiles, or permissions. A single shared
|
- No user accounts, per-person profiles, or permissions *in the app*.
|
||||||
password gates the whole app (§9).
|
Authentication is the reverse proxy's job (§9).
|
||||||
- No nutrition tracking, calorie counting, or dietary-goal optimization.
|
- No nutrition tracking, calorie counting, or dietary-goal optimization.
|
||||||
- No mobile-native apps. Web only (mobile-friendly responsive is enough).
|
- No mobile-native apps. Web only (mobile-friendly responsive is enough).
|
||||||
- No per-user accounts or sessions. The app *is* reachable from the internet
|
- No per-user accounts or sessions in the app. It *is* reachable from the
|
||||||
(§9, §10), gated by a single shared password over TLS.
|
internet (§9, §10), behind Authelia at the proxy.
|
||||||
|
|
||||||
## 4. Delivery stages
|
## 4. Delivery stages
|
||||||
|
|
||||||
@@ -72,9 +72,9 @@ weighting to be meaningful (a few weeks of logged meals).
|
|||||||
|
|
||||||
## 5. Users
|
## 5. Users
|
||||||
|
|
||||||
A single household. One shared instance, no per-person accounts. Anyone on the
|
A single household. One shared instance, no per-person accounts. Everyone who
|
||||||
home network who knows the shared password can open the app and interact with
|
gets past Authelia sees and edits the same log; the app draws no distinction
|
||||||
it.
|
between them.
|
||||||
|
|
||||||
The interface is written in **Finnish** — every user of this instance is a
|
The interface is written in **Finnish** — every user of this instance is a
|
||||||
Finnish speaker, so there is no i18n layer and no language switcher. Strings
|
Finnish speaker, so there is no i18n layer and no language switcher. Strings
|
||||||
@@ -350,21 +350,21 @@ build and no asset bundler.
|
|||||||
to UTC would shift logged dinners to the wrong calendar day. `time/tzdata`
|
to UTC would shift logged dinners to the wrong calendar day. `time/tzdata`
|
||||||
is imported because the runtime image carries no zoneinfo. All date logic
|
is imported because the runtime image carries no zoneinfo. All date logic
|
||||||
uses that location explicitly and never `time.Local`.
|
uses that location explicitly and never `time.Local`.
|
||||||
- **Auth**: HTTP Basic with one shared household password read from
|
- **Auth**: none in the app. Every route is served unauthenticated, because
|
||||||
`PASSWORD`; the username is ignored. Compared using
|
the only client that can reach the app is Traefik, which forwards each
|
||||||
`subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor
|
request to **Authelia** first. Sessions, brute-force protection and
|
||||||
its length leaks through timing. `/healthz` is the only route outside auth.
|
multi-factor are configured there once for every service on the host.
|
||||||
- **Exposure**: the app is served on a public hostname behind Traefik, which
|
Deliberately not reimplemented per app: the earlier in-app HTTP Basic layer
|
||||||
terminates TLS, so Basic credentials are encrypted in transit. A shared
|
meant two prompts for one door, and the weaker of the two was the one
|
||||||
password is therefore the only thing between the internet and the app, and
|
holding a shared password.
|
||||||
it is guarded by a per-address rate limiter: five wrong guesses, then one
|
- **Exposure**: served on a public hostname behind Traefik, which terminates
|
||||||
per ten seconds, answered with `429`. Only requests that actually present
|
TLS. Two invariants carry the whole security model, and both are asserted
|
||||||
a wrong password spend the allowance — a request with no `Authorization`
|
in `compose.yaml`. The router names the Authelia middleware through `AUTH`
|
||||||
header is the normal browser handshake that opens every session.
|
— unset or misspelt, Traefik takes the router out of service, so a typo
|
||||||
`X-Forwarded-For` is trusted only when the connection arrived from a
|
fails shut. And the container publishes no ports, so it is reachable only
|
||||||
private address, so a direct client cannot forge a new identity per
|
over the shared proxy network; publishing `8080` would expose an
|
||||||
attempt. None of this substitutes for a strong password; it only removes
|
unauthenticated plaintext copy on the host. `/healthz` returns only the
|
||||||
brute force as a practical route.
|
version and is safe to bypass in Authelia for monitoring.
|
||||||
- **Containers**: built with Podman in development, run under Docker Compose
|
- **Containers**: built with Podman in development, run under Docker Compose
|
||||||
in production. Images are OCI, so one image works with both engines.
|
in production. Images are OCI, so one image works with both engines.
|
||||||
|
|
||||||
@@ -406,8 +406,10 @@ the server and run with Docker Compose.
|
|||||||
`.env.example`):
|
`.env.example`):
|
||||||
Names carry no application prefix: the container namespaces them already.
|
Names carry no application prefix: the container namespaces them already.
|
||||||
- `REPO` and `TAG` — image coordinates.
|
- `REPO` and `TAG` — image coordinates.
|
||||||
- `PASSWORD` — the shared password. Required; the app refuses to start
|
- `AUTH` — the Traefik middleware that authenticates the app, e.g.
|
||||||
without it.
|
`authelia@docker`. Required; it is the app's only access control.
|
||||||
|
- `HOST` and `CERTRESOLVER` — the hostname Traefik matches on and the
|
||||||
|
resolver that issues its certificate.
|
||||||
- `DB` — database file path, default `./data/foodster.db`. The directory is
|
- `DB` — database file path, default `./data/foodster.db`. The directory is
|
||||||
created on startup if missing.
|
created on startup if missing.
|
||||||
- `ENV` — anything but `prod` is prefixed to the browser tab title, so a
|
- `ENV` — anything but `prod` is prefixed to the browser tab title, so a
|
||||||
@@ -422,8 +424,9 @@ the server and run with Docker Compose.
|
|||||||
doing so would put an unencrypted copy of the app on the host, bypassing
|
doing so would put an unencrypted copy of the app on the host, bypassing
|
||||||
the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no
|
the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no
|
||||||
infrastructure detail is committed.
|
infrastructure detail is committed.
|
||||||
- **Health**: `GET /healthz` returns the build version and is exempt from
|
- **Health**: `GET /healthz` returns the build version and nothing else, so it
|
||||||
auth. There is no Docker `HEALTHCHECK` directive, because a `scratch` image
|
is safe to exempt in Authelia. There is no Docker `HEALTHCHECK` directive,
|
||||||
|
because a `scratch` image
|
||||||
has no shell to run one and `restart: unless-stopped` already covers a dead
|
has no shell to run one and `restart: unless-stopped` already covers a dead
|
||||||
process. Adding one would mean giving the binary a `-healthcheck` flag that
|
process. Adding one would mean giving the binary a `-healthcheck` flag that
|
||||||
calls its own endpoint.
|
calls its own endpoint.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ One static Go binary. No Node.js, no bundler, no separate database server.
|
|||||||
| Interactivity | [Datastar](https://data-star.dev) — signals and DOM patching in one ~11 kB script |
|
| Interactivity | [Datastar](https://data-star.dev) — signals and DOM patching in one ~11 kB script |
|
||||||
| Styling | hand-written CSS, `light-dark()` for themes |
|
| Styling | hand-written CSS, `light-dark()` for themes |
|
||||||
| Database | SQLite via `modernc.org/sqlite` (pure Go) |
|
| Database | SQLite via `modernc.org/sqlite` (pure Go) |
|
||||||
| Auth | HTTP Basic, one shared household password |
|
| Auth | none in-app — Authelia, via a Traefik forward-auth middleware |
|
||||||
| Runtime image | `FROM scratch` |
|
| Runtime image | `FROM scratch` |
|
||||||
|
|
||||||
Working on it: [CONTRIBUTING.md](CONTRIBUTING.md) — branches, commit messages,
|
Working on it: [CONTRIBUTING.md](CONTRIBUTING.md) — branches, commit messages,
|
||||||
@@ -196,7 +196,6 @@ Everything is environment variables. `.env` is gitignored; start from
|
|||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PASSWORD` | *required* | Shared password. The app will not start without it. |
|
|
||||||
| `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
|
| `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
|
||||||
| `ENV` | `prod` | Anything else is prefixed to the tab title (`dev · Foodster`). |
|
| `ENV` | `prod` | Anything else is prefixed to the tab title (`dev · Foodster`). |
|
||||||
| `ADDR` | `:8080` | Listen address. Only useful for a second local instance. |
|
| `ADDR` | `:8080` | Listen address. Only useful for a second local instance. |
|
||||||
@@ -205,6 +204,8 @@ Everything is environment variables. `.env` is gitignored; start from
|
|||||||
| `REPO` | *required to run* | Image repository, no tag. Used by `compose.yaml`. |
|
| `REPO` | *required to run* | Image repository, no tag. Used by `compose.yaml`. |
|
||||||
| `TAG` | `latest` | Tag to run under compose. |
|
| `TAG` | `latest` | Tag to run under compose. |
|
||||||
| `HOST` | *required to run* | Hostname Traefik routes to. |
|
| `HOST` | *required to run* | Hostname Traefik routes to. |
|
||||||
|
| `AUTH` | *required to run* | Traefik middleware that authenticates the app, e.g. `authelia@docker`. |
|
||||||
|
| `CERTRESOLVER` | *required to run* | Traefik certificate resolver for `HOST`. |
|
||||||
|
|
||||||
Names carry no prefix: the container gives them their own namespace already.
|
Names carry no prefix: the container gives them their own namespace already.
|
||||||
`PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set
|
`PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set
|
||||||
@@ -254,24 +255,27 @@ docker compose restart
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Access is a single shared password over HTTP Basic — no accounts, no
|
**The app has no authentication of its own.** It trusts every request it
|
||||||
sessions. Credentials are compared in constant time over SHA-256 digests, so
|
receives, because the only thing that can reach it is Traefik, and Traefik
|
||||||
neither the password nor its length leaks through timing.
|
hands each request to Authelia first. Access control, sessions, brute-force
|
||||||
|
protection and multi-factor all live there, where they are configured once
|
||||||
|
for every service on the host instead of reimplemented per app.
|
||||||
|
|
||||||
The app is served on a public hostname behind Traefik, which terminates TLS,
|
Two things make that safe, and both must hold:
|
||||||
so the credentials are encrypted in transit. That leaves the password as the
|
|
||||||
only thing between the internet and the app, so wrong guesses are rate
|
|
||||||
limited per client address: five in a burst, then one per ten seconds,
|
|
||||||
answered with `429`. Requests carrying no `Authorization` header are not
|
|
||||||
charged — that is the handshake every browser session begins with, and
|
|
||||||
counting it would lock the household out for simply opening the app.
|
|
||||||
|
|
||||||
`X-Forwarded-For` is trusted only when the connection came from a private
|
- **`AUTH` names the Authelia middleware** on the router. It is the whole of
|
||||||
address, meaning it arrived through the proxy. A client connecting directly
|
the app's access control. Traefik takes a router out of service when its
|
||||||
could otherwise forge a new address per attempt and skip the limiter.
|
middleware does not resolve, so a typo fails shut rather than open.
|
||||||
|
- **The container publishes no ports.** It is reachable only over the shared
|
||||||
|
`traefik` network. Publishing `8080` would put an unauthenticated,
|
||||||
|
unencrypted copy of the app on the host and defeat both of the above.
|
||||||
|
|
||||||
**None of this replaces a strong `PASSWORD`.** Rate limiting removes
|
`/healthz` returns nothing but the version, so it is safe to bypass in
|
||||||
brute force as a practical route; it does not make a guessable password safe.
|
Authelia if a monitor needs to poll it from outside.
|
||||||
|
|
||||||
|
Earlier versions carried HTTP Basic auth and a per-IP guess limiter. Both
|
||||||
|
were removed once Authelia was in front: two prompts for one door, and the
|
||||||
|
weaker of the two was the one holding a shared password.
|
||||||
|
|
||||||
## Mockups
|
## Mockups
|
||||||
|
|
||||||
|
|||||||
+12
-53
@@ -7,8 +7,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -76,16 +74,11 @@ func run() error {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
// Importing is an offline chore: no password needed, no server started.
|
// Importing is an offline chore: no server started, nothing to serve.
|
||||||
if *importPath != "" {
|
if *importPath != "" {
|
||||||
return runImport(db, *importPath)
|
return runImport(db, *importPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
password := os.Getenv("PASSWORD")
|
|
||||||
if password == "" {
|
|
||||||
return errors.New("PASSWORD is not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail rather than fall back to UTC: a silently wrong zone shifts logged
|
// Fail rather than fall back to UTC: a silently wrong zone shifts logged
|
||||||
// dinners onto the wrong calendar day, which is invisible until the
|
// dinners onto the wrong calendar day, which is invisible until the
|
||||||
// history is already corrupt.
|
// history is already corrupt.
|
||||||
@@ -105,7 +98,7 @@ func run() error {
|
|||||||
// response blocks every other request behind it.
|
// response blocks every other request behind it.
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: routes(db, loc, password),
|
Handler: routes(db, loc),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
@@ -168,7 +161,11 @@ func openDB(path string) (*sql.DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
// routes serves the app unauthenticated. Access control is the reverse proxy's
|
||||||
|
// job: Traefik forwards every request to Authelia before it reaches here, so a
|
||||||
|
// second password in front of it only ever meant two prompts for one door. The
|
||||||
|
// container publishes no ports, so nothing but the proxy can reach it.
|
||||||
|
func routes(db *sql.DB, loc *time.Location) http.Handler {
|
||||||
// Go's mime table has no entry for .webmanifest, and a manifest served as
|
// Go's mime table has no entry for .webmanifest, and a manifest served as
|
||||||
// octet-stream is ignored by the browser.
|
// octet-stream is ignored by the browser.
|
||||||
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
||||||
@@ -191,51 +188,13 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
|||||||
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
|
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
|
||||||
mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
|
mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
|
||||||
|
|
||||||
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
|
// /healthz is an ordinary route now that the app has no auth of its own.
|
||||||
root := http.NewServeMux()
|
// It reveals only the version, so an Authelia bypass rule for it is safe if
|
||||||
root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
// a monitor needs to poll from outside the container network.
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprintln(w, version)
|
fmt.Fprintln(w, version)
|
||||||
})
|
})
|
||||||
root.Handle("/", auth(password, mux))
|
return mux
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
// auth gates everything behind one shared household password. There are no
|
|
||||||
// accounts, so the username is ignored (PRD §9).
|
|
||||||
func auth(password string, next http.Handler) http.Handler {
|
|
||||||
want := sha256.Sum256([]byte(password))
|
|
||||||
guesses := newThrottle()
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, given, ok := r.BasicAuth()
|
|
||||||
|
|
||||||
// A request with no Authorization header is the normal browser
|
|
||||||
// handshake, not a guess: every session opens with one. Challenge it
|
|
||||||
// without spending the address's allowance.
|
|
||||||
if !ok {
|
|
||||||
challenge(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hashing first keeps the comparison a fixed length, so neither the
|
|
||||||
// password nor its length leaks through timing.
|
|
||||||
got := sha256.Sum256([]byte(given))
|
|
||||||
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
|
||||||
if !guesses.allow(clientIP(r)) {
|
|
||||||
http.Error(w, "Liikaa yrityksiä.", http.StatusTooManyRequests)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
challenge(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func challenge(w http.ResponseWriter) {
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// today is the current calendar day in the configured location, truncated to
|
// today is the current calendar day in the configured location, truncated to
|
||||||
|
|||||||
+11
-51
@@ -306,63 +306,23 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuth(t *testing.T) {
|
// The app carries no authentication of its own — Authelia in front of Traefik
|
||||||
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
// does that — so the only thing left to assert is that every route answers
|
||||||
w.WriteHeader(http.StatusTeapot) // proves we reached the wrapped handler
|
// without credentials. A 401 from here would mean auth crept back in.
|
||||||
}))
|
func TestRoutesNeedNoCredentials(t *testing.T) {
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
user string
|
|
||||||
pass string
|
|
||||||
withAuth bool
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"correct password", "", "hunter2", true, http.StatusTeapot},
|
|
||||||
{"username is ignored", "anyone", "hunter2", true, http.StatusTeapot},
|
|
||||||
{"wrong password", "", "wrong", true, http.StatusUnauthorized},
|
|
||||||
{"empty password", "", "", true, http.StatusUnauthorized},
|
|
||||||
{"no credentials", "", "", false, http.StatusUnauthorized},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
if c.withAuth {
|
|
||||||
r.SetBasicAuth(c.user, c.pass)
|
|
||||||
}
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, r)
|
|
||||||
|
|
||||||
if w.Code != c.want {
|
|
||||||
t.Errorf("status = %d, want %d", w.Code, c.want)
|
|
||||||
}
|
|
||||||
if c.want == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" {
|
|
||||||
t.Error("401 without a WWW-Authenticate header; the browser will not prompt")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHealthzSkipsAuth(t *testing.T) {
|
|
||||||
db, err := openDB(t.TempDir() + "/test.db")
|
db, err := openDB(t.TempDir() + "/test.db")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("openDB: %v", err)
|
t.Fatalf("openDB: %v", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
h := routes(db, time.UTC, "hunter2")
|
h := routes(db, time.UTC)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
for _, path := range []string{"/healthz", "/", "/ruuat"} {
|
||||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
w := httptest.NewRecorder()
|
||||||
if w.Code != http.StatusOK {
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
t.Errorf("/healthz without credentials = %d, want 200", w.Code)
|
if w.Code != http.StatusOK {
|
||||||
}
|
t.Errorf("GET %s = %d, want 200", path, w.Code)
|
||||||
|
}
|
||||||
// Everything else must still be gated.
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("/ without credentials = %d, want 401", w.Code)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The app is reachable from the internet, so a shared password needs more
|
|
||||||
// than a sleep in front of it. These allow a family fumbling the password a
|
|
||||||
// handful of quick retries, then roughly six a minute — useless for guessing,
|
|
||||||
// unnoticeable to anyone who knows it.
|
|
||||||
//
|
|
||||||
// This buys time; it is not the defence. A strong password is.
|
|
||||||
const (
|
|
||||||
guessBurst = 5
|
|
||||||
guessInterval = 10 * time.Second
|
|
||||||
|
|
||||||
// Bounds on the per-IP table, so a spray across many addresses cannot
|
|
||||||
// grow it without limit.
|
|
||||||
throttleMaxEntries = 4096
|
|
||||||
throttleIdle = 15 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
type visitor struct {
|
|
||||||
limiter *rate.Limiter
|
|
||||||
seen time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// throttle rate-limits failed password attempts per client address.
|
|
||||||
//
|
|
||||||
// ponytail: one mutex over one map. At household traffic this will never be
|
|
||||||
// contended; shard it if that ever stops being true.
|
|
||||||
type throttle struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
visitors map[string]*visitor
|
|
||||||
}
|
|
||||||
|
|
||||||
func newThrottle() *throttle {
|
|
||||||
return &throttle{visitors: make(map[string]*visitor)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// allow reports whether another wrong guess from this address is permitted.
|
|
||||||
func (t *throttle) allow(ip string) bool {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
if len(t.visitors) >= throttleMaxEntries {
|
|
||||||
t.pruneLocked(now)
|
|
||||||
}
|
|
||||||
|
|
||||||
v := t.visitors[ip]
|
|
||||||
if v == nil {
|
|
||||||
v = &visitor{limiter: rate.NewLimiter(rate.Every(guessInterval), guessBurst)}
|
|
||||||
t.visitors[ip] = v
|
|
||||||
}
|
|
||||||
v.seen = now
|
|
||||||
|
|
||||||
return v.limiter.Allow()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *throttle) pruneLocked(now time.Time) {
|
|
||||||
for ip, v := range t.visitors {
|
|
||||||
if now.Sub(v.seen) > throttleIdle {
|
|
||||||
delete(t.visitors, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Still full of live entries: a spray is in progress. Drop the lot rather
|
|
||||||
// than grow without bound. Everyone gets a fresh allowance, which is the
|
|
||||||
// safe direction to fail — the password is still required.
|
|
||||||
if len(t.visitors) >= throttleMaxEntries {
|
|
||||||
clear(t.visitors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientIP resolves the address to rate-limit against.
|
|
||||||
//
|
|
||||||
// X-Forwarded-For is only believed when the connection itself came from a
|
|
||||||
// private address, meaning it arrived through the reverse proxy on the
|
|
||||||
// container network. A client connecting directly could otherwise forge a
|
|
||||||
// fresh address on every attempt and walk straight past the limiter.
|
|
||||||
func clientIP(r *http.Request) string {
|
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
host = r.RemoteAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
ip := net.ParseIP(host)
|
|
||||||
if ip == nil || !(ip.IsPrivate() || ip.IsLoopback()) {
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
|
|
||||||
forwarded := r.Header.Get("X-Forwarded-For")
|
|
||||||
if forwarded == "" {
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
// The nearest proxy appends the address it saw, so the last entry is the
|
|
||||||
// trustworthy one; anything before it was supplied by the client.
|
|
||||||
parts := strings.Split(forwarded, ",")
|
|
||||||
last := strings.TrimSpace(parts[len(parts)-1])
|
|
||||||
if net.ParseIP(last) == nil {
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
return last
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestThrottleBlocksRepeatedGuesses(t *testing.T) {
|
|
||||||
th := newThrottle()
|
|
||||||
|
|
||||||
for i := 0; i < guessBurst; i++ {
|
|
||||||
if !th.allow("198.51.100.7") {
|
|
||||||
t.Fatalf("guess %d refused inside the burst", i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if th.allow("198.51.100.7") {
|
|
||||||
t.Error("guess allowed past the burst")
|
|
||||||
}
|
|
||||||
// A different address has its own allowance.
|
|
||||||
if !th.allow("198.51.100.8") {
|
|
||||||
t.Error("a second address was blocked by the first one's guesses")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientIPIgnoresForwardedHeaderFromDirectClients(t *testing.T) {
|
|
||||||
// Connecting straight from the internet: X-Forwarded-For is attacker
|
|
||||||
// input, so a forged value must not create a fresh rate-limit bucket.
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
r.RemoteAddr = "203.0.113.9:44321"
|
|
||||||
r.Header.Set("X-Forwarded-For", "1.2.3.4")
|
|
||||||
|
|
||||||
if got := clientIP(r); got != "203.0.113.9" {
|
|
||||||
t.Errorf("clientIP = %q, want the real peer 203.0.113.9", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientIPTakesLastForwardedEntryBehindProxy(t *testing.T) {
|
|
||||||
// Arriving through Traefik on the container network. The proxy appends
|
|
||||||
// the address it saw, so the last entry is the trustworthy one and the
|
|
||||||
// forged entry in front of it must be ignored.
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
r.RemoteAddr = "172.18.0.4:53000"
|
|
||||||
r.Header.Set("X-Forwarded-For", "1.2.3.4, 198.51.100.22")
|
|
||||||
|
|
||||||
if got := clientIP(r); got != "198.51.100.22" {
|
|
||||||
t.Errorf("clientIP = %q, want 198.51.100.22", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientIPFallsBackWhenNoForwardedHeader(t *testing.T) {
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
r.RemoteAddr = "172.18.0.4:53000"
|
|
||||||
|
|
||||||
if got := clientIP(r); got != "172.18.0.4" {
|
|
||||||
t.Errorf("clientIP = %q, want 172.18.0.4", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthRateLimitsWrongPasswords(t *testing.T) {
|
|
||||||
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusTeapot)
|
|
||||||
}))
|
|
||||||
|
|
||||||
send := func(pass string) int {
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
r.RemoteAddr = "203.0.113.5:40000"
|
|
||||||
r.SetBasicAuth("", pass)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, r)
|
|
||||||
return w.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < guessBurst; i++ {
|
|
||||||
if code := send("wrong"); code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("guess %d returned %d, want 401", i+1, code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if code := send("wrong"); code != http.StatusTooManyRequests {
|
|
||||||
t.Errorf("guess past the burst returned %d, want 429", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthDoesNotSpendAllowanceOnTheBrowserHandshake(t *testing.T) {
|
|
||||||
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusTeapot)
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Every session opens with a credential-less request. Charging those
|
|
||||||
// would lock a family out by simply opening the app a few times.
|
|
||||||
for i := 0; i < guessBurst*4; i++ {
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
r.RemoteAddr = "203.0.113.6:40000"
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, r)
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("handshake %d returned %d, want 401", i+1, w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The correct password still works afterwards.
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
r.RemoteAddr = "203.0.113.6:40000"
|
|
||||||
r.SetBasicAuth("", "hunter2")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, r)
|
|
||||||
if w.Code != http.StatusTeapot {
|
|
||||||
t.Errorf("correct password returned %d, want the wrapped handler", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -143,8 +143,9 @@ templ page(title, current string) {
|
|||||||
<title>{ pageTitle(title) }</title>
|
<title>{ pageTitle(title) }</title>
|
||||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
|
||||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
|
||||||
<!-- use-credentials: the manifest is fetched behind Basic auth and
|
<!-- use-credentials: the manifest fetch is anonymous by default, so
|
||||||
would otherwise come back 401 and be ignored. -->
|
behind Authelia it would be redirected to the login page and
|
||||||
|
the manifest quietly ignored. -->
|
||||||
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials"/>
|
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials"/>
|
||||||
<link rel="stylesheet" href="/static/app.css"/>
|
<link rel="stylesheet" href="/static/app.css"/>
|
||||||
<!-- Not deferred: it applies the stored theme before first paint. -->
|
<!-- Not deferred: it applies the stored theme before first paint. -->
|
||||||
|
|||||||
+9
-3
@@ -15,19 +15,25 @@ services:
|
|||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
PASSWORD: ${PASSWORD:?set PASSWORD in .env}
|
|
||||||
DB: /data/foodster.db
|
DB: /data/foodster.db
|
||||||
ENV: ${ENV:-prod}
|
ENV: ${ENV:-prod}
|
||||||
TZ: ${TZ:-Europe/Helsinki}
|
TZ: ${TZ:-Europe/Helsinki}
|
||||||
|
|
||||||
# No published ports: Traefik reaches the container over the shared
|
# No published ports: Traefik reaches the container over the shared
|
||||||
# network. Publishing 8080 as well would put an unencrypted copy of the
|
# network. Publishing 8080 as well would put an unencrypted copy of the
|
||||||
# app on the host, bypassing TLS.
|
# app on the host, bypassing TLS — and, now that the app has no login of
|
||||||
|
# its own, bypassing authentication entirely.
|
||||||
|
#
|
||||||
|
# The middleware is the only thing standing in front of the app. If AUTH
|
||||||
|
# is unset or names a middleware Traefik does not know, Traefik takes the
|
||||||
|
# router out of service rather than serving it open, so a typo fails shut.
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.foodster.entrypoints=websecure
|
- traefik.http.routers.foodster.entrypoints=websecure
|
||||||
- traefik.http.routers.foodster.rule=Host(`${HOST:?set HOST in .env}`)
|
- traefik.http.routers.foodster.rule=Host(`${HOST:?set HOST in .env}`)
|
||||||
- traefik.http.routers.foodster.tls=true
|
# Naming a resolver implies tls=true, so this is one label, not two.
|
||||||
|
- traefik.http.routers.foodster.tls.certresolver=${CERTRESOLVER:?set CERTRESOLVER in .env}
|
||||||
|
- traefik.http.routers.foodster.middlewares=${AUTH:?set AUTH in .env, e.g. authelia@docker}
|
||||||
- traefik.http.services.foodster.loadbalancer.server.port=8080
|
- traefik.http.services.foodster.loadbalancer.server.port=8080
|
||||||
- traefik.docker.network=traefik
|
- traefik.docker.network=traefik
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ tool github.com/a-h/templ/cmd/templ
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/a-h/templ v0.3.1020
|
github.com/a-h/templ v0.3.1020
|
||||||
golang.org/x/time v0.15.0
|
|
||||||
modernc.org/sqlite v1.58.0
|
modernc.org/sqlite v1.58.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,6 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
+64
-65
@@ -1,7 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# End-to-end check of a running Foodster: auth, static assets and the bundle
|
# End-to-end check of a running Foodster: static assets, the logger and the
|
||||||
# import flow. Builds its own binary, uses a scratch database and a spare
|
# bundle import flow. Builds its own binary, uses a scratch database and a
|
||||||
# port, and cleans up after itself, so it never touches a real instance.
|
# spare port, and cleans up after itself, so it never touches a real instance.
|
||||||
|
#
|
||||||
|
# There is nothing to authenticate as: the app is served behind Authelia and
|
||||||
|
# has no login of its own.
|
||||||
#
|
#
|
||||||
# Run it with `make smoke`.
|
# Run it with `make smoke`.
|
||||||
|
|
||||||
@@ -10,7 +13,6 @@ set -eu
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
addr=127.0.0.1:8099
|
addr=127.0.0.1:8099
|
||||||
pass=smoke
|
|
||||||
|
|
||||||
# Dates are relative, never literal. A hardcoded one turns into "some day in
|
# Dates are relative, never literal. A hardcoded one turns into "some day in
|
||||||
# the past" at the next midnight, and the assertions quietly start meaning
|
# the past" at the next midnight, and the assertions quietly start meaning
|
||||||
@@ -22,7 +24,7 @@ trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
|
|||||||
|
|
||||||
go build -o "$tmp/foodster" ./cmd/foodster
|
go build -o "$tmp/foodster" ./cmd/foodster
|
||||||
|
|
||||||
PASSWORD="$pass" DB="$tmp/smoke.db" ADDR="$addr" \
|
DB="$tmp/smoke.db" ADDR="$addr" \
|
||||||
"$tmp/foodster" >"$tmp/server.log" 2>&1 &
|
"$tmp/foodster" >"$tmp/server.log" 2>&1 &
|
||||||
srv=$!
|
srv=$!
|
||||||
|
|
||||||
@@ -61,22 +63,19 @@ refute() {
|
|||||||
|
|
||||||
echo "smoke: http://$addr"
|
echo "smoke: http://$addr"
|
||||||
|
|
||||||
check "unauthenticated request is refused" \
|
check "healthz answers" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/")" "401"
|
|
||||||
|
|
||||||
check "healthz needs no password" \
|
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/healthz")" "200"
|
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/healthz")" "200"
|
||||||
|
|
||||||
check "datastar client is served" \
|
check "datastar client is served" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/datastar.js")" "200"
|
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/datastar.js")" "200"
|
||||||
|
|
||||||
check "favicon is served" \
|
check "favicon is served" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/favicon.svg")" "200"
|
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/favicon.svg")" "200"
|
||||||
|
|
||||||
check "theme script is served" \
|
check "theme script is served" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/theme.js")" "200"
|
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/theme.js")" "200"
|
||||||
|
|
||||||
home=$(curl -s -u ":$pass" "http://$addr/")
|
home=$(curl -s "http://$addr/")
|
||||||
check "the header carries the brand" "$home" "Foodster"
|
check "the header carries the brand" "$home" "Foodster"
|
||||||
# ENV is unset here, so this instance is production and unmarked.
|
# ENV is unset here, so this instance is production and unmarked.
|
||||||
check "production tabs are not tagged" "$home" "<title>Foodster</title>"
|
check "production tabs are not tagged" "$home" "<title>Foodster</title>"
|
||||||
@@ -85,47 +84,47 @@ check "the theme toggle is present" "$home" "data-theme-toggle"
|
|||||||
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'
|
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'
|
||||||
|
|
||||||
check "apple touch icon is served" \
|
check "apple touch icon is served" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/apple-touch-icon.png")" "200"
|
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/static/apple-touch-icon.png")" "200"
|
||||||
|
|
||||||
# A manifest served as octet-stream is silently ignored by the browser.
|
# A manifest served as octet-stream is silently ignored by the browser.
|
||||||
check "manifest has the right content type" \
|
check "manifest has the right content type" \
|
||||||
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" "http://$addr/static/manifest.webmanifest")" \
|
"$(curl -s -o /dev/null -w '%{content_type}' "http://$addr/static/manifest.webmanifest")" \
|
||||||
"application/manifest+json"
|
"application/manifest+json"
|
||||||
|
|
||||||
check "catalog starts empty" \
|
check "catalog starts empty" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "0 pääruokaa"
|
"$(curl -s "http://$addr/ruuat")" "0 pääruokaa"
|
||||||
|
|
||||||
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")
|
out=$(curl -s -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")
|
||||||
check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0"
|
check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0"
|
||||||
check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
|
check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
|
||||||
|
|
||||||
check "re-import refuses duplicates" \
|
check "re-import refuses duplicates" \
|
||||||
"$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")" \
|
"$(curl -s -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")" \
|
||||||
"jo listalla"
|
"jo listalla"
|
||||||
|
|
||||||
check "pasted JSON imports" \
|
check "pasted JSON imports" \
|
||||||
"$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
|
"$(curl -s -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
|
||||||
"http://$addr/ruuat/tuonti")" "Lisätty 1"
|
"http://$addr/ruuat/tuonti")" "Lisätty 1"
|
||||||
|
|
||||||
check "unknown category is reported" \
|
check "unknown category is reported" \
|
||||||
"$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
|
"$(curl -s -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
|
||||||
"http://$addr/ruuat/tuonti")" "tuntematon kategoria"
|
"http://$addr/ruuat/tuonti")" "tuntematon kategoria"
|
||||||
|
|
||||||
check "empty submit is explained" \
|
check "empty submit is explained" \
|
||||||
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa"
|
"$(curl -s -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa"
|
||||||
|
|
||||||
check "malformed JSON is explained" \
|
check "malformed JSON is explained" \
|
||||||
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa"
|
"$(curl -s -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa"
|
||||||
|
|
||||||
# ---- the log flow, against the dishes imported above --------------------
|
# ---- the log flow, against the dishes imported above --------------------
|
||||||
|
|
||||||
board=$(curl -s -u ":$pass" "http://$addr/")
|
board=$(curl -s "http://$addr/")
|
||||||
check "board lists imported dishes" "$board" "Lihapullat"
|
check "board lists imported dishes" "$board" "Lihapullat"
|
||||||
|
|
||||||
# Tähteet is loggable but is not food: on the board, never in the catalog.
|
# Tähteet is loggable but is not food: on the board, never in the catalog.
|
||||||
check "leftovers are on the board" "$board" "Tähteet"
|
check "leftovers are on the board" "$board" "Tähteet"
|
||||||
refute "leftovers are not in the catalog" \
|
refute "leftovers are not in the catalog" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Tähteet"
|
"$(curl -s "http://$addr/ruuat")" "Tähteet"
|
||||||
|
|
||||||
# Pull a real dish id out of the board rather than assuming one.
|
# Pull a real dish id out of the board rather than assuming one.
|
||||||
ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
|
ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
|
||||||
@@ -136,147 +135,147 @@ if [ -z "$ruoka" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
check "picking a dish opens the sides step" \
|
check "picking a dish opens the sides step" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/?ruoka=$ruoka")" "Tallenna"
|
"$(curl -s "http://$addr/?ruoka=$ruoka")" "Tallenna"
|
||||||
|
|
||||||
check "saving redirects back to the day" \
|
check "saving redirects back to the day" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
-d "pvm=$d0&ruoka=$ruoka" "http://$addr/kirjaa")" "303"
|
-d "pvm=$d0&ruoka=$ruoka" "http://$addr/kirjaa")" "303"
|
||||||
|
|
||||||
check "the saved day shows what was eaten" \
|
check "the saved day shows what was eaten" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=$d0")" "kirjattu"
|
"$(curl -s "http://$addr/?pvm=$d0")" "kirjattu"
|
||||||
|
|
||||||
# The selected day expands inside the list rather than in a panel above it,
|
# The selected day expands inside the list rather than in a panel above it,
|
||||||
# so the rows below do not shift when one is tapped.
|
# so the rows below do not shift when one is tapped.
|
||||||
day=$(curl -s -u ":$pass" "http://$addr/?pvm=$d0")
|
day=$(curl -s "http://$addr/?pvm=$d0")
|
||||||
check "the selected day expands in place" "$day" 'class="open"'
|
check "the selected day expands in place" "$day" 'class="open"'
|
||||||
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
|
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
|
||||||
|
|
||||||
# ---- the day list patches in place instead of navigating ----------------
|
# ---- the day list patches in place instead of navigating ----------------
|
||||||
|
|
||||||
dayp=$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/paiva?pvm=$d0")
|
dayp=$(curl -s -H 'Datastar-Request: true' "http://$addr/paiva?pvm=$d0")
|
||||||
check "opening a day patches the list" "$dayp" 'id="paivat"'
|
check "opening a day patches the list" "$dayp" 'id="paivat"'
|
||||||
refute "and returns a fragment, not a page" "$dayp" "<html"
|
refute "and returns a fragment, not a page" "$dayp" "<html"
|
||||||
|
|
||||||
check "picking a dish patches to the sides step" \
|
check "picking a dish patches to the sides step" \
|
||||||
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
"$(curl -s -H 'Datastar-Request: true' \
|
||||||
"http://$addr/paiva?pvm=$d0&ruoka=$ruoka")" "Tallenna"
|
"http://$addr/paiva?pvm=$d0&ruoka=$ruoka")" "Tallenna"
|
||||||
|
|
||||||
check "saving from Datastar patches back" \
|
check "saving from Datastar patches back" \
|
||||||
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
"$(curl -s -H 'Datastar-Request: true' \
|
||||||
-d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
|
-d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
|
||||||
|
|
||||||
check "deleting from Datastar patches back" \
|
check "deleting from Datastar patches back" \
|
||||||
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
"$(curl -s -H 'Datastar-Request: true' \
|
||||||
-d "pvm=$d1" "http://$addr/poista")" 'id="paivat"'
|
-d "pvm=$d1" "http://$addr/poista")" 'id="paivat"'
|
||||||
|
|
||||||
# Without the header it must still redirect, for no JavaScript.
|
# Without the header it must still redirect, for no JavaScript.
|
||||||
check "a plain save still redirects to the day" \
|
check "a plain save still redirects to the day" \
|
||||||
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{redirect_url}' \
|
||||||
-d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=$d1"
|
-d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=$d1"
|
||||||
|
|
||||||
# Deleting a logged meal drops the row outright, so it asks first.
|
# Deleting a logged meal drops the row outright, so it asks first.
|
||||||
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=$d0&poista=1")
|
saved=$(curl -s "http://$addr/?pvm=$d0&poista=1")
|
||||||
check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?"
|
check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?"
|
||||||
# Assert the entry is still shown, rather than that no gap row exists anywhere
|
# Assert the entry is still shown, rather than that no gap row exists anywhere
|
||||||
# on the page: other days are legitimately unlogged and render their own.
|
# on the page: other days are legitimately unlogged and render their own.
|
||||||
check "and the entry is still there while asking" "$saved" "kirjattu"
|
check "and the entry is still there while asking" "$saved" "kirjattu"
|
||||||
|
|
||||||
check "deleting redirects back" \
|
check "deleting redirects back" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
-d "pvm=$d0" "http://$addr/poista")" "303"
|
-d "pvm=$d0" "http://$addr/poista")" "303"
|
||||||
|
|
||||||
check "the day is empty again" \
|
check "the day is empty again" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=$d0")" "Etsi"
|
"$(curl -s "http://$addr/?pvm=$d0")" "Etsi"
|
||||||
|
|
||||||
check "search filters the board" \
|
check "search filters the board" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto"
|
"$(curl -s "http://$addr/?haku=keitto")" "keitto"
|
||||||
|
|
||||||
# ---- live search: Datastar sends signals as JSON in ?datastar= -----------
|
# ---- live search: Datastar sends signals as JSON in ?datastar= -----------
|
||||||
|
|
||||||
live=$(curl -s -u ":$pass" --get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")
|
live=$(curl -s --get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")
|
||||||
check "live search returns the board fragment" "$live" 'id="lauta"'
|
check "live search returns the board fragment" "$live" 'id="lauta"'
|
||||||
check "live search applies the term" "$live" "keitto"
|
check "live search applies the term" "$live" "keitto"
|
||||||
refute "live search excludes non-matches" "$live" "Lihapullat"
|
refute "live search excludes non-matches" "$live" "Lihapullat"
|
||||||
refute "the fragment is not a whole page" "$live" "<html"
|
refute "the fragment is not a whole page" "$live" "<html"
|
||||||
|
|
||||||
check "live search is served as html for Datastar to patch" \
|
check "live search is served as html for Datastar to patch" \
|
||||||
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{content_type}' \
|
||||||
--get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")" \
|
--get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")" \
|
||||||
"text/html"
|
"text/html"
|
||||||
|
|
||||||
cat_live=$(curl -s -u ":$pass" --get --data-urlencode 'datastar={"haku":"riisi"}' "http://$addr/ruuat/etsi")
|
cat_live=$(curl -s --get --data-urlencode 'datastar={"haku":"riisi"}' "http://$addr/ruuat/etsi")
|
||||||
check "catalog live search returns its fragment" "$cat_live" 'id="ruokalista"'
|
check "catalog live search returns its fragment" "$cat_live" 'id="ruokalista"'
|
||||||
check "catalog live search matches sides too" "$cat_live" "Riisi"
|
check "catalog live search matches sides too" "$cat_live" "Riisi"
|
||||||
refute "catalog live search excludes non-matches" "$cat_live" "Lihapullat"
|
refute "catalog live search excludes non-matches" "$cat_live" "Lihapullat"
|
||||||
|
|
||||||
# The plain form still works without JavaScript.
|
# The plain form still works without JavaScript.
|
||||||
check "catalog search works as a plain form too" \
|
check "catalog search works as a plain form too" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat?haku=riisi")" "Riisi"
|
"$(curl -s "http://$addr/ruuat?haku=riisi")" "Riisi"
|
||||||
|
|
||||||
# Nothing was eaten tomorrow. A future date is clamped rather than logged.
|
# Nothing was eaten tomorrow. A future date is clamped rather than logged.
|
||||||
future=$(date -d '+30 days' +%Y-%m-%d)
|
future=$(date -d '+30 days' +%Y-%m-%d)
|
||||||
check "a future date falls back to today" \
|
check "a future date falls back to today" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
|
"$(curl -s "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
|
||||||
|
|
||||||
check "saving a future date is clamped too" \
|
check "saving a future date is clamped too" \
|
||||||
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{redirect_url}' \
|
||||||
-d "pvm=$future&ruoka=$ruoka" "http://$addr/kirjaa")" "/"
|
-d "pvm=$future&ruoka=$ruoka" "http://$addr/kirjaa")" "/"
|
||||||
|
|
||||||
check "tomorrow was not written to the log" \
|
check "tomorrow was not written to the log" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
|
"$(curl -s "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
|
||||||
|
|
||||||
# Clean up the entry that clamped onto today.
|
# Clean up the entry that clamped onto today.
|
||||||
curl -s -o /dev/null -u ":$pass" -d "pvm=$(date +%Y-%m-%d)" "http://$addr/poista"
|
curl -s -o /dev/null -d "pvm=$(date +%Y-%m-%d)" "http://$addr/poista"
|
||||||
|
|
||||||
# ---- adding a dish without leaving Kirjaa --------------------------------
|
# ---- adding a dish without leaving Kirjaa --------------------------------
|
||||||
|
|
||||||
miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste")
|
miss=$(curl -s "http://$addr/?haku=Poronkariste")
|
||||||
check "a search with no hits offers to add it" "$miss" "Ei osumia. Lisätäänkö?"
|
check "a search with no hits offers to add it" "$miss" "Ei osumia. Lisätäänkö?"
|
||||||
check "the add form is prefilled with the search" "$miss" 'value="Poronkariste"'
|
check "the add form is prefilled with the search" "$miss" 'value="Poronkariste"'
|
||||||
|
|
||||||
check "quick add goes straight to the sides step" \
|
check "quick add goes straight to the sides step" \
|
||||||
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{redirect_url}' \
|
||||||
-d 'nimi=Poronkariste&kategoria=meat&lisukkeita=1' "http://$addr/lisaa")" \
|
-d 'nimi=Poronkariste&kategoria=meat&lisukkeita=1' "http://$addr/lisaa")" \
|
||||||
"ruoka="
|
"ruoka="
|
||||||
|
|
||||||
check "quick add rejects a dish with no category" \
|
check "quick add rejects a dish with no category" \
|
||||||
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/lisaa")" \
|
"$(curl -s -d 'nimi=Kategoriaton' "http://$addr/lisaa")" \
|
||||||
"Valitse vähintään yksi kategoria."
|
"Valitse vähintään yksi kategoria."
|
||||||
|
|
||||||
check "the quick-added dish is on the board" \
|
check "the quick-added dish is on the board" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/")" "Poronkariste"
|
"$(curl -s "http://$addr/")" "Poronkariste"
|
||||||
|
|
||||||
# ---- catalog CRUD from the UI -------------------------------------------
|
# ---- catalog CRUD from the UI -------------------------------------------
|
||||||
|
|
||||||
# Assert where it redirects, not just that it does: these pointed at the old
|
# Assert where it redirects, not just that it does: these pointed at the old
|
||||||
# /ruoat spelling for a while and every 303-only check was happy.
|
# /ruoat spelling for a while and every 303-only check was happy.
|
||||||
check "adding a main redirects back to the catalog" \
|
check "adding a main redirects back to the catalog" \
|
||||||
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{redirect_url}' \
|
||||||
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" \
|
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" \
|
||||||
"/ruuat"
|
"/ruuat"
|
||||||
|
|
||||||
catalog=$(curl -s -u ":$pass" "http://$addr/ruuat")
|
catalog=$(curl -s "http://$addr/ruuat")
|
||||||
check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
|
check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
|
||||||
|
|
||||||
check "a duplicate name is refused" \
|
check "a duplicate name is refused" \
|
||||||
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
"$(curl -s -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
||||||
"Nimi on jo listalla."
|
"Nimi on jo listalla."
|
||||||
|
|
||||||
check "a main with no category is refused" \
|
check "a main with no category is refused" \
|
||||||
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruuat/paaruoka")" \
|
"$(curl -s -d 'nimi=Kategoriaton' "http://$addr/ruuat/paaruoka")" \
|
||||||
"Valitse vähintään yksi kategoria."
|
"Valitse vähintään yksi kategoria."
|
||||||
|
|
||||||
check "a nameless dish is refused" \
|
check "a nameless dish is refused" \
|
||||||
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
"$(curl -s -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
||||||
"Anna nimi."
|
"Anna nimi."
|
||||||
|
|
||||||
check "adding a side redirects back to the catalog" \
|
check "adding a side redirects back to the catalog" \
|
||||||
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{redirect_url}' \
|
||||||
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" \
|
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" \
|
||||||
"/ruuat"
|
"/ruuat"
|
||||||
|
|
||||||
check "the new side is listed" \
|
check "the new side is listed" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat"
|
"$(curl -s "http://$addr/ruuat")" "Lohkoperunat"
|
||||||
|
|
||||||
# The id of Uunikala specifically: the catalog is grouped and alphabetical, so
|
# The id of Uunikala specifically: the catalog is grouped and alphabetical, so
|
||||||
# the first id on the page belongs to some other dish entirely.
|
# the first id on the page belongs to some other dish entirely.
|
||||||
@@ -287,20 +286,20 @@ if [ -z "$uusi" ]; then
|
|||||||
uusi=0
|
uusi=0
|
||||||
fi
|
fi
|
||||||
check "the edit form is prefilled" \
|
check "the edit form is prefilled" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa"
|
"$(curl -s "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa"
|
||||||
|
|
||||||
# A bin icon is easy to hit by accident, so the row asks before anything goes.
|
# A bin icon is easy to hit by accident, so the row asks before anything goes.
|
||||||
check "the bin asks before deleting" \
|
check "the bin asks before deleting" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Poista?"
|
"$(curl -s "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Poista?"
|
||||||
|
|
||||||
check "the dish is still there while it asks" \
|
check "the dish is still there while it asks" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
|
"$(curl -s "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
|
||||||
|
|
||||||
# ---- the catalog patches in place instead of navigating -----------------
|
# ---- the catalog patches in place instead of navigating -----------------
|
||||||
|
|
||||||
# A delete confirmation halfway down a long list must not send the browser
|
# A delete confirmation halfway down a long list must not send the browser
|
||||||
# back to the top, so these answer with a Datastar patch rather than a page.
|
# back to the top, so these answer with a Datastar patch rather than a page.
|
||||||
patch=$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
patch=$(curl -s -H 'Datastar-Request: true' \
|
||||||
"http://$addr/ruuat/nayta?poista=$uusi&tyyppi=paa")
|
"http://$addr/ruuat/nayta?poista=$uusi&tyyppi=paa")
|
||||||
check "asking to delete patches rather than navigates" "$patch" "event: datastar-patch-elements"
|
check "asking to delete patches rather than navigates" "$patch" "event: datastar-patch-elements"
|
||||||
check "the patch carries the list" "$patch" 'id="ruokalista"'
|
check "the patch carries the list" "$patch" 'id="ruokalista"'
|
||||||
@@ -308,26 +307,26 @@ check "and both forms, so an open one closes" "$patch" 'id="paaruoka"'
|
|||||||
check "the row it patches in is asking" "$patch" "Poista?"
|
check "the row it patches in is asking" "$patch" "Poista?"
|
||||||
|
|
||||||
check "patches are served as an event stream" \
|
check "patches are served as an event stream" \
|
||||||
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" -H 'Datastar-Request: true' \
|
"$(curl -s -o /dev/null -w '%{content_type}' -H 'Datastar-Request: true' \
|
||||||
"http://$addr/ruuat/nayta")" "text/event-stream"
|
"http://$addr/ruuat/nayta")" "text/event-stream"
|
||||||
|
|
||||||
check "deleting from Datastar patches too" \
|
check "deleting from Datastar patches too" \
|
||||||
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
"$(curl -s -H 'Datastar-Request: true' \
|
||||||
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \
|
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \
|
||||||
"event: datastar-patch-elements"
|
"event: datastar-patch-elements"
|
||||||
|
|
||||||
refute "and the dish is gone from the patched list" \
|
refute "and the dish is gone from the patched list" \
|
||||||
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/ruuat/nayta")" \
|
"$(curl -s -H 'Datastar-Request: true' "http://$addr/ruuat/nayta")" \
|
||||||
"Uunikala"
|
"Uunikala"
|
||||||
|
|
||||||
# Without the header it must still be an ordinary redirect, for no JavaScript.
|
# Without the header it must still be an ordinary redirect, for no JavaScript.
|
||||||
check "a plain form post still redirects" \
|
check "a plain form post still redirects" \
|
||||||
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{redirect_url}' \
|
||||||
-d 'nimi=Testiruoka&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
-d 'nimi=Testiruoka&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
||||||
"/ruuat"
|
"/ruuat"
|
||||||
|
|
||||||
refute "the dish is gone once confirmed" \
|
refute "the dish is gone once confirmed" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala"
|
"$(curl -s "http://$addr/ruuat")" "Uunikala"
|
||||||
|
|
||||||
if [ "$fail" -ne 0 ]; then
|
if [ "$fail" -ne 0 ]; then
|
||||||
echo "smoke: FAILED"
|
echo "smoke: FAILED"
|
||||||
|
|||||||
Reference in New Issue
Block a user