Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2974ffc621 | ||
|
|
6d5f3e3de3 | ||
|
|
6590c18412 | ||
|
|
c0b48ee71a | ||
|
|
f2ac6c8373 | ||
|
|
5e3650deea | ||
|
|
00b737cf8d | ||
|
|
0f0f04240a | ||
|
|
2695b8c01b | ||
|
|
85bf5e1a25 |
+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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
for _, path := range []string{"/healthz", "/", "/ruuat"} {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Errorf("/healthz without credentials = %d, want 200", w.Code)
|
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