diff --git a/.env.example b/.env.example
index c5a5fb3..1cf1054 100644
--- a/.env.example
+++ b/.env.example
@@ -3,6 +3,10 @@
# In production these are injected by docker-compose via env_file; the .env
# loader only runs outside production and never overrides an already-set
# variable.
+#
+# ENV and NODE_ENV cannot be set here. Whether this file is read at all depends
+# on the environment name, so it is resolved before the file is opened — set
+# them in the real environment instead (docker-compose sets ENV).
# ── FusionAuth (identity) ──────────────────────────────────────────────────
FUSION_AUTH_HOST=https://auth.itemize.no
@@ -40,7 +44,17 @@ DISCORD_SERVER_MEMBER_ROLE_ID=
# ── HTTP ───────────────────────────────────────────────────────────────────
# Port to bind. PORT wins if both are set; LISTEN is kept for compatibility
-# with the old deployment and with docker-compose.
+# with the old deployment and with docker-compose. A bare port, :port or
+# host:port are all accepted; the port must be between 1 and 65535, and 0 is
+# refused because the kernel would pick a port the health check cannot find.
+#
+# Under docker-compose, keep this a bare port. The compose file spends LISTEN
+# twice — as the host side of '${LISTEN:-3000}:3000', and, via env_file, as the
+# port the process inside the container binds. The container side of that
+# mapping is hardcoded to 3000, so the two only agree when LISTEN is 3000.
+# Anything else publishes a port nothing is listening on, and :port or
+# host:port is not a shape the ports: mapping accepts at all. Running the
+# binary directly, every form above works.
LISTEN=3000
# No trailing slash — OAuth redirect URIs are built by concatenation.
BASE_URL=https://itemize.no
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 4f2ac9f..108d3f8 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -8,11 +8,17 @@
# the repository's history is otherwise dominated by one-at-a-time
# "Bump X from A to B". Major versions still arrive on their own, so somebody
# actually reads them.
+#
+# Updates open against dev rather than main, so they land where the rest of the
+# work does and reach main through the usual pull request. Dependabot reads this
+# file from the default branch regardless, so target-branch has to be set here
+# on main to take effect. Security updates are exempt and still arrive on main.
version: 2
updates:
- package-ecosystem: gomod
directory: /
+ target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
@@ -24,11 +30,13 @@ updates:
- package-ecosystem: docker
directory: /
+ target-branch: dev
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
+ target-branch: dev
schedule:
interval: weekly
groups:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 835554c..d2d1e5d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,9 +29,9 @@ jobs:
--health-retries 10
steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
@@ -65,18 +65,18 @@ jobs:
packages: write
steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
+ - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
+ - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
- uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
# latest on main, dev on dev — unchanged from the previous pipeline,
@@ -88,7 +88,7 @@ jobs:
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
type=sha,prefix=sha-,format=short
- - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
+ - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
diff --git a/assets/embed_edge_test.go b/assets/embed_edge_test.go
new file mode 100644
index 0000000..c76cea2
--- /dev/null
+++ b/assets/embed_edge_test.go
@@ -0,0 +1,554 @@
+package assets
+
+// What the two filesystems contain, and what happens at their edges.
+//
+// The embedded tree is fixed at build time and nothing about it can be checked
+// by the compiler: a file that go:embed quietly skipped, a stylesheet that was
+// renamed out from under a template, or a stray editor backup that got shipped
+// inside the binary all build perfectly well and only show up in production.
+// Everything here is about catching that at test time instead.
+
+import (
+ "html/template"
+ "io/fs"
+ "os"
+ "path"
+ "regexp"
+ "slices"
+ "strings"
+ "testing"
+)
+
+// walkFiles lists every file (not directory) under the given roots, sorted.
+func walkFiles(t *testing.T, fsys fs.FS, roots ...string) []string {
+ t.Helper()
+
+ var out []string
+ for _, root := range roots {
+ err := fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() {
+ out = append(out, p)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walking %q failed: %v", root, err)
+ }
+ }
+ slices.Sort(out)
+ return out
+}
+
+// The embedded tree must be exactly the tree on disk.
+//
+// go:embed silently skips files whose names begin with a dot or an underscore.
+// A contributor adding _draft.html or .htaccess under assets/ sees it work in
+// -dev mode, where the files are read from disk, and never learn that it is
+// missing from the binary everyone else runs. Comparing the two filesystems is
+// the only way that difference ever becomes visible.
+func TestEmbeddedTreeMatchesTheRepository(t *testing.T) {
+ // The test binary runs in its own package directory, so the repository
+ // root — which is what the -dev filesystem is relative to — is one level
+ // up. t.Chdir restores the previous directory when the test ends.
+ t.Chdir("..")
+
+ embeddedFiles := walkFiles(t, FS(false), "templates", "static")
+ diskFiles := walkFiles(t, os.DirFS("assets"), "templates", "static")
+
+ if slices.Equal(embeddedFiles, diskFiles) {
+ return
+ }
+
+ for _, name := range diskFiles {
+ if !slices.Contains(embeddedFiles, name) {
+ t.Errorf("%s exists on disk but is not in the binary. go:embed skips "+
+ "names beginning with a dot or an underscore, so this file works "+
+ "in -dev mode and is missing everywhere else.", name)
+ }
+ }
+ for _, name := range embeddedFiles {
+ if !slices.Contains(diskFiles, name) {
+ t.Errorf("%s is embedded but no longer exists on disk; the binary is "+
+ "serving something no one can edit", name)
+ }
+ }
+}
+
+// The embed patterns name two directories, and nothing else may come along.
+// A widened pattern would pull this test file, embed.go and anything else in
+// the package into the shipped binary.
+func TestEmbeddedRootHoldsOnlyTheAssetDirectories(t *testing.T) {
+ entries, err := fs.ReadDir(FS(false), ".")
+ if err != nil {
+ t.Fatalf("reading the embed root failed: %v", err)
+ }
+
+ var got []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ t.Errorf("%s is embedded at the top level; only the templates and "+
+ "static directories belong in the binary", e.Name())
+ }
+ got = append(got, e.Name())
+ }
+ slices.Sort(got)
+
+ if want := []string{"static", "templates"}; !slices.Equal(got, want) {
+ t.Errorf("embed root = %v, want %v", got, want)
+ }
+}
+
+// Every directory the server reaches for by name. An absent one is not a build
+// error — fs.Glob simply returns nothing and ReadDir is treated as optional by
+// the asset builder — so the site comes up unstyled or without images instead
+// of failing loudly.
+func TestExpectedDirectoriesArePresent(t *testing.T) {
+ dirs := map[string]string{
+ "templates/layout": "every page render starts from the layout",
+ "templates/pages": "the renderer refuses to start without page templates",
+ "templates/partials": "pages that include partials would fail to parse",
+ "static/css": "the site would render unstyled",
+ "static/js": "client-side behaviour would be missing",
+ "static/img": "the logo and favicon would 404",
+ "static/fonts": "the page would fall back to system fonts",
+ }
+
+ for dir, why := range dirs {
+ t.Run(dir, func(t *testing.T) {
+ entries, err := fs.ReadDir(FS(false), dir)
+ if err != nil {
+ t.Fatalf("%s is not embedded: %v. %s.", dir, err, why)
+ }
+ if len(entries) == 0 {
+ t.Errorf("%s is embedded but empty. %s.", dir, why)
+ }
+ })
+ }
+}
+
+// Nothing that is not an asset may be shipped inside the binary. Editor
+// backups, macOS metadata and source maps are all things that arrive by
+// accident, and once embedded they are served to anyone who guesses the path.
+func TestNoJunkIsEmbedded(t *testing.T) {
+ // One mebibyte. The largest asset today is a 320 KB SVG; anything past
+ // this is a file that was committed by mistake, and every byte of it is
+ // carried by every deployment.
+ const maxFileSize = 1 << 20
+
+ badNames := []string{".DS_Store", "Thumbs.db", "desktop.ini"}
+ badSuffixes := []string{
+ ".map", // a source map exposes the unminified original
+ ".bak", ".orig", ".rej", ".swp", ".swo", "~", // editor and merge leftovers
+ ".go", // no source belongs in the asset tree
+ ".psd", ".ai", ".sketch", // design sources; large and useless at runtime
+ }
+
+ fsys := FS(false)
+ err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+
+ name := path.Base(p)
+ if slices.Contains(badNames, name) {
+ t.Errorf("%s is embedded; it is metadata, not an asset", p)
+ }
+ for _, suffix := range badSuffixes {
+ if strings.HasSuffix(name, suffix) {
+ t.Errorf("%s is embedded and ends in %q, which is not something "+
+ "the site serves on purpose", p, suffix)
+ }
+ }
+ if strings.HasPrefix(name, "#") {
+ t.Errorf("%s looks like an Emacs autosave file", p)
+ }
+
+ info, err := d.Info()
+ if err != nil {
+ return err
+ }
+ if info.Size() == 0 {
+ t.Errorf("%s is embedded but empty; a truncated asset is served as a "+
+ "blank page rather than a 404, which is far harder to notice", p)
+ }
+ if info.Size() > maxFileSize {
+ t.Errorf("%s is %d bytes, over the %d byte limit this test pins. Every "+
+ "deployment carries it; if it really belongs in the binary, raise "+
+ "the limit deliberately.", p, info.Size(), maxFileSize)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walking the embedded filesystem failed: %v", err)
+ }
+}
+
+// The production filesystem must not depend on where the process was started
+// from, and the development one must. That difference is the entire point of
+// the flag: a deployment that suddenly needed a working directory would fail
+// only once it was running somewhere other than the build machine.
+func TestFSSelection(t *testing.T) {
+ const probe = "templates/layout/base.html"
+
+ t.Run("embedded is independent of the working directory", func(t *testing.T) {
+ t.Chdir(t.TempDir()) // nothing resembling the repository is here
+
+ if _, err := fs.ReadFile(FS(false), probe); err != nil {
+ t.Errorf("the embedded filesystem could not read %s from an unrelated "+
+ "directory: %v. Production would depend on where it was started.",
+ probe, err)
+ }
+ if _, err := fs.ReadFile(FS(true), probe); err == nil {
+ t.Error("the -dev filesystem found the templates in a directory that " +
+ "has none, so it is not reading from disk at all")
+ }
+ })
+
+ t.Run("dev reads the working copy", func(t *testing.T) {
+ t.Chdir("..") // the repository root, which is where -dev must be run
+
+ disk, err := fs.ReadFile(FS(true), probe)
+ if err != nil {
+ t.Fatalf("the -dev filesystem could not read %s from the repository "+
+ "root: %v. Template edits would not show up on reload.", probe, err)
+ }
+ embedded, err := fs.ReadFile(FS(false), probe)
+ if err != nil {
+ t.Fatalf("reading %s from the embed failed: %v", probe, err)
+ }
+ if string(disk) != string(embedded) {
+ t.Error("the -dev and embedded copies of the layout differ, so the " +
+ "two modes are reading different trees")
+ }
+ })
+}
+
+// Paths that are not in the form io/fs defines must be refused rather than
+// quietly normalised. The asset server builds lookups from request paths, and
+// an fs that resolved "../" or a leading slash would widen what a crafted
+// request can reach beyond the two embedded directories.
+func TestEmbeddedRefusesUnnormalisedPaths(t *testing.T) {
+ const real = "templates/layout/base.html"
+ fsys := FS(false)
+
+ if _, err := fs.ReadFile(fsys, real); err != nil {
+ t.Fatalf("the control case failed: %v", err)
+ }
+
+ for _, p := range []string{
+ "/templates/layout/base.html", // absolute
+ "./templates/layout/base.html", // a dot segment
+ "templates//layout/base.html", // an empty segment
+ "templates/../templates/layout/base.html", // a traversal that resolves inside
+ "static/../../assets/embed.go", // one that resolves outside
+ "templates/layout/../layout/base.html", // and one in the middle
+ "templates/layout/base.html/", // a trailing slash
+ } {
+ t.Run(p, func(t *testing.T) {
+ if _, err := fs.ReadFile(fsys, p); err == nil {
+ t.Errorf("%q resolved to a file; io/fs paths are unrooted and "+
+ "already clean, and an fs that normalises them makes every "+
+ "caller responsible for doing so first", p)
+ }
+ })
+ }
+
+ // A directory is not a file, however it is asked for.
+ for _, p := range []string{"templates", "static/css", "."} {
+ if _, err := fs.ReadFile(fsys, p); err == nil {
+ t.Errorf("reading the directory %q as a file succeeded", p)
+ }
+ }
+}
+
+// fs.Sub is how a caller narrows the filesystem to one subtree. embed.FS does
+// not implement it natively, so the wrapper is lazy: an invalid path fails at
+// once, a merely absent one fails only when read. Both have to be errors and
+// neither may panic.
+func TestSubFilesystems(t *testing.T) {
+ fsys := FS(false)
+
+ t.Run("a subtree serves its own paths", func(t *testing.T) {
+ static, err := fs.Sub(fsys, "static")
+ if err != nil {
+ t.Fatalf("fs.Sub(static) failed: %v", err)
+ }
+ if _, err := fs.ReadFile(static, "img/icon.svg"); err != nil {
+ t.Errorf("img/icon.svg is not readable under the static subtree: %v", err)
+ }
+ // The prefix is gone, not optional.
+ if _, err := fs.ReadFile(static, "static/img/icon.svg"); err == nil {
+ t.Error("the full path still resolves under the subtree, so fs.Sub " +
+ "did not narrow anything")
+ }
+ // And the subtree cannot see its siblings.
+ if _, err := fs.ReadFile(static, "templates/layout/base.html"); err == nil {
+ t.Error("the templates are reachable from the static subtree")
+ }
+ })
+
+ t.Run("the root subtree is the whole filesystem", func(t *testing.T) {
+ same, err := fs.Sub(fsys, ".")
+ if err != nil {
+ t.Fatalf("fs.Sub(.) failed: %v", err)
+ }
+ if _, err := fs.ReadFile(same, "templates/layout/base.html"); err != nil {
+ t.Errorf("the layout is unreadable through fs.Sub(.): %v", err)
+ }
+ })
+
+ t.Run("invalid subtree paths are refused", func(t *testing.T) {
+ for _, p := range []string{"/static", "static/", "./static", "../assets", ""} {
+ if _, err := fs.Sub(fsys, p); err == nil {
+ t.Errorf("fs.Sub(%q) was accepted; only unrooted, clean paths are "+
+ "filesystem paths", p)
+ }
+ }
+ })
+
+ t.Run("an absent subtree fails on read, not on Sub", func(t *testing.T) {
+ missing, err := fs.Sub(fsys, "static/does-not-exist")
+ if err != nil {
+ t.Skipf("fs.Sub now validates existence (%v), which is stricter than "+
+ "this test assumed", err)
+ }
+ if _, err := fs.ReadFile(missing, "anything.css"); err == nil {
+ t.Error("reading through a subtree that does not exist succeeded")
+ }
+ })
+}
+
+// templateFuncs pins the helper names the templates are allowed to call. The
+// real map is built in internal/web; naming them here keeps this package from
+// depending on the server, and a template calling something new fails to parse
+// with a message pointing straight at this list.
+var templateFuncs = []string{
+ "asset", "hasAsset", "eml", "emlfallback", "csrf",
+ "smartTime", "dict", "list", "hasRole",
+}
+
+func stubFuncs() template.FuncMap {
+ funcs := template.FuncMap{}
+ for _, name := range templateFuncs {
+ funcs[name] = func(...any) any { return nil }
+ }
+ return funcs
+}
+
+// Every page must parse together with the layout and all the partials, the
+// same combination the renderer builds. A template that does not parse is a
+// 500 on that page and nothing else — the rest of the site keeps working, so
+// nobody notices until someone visits it.
+func TestEveryPageParses(t *testing.T) {
+ fsys := FS(false)
+
+ pages, err := fs.Glob(fsys, "templates/pages/*.html")
+ if err != nil {
+ t.Fatalf("globbing pages failed: %v", err)
+ }
+ if len(pages) == 0 {
+ t.Fatal("no page templates are embedded; the renderer refuses to start")
+ }
+
+ for _, page := range pages {
+ t.Run(path.Base(page), func(t *testing.T) {
+ _, err := template.New("base.html").
+ Funcs(stubFuncs()).
+ ParseFS(fsys, "templates/layout/*.html", "templates/partials/*.html", page)
+ if err != nil {
+ t.Errorf("%s does not parse: %v. If the failure names an undefined "+
+ "function, add it to templateFuncs here and to web.Funcs.", page, err)
+ }
+ })
+ }
+}
+
+var templateCall = regexp.MustCompile(`\{\{-?\s*template\s+"([^"]+)"`)
+
+// Every {{ template }} invocation has to name something that exists. Missing
+// ones are only found at execution time, and only on the branch that reaches
+// them — so a partial dropped from a conditional survives every render until
+// the day the condition is true.
+func TestEveryTemplateInvocationIsDefined(t *testing.T) {
+ fsys := FS(false)
+
+ set, err := template.New("base.html").
+ Funcs(stubFuncs()).
+ ParseFS(fsys,
+ "templates/layout/*.html",
+ "templates/partials/*.html",
+ "templates/pages/*.html")
+ if err != nil {
+ t.Fatalf("parsing the whole template tree failed: %v", err)
+ }
+
+ var checked int
+ for _, name := range walkFiles(t, fsys, "templates") {
+ body, err := fs.ReadFile(fsys, name)
+ if err != nil {
+ t.Fatalf("reading %s failed: %v", name, err)
+ }
+ for _, match := range templateCall.FindAllStringSubmatch(string(body), -1) {
+ checked++
+ called := match[1]
+ if defined := set.Lookup(called); defined == nil || defined.Tree == nil {
+ t.Errorf("%s invokes the template %q, which nothing defines; the "+
+ "page renders until that branch is taken and then 500s",
+ name, called)
+ }
+ }
+ }
+ // The layout alone pulls in several partials, so finding none means the
+ // pattern stopped matching rather than the invocations going away.
+ if checked == 0 {
+ t.Error("no {{ template }} invocations were found in the whole tree, so " +
+ "this test checked nothing")
+ }
+}
+
+var (
+ assetCall = regexp.MustCompile(`\{\{-?\s*(?:hasAsset|asset)\s+"([^"]+)"`)
+ // Only literal paths: anything holding a template action is resolved at
+ // render time and cannot be checked here.
+ markupRef = regexp.MustCompile(`(?:href|src)="(/[^"{]+)"`)
+ manifestRef = regexp.MustCompile(`"src"\s*:\s*"([^"]+)"`)
+)
+
+// rootFileSources mirrors the table in internal/httpx: files served from /
+// because their URLs are referenced from outside our own HTML.
+var rootFileSources = map[string]string{
+ "/icon.svg": "static/img/icon.svg",
+ "/logo.png": "static/img/logo.png",
+ "/logo-192.png": "static/img/logo-192.png",
+ "/logo-512.png": "static/img/logo-512.png",
+ "/manifest.json": "static/manifest.json",
+ "/robots.txt": "static/robots.txt",
+ "/service-worker.js": "static/service-worker.js",
+}
+
+// Every file the markup points at must exist.
+//
+// An unknown logical name does not fail: the asset resolver returns "/" + the
+// name so the mistake becomes a 404 rather than a crash. That is the right
+// runtime behaviour and a terrible way to find out, because the page still
+// renders — unstyled, or without its logo, with nothing in the server log.
+func TestReferencedFilesExist(t *testing.T) {
+ fsys := FS(false)
+
+ // Logical names, as passed to the asset helper. Stylesheets and scripts
+ // are bundles built from a whole directory; everything else is one file
+ // under static/.
+ resolve := func(name string) (string, bool) {
+ switch name {
+ case "app.css":
+ matches, _ := fs.Glob(fsys, "static/css/*.css")
+ return "static/css/*.css", len(matches) > 0
+ case "app.js":
+ matches, _ := fs.Glob(fsys, "static/js/*.js")
+ return "static/js/*.js", len(matches) > 0
+ case "boot.js":
+ _, err := fs.Stat(fsys, "static/boot.js")
+ return "static/boot.js", err == nil
+ default:
+ source := "static/" + name
+ _, err := fs.Stat(fsys, source)
+ return source, err == nil
+ }
+ }
+
+ var checked int
+ for _, name := range walkFiles(t, fsys, "templates") {
+ body, err := fs.ReadFile(fsys, name)
+ if err != nil {
+ t.Fatalf("reading %s failed: %v", name, err)
+ }
+ text := string(body)
+
+ for _, match := range assetCall.FindAllStringSubmatch(text, -1) {
+ checked++
+ source, ok := resolve(match[1])
+ if !ok {
+ t.Errorf("%s asks for the asset %q, which nothing under %s "+
+ "provides; the page would link a path that 404s",
+ name, match[1], source)
+ }
+ }
+
+ for _, match := range markupRef.FindAllStringSubmatch(text, -1) {
+ ref := match[1]
+ if path.Ext(ref) == "" {
+ continue // a route, not a file
+ }
+ checked++
+ source, served := rootFileSources[ref]
+ if !served {
+ t.Errorf("%s links %s, which is not one of the files served from "+
+ "the site root; either add it to internal/httpx or fix the link",
+ name, ref)
+ continue
+ }
+ if _, err := fs.Stat(fsys, source); err != nil {
+ t.Errorf("%s links %s, which is served from %s — but that file is "+
+ "not embedded: %v", name, ref, source, err)
+ }
+ }
+ }
+
+ // The layout alone links the stylesheet, two scripts and the favicon, so
+ // an empty run means the patterns stopped matching the markup.
+ if checked == 0 {
+ t.Error("no asset references were found in any template, so this test " +
+ "checked nothing")
+ }
+
+ // The manifest is not markup, but browsers fetch what it points at and a
+ // missing icon there is an install prompt with a blank square.
+ manifest, err := fs.ReadFile(fsys, "static/manifest.json")
+ if err != nil {
+ t.Fatalf("reading the web app manifest failed: %v", err)
+ }
+ for _, match := range manifestRef.FindAllStringSubmatch(string(manifest), -1) {
+ source, served := rootFileSources[match[1]]
+ if !served {
+ t.Errorf("the manifest references %s, which is not served from the "+
+ "site root", match[1])
+ continue
+ }
+ if _, err := fs.Stat(fsys, source); err != nil {
+ t.Errorf("the manifest references %s, but %s is not embedded: %v",
+ match[1], source, err)
+ }
+ }
+}
+
+// The two fonts the layout preloads. The preload is guarded by hasAsset, so a
+// renamed font file does not break the page — it silently stops being
+// preloaded, and the first paint waits for the font instead.
+func TestPreloadedFontsExist(t *testing.T) {
+ fsys := FS(false)
+
+ layout, err := fs.ReadFile(fsys, "templates/layout/base.html")
+ if err != nil {
+ t.Fatalf("reading the layout failed: %v", err)
+ }
+
+ fonts := regexp.MustCompile(`"(fonts/[^"]+\.woff2)"`).
+ FindAllStringSubmatch(string(layout), -1)
+ if len(fonts) == 0 {
+ t.Fatal("the layout preloads no fonts at all; if that is deliberate, " +
+ "this test should go")
+ }
+ for _, match := range fonts {
+ if _, err := fs.Stat(fsys, "static/"+match[1]); err != nil {
+ t.Errorf("the layout preloads %s, which is not embedded: %v. The "+
+ "hasAsset guard means the page still renders, so this would only "+
+ "show up as a slower first paint.", match[1], err)
+ }
+ }
+}
diff --git a/assets/templates/partials/event-form.html b/assets/templates/partials/event-form.html
index b1a57da..f6119d0 100644
--- a/assets/templates/partials/event-form.html
+++ b/assets/templates/partials/event-form.html
@@ -68,9 +68,9 @@
-
Antall timer.
+
Antall timer. Kan stå tomt.
{{- with index .Errors "duration" }}
{{ . }}
{{ end }}
diff --git a/cmd/website/main.go b/cmd/website/main.go
index a516daf..fb33ab6 100644
--- a/cmd/website/main.go
+++ b/cmd/website/main.go
@@ -11,6 +11,7 @@ import (
"flag"
"fmt"
"log/slog"
+ "net"
"net/http"
"os"
"os/signal"
@@ -237,16 +238,21 @@ func openEvents(cfg *config.Config, log *slog.Logger, dev bool) (events.Reposito
// The container image has no shell and no curl — that is the point of a
// distroless base — so the binary doubles as its own health probe.
func healthcheck() int {
- port := os.Getenv("PORT")
- if port == "" {
- port = os.Getenv("LISTEN")
+ // The same call the server makes, so the probe can never disagree with
+ // what was bound.
+ addr, err := config.ResolveAddr()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "healthcheck:", err)
+ return 1
}
- if port == "" {
- port = "3000"
+ target, err := probeURL(addr)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "healthcheck:", err)
+ return 1
}
client := &http.Client{Timeout: 3 * time.Second}
- resp, err := client.Get("http://127.0.0.1:" + port + "/healthz")
+ resp, err := client.Get(target)
if err != nil {
fmt.Fprintln(os.Stderr, "healthcheck:", err)
return 1
@@ -260,6 +266,29 @@ func healthcheck() int {
return 0
}
+// probeURL turns a listen address into a URL that reaches the server from
+// inside its own container.
+//
+// The address is split rather than concatenated: LISTEN=:3000 and
+// PORT=0.0.0.0:3000 both bind, and pasting either after "http://127.0.0.1:"
+// produced a URL that will not even parse, so -healthcheck exited 1 forever
+// and the container was reported unhealthy while serving normally.
+func probeURL(addr string) (string, error) {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil {
+ return "", fmt.Errorf("cannot probe listen address %q: %w", addr, err)
+ }
+ // A wildcard bind is not an address to connect to. It answers on loopback,
+ // which is the only interface the probe shares with the server for certain.
+ switch host {
+ case "", "0.0.0.0":
+ host = "127.0.0.1"
+ case "::":
+ host = "::1"
+ }
+ return "http://" + net.JoinHostPort(host, port) + "/healthz", nil
+}
+
func healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
diff --git a/cmd/website/run_test.go b/cmd/website/run_test.go
new file mode 100644
index 0000000..ab2b660
--- /dev/null
+++ b/cmd/website/run_test.go
@@ -0,0 +1,479 @@
+package main
+
+// No test in this file may call t.Parallel: they use t.Setenv and t.Chdir,
+// both of which panic in a parallel test, and TestServeShutsDownOnSIGTERM
+// signals the whole process.
+
+import (
+ "bytes"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/signal"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/ItemizeNTNU/website/internal/config"
+)
+
+// serverEnv is every variable config.Load consults. Clearing all of them is
+// what stops a contributor's own exported BASE_URL or MONGO_DB_URL from
+// deciding whether a case here passes.
+var serverEnv = []string{
+ "NODE_ENV", "ENV",
+ "PORT", "LISTEN",
+ "BASE_URL",
+ "FUSION_AUTH_HOST",
+ "FUSION_AUTH_CLIENT_ID",
+ "FUSION_AUTH_CLIENT_SECRET",
+ "FUSION_AUTH_SECRET",
+ "FUSION_AUTH_API_TOKEN",
+ "FUSION_AUTH_ID_TOKEN_ALG",
+ "FUSION_AUTH_ID_TOKEN_HMAC_SECRET",
+ "MONGO_DB_URL",
+ "MONGO_DB_NAME",
+ "DISCORD_CLIENT_ID",
+ "DISCORD_CLIENT_SECRET",
+ "DISCORD_BOT_TOKEN",
+ "DISCORD_SERVER_ID",
+ "DISCORD_SERVER_MEMBER_ROLE_ID",
+}
+
+// withServerEnv installs env as the entire server environment and moves the
+// test into an empty directory, so the ./.env that config.Load reads outside
+// production is guaranteed not to exist.
+func withServerEnv(t *testing.T, env map[string]string) {
+ t.Helper()
+ t.Chdir(t.TempDir())
+ for _, key := range serverEnv {
+ t.Setenv(key, "")
+ }
+ for key, value := range env {
+ t.Setenv(key, value)
+ }
+}
+
+// discardLogger keeps the server's own log output out of the test run.
+func discardLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(io.Discard, nil))
+}
+
+// keepDefaultLogger restores the process-wide default logger afterwards. run
+// calls slog.SetDefault, and a test that leaves the default pointing at its own
+// handler would silently change what every later test logs.
+func keepDefaultLogger(t *testing.T) {
+ t.Helper()
+ previous := slog.Default()
+ t.Cleanup(func() { slog.SetDefault(previous) })
+}
+
+// A server that cannot be configured must never reach the point of listening.
+// Half-starting — binding the port, then failing on the first request because
+// there is no identity provider — is what makes a bad rollout look healthy.
+func TestRunRefusesToStartWithoutConfiguration(t *testing.T) {
+ keepDefaultLogger(t)
+ withServerEnv(t, nil)
+
+ err := run(false)
+ if err == nil {
+ t.Fatal("run started the server with no configuration at all")
+ }
+ for _, want := range []string{
+ "BASE_URL is required",
+ "FUSION_AUTH_HOST is required",
+ "FUSION_AUTH_CLIENT_ID is required",
+ "FUSION_AUTH_SECRET is required",
+ "MONGO_DB_URL is required",
+ } {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("run's error does not mention %q, so an operator fixing the deployment has to restart to find that problem.\nfull error: %v", want, err)
+ }
+ }
+}
+
+// The calendar is half the reason the site exists, so a database that will not
+// answer is fatal in production rather than a warning — a deployment that comes
+// up with no events must not be mistaken for a healthy one.
+//
+// The connection string here is rejected by the driver before any socket is
+// opened, which is what keeps this test off the network.
+func TestRunFailsOnAnUnusableDatabaseInProduction(t *testing.T) {
+ keepDefaultLogger(t)
+ withServerEnv(t, map[string]string{
+ "ENV": "production",
+ "BASE_URL": "https://itemize.no",
+ "FUSION_AUTH_HOST": "https://auth.itemize.no",
+ "FUSION_AUTH_CLIENT_ID": "5c1b8e2a-0000-4000-8000-000000000001",
+ "FUSION_AUTH_CLIENT_SECRET": "client-secret",
+ "FUSION_AUTH_SECRET": strings.Repeat("a", 32),
+ // Valid enough for config.Load — it names a database — and rejected by
+ // the driver's own URI parser the moment it is applied.
+ "MONGO_DB_URL": "mongodb://localhost:27017/website?connectTimeoutMS=ikke-et-tall",
+ })
+
+ err := run(false)
+ if err == nil {
+ t.Fatal("run started in production without a database; the events page would be empty and the rollout would look successful")
+ }
+ if !strings.Contains(err.Error(), "MongoDB") && !strings.Contains(err.Error(), "mongo") {
+ t.Errorf("run's error does not point at the database (%v); the operator has nothing to act on", err)
+ }
+}
+
+// The same unreachable database is fatal in production and a warning in
+// development: a contributor has to be able to work on the content pages
+// without running MongoDB at all, and the warning is the only thing telling
+// them why the calendar is empty.
+func TestOpenEvents(t *testing.T) {
+ // Rejected by the driver's URI parser, so no socket is opened either way.
+ cfg := &config.Config{Mongo: config.Mongo{
+ URI: "mongodb://localhost:27017/website?connectTimeoutMS=ikke-et-tall",
+ Database: "website",
+ }}
+
+ t.Run("development carries on without a database", func(t *testing.T) {
+ var logged bytes.Buffer
+ log := slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ repo, disconnect, err := openEvents(cfg, log, true)
+ if err != nil {
+ t.Fatalf("a missing database stopped a development server from starting: %v", err)
+ }
+ if repo != nil {
+ t.Error("openEvents returned a repository backed by a connection that was never established")
+ }
+ if disconnect == nil {
+ t.Fatal("openEvents returned a nil disconnect function; run defers it unconditionally and would panic")
+ }
+ // Safe to call even though nothing was opened — run defers it before
+ // it knows whether there is a connection.
+ disconnect()
+
+ if !strings.Contains(logged.String(), "no database") {
+ t.Errorf("nothing was logged about the missing database, so an empty calendar looks like a bug in the site.\nlog: %s", logged.String())
+ }
+ })
+
+ t.Run("production refuses to start", func(t *testing.T) {
+ _, disconnect, err := openEvents(cfg, discardLogger(), false)
+ if err == nil {
+ t.Fatal("a production server started without a database; the deployment would look healthy with no events on it")
+ }
+ if disconnect == nil {
+ t.Fatal("openEvents returned a nil disconnect function alongside its error; run defers it before checking the error")
+ }
+ disconnect()
+ })
+}
+
+// A listen address that cannot be bound has to come back out of serve. The
+// goroutine that calls ListenAndServe is the only thing that sees the error, so
+// a broken hand-off here is a process that exits zero without ever serving.
+func TestServeReturnsListenErrors(t *testing.T) {
+ // Held open for the duration so the "address already in use" case has
+ // something to collide with.
+ taken, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("taking an ephemeral port failed: %v", err)
+ }
+ t.Cleanup(func() { _ = taken.Close() })
+
+ tests := []struct {
+ name string
+ addr string
+ }{
+ {name: "port out of range", addr: "127.0.0.1:99999"},
+ {name: "not an address at all", addr: "ikke-en-adresse"},
+ {name: "address already in use", addr: taken.Addr().String()},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ srv := &http.Server{Addr: tt.addr, Handler: http.NewServeMux()}
+ t.Cleanup(func() { _ = srv.Close() })
+
+ done := make(chan error, 1)
+ go func() { done <- serve(srv, discardLogger()) }()
+
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Errorf("serve(%q) returned no error; the process would exit successfully having never listened", tt.addr)
+ }
+ case <-time.After(30 * time.Second):
+ t.Fatalf("serve(%q) never returned; a bind failure would hang the process instead of reporting it", tt.addr)
+ }
+ })
+ }
+}
+
+// The whole point of the shutdown path is that a redeploy does not cut off a
+// member mid-request: SIGTERM stops the listener and lets what is in flight
+// finish. If the signal is not wired up, the container is killed after the
+// orchestrator's grace period instead.
+func TestServeShutsDownOnSIGTERM(t *testing.T) {
+ // Keep a handler registered for the whole test. Without one, a SIGTERM
+ // that lands before serve has installed its own would be delivered with
+ // its default disposition and kill the test binary outright.
+ guard := make(chan os.Signal, 1)
+ signal.Notify(guard, syscall.SIGTERM)
+ t.Cleanup(func() { signal.Stop(guard) })
+
+ // Port zero: the kernel picks a free port, so this can never fail because
+ // something else on the machine holds a fixed one.
+ srv := &http.Server{Addr: "127.0.0.1:0", Handler: http.NewServeMux()}
+ t.Cleanup(func() { _ = srv.Close() })
+
+ done := make(chan error, 1)
+ go func() { done <- serve(srv, discardLogger()) }()
+
+ // Signalling repeatedly rather than once removes the race with serve
+ // installing its handler: an early SIGTERM is absorbed by the guard above
+ // and simply retried. Extra signals after serve returns are harmless.
+ ticker := time.NewTicker(20 * time.Millisecond)
+ defer ticker.Stop()
+ deadline := time.After(30 * time.Second)
+
+ for {
+ if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil {
+ t.Fatalf("signalling the test process failed: %v", err)
+ }
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("serve returned %v on SIGTERM; a clean redeploy would be reported as a crash", err)
+ }
+ // The listener must actually be closed, not merely reported as
+ // shut down — otherwise the port stays held by a process that
+ // believes it has stopped.
+ if err := srv.ListenAndServe(); err != http.ErrServerClosed {
+ t.Errorf("the server was not left in a shut-down state (ListenAndServe returned %v)", err)
+ }
+ return
+ case <-ticker.C:
+ case <-deadline:
+ t.Fatal("serve did not return after SIGTERM; the orchestrator would have to kill the container")
+ }
+ }
+}
+
+// The container health check runs the binary against itself, so PORT is read
+// twice by two different pieces of code: config.ResolveAddr turns it into a
+// listen address, healthcheck turns it into a URL. Every form the server binds
+// has to be probeable, or the container is reported unhealthy — and eventually
+// restarted — while it is serving perfectly well.
+//
+// The server here listens on 127.0.0.1, which every one of these forms reaches.
+func TestHealthcheckProbesEveryAddressForm(t *testing.T) {
+ port, _ := startHealthServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ tests := []struct {
+ name string
+ port string
+ }{
+ {name: "a bare port", port: port},
+ // docker-compose and .env.example both document LISTEN, and ":3000" is
+ // a perfectly ordinary thing to put in it.
+ {name: "a leading colon", port: ":" + port},
+ {name: "a full host:port", port: "127.0.0.1:" + port},
+ // A wildcard bind is not an address to connect to; the probe has to
+ // fall back to loopback rather than dial 0.0.0.0.
+ {name: "a wildcard host:port", port: "0.0.0.0:" + port},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("PORT", tt.port)
+ t.Setenv("LISTEN", "")
+
+ if got := healthcheck(); got != 0 {
+ t.Errorf("healthcheck returned %d for PORT=%q, but the server is answering; the container would be marked unhealthy and restarted while serving normally", got, tt.port)
+ }
+ })
+ }
+}
+
+// IPv6 is split out because the loopback interface is not guaranteed to exist
+// on every build machine. The bracketed form is the one thing a naive
+// "host:" + port concatenation gets wrong even when the host is right.
+func TestHealthcheckProbesAnIPv6Address(t *testing.T) {
+ ln, err := net.Listen("tcp", "[::1]:0")
+ if err != nil {
+ t.Skipf("no IPv6 loopback on this machine: %v", err)
+ }
+ srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })}
+ go func() { _ = srv.Serve(ln) }()
+ t.Cleanup(func() { _ = srv.Close() })
+
+ t.Setenv("PORT", ln.Addr().String())
+ t.Setenv("LISTEN", "")
+
+ if got := healthcheck(); got != 0 {
+ t.Errorf("healthcheck returned %d for PORT=%q, but the server is answering there", got, ln.Addr())
+ }
+}
+
+// probeURL is what turns the bound address into something dialable. The
+// wildcard cases are the ones that matter in the container: the server binds
+// every interface, and the probe has to pick the one it shares with it.
+func TestProbeURL(t *testing.T) {
+ tests := []struct {
+ name string
+ addr string
+ want string
+ wantErr bool
+ }{
+ {name: "a bare port from PORT=3000", addr: ":3000", want: "http://127.0.0.1:3000/healthz"},
+ {name: "an explicit loopback host", addr: "127.0.0.1:3000", want: "http://127.0.0.1:3000/healthz"},
+ {name: "an IPv4 wildcard probes loopback", addr: "0.0.0.0:3000", want: "http://127.0.0.1:3000/healthz"},
+ {name: "an IPv6 wildcard probes IPv6 loopback", addr: "[::]:3000", want: "http://[::1]:3000/healthz"},
+ {name: "an IPv6 host keeps its brackets", addr: "[::1]:3000", want: "http://[::1]:3000/healthz"},
+ {name: "a named host", addr: "localhost:3000", want: "http://localhost:3000/healthz"},
+ // Not reachable through config.ResolveAddr any more, but probeURL must
+ // still refuse rather than build a URL that will not parse.
+ {name: "no port at all", addr: "3000", wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := probeURL(tt.addr)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("probeURL(%q) = %q; an address that cannot be split must be reported, not turned into a malformed URL", tt.addr, got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("probeURL(%q) failed: %v; the health check cannot probe an address the server binds", tt.addr, err)
+ }
+ if got != tt.want {
+ t.Errorf("probeURL(%q) = %q, want %q; the probe would dial the wrong place and report a serving container as unhealthy", tt.addr, got, tt.want)
+ }
+ })
+ }
+}
+
+// An unusable PORT stops the server from starting, so -healthcheck must not
+// answer 0 for it either — a probe that succeeds against a configuration the
+// server refuses would mark a container healthy that can never come up.
+func TestHealthcheckRejectsAnUnusablePort(t *testing.T) {
+ for _, port := range []string{"0", "99999", "ikke-et-tall"} {
+ t.Run(port, func(t *testing.T) {
+ t.Setenv("PORT", port)
+ t.Setenv("LISTEN", "")
+
+ if got := healthcheck(); got != 1 {
+ t.Errorf("healthcheck returned %d for PORT=%q, which the server itself refuses to bind", got, port)
+ }
+ })
+ }
+}
+
+// mainArgsEnv both marks the re-executed child process and carries the command
+// line main should see. Its presence is the only thing that distinguishes the
+// child from an ordinary test run.
+const mainArgsEnv = "ITEMIZE_TEST_MAIN_ARGS"
+
+// TestMainHelperProcess is not a test. It is the entry point of the child
+// process spawned by TestMainDispatch: main calls os.Exit, so the only way to
+// observe what it does with a flag is from outside the process.
+//
+// os.Args is rewritten rather than passed on the child's command line because
+// the testing package parses the real command line first, and would reject
+// -healthcheck as an unknown flag before main ever registers it.
+func TestMainHelperProcess(t *testing.T) {
+ args, ok := os.LookupEnv(mainArgsEnv)
+ if !ok {
+ t.Skip("not the re-executed child process")
+ }
+ os.Args = append([]string{"website"}, strings.Fields(args)...)
+ main()
+}
+
+// main is a flag switch and an exit code, and both matter operationally: the
+// container health check depends on -healthcheck exiting 0 or 1, and the
+// orchestrator's restart loop depends on a failed start exiting non-zero.
+func TestMainDispatch(t *testing.T) {
+ // A server the child can probe, and a port that is guaranteed to have
+ // nothing on it once the second server is shut down.
+ livePort, _ := startHealthServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ deadPort, shutdown := startHealthServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+ shutdown()
+
+ tests := []struct {
+ name string
+ args string
+ env []string
+ wantExit int
+ wantOut string
+ }{
+ {
+ name: "-healthcheck reports a serving instance",
+ args: "-healthcheck",
+ env: []string{"PORT=" + livePort},
+ wantExit: 0,
+ },
+ {
+ name: "-healthcheck reports an instance that is not answering",
+ args: "-healthcheck",
+ env: []string{"PORT=" + deadPort},
+ wantExit: 1,
+ wantOut: "healthcheck:",
+ },
+ {
+ // No flags: main goes on to start the server, which refuses an
+ // empty environment. Exiting non-zero is what makes the
+ // orchestrator retry rather than mark the deployment healthy.
+ name: "a failed start exits non-zero and says why",
+ args: "",
+ wantExit: 1,
+ wantOut: "BASE_URL is required",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cmd := exec.Command(os.Args[0], "-test.run=^TestMainHelperProcess$")
+ // A deliberately minimal environment: nothing the parent happens
+ // to export can configure the child by accident.
+ cmd.Env = append([]string{
+ mainArgsEnv + "=" + tt.args,
+ "PATH=" + os.Getenv("PATH"),
+ "HOME=" + os.Getenv("HOME"),
+ }, tt.env...)
+ // An empty directory, so the .env that config.Load reads outside
+ // production cannot exist.
+ cmd.Dir = t.TempDir()
+
+ out, err := cmd.CombinedOutput()
+ exit := cmd.ProcessState.ExitCode()
+ if exit == -1 {
+ t.Fatalf("the child process did not exit normally: %v\noutput:\n%s", err, out)
+ }
+ if exit != tt.wantExit {
+ t.Errorf("`website %s` exited %d, want %d\noutput:\n%s", tt.args, exit, tt.wantExit, out)
+ }
+ if tt.wantOut != "" && !strings.Contains(string(out), tt.wantOut) {
+ t.Errorf("`website %s` printed nothing about %q, so the failure is invisible in the container log\noutput:\n%s", tt.args, tt.wantOut, out)
+ }
+ // main always ends in os.Exit, so the testing package never gets
+ // to print its verdict. Seeing it means the helper returned
+ // without main taking over, and the exit code above proves
+ // nothing.
+ if strings.Contains(string(out), "PASS") {
+ t.Errorf("the child finished as an ordinary test run rather than through main\noutput:\n%s", out)
+ }
+ })
+ }
+}
diff --git a/internal/api/apitest_test.go b/internal/api/apitest_test.go
new file mode 100644
index 0000000..4ba3913
--- /dev/null
+++ b/internal/api/apitest_test.go
@@ -0,0 +1,414 @@
+package api
+
+// Shared fixtures and fakes for the API handler tests. Helpers only — the
+// tests themselves live in events_test.go, checkin_test.go, users_test.go,
+// respond_test.go and routes_test.go.
+//
+// These are in-package rather than in an api_test package because the wire
+// contract lives as much in the unexported helpers (toDTO, truthy, num,
+// yearOf) as in the handlers, and those are worth testing directly instead of
+// only through whatever combination of requests happens to reach them.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+
+ "github.com/ItemizeNTNU/website/internal/auth"
+ "github.com/ItemizeNTNU/website/internal/events"
+ "github.com/ItemizeNTNU/website/internal/fusionauth"
+)
+
+func discardLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(io.Discard, nil))
+}
+
+// logCapture collects what a handler logged. Some of these handlers answer a
+// caller with deliberately less than they know — a storage failure served as
+// "not found", so that somebody working through guessed codes cannot tell which
+// of them hit — and the log line is then the only place the real failure
+// appears. A test that only reads the response cannot tell that apart from
+// swallowing the error entirely.
+type logCapture struct {
+ mu sync.Mutex
+ buf strings.Builder
+}
+
+func (c *logCapture) Write(p []byte) (int, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.buf.Write(p)
+}
+
+func (c *logCapture) String() string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.buf.String()
+}
+
+// captureLogger is a logger whose output a test can read back.
+func captureLogger() (*slog.Logger, *logCapture) {
+ capture := &logCapture{}
+ return slog.New(slog.NewTextHandler(capture, &slog.HandlerOptions{Level: slog.LevelDebug})), capture
+}
+
+// errRepo is a generic infrastructure failure — anything that is not one of
+// the sentinel errors the handlers branch on.
+var errRepo = errors.New("the database is on fire")
+
+// stubRepo is an in-memory events.Repository. The embedded interface supplies
+// the methods no API handler should ever reach: a call to one of them is a nil
+// dereference, which is a louder failure than quietly returning a zero value
+// and is exactly what a handler reaching for storage it has no business
+// touching deserves.
+type stubRepo struct {
+ events.Repository
+
+ // list is returned verbatim by List. The handler's job is deriving the
+ // filter and mapping the result, not filtering — so the fake deliberately
+ // does no filtering of its own, and the filter it was handed is recorded
+ // instead.
+ list []events.Event
+ public []events.Event
+ byCode map[string]events.Event
+
+ listErr error
+ publicErr error
+ byCodeErr error
+ addErr error
+
+ gotFilter events.Filter
+ gotCode string
+ gotAttendance events.Attendance
+ adds int
+}
+
+func (s *stubRepo) List(_ context.Context, f events.Filter) ([]events.Event, error) {
+ s.gotFilter = f
+ if s.listErr != nil {
+ return nil, s.listErr
+ }
+ return s.list, nil
+}
+
+func (s *stubRepo) Public(context.Context) ([]events.Event, error) {
+ if s.publicErr != nil {
+ return nil, s.publicErr
+ }
+ return s.public, nil
+}
+
+func (s *stubRepo) ByCheckInCode(_ context.Context, code string) (*events.Event, error) {
+ s.gotCode = code
+ if s.byCodeErr != nil {
+ return nil, s.byCodeErr
+ }
+ e, ok := s.byCode[code]
+ if !ok {
+ return nil, events.ErrNotFound
+ }
+ return &e, nil
+}
+
+func (s *stubRepo) AddAttendance(_ context.Context, code string, a events.Attendance) error {
+ s.gotCode = code
+ s.gotAttendance = a
+ s.adds++
+ return s.addErr
+}
+
+// apiConfig is what a test can vary about the API under test. The zero value
+// is "like a deployment with no FusionAuth key": every user call answers 503.
+type apiConfig struct {
+ repo events.Repository
+ fusion *fusionauth.Client
+ baseURL string
+
+ // log defaults to a discarding one. Set it from captureLogger to assert on
+ // what a handler recorded.
+ log *slog.Logger
+
+ // nilFusion passes a literal nil client, which is what a caller that
+ // forgot to wire FusionAuth would produce. Configured() has a nil check
+ // for exactly this, and the handlers rely on it.
+ nilFusion bool
+}
+
+// newAPI builds the real routing table over injectable dependencies.
+//
+// Every call builds a fresh Server, and with it a fresh signupLimit rate
+// limiter holding five tokens. The 429 test in users_test.go burns the whole
+// allowance on its own mux; every other test must stay at five or fewer PUTs
+// to /api/user per mux, or it starts seeing 429s that have nothing to do with
+// what it is testing.
+func newAPI(t *testing.T, cfg apiConfig) *http.ServeMux {
+ t.Helper()
+
+ fusion := cfg.fusion
+ if fusion == nil && !cfg.nilFusion {
+ fusion = fusionauth.New("https://auth.example", "")
+ }
+ baseURL := cfg.baseURL
+ if baseURL == "" {
+ baseURL = "https://itemize.no"
+ }
+
+ log := cfg.log
+ if log == nil {
+ log = discardLogger()
+ }
+
+ mux := http.NewServeMux()
+ NewServer(cfg.repo, fusion, baseURL, log).Routes(mux)
+ return mux
+}
+
+// fusionSpy records what reached the fake FusionAuth. Everything is behind a
+// mutex because the handler runs on the server's goroutine while the test reads
+// it from its own.
+type fusionSpy struct {
+ mu sync.Mutex
+ calls int
+ path string
+ method string
+ auth string
+ body string
+}
+
+func (f *fusionSpy) snapshot() fusionSpy {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return fusionSpy{calls: f.calls, path: f.path, method: f.method, auth: f.auth, body: f.body}
+}
+
+// fakeFusion points a configured FusionAuth client at handler and records what
+// was asked of it — which is how the identifier-validation tests tell "refused
+// here" from "refused by FusionAuth".
+func fakeFusion(t *testing.T, handler http.HandlerFunc) (*fusionauth.Client, *fusionSpy) {
+ t.Helper()
+ spy := &fusionSpy{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+
+ spy.mu.Lock()
+ spy.calls++
+ spy.path = r.URL.Path
+ spy.method = r.Method
+ spy.auth = r.Header.Get("Authorization")
+ spy.body = string(body)
+ spy.mu.Unlock()
+
+ handler(w, r)
+ }))
+ t.Cleanup(srv.Close)
+ return fusionauth.New(srv.URL, "test-api-key"), spy
+}
+
+// deadFusion is a client pointed at a server that is already gone: the call
+// fails at the transport rather than with a status code, which is the branch a
+// FusionAuth outage or a DNS failure takes.
+func deadFusion(t *testing.T) *fusionauth.Client {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ url := srv.URL
+ srv.Close()
+ return fusionauth.New(url, "test-api-key")
+}
+
+// The user fixtures. The identifiers are canonical UUIDs because
+// fusionauth.ValidID refuses anything else before a request is made — a
+// placeholder like "user-1" would turn every FusionAuth-backed test into an
+// ErrInvalidID test by accident.
+var styret = &auth.User{
+ ID: "11111111-2222-4333-8444-999999999999",
+ Name: "Styremedlem",
+ FullName: "Åse Øverland",
+ Roles: []string{auth.RoleStyret},
+}
+
+var member = &auth.User{
+ ID: "22222222-3333-4444-8555-666666666666",
+ Name: "Kari",
+ FullName: "Kari Nordmann",
+ Email: "medlem@example.no",
+}
+
+// asUser attaches u to the request context the way the authn middleware would.
+// The test mux carries no Inject middleware, so this is the only way a request
+// is ever signed in. A nil user is an anonymous visitor.
+func asUser(r *http.Request, u *auth.User) *http.Request {
+ if u == nil {
+ return r
+ }
+ return r.WithContext(auth.WithUser(r.Context(), u))
+}
+
+// do serves a request through the mux as u.
+//
+// Requests go through the mux rather than at a handler directly because the
+// authorization middleware and the path wildcards are part of what is being
+// tested: a handler reached without them sees an empty PathValue and an
+// unchecked role.
+func do(t *testing.T, mux *http.ServeMux, method, path string, body io.Reader, u *auth.User) *httptest.ResponseRecorder {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, asUser(httptest.NewRequest(method, path, body), u))
+ return rec
+}
+
+// getAs is do for the read endpoints, which never carry a body.
+func getAs(t *testing.T, mux *http.ServeMux, path string, u *auth.User) *httptest.ResponseRecorder {
+ t.Helper()
+ return do(t, mux, http.MethodGet, path, nil, u)
+}
+
+// putJSON is do for the registration endpoint, whose body is JSON.
+func putJSON(t *testing.T, mux *http.ServeMux, path, body string, u *auth.User) *httptest.ResponseRecorder {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, path, strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, asUser(req, u))
+ return rec
+}
+
+// wantStatus fails when the response carries a status other than want. The
+// body is included because an unexpected status is nearly always explained by
+// the message the handler put in it.
+func wantStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
+ t.Helper()
+ if rec.Code != want {
+ t.Fatalf("got %d, want %d; body was %s", rec.Code, want, strings.TrimSpace(rec.Body.String()))
+ }
+}
+
+// wantJSON checks the Content-Type. Clients parse the body without sniffing
+// it, so a response served as anything else is one they will not read at all
+// — which is a different and much more confusing failure than an error status.
+func wantJSON(t *testing.T, rec *httptest.ResponseRecorder) {
+ t.Helper()
+ const want = "application/json; charset=utf-8"
+ if got := rec.Header().Get("Content-Type"); got != want {
+ t.Errorf("Content-Type is %q, want %q; a JSON client will not parse this", got, want)
+ }
+}
+
+// messageOf decodes the {"message": ...} envelope every status and error
+// response uses. Clients key off that field, so a response that has lost the
+// shape is a break even when the status code is right.
+func messageOf(t *testing.T, rec *httptest.ResponseRecorder) string {
+ t.Helper()
+ var body message
+ decodeBody(t, rec, &body)
+ return body.Message
+}
+
+func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, dst any) {
+ t.Helper()
+ if err := json.Unmarshal(rec.Body.Bytes(), dst); err != nil {
+ t.Fatalf("the response is not JSON the client can decode: %v; body was %s",
+ err, strings.TrimSpace(rec.Body.String()))
+ }
+}
+
+// The event fixtures. Every timestamp is fixed and in UTC so the encoded shape
+// can be compared literally rather than field by field.
+var (
+ fixedCreated = time.Date(2098, 3, 1, 9, 0, 0, 0, time.UTC)
+ fixedEdited = time.Date(2098, 3, 2, 10, 30, 0, 0, time.UTC)
+ fixedStart = time.Date(2098, 9, 1, 17, 15, 0, 0, time.UTC)
+ fixedEnd = time.Date(2098, 9, 1, 20, 15, 0, 0, time.UTC)
+ fixedCheckIn = time.Date(2098, 9, 1, 17, 22, 0, 0, time.UTC)
+)
+
+// testCode is the check-in credential. It is UUID-shaped because the real ones
+// are, and the tests that put it in a URL want a realistic path segment.
+const testCode = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+
+const testHexID = "507f1f77bcf86cd799439011"
+
+func mustObjectID(t *testing.T, hex string) bson.ObjectID {
+ t.Helper()
+ id, err := bson.ObjectIDFromHex(hex)
+ if err != nil {
+ t.Fatalf("the fixture identifier %q is not a valid ObjectID: %v", hex, err)
+ }
+ return id
+}
+
+// pizzakveld is a fully populated event: every optional field set, so a test
+// that pins the encoded shape sees all of them, and Norwegian text in the
+// places a board member would actually type it.
+func pizzakveld(t *testing.T) events.Event {
+ t.Helper()
+ edited := fixedEdited
+ return events.Event{
+ ID: mustObjectID(t, testHexID),
+ Name: "Pizza og CTF",
+ Location: events.Place{Name: "Savannen", URL: "https://itemize.no/savannen"},
+ RegisterURL: "https://itemize.no/pamelding",
+ Date: fixedStart,
+ Duration: 3,
+ End: fixedEnd,
+ CTF: events.Place{Name: "ItemizeCTF", URL: "https://ctf.itemize.no"},
+ Info: "Ta med laptop.",
+ Hidden: false,
+ Discord: true,
+ DiscordEventID: "1234567890",
+ Created: fixedCreated,
+ Edited: &edited,
+ CheckIn: events.CheckIn{
+ Code: testCode,
+ Attendances: []events.Attendance{{
+ ID: mustObjectID(t, "507f191e810c19729de860ea"),
+ Name: "Kari Nordmann",
+ UserID: member.ID,
+ Registered: fixedCheckIn,
+ }},
+ },
+ }
+}
+
+// pizzakveldFields is the encoded form of the fixture, closing brace omitted
+// so the check-in block can be appended.
+//
+// It is written out in full rather than asserted field by field because it is
+// a published contract: this JSON and the iCal feed are what things outside
+// this repository depend on, and a renamed, reordered or newly-omitempty field
+// breaks a consumer nobody here can see. A diff on this string is the warning.
+const pizzakveldFields = `{"_id":"507f1f77bcf86cd799439011",` +
+ `"name":"Pizza og CTF",` +
+ `"location":{"name":"Savannen","url":"https://itemize.no/savannen"},` +
+ `"register_url":"https://itemize.no/pamelding",` +
+ `"date":"2098-09-01T17:15:00Z",` +
+ `"duration":3,` +
+ `"end":"2098-09-01T20:15:00Z",` +
+ `"ctf":{"name":"ItemizeCTF","url":"https://ctf.itemize.no"},` +
+ `"info":"Ta med laptop.",` +
+ `"hidden":false,` +
+ `"discord":true,` +
+ `"discordEventId":"1234567890",` +
+ `"created":"2098-03-01T09:00:00Z",` +
+ `"edited":"2098-03-02T10:30:00Z"`
+
+// pizzakveldJSON is what a caller who is not on the board gets: no check-in
+// block at all.
+const pizzakveldJSON = pizzakveldFields + `}`
+
+// pizzakveldStyretJSON is the same event for the board, carrying the check-in
+// code and the attendance register.
+const pizzakveldStyretJSON = pizzakveldFields +
+ `,"check_in":{"code":"` + testCode + `",` +
+ `"attendances":[{"name":"Kari Nordmann",` +
+ `"user_id":"22222222-3333-4444-8555-666666666666",` +
+ `"registered":"2098-09-01T17:22:00Z"}]}}`
diff --git a/internal/api/checkin.go b/internal/api/checkin.go
index c1b24b9..0f1c398 100644
--- a/internal/api/checkin.go
+++ b/internal/api/checkin.go
@@ -17,8 +17,17 @@ import (
// Its only caller was a page already restricted to the board, so nothing is
// lost by enforcing that on the server too.
func (s *Server) getCheckIn(w http.ResponseWriter, r *http.Request) {
- event, err := s.events.ByCheckInCode(r.Context(), r.PathValue("code"))
+ code := r.PathValue("code")
+ event, err := s.events.ByCheckInCode(r.Context(), code)
if err != nil {
+ // A storage failure is still answered as "not found", on purpose:
+ // telling the two apart would confirm to somebody working through
+ // guesses which of them hit a real code. It does have to be logged
+ // though — swallowing it made an outage on this path look to everyone,
+ // operators included, like a mistyped code.
+ if !errors.Is(err, events.ErrNotFound) {
+ s.log.Error("looking up the check-in code failed", "code", code, "err", err)
+ }
writeJSON(w, http.StatusNotFound, message{"Event not found"})
return
}
@@ -28,6 +37,15 @@ func (s *Server) getCheckIn(w http.ResponseWriter, r *http.Request) {
// postCheckIn registers the caller's attendance.
func (s *Server) postCheckIn(w http.ResponseWriter, r *http.Request) {
user := auth.FromRequest(r)
+ if user == nil {
+ // Unreachable through Routes, which mounts this behind
+ // RequireLoginAPI. It is here so the handler is safe wherever it is
+ // mounted: user.ID below is a nil dereference, so a future route
+ // registration that forgets the wrapper would turn a missing gate into
+ // a panic in the request goroutine rather than a 401.
+ writeJSON(w, http.StatusUnauthorized, message{"You are not logged in"})
+ return
+ }
code := r.PathValue("code")
err := s.events.AddAttendance(r.Context(), code, events.Attendance{
diff --git a/internal/api/checkin_test.go b/internal/api/checkin_test.go
new file mode 100644
index 0000000..5320a07
--- /dev/null
+++ b/internal/api/checkin_test.go
@@ -0,0 +1,388 @@
+package api
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/ItemizeNTNU/website/internal/auth"
+ "github.com/ItemizeNTNU/website/internal/events"
+)
+
+func checkInMux(t *testing.T, repo *stubRepo) *http.ServeMux {
+ t.Helper()
+ return newAPI(t, apiConfig{repo: repo})
+}
+
+func seededRepo(t *testing.T) *stubRepo {
+ t.Helper()
+ return &stubRepo{byCode: map[string]events.Event{testCode: pizzakveld(t)}}
+}
+
+// Reading the register hands out the check-in code itself — the credential that
+// registers attendance — together with every attendee's name and FusionAuth
+// identifier. The previous version served that to anyone who knew a code. The
+// board gate is the fix, and these are the two ways past it that must not work.
+//
+// The status is 401 for both, including for a signed-in member without the
+// role, because clients key off the message and the previous API answered that
+// way. It reads oddly next to 403 but changing it is a silent break.
+func TestGetCheckInRequiresTheBoard(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ user *auth.User
+ want string
+ }{
+ {"an anonymous caller", nil, "You are not logged in"},
+ {"a member without the board role", member, "Permission denied"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ repo := seededRepo(t)
+ rec := getAs(t, checkInMux(t, repo), "/api/checkin/"+testCode, tc.user)
+
+ wantStatus(t, rec, http.StatusUnauthorized)
+ wantJSON(t, rec)
+ if got := messageOf(t, rec); got != tc.want {
+ t.Errorf("the refusal reads %q, want %q", got, tc.want)
+ }
+ if strings.Contains(rec.Body.String(), member.ID) {
+ t.Error("a refused request still leaked an attendee identifier")
+ }
+ if repo.gotCode != "" {
+ t.Error("storage was queried before the caller was refused; the gate " +
+ "must come first, not merely hide the answer")
+ }
+ })
+ }
+}
+
+// What the board actually gets: the event plus the register, in the shape the
+// check-in page reads.
+func TestGetCheckInReturnsTheRegister(t *testing.T) {
+ repo := seededRepo(t)
+ rec := getAs(t, checkInMux(t, repo), "/api/checkin/"+testCode, styret)
+
+ wantStatus(t, rec, http.StatusOK)
+ wantJSON(t, rec)
+ if got := strings.TrimSuffix(rec.Body.String(), "\n"); got != pizzakveldStyretJSON {
+ t.Errorf("the register does not match the shape the check-in page reads.\n got: %s\nwant: %s",
+ got, pizzakveldStyretJSON)
+ }
+ if repo.gotCode != testCode {
+ t.Errorf("storage was asked for code %q, want %q", repo.gotCode, testCode)
+ }
+}
+
+// An event whose register is still empty must come back with the code and
+// without an attendances key — the page shows "nobody yet" from the absence,
+// and an event with no code at all still has to render rather than 500.
+func TestGetCheckInWithAnEmptyRegister(t *testing.T) {
+ repo := &stubRepo{byCode: map[string]events.Event{
+ testCode: {Name: "Nytt arrangement", CheckIn: events.CheckIn{Code: testCode}},
+ }}
+
+ rec := getAs(t, checkInMux(t, repo), "/api/checkin/"+testCode, styret)
+
+ wantStatus(t, rec, http.StatusOK)
+ body := strings.TrimSpace(rec.Body.String())
+ if !strings.Contains(body, `"check_in":{"code":"`+testCode+`"}`) {
+ t.Errorf("an unused register did not encode as a bare code: %s", body)
+ }
+ if strings.Contains(body, "attendances") {
+ t.Errorf("an empty register emitted an attendances key: %s", body)
+ }
+}
+
+// A code that matches nothing is a 404 with the JSON envelope, not the site's
+// HTML error page — the check-in screen fetches this and parses the answer.
+func TestGetCheckInUnknownCode(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ path string
+ }{
+ {"a code that matches no event", "/api/checkin/ingen-slik-kode"},
+ {"a Norwegian code", "/api/checkin/" + url.PathEscape("blåbærsyltetøy")},
+ {"a single character", "/api/checkin/x"},
+ {"a code that looks like a path traversal", "/api/checkin/" + url.PathEscape("../../etc/passwd")},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := getAs(t, checkInMux(t, seededRepo(t)), tc.path, styret)
+
+ wantStatus(t, rec, http.StatusNotFound)
+ wantJSON(t, rec)
+ if got := messageOf(t, rec); got != "Event not found" {
+ t.Errorf("the message is %q, want %q", got, "Event not found")
+ }
+ })
+ }
+}
+
+// A database that will not answer is still reported as "not found" to the
+// caller: a 500 that distinguishes the two would tell somebody working through
+// guessed codes which of them hit a real event during an outage.
+//
+// The operator is a different audience, and used not to be told at all. Every
+// error became a bare 404 with no log line, so an outage on this path looked to
+// everyone — the board member at the door and whoever they asked for help —
+// like a mistyped code. The 404 stays; the log line is what makes it
+// diagnosable.
+func TestGetCheckInReportsStorageFailureAsNotFoundButLogsIt(t *testing.T) {
+ repo := seededRepo(t)
+ repo.byCodeErr = errRepo
+ log, capture := captureLogger()
+
+ rec := getAs(t, newAPI(t, apiConfig{repo: repo, log: log}), "/api/checkin/"+testCode, styret)
+
+ wantStatus(t, rec, http.StatusNotFound)
+ if got := messageOf(t, rec); got != "Event not found" {
+ t.Errorf("the message is %q, want %q", got, "Event not found")
+ }
+ if strings.Contains(rec.Body.String(), errRepo.Error()) {
+ t.Error("the internal failure was echoed to the caller")
+ }
+
+ logged := capture.String()
+ if !strings.Contains(logged, "level=ERROR") {
+ t.Errorf("a storage failure was not logged at error level, so nothing "+
+ "watching the logs sees the outage; got %q", logged)
+ }
+ if !strings.Contains(logged, errRepo.Error()) {
+ t.Errorf("the log line does not carry the underlying failure, which is the "+
+ "only place it survives; got %q", logged)
+ }
+}
+
+// The ordinary miss must stay quiet. This endpoint is the one a scan hits, and
+// logging every wrong code at error level would bury the storage failure above
+// in noise generated by anybody who wants to.
+func TestGetCheckInDoesNotLogAnOrdinaryMiss(t *testing.T) {
+ log, capture := captureLogger()
+
+ rec := getAs(t, newAPI(t, apiConfig{repo: seededRepo(t), log: log}), "/api/checkin/ingen-slik-kode", styret)
+
+ wantStatus(t, rec, http.StatusNotFound)
+ if logged := capture.String(); logged != "" {
+ t.Errorf("a wrong code was logged as %q; a scan would drown the log", logged)
+ }
+}
+
+// Registering attendance only needs the caller to be somebody — but it does
+// need that, or the register fills with rows attributed to nobody and the
+// endpoint becomes a way to inflate an attendance list without turning up.
+func TestPostCheckInRequiresALogin(t *testing.T) {
+ repo := seededRepo(t)
+ rec := do(t, checkInMux(t, repo), http.MethodPost, "/api/checkin/"+testCode, nil, nil)
+
+ wantStatus(t, rec, http.StatusUnauthorized)
+ wantJSON(t, rec)
+ if got := messageOf(t, rec); got != "You are not logged in" {
+ t.Errorf("the refusal reads %q, want %q", got, "You are not logged in")
+ }
+ if repo.adds != 0 {
+ t.Error("an anonymous request reached storage")
+ }
+}
+
+// The same request with the middleware taken away, which is what a future route
+// registration that forgets RequireLoginAPI would produce.
+//
+// The handler dereferences the user for the attendance it writes, so without a
+// check of its own a forgotten wrapper is not a missing gate — it is a panic in
+// the request goroutine on an anonymous request, which is far worse than the
+// 401 that was intended. Reaching the handler directly is the point of this
+// test: through Routes it is unreachable, and that is exactly the assumption
+// being removed.
+func TestPostCheckInIsSafeWithoutItsMiddleware(t *testing.T) {
+ repo := seededRepo(t)
+ srv := NewServer(repo, nil, "https://itemize.no", discardLogger())
+
+ req := httptest.NewRequest(http.MethodPost, "/api/checkin/"+testCode, nil)
+ req.SetPathValue("code", testCode)
+ rec := httptest.NewRecorder()
+
+ srv.postCheckIn(rec, req)
+
+ wantStatus(t, rec, http.StatusUnauthorized)
+ wantJSON(t, rec)
+ if got := messageOf(t, rec); got != "You are not logged in" {
+ t.Errorf("the refusal reads %q, want %q", got, "You are not logged in")
+ }
+ if repo.adds != 0 {
+ t.Error("an anonymous request was written to the attendance register")
+ }
+}
+
+// The happy path, and the one thing about it that matters afterwards: the
+// register is read by people, so it wants the legal name rather than the
+// identifier.
+func TestPostCheckInRegistersAttendance(t *testing.T) {
+ repo := seededRepo(t)
+ rec := do(t, checkInMux(t, repo), http.MethodPost, "/api/checkin/"+testCode, nil, member)
+
+ wantStatus(t, rec, http.StatusOK)
+ wantJSON(t, rec)
+ if got := messageOf(t, rec); got != "Success" {
+ t.Errorf("the confirmation reads %q, want %q", got, "Success")
+ }
+ if repo.adds != 1 {
+ t.Fatalf("storage recorded %d attendances, want 1", repo.adds)
+ }
+ if repo.gotCode != testCode {
+ t.Errorf("attendance was recorded against code %q, want %q", repo.gotCode, testCode)
+ }
+ if repo.gotAttendance.Name != member.FullName {
+ t.Errorf("the register would show %q, want the member's full name %q",
+ repo.gotAttendance.Name, member.FullName)
+ }
+ if repo.gotAttendance.UserID != member.ID {
+ t.Errorf("the attendance is attributed to %q, want %q", repo.gotAttendance.UserID, member.ID)
+ }
+}
+
+// The name is what somebody reads off the list afterwards, so an incomplete
+// token must not produce a blank row — which is what writing fullName
+// unconditionally used to do, unnoticed until the list was read.
+func TestNameFor(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ user *auth.User
+ want string
+ }{
+ {"nil user", nil, ""},
+ {"full name wins", &auth.User{FullName: "Kari Nordmann", Name: "Kari"}, "Kari Nordmann"},
+ {"display name when the legal name is missing", &auth.User{Name: "Kari"}, "Kari"},
+ {"email as a last resort", &auth.User{Email: "kari@example.no"}, "kari@example.no"},
+ {"nothing at all", &auth.User{}, ""},
+ {"Norwegian characters are untouched", &auth.User{FullName: "Åse Øverland-Æsberg"}, "Åse Øverland-Æsberg"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := nameFor(tc.user); got != tc.want {
+ t.Errorf("nameFor gave %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// The same fallback, reached the way it actually happens: a member whose token
+// carries no legal-name claim scans the code at the door.
+func TestPostCheckInFallsBackToTheDisplayName(t *testing.T) {
+ repo := seededRepo(t)
+ user := &auth.User{ID: "33333333-4444-4555-8666-777777777777", Name: "Øyvind"}
+
+ do(t, checkInMux(t, repo), http.MethodPost, "/api/checkin/"+testCode, nil, user)
+
+ if repo.gotAttendance.Name != "Øyvind" {
+ t.Errorf("the register would show %q, want the display name; a blank row is "+
+ "a person nobody can identify afterwards", repo.gotAttendance.Name)
+ }
+}
+
+// Every branch the write path can take, and the status each one has to keep. A
+// second scan is a conflict rather than an error, because scanning twice is
+// what people do and the response is shown to them at the door.
+func TestPostCheckInErrorMapping(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ addErr error
+ wantStatus int
+ wantMsg string
+ }{
+ {
+ name: "a second scan of the same code",
+ addErr: events.ErrAlreadyCheckedIn,
+ wantStatus: http.StatusConflict,
+ wantMsg: "You have already registered your attendance for this event",
+ },
+ {
+ name: "a code that matches no event",
+ addErr: events.ErrNotFound,
+ wantStatus: http.StatusNotFound,
+ wantMsg: `Event not found with check_in code "` + testCode + `"`,
+ },
+ {
+ name: "storage is unavailable",
+ addErr: errRepo,
+ wantStatus: http.StatusInternalServerError,
+ wantMsg: "Something broke :/",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ repo := seededRepo(t)
+ repo.addErr = tc.addErr
+
+ rec := do(t, checkInMux(t, repo), http.MethodPost, "/api/checkin/"+testCode, nil, member)
+
+ wantStatus(t, rec, tc.wantStatus)
+ wantJSON(t, rec)
+ if got := messageOf(t, rec); got != tc.wantMsg {
+ t.Errorf("the message reads %q, want %q", got, tc.wantMsg)
+ }
+ })
+ }
+}
+
+// The sentinel errors are matched with errors.Is, so a repository that wraps
+// them for context must still land on the right status. Unwrapping by equality
+// would turn a duplicate scan into a 500 at the door.
+func TestPostCheckInMatchesWrappedSentinels(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ addErr error
+ wantStatus int
+ }{
+ {
+ "a wrapped duplicate",
+ fmt.Errorf("recording attendance: %w", events.ErrAlreadyCheckedIn),
+ http.StatusConflict,
+ },
+ {
+ "a wrapped miss",
+ fmt.Errorf("looking up the code: %w", events.ErrNotFound),
+ http.StatusNotFound,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ repo := seededRepo(t)
+ repo.addErr = tc.addErr
+
+ rec := do(t, checkInMux(t, repo), http.MethodPost, "/api/checkin/"+testCode, nil, member)
+
+ wantStatus(t, rec, tc.wantStatus)
+ })
+ }
+}
+
+// The rejected code is echoed back in the message, so a code carrying a quote
+// or a tag must not break out of the JSON string. This is the one place in the
+// package where caller-controlled text reaches a response body, and the
+// envelope is what keeps it inert.
+func TestPostCheckInEscapesTheEchoedCode(t *testing.T) {
+ for _, code := range []string{
+ `"; DROP TABLE`,
+ ``,
+ `æøå`,
+ `back\slash`,
+ } {
+ t.Run(code, func(t *testing.T) {
+ repo := seededRepo(t)
+ repo.addErr = events.ErrNotFound
+
+ rec := do(t, checkInMux(t, repo), http.MethodPost,
+ "/api/checkin/"+url.PathEscape(code), nil, member)
+
+ wantStatus(t, rec, http.StatusNotFound)
+ // Decoding is the assertion: a body that has broken out of the
+ // string would not parse, and the raw tag must not appear in it.
+ got := messageOf(t, rec)
+ if want := `Event not found with check_in code "` + code + `"`; got != want {
+ t.Errorf("the message decoded to %q, want %q", got, want)
+ }
+ if strings.Contains(rec.Body.String(), ""},
+ {name: "an ampersand", in: "Kari & Ola"},
+ {name: "an HTML comment", in: ""},
+ // JSON requires escaping these two, so only the round trip is asserted.
+ {name: "a quote", in: `si "hei"`},
+ {name: "a newline", in: "linje\nto"},
+ {name: "Norwegian characters", in: "Ærlig øl på Åsen", literal: true},
+ {name: "an emoji", in: "ferdig 🎉", literal: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+
+ writeJSON(rec, http.StatusOK, message{tc.in})
+ body := rec.Body.String()
+
+ for _, char := range htmlSignificant {
+ if strings.Contains(tc.in, char) && strings.Contains(body, char) {
+ t.Errorf("%q reached the body unescaped: %s", char, body)
+ }
+ }
+ if tc.literal && !strings.Contains(body, tc.in) {
+ t.Errorf("%q was escaped rather than written literally: %s", tc.in, body)
+ }
+
+ // Whatever the escaping, it has to decode back to the original.
+ var out message
+ if err := json.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatalf("the encoded body no longer parses: %v", err)
+ }
+ if out.Message != tc.in {
+ t.Errorf("decoded to %q, want %q", out.Message, tc.in)
+ }
+ })
+ }
+}
+
+// A value that cannot be encoded arrives after the status line has been sent,
+// so there is nothing to salvage — but it must not panic and take the
+// connection with it, and it must not be silent either.
+func TestWriteJSONWithAnUnencodableBody(t *testing.T) {
+ // The failure is logged through the package-level logger, which would
+ // otherwise print to stderr during the run.
+ previous := slog.Default()
+ slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
+ t.Cleanup(func() { slog.SetDefault(previous) })
+
+ rec := httptest.NewRecorder()
+
+ writeJSON(rec, http.StatusOK, map[string]any{"ch": make(chan int)})
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("the status is %d; it was already sent and cannot change", rec.Code)
+ }
+ // The truncated body is the honest outcome: the header promised JSON and the
+ // encoder produced none.
+ if body := strings.TrimSpace(rec.Body.String()); body != "" {
+ t.Errorf("an unencodable value produced the partial body %q", body)
+ }
+}
diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go
new file mode 100644
index 0000000..d250d2a
--- /dev/null
+++ b/internal/api/routes_test.go
@@ -0,0 +1,145 @@
+package api
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/ItemizeNTNU/website/internal/events"
+)
+
+// Registering the API twice on one mux, or alongside a pattern it conflicts
+// with, panics at registration — which is a crash at container start, in
+// production, on a Friday. Building the table here turns that into a test
+// failure instead.
+func TestRoutesRegisterWithoutConflict(t *testing.T) {
+ newAPI(t, apiConfig{repo: &stubRepo{}})
+}
+
+// Everything under /api answers as JSON rather than falling through to the
+// site's HTML error page. A script that fetches a mistyped path and gets a page
+// of markup fails with a parse error that says nothing about what went wrong.
+func TestUnknownAPIPathAnswersJSON(t *testing.T) {
+ mux := newAPI(t, apiConfig{repo: &stubRepo{}})
+
+ for _, path := range []string{
+ "/api/",
+ "/api/nope",
+ "/api/events/nope",
+ "/api/checkin",
+ "/api/user",
+ "/api/arrangementer",
+ } {
+ t.Run(path, func(t *testing.T) {
+ rec := getAs(t, mux, path, styret)
+
+ wantStatus(t, rec, http.StatusNotFound)
+ wantJSON(t, rec)
+ if got := messageOf(t, rec); got != "API endpoint not found" {
+ t.Errorf("the message is %q, want %q", got, "API endpoint not found")
+ }
+ if strings.Contains(rec.Body.String(), "maintenance