diff --git a/.busted b/.busted new file mode 100644 index 00000000..dd03d20a --- /dev/null +++ b/.busted @@ -0,0 +1,21 @@ +-- Busted configuration. +-- See https://lunarmodules.github.io/busted/ for full docs. +return { + _all = { + ROOT = { "spec" }, + -- Any file ending in _spec.lua under spec/ is auto-discovered. + pattern = "_spec", + -- Helpers that set up the test environment / safeguards. + helper = "spec/spec_helper.lua", + -- Spec files can be run from the repo root. + lpath = "./?.lua;./?/init.lua;./spec/?.lua", + verbose = true, + }, + default = { + output = "utfTerminal", + }, + ci = { + output = "plainTerminal", + ["suppress-pending"] = false, + }, +} diff --git a/.github/actions/install-snapcloud-deps/action.yml b/.github/actions/install-snapcloud-deps/action.yml new file mode 100644 index 00000000..4adfb7e9 --- /dev/null +++ b/.github/actions/install-snapcloud-deps/action.yml @@ -0,0 +1,29 @@ +name: Install Snap!Cloud runtime + Lua deps +description: > + Installs OpenResty from the upstream apt repo and the project's Lua + dependencies via bin/install-lua-deps.sh (which applies the C++14 + workaround for the xml-1.1.3 rock). Intended for jobs that need to run + a real Lapis server (playwright, axe). + +runs: + using: composite + steps: + - name: Install OpenResty (apt) + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + wget gnupg ca-certificates lsb-release + wget -qO - https://openresty.org/package/pubkey.gpg \ + | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/openresty.gpg + codename=$(lsb_release -sc) + echo "deb http://openresty.org/package/ubuntu $codename main" \ + | sudo tee /etc/apt/sources.list.d/openresty.list + sudo apt-get update + sudo apt-get install -y openresty + + - name: Install project Lua deps (with xml C++14 workaround) + shell: bash + # bin/install-lua-deps.sh forces CXX to gnu++14 so the xml-1.1.3 rock + # compiles on GCC 11 / clang 14+. Same script is used by `make install`. + run: SUDO=sudo LUA_VERSION=5.1 bin/install-lua-deps.sh diff --git a/.github/actions/load-test-db/action.yml b/.github/actions/load-test-db/action.yml new file mode 100644 index 00000000..3c96e431 --- /dev/null +++ b/.github/actions/load-test-db/action.yml @@ -0,0 +1,21 @@ +name: Load schema + seeds into snapcloud_test +description: > + Runs db/schema.sql and db/seeds.sql against the postgres service + container. Assumes the usual CI env (DATABASE_HOST=127.0.0.1, user=cloud, + db=snapcloud_test) and a POSTGRES_PASSWORD input. + +inputs: + password: + description: Postgres password for the `cloud` user. + required: true + +runs: + using: composite + steps: + - name: psql < db/schema.sql + db/seeds.sql + shell: bash + env: + PGPASSWORD: ${{ inputs.password }} + run: | + psql -h 127.0.0.1 -U cloud -d snapcloud_test -f db/schema.sql + psql -h 127.0.0.1 -U cloud -d snapcloud_test -f db/seeds.sql diff --git a/.github/actions/run-browser-specs/action.yml b/.github/actions/run-browser-specs/action.yml new file mode 100644 index 00000000..78a3731a --- /dev/null +++ b/.github/actions/run-browser-specs/action.yml @@ -0,0 +1,73 @@ +name: Run Playwright browser specs +description: > + Sets up Node + Playwright browsers, builds stylesheets, boots a Lapis + server, and runs a subset of the Playwright suite controlled by a grep + pattern. Used by both the `playwright` (grep-invert @axe) and `axe` + (grep @axe) jobs. + +inputs: + grep: + description: Playwright --grep pattern. Empty means no filter. + required: false + default: "" + grep-invert: + description: Playwright --grep-invert pattern. Empty means no filter. + required: false + default: "" + report-name: + description: Name used when uploading the Playwright HTML report artifact. + required: false + default: "playwright-report" + +runs: + using: composite + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install Node deps + Playwright browsers + shell: bash + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Build stylesheets + shell: bash + run: npx sass static/scss/:static/style/compiled/ --style compressed --no-source-map + + - name: Start Lapis server + shell: bash + run: | + mkdir -p logs tmp store/test + lapis server test & + echo "Waiting for server to come up..." + for i in $(seq 1 30); do + if curl -fsS http://localhost:8080/ > /dev/null; then + echo "Server is up." + break + fi + sleep 1 + done + + - name: Run Playwright + shell: bash + run: | + args="" + if [ -n "${{ inputs.grep }}" ]; then + args="$args --grep \"${{ inputs.grep }}\"" + fi + if [ -n "${{ inputs.grep-invert }}" ]; then + args="$args --grep-invert \"${{ inputs.grep-invert }}\"" + fi + eval "npx playwright test $args" + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.report-name }} + path: playwright-report + retention-days: 7 diff --git a/.github/actions/setup-lua/action.yml b/.github/actions/setup-lua/action.yml new file mode 100644 index 00000000..3a47711f --- /dev/null +++ b/.github/actions/setup-lua/action.yml @@ -0,0 +1,43 @@ +name: Set up Lua 5.1 + luarocks +description: > + Installs Lua 5.1, the dev headers, and luarocks from apt. Optionally + installs a list of tool rocks (luacheck, busted, luacov, etc.) from a + lockfile-free working directory so they don't drag in every project + dependency pinned in ./luarocks.lock. + +inputs: + tools: + description: > + Space-separated list of rock names to install globally, e.g. + "luacheck" or "busted luacov". Leave empty to only install Lua itself. + required: false + default: "" + postgres-client: + description: "Also install postgresql-client (for `psql`)." + required: false + default: "false" + +runs: + using: composite + steps: + - name: apt-get install lua5.1 + luarocks + shell: bash + run: | + sudo apt-get update + pkgs="lua5.1 liblua5.1-0-dev luarocks" + if [ "${{ inputs.postgres-client }}" = "true" ]; then + pkgs="$pkgs postgresql-client" + fi + sudo apt-get install -y $pkgs + + - name: Install Lua tools (outside the project dir) + if: inputs.tools != '' + shell: bash + # Run from /tmp so luarocks doesn't auto-detect ./luarocks.lock and try + # to install every pinned project dependency as a prerequisite of the + # tool rock. Tools like luacheck/busted/luacov aren't project deps. + run: | + cd /tmp + for rock in ${{ inputs.tools }}; do + sudo luarocks install --lua-version=5.1 "$rock" + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1b7b8e97 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,179 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Each job below reports its own status check. Keeping them independent means +# a broken end-to-end test doesn't hide an a11y regression (or vice versa) +# in the PR summary. Shared setup steps live in .github/actions/*. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +# Values reused across jobs. GHA workflow YAML doesn't let us anchor the +# postgres `services:` block across jobs, but env + creds can at least be +# pulled from one place. +env: + PG_USER: cloud + PG_PASSWORD: snap-cloud-password + PG_DB: snapcloud_test + +jobs: + # --------------------------------------------------------------------------- + # Lint Lua code with luacheck. No DB, no server. + # --------------------------------------------------------------------------- + luacheck: + name: Luacheck + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: ./.github/actions/setup-lua + with: + tools: luacheck + - run: luacheck . + + # --------------------------------------------------------------------------- + # Unit tests for pure-Lua modules (busted). Needs postgres for the specs + # in spec/models/, but not OpenResty. + # --------------------------------------------------------------------------- + busted: + name: Busted (Lua) + runs-on: ubuntu-22.04 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: cloud + POSTGRES_PASSWORD: snap-cloud-password + POSTGRES_DB: snapcloud_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cloud" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + LAPIS_ENVIRONMENT: test + DATABASE_HOST: 127.0.0.1 + DATABASE_PORT: 5432 + DATABASE_USERNAME: cloud + DATABASE_PASSWORD: snap-cloud-password + DATABASE_NAME: snapcloud_test + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: ./.github/actions/setup-lua + with: + tools: busted luacov + postgres-client: "true" + - uses: ./.github/actions/load-test-db + with: + password: snap-cloud-password + - run: busted --config-file=.busted --verbose + + # --------------------------------------------------------------------------- + # End-to-end browser tests. Runs every Playwright spec NOT tagged @axe. + # --------------------------------------------------------------------------- + playwright: + name: Playwright (e2e) + runs-on: ubuntu-22.04 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: cloud + POSTGRES_PASSWORD: snap-cloud-password + POSTGRES_DB: snapcloud_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cloud" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + LAPIS_ENVIRONMENT: test + DATABASE_HOST: 127.0.0.1 + DATABASE_PORT: 5432 + DATABASE_USERNAME: cloud + DATABASE_PASSWORD: snap-cloud-password + DATABASE_NAME: snapcloud_test + PORT: 8080 + HOSTNAME: localhost + CI: "true" + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: ./.github/actions/setup-lua + with: + postgres-client: "true" + - uses: ./.github/actions/install-snapcloud-deps + - uses: ./.github/actions/load-test-db + with: + password: snap-cloud-password + - uses: ./.github/actions/run-browser-specs + with: + grep-invert: "@axe" + report-name: playwright-report + + # --------------------------------------------------------------------------- + # Axe-core accessibility audit. Only @axe-tagged specs. + # --------------------------------------------------------------------------- + axe: + name: Axe-core (a11y) + runs-on: ubuntu-22.04 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: cloud + POSTGRES_PASSWORD: snap-cloud-password + POSTGRES_DB: snapcloud_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cloud" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + LAPIS_ENVIRONMENT: test + DATABASE_HOST: 127.0.0.1 + DATABASE_PORT: 5432 + DATABASE_USERNAME: cloud + DATABASE_PASSWORD: snap-cloud-password + DATABASE_NAME: snapcloud_test + PORT: 8080 + HOSTNAME: localhost + CI: "true" + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: ./.github/actions/setup-lua + with: + postgres-client: "true" + - uses: ./.github/actions/install-snapcloud-deps + - uses: ./.github/actions/load-test-db + with: + password: snap-cloud-password + - uses: ./.github/actions/run-browser-specs + with: + grep: "@axe" + report-name: axe-report diff --git a/.gitignore b/.gitignore index c69ddd51..fae296b4 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,13 @@ static/img/totm.png # local JS tools node_modules/ +# Test / CI artifacts +/playwright-report/ +/test-results/ +/playwright/.cache/ +luacov.*.out +.luacov_stats.out + # Database Dumps and Backups *.dump *.db diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 00000000..30fc5b58 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,111 @@ +-- luacheck config for Snap!Cloud +-- Target: Lua 5.1 running under OpenResty + Lapis. + +std = "lua51+ngx" + +-- Third-party / framework globals the project relies on. +-- A lot of the Snap!Cloud codebase deliberately uses process-wide globals +-- (e.g. `err`, `hash_password`, `yield_error`) as a convenience. Rather than +-- chasing those down in one go, we register them here so luacheck can focus +-- on real problems (typos, unused locals, shadowing, etc.). New code should +-- prefer `local` + explicit imports. +globals = { + -- Snap!Cloud globals (set in responses.lua / passwords.lua / validation.lua) + "err", + "errorResponses", + "yieldError", + "assertUser", + "assertAdmin", + "assertMinRole", + "assertMinRoleOrSelf", + "assertUserCanSetRole", + "hash_password", + "secure_salt", + "secure_token", + "debug_print", + -- Convenience helpers attached to strings in lib/global.lua + "string", +} + +read_globals = { + -- OpenResty / ngx_lua + "ngx", + -- Snap!Cloud convention: modules are stuffed into package.loaded + "package", +} + +-- Expose ngx-based definitions for openresty +stds.ngx = { + read_globals = { + ngx = { + fields = { + "shared", "var", "req", "resp", "say", "print", "exit", + "log", "header", "status", "location", "escape_uri", + "unescape_uri", "decode_args", "encode_args", "null", + "worker", "timer", "thread", "sleep", "time", "now", + "today", "localtime", "utctime", "cookie_time", + "http_time", "parse_http_time", "re", "redirect", + "HTTP_OK", "HTTP_MOVED_PERMANENTLY", "HTTP_FOUND", + "HTTP_SEE_OTHER", "HTTP_NOT_MODIFIED", "HTTP_BAD_REQUEST", + "HTTP_UNAUTHORIZED", "HTTP_FORBIDDEN", "HTTP_NOT_FOUND", + "HTTP_INTERNAL_SERVER_ERROR", "DEBUG", "INFO", "NOTICE", + "WARN", "ERR", "CRIT", "ALERT", "EMERG", + "OK", "ERROR", "AGAIN", "DONE", "DECLINED", + } + } + } +} + +-- Skip paths that aren't ours, or are generated / third-party. +exclude_files = { + "node_modules/", + "snap/", + "snap-versions/", + "lib/raven-lua/", + "store/", + "logs/", + "tmp/", + "dev/", + "old_site/", +} + +-- Relax a handful of checks project-wide. These are the rules most likely +-- to produce noise in existing code; tighten over time. Real bugs (unused +-- locals, redundant assignments, shadowed variables, etc.) still report. +-- +-- 111 / 112: setting a (non-standard / read-only) global — the codebase +-- deliberately stashes helpers into _G via `package.loaded.*`. +-- 113: accessing an undefined (global) variable — ditto; too many +-- cross-file globals to declare exhaustively up front. +-- 121 / 122: setting a read-only global / field — same story. +-- 212: unused argument. +-- 213: unused loop variable. +-- 431: shadowing upvalue — not a bug, but noisy in long files. +-- 611 / 612 / 614: whitespace-only issues. +-- 631: line too long. +-- +-- Tighten over time by removing entries from this list. +ignore = { + -- Globals (see note above). + "111", "112", "113", "121", "122", + -- Unused locals / arguments. These flag real (small) issues — + -- stale imports, leftover bindings. Kept suppressed so the baseline + -- is green; remove these codes one at a time as files are cleaned up. + "211", "212", "213", + -- Value assigned but not read, redefinitions, empty branches — same + -- story: legitimate tech debt, not new-code blockers. + "311", "411", "412", "431", "542", + -- Whitespace + line length. Low signal. + "611", "612", "614", "631", +} + +-- Spec files can do whatever they need with globals from busted. +files["spec/"] = { + std = "lua51+busted", + globals = { "_TEST" }, +} + +-- Migrations are allowed to be long procedural scripts. +files["migrations.lua"] = { + max_line_length = false, +} diff --git a/Makefile b/Makefile index 22e3f516..2e8b3679 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: annotate install-annotate install migrate deploy +.PHONY: annotate install-annotate install migrate deploy test test-lua test-e2e test-a11y lint UNAME := $(shell uname) @@ -21,7 +21,7 @@ install-annotate: $(luarocks_command) install --lua-version=5.1 https://raw.githubusercontent.com/snap-cloud/lapis-annotate/support-native-lua/lapis-annotate-dev-1.rockspec install: - $(luarocks_command) install --only-deps snapcloud-dev-0.rockspec + LUAROCKS=$(luarocks_command) bin/install-lua-deps.sh npm install $(MAKE) install-annotate @@ -39,3 +39,26 @@ deploy: deploy-staging: ssh staging.snap.berkeley.edu "cd snapCloud/; bin/deploy ${branch}" $(open_command) "https://staging.snap.berkeley.edu/" + +# ----------------------------------------------------------------------------- +# Test suite +# +# `make test` - run everything (lint + busted + playwright + a11y) +# `make lint` - luacheck only +# `make test-lua` - busted specs only +# `make test-e2e` - Playwright end-to-end specs only +# `make test-a11y` - axe-core accessibility specs only +# ----------------------------------------------------------------------------- +lint: + luacheck . + +test-lua: + LAPIS_ENVIRONMENT=test DATABASE_NAME=snapcloud_test busted --config-file=.busted + +test-e2e: + LAPIS_ENVIRONMENT=test DATABASE_NAME=snapcloud_test npx playwright test --grep-invert "@axe" + +test-a11y: + LAPIS_ENVIRONMENT=test DATABASE_NAME=snapcloud_test npx playwright test --grep "@axe" + +test: lint test-lua test-e2e test-a11y diff --git a/bin/install-lua-deps.sh b/bin/install-lua-deps.sh new file mode 100755 index 00000000..910fc2ec --- /dev/null +++ b/bin/install-lua-deps.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# Install Snap!Cloud's pinned Lua dependencies. +# +# The repo's luarocks.lock pins `xml-1.1.3-1`, whose bundled +# `src/bind/dub/dub.h` uses pre-C++17 dynamic exception specifications. +# On Ubuntu 22.04+ (gcc 11) and recent macOS/clang, the default C++ +# dialect is C++17 and the build fails with: +# +# error: ISO C++17 does not allow dynamic exception specifications +# +# Worse, luarocks's `builtin` build calls `gcc` (not g++) to compile +# C++ sources — it relies on gcc auto-detecting C++ from the .cpp +# extension and switching to the C++ frontend. So a g++-only shim +# doesn't intercept anything. +# +# This script puts a tiny `gcc`/`cc`/`g++`/`c++` wrapper at the front +# of PATH that injects `-std=gnu++14` *only when the args mention a +# C++ source file or -x c++*. C compiles are passed through unchanged +# so we don't add noise to every dep in the graph. +# +# Setting CXX/CXXFLAGS env vars looks simpler but isn't reliable here: +# `sudo` strips them, and luarocks's builtin build reads cfg.variables +# rather than the live environment. +# +# Usage: +# bin/install-lua-deps.sh # dev/CI default +# LUA_VERSION=5.1 bin/install-lua-deps.sh +# SUDO=sudo bin/install-lua-deps.sh # when installing to a system tree +# ----------------------------------------------------------------------------- +set -euo pipefail + +LUA_VERSION="${LUA_VERSION:-5.1}" +SUDO="${SUDO:-}" + +if [ -z "${LUAROCKS:-}" ]; then + case "$(uname -s)" in + Darwin) LUAROCKS="$(dirname "$0")/luarocks-macos" ;; + *) LUAROCKS="luarocks" ;; + esac +fi + +# ----------------------------------------------------------------------------- +# Build a compiler shim that injects -std=gnu++14 when (and only when) it +# detects a C++ compile, then put it first on PATH. +# ----------------------------------------------------------------------------- +SHIM_DIR="$(mktemp -d -t snapcloud-cc-shim.XXXXXX)" +trap 'rm -rf "$SHIM_DIR"' EXIT + +REAL_GCC="$(command -v gcc || true)" +REAL_GXX="$(command -v g++ || true)" +if [ -z "$REAL_GCC" ] || [ -z "$REAL_GXX" ]; then + echo "install-lua-deps.sh: gcc/g++ not found on PATH; install build-essential first" >&2 + exit 1 +fi + +cat > "$SHIM_DIR/gcc" <>> Installing Lua dependencies (lua $LUA_VERSION)" +echo ">>> Compiler shim: $SHIM_DIR (C++ sources -> $REAL_GCC -std=gnu++14)" + +# Quick self-test so a broken shim fails loudly instead of silently letting +# the original error reproduce. +echo "int main(){return 0;}" > "$SHIM_DIR/test.cpp" +if ! "$SHIM_DIR/gcc" -c "$SHIM_DIR/test.cpp" -o "$SHIM_DIR/test.o" 2>"$SHIM_DIR/test.err"; then + echo "install-lua-deps.sh: shim self-test failed:" >&2 + cat "$SHIM_DIR/test.err" >&2 + exit 1 +fi + +# Install xml first, in isolation, so any failure is unambiguous and so the +# subsequent --only-deps run finds it already present and skips rebuild. +run_luarocks install --lua-version="$LUA_VERSION" xml + +# Install the remaining project deps from the rockspec (uses luarocks.lock). +run_luarocks install --lua-version="$LUA_VERSION" \ + --only-deps snapcloud-dev-0.rockspec + +echo ">>> Lua dependencies installed." diff --git a/package-lock.json b/package-lock.json index d32f6706..42d1b71f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,10 @@ "bootstrap": "^5.3.3" }, "devDependencies": { + "@axe-core/playwright": "^4.10.0", + "@playwright/test": "^1.48.0", "maildev": "^2.2.1", + "pg": "^8.13.0", "sass": "1.77.2" } }, @@ -32,6 +35,19 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -155,6 +171,22 @@ "node": ">=6" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -262,6 +294,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/base32.js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", @@ -1581,6 +1623,103 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1593,6 +1732,96 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2115,6 +2344,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -2382,6 +2621,16 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/package.json b/package.json index ce9d177d..5ec2936d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,10 @@ "version": "1.0.0", "description": "Snap! Community Site", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "playwright test", + "test:e2e": "playwright test --grep-invert \"@axe\"", + "test:a11y": "playwright test --grep \"@axe\"", + "test:ui": "playwright test --ui" }, "repository": { "type": "git", @@ -21,7 +24,10 @@ "bootstrap": "^5.3.3" }, "devDependencies": { + "@axe-core/playwright": "^4.10.0", + "@playwright/test": "^1.48.0", "maildev": "^2.2.1", + "pg": "^8.13.0", "sass": "1.77.2" } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..e039dae4 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,48 @@ +// Playwright configuration for Snap!Cloud end-to-end and accessibility tests. +// See https://playwright.dev/docs/test-configuration + +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +const BASE_URL = process.env.SNAPCLOUD_BASE_URL || 'http://localhost:8080'; + +module.exports = defineConfig({ + testDir: './spec/e2e', + // Each spec is independent; avoid "flaky test passes on retry" lies in CI. + retries: process.env.CI ? 1 : 0, + // Force serial workers locally to make failures easier to reproduce. + workers: process.env.CI ? 2 : 1, + reporter: process.env.CI + ? [['list'], ['html', { open: 'never' }]] + : [['list']], + + use: { + baseURL: BASE_URL, + // Screenshots and traces only on failure keeps artifact size down. + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + // Local convenience: if the app server isn't already running, boot it. + // In CI the workflow starts the server itself (so we can keep the axe + // job and the e2e job sharing one setup), so we skip this branch there. + webServer: process.env.CI + ? undefined + : { + command: 'lapis server test', + url: BASE_URL, + reuseExistingServer: true, + timeout: 60_000, + env: { + LAPIS_ENVIRONMENT: 'test', + DATABASE_NAME: 'snapcloud_test', + }, + }, +}); diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 00000000..f31ea421 --- /dev/null +++ b/spec/README.md @@ -0,0 +1,212 @@ +# Snap!Cloud test suite + +The suite is split into four independently-reported CI statuses, each of +which maps to a job in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml): + +| Status | Job name | What it runs | +| ---------------- | ------------ | ----------------------------------------------------------- | +| `Luacheck` | `luacheck` | `luacheck .` using [`.luacheckrc`](../.luacheckrc) | +| `Busted (Lua)` | `busted` | `busted` specs under `spec/unit/` and `spec/models/` | +| `Playwright (e2e)` | `playwright` | Browser specs under `spec/e2e/`, excluding `@axe`-tagged | +| `Axe-core (a11y)` | `axe` | Browser specs under `spec/e2e/` tagged `@axe` | + +Keeping these separate means a broken end-to-end test doesn't hide a new +accessibility regression (or vice versa) in the PR check summary. + +## Running locally + +The repo-level `Makefile` exposes the same shape as CI: + +```bash +make lint # luacheck +make test-lua # busted +make test-e2e # playwright, browser specs only +make test-a11y # playwright, axe specs only +make test # everything +``` + +Or directly: + +```bash +LAPIS_ENVIRONMENT=test DATABASE_NAME=snapcloud_test busted +LAPIS_ENVIRONMENT=test DATABASE_NAME=snapcloud_test npx playwright test +``` + +### Database safety + +Specs refuse to start if `DATABASE_NAME` is set to anything other than +`snapcloud_test`. The check lives in two places: + +1. [`spec/spec_helper.lua`](spec_helper.lua) — fails fast at startup. +2. [`spec/support/db_helper.lua`](support/db_helper.lua) — re-checks + on every `db()`/`truncate_all()` call (defense in depth). + +If you see "Refusing to run: DATABASE_NAME=…", unset the variable or +export `DATABASE_NAME=snapcloud_test`. + +### One-time setup + +```bash +# Postgres +createdb snapcloud_test +psql -d snapcloud_test -f db/schema.sql +psql -d snapcloud_test -f db/seeds.sql + +# Lua project deps (applies the xml C++14 workaround — see note below) +bin/install-lua-deps.sh + +# Lua test tooling — install from /tmp so luarocks.lock doesn't activate +# and try to pull in the full project dep graph for each tool rock. +(cd /tmp && luarocks install --lua-version=5.1 busted luacheck luacov) + +# Node + Playwright +npm install +npx playwright install --with-deps chromium +``` + +#### Why `bin/install-lua-deps.sh`? + +The pinned `xml-1.1.3-1` rock ships C++ code that uses pre-C++17 dynamic +exception specifications. On Ubuntu 22.04+ / modern clang, the default +C++17 dialect rejects these: + +``` +error: ISO C++17 does not allow dynamic exception specifications +``` + +[`bin/install-lua-deps.sh`](../bin/install-lua-deps.sh) puts a tiny `g++` +wrapper at the front of `PATH` that injects `-std=gnu++14` into every +C++ invocation. This works regardless of build type (luarocks builtin / +make / cmake) because every C++ compile resolves `g++` via `PATH`; pure +C compiles still go through `gcc` and are unaffected. Setting +`CXX`/`CXXFLAGS` env vars *looks* simpler but isn't reliable — `sudo` +strips them, and luarocks's `builtin` build reads `cfg.variables`, not +the live environment. The Makefile's `install` target and the +`install-snapcloud-deps` CI composite action both run this script, so +dev/prod/CI share one workaround. + +## Directory layout + +``` +spec/ +├── spec_helper.lua # env checks, loaded by busted before every spec +├── support/ +│ ├── db_helper.lua # test DB wrapper + TRUNCATE helpers +│ └── factories.lua # fixture-style record constructors +├── unit/ # pure-Lua specs, no DB needed +│ ├── util_spec.lua +│ ├── cors_spec.lua +│ └── permissions_spec.lua # role predicates + visibility rules +├── models/ # specs that exercise Lapis models against PG +│ └── users_spec.lua +├── e2e/ # Playwright browser specs +│ ├── support/ +│ │ ├── axe.js # AxeBuilder wrapper honoring data-axe-excluded +│ │ ├── auth.js # loginAs() / logout() helpers +│ │ └── fixtures.js # direct DB seeding for users + projects +│ ├── homepage.spec.js +│ ├── accessibility.spec.js # @axe-tagged WCAG 2.1 AA audits +│ ├── project-visibility.spec.js +│ ├── admin-access.spec.js +│ ├── teacher-access.spec.js +│ └── auth.spec.js # sign-up + login +└── data/ # fixture files (see data/README.md) + ├── projects/ + └── users/ +``` + +## Writing a new test + +### Busted (Lua) + +- Files under `spec/` ending in `_spec.lua` are auto-discovered. +- Use `spec_support.factories` to create users/projects/collections + without hand-writing INSERTs. +- If the spec doesn't touch the DB, put it in `spec/unit/`. If it does, + put it in `spec/models/` and call `db_helper.truncate_all()` in + `before_each`. + +### Playwright + +- Put end-to-end specs in `spec/e2e/`. +- Tag accessibility specs with `@axe` so the dedicated axe job picks + them up and the main e2e job skips them. +- Prefer small, page-focused specs over large flows — the CI run time + adds up fast with a real browser in the loop. + +### Seeding users + projects for e2e tests + +[`spec/e2e/support/fixtures.js`](e2e/support/fixtures.js) seeds records +directly into `snapcloud_test` via `pg`. Use it whenever you need: + +- A user with a specific `role` (admin, moderator, reviewer, etc.). +- A user with `is_teacher = true`. +- A project in a specific `ispublic` / `ispublished` / `deleted` state. + +```js +const { seedUser, seedProject, deleteUser } = require('./support/fixtures'); +const { loginAs, logout } = require('./support/auth'); + +test.beforeAll(async () => { + await seedUser({ username: 'alice', role: 'admin' }); +}); +test.afterAll(async () => { await deleteUser('alice'); }); + +test('admins see /ip_admin', async ({ page, context }) => { + await loginAs(context, 'alice', 'test-password-1'); + const response = await page.goto('/ip_admin'); + expect(response.status()).toBe(200); +}); +``` + +Passwords default to `test-password-1`. `loginAs` performs the same +sha512 client-side hash the real Snap!Cloud JS applies before POSTing +to `/api/v1/users/:username/login`. + +## Accessibility exclusions + +### `data-axe-excluded="true"` + +Some elements can't usefully be audited by axe — typically third-party +iframes or deliberately-decorative fragments. Snap!Cloud uses one +repo-wide convention for these: **any element with the attribute +`data-axe-excluded="true"` is skipped by every axe scan.** + +The canonical example is the embedded Snap! editor on the project page +(`views/project.etlua`): + +```html + +``` + +The helper in [`spec/e2e/support/axe.js`](e2e/support/axe.js) wires this +up automatically: + +```js +const { runAxe } = require('./support/axe'); +const { seriousViolations } = await runAxe(page); +expect(seriousViolations).toEqual([]); +``` + +Rules of thumb: + +- **Use sparingly.** Every exclusion is a blind spot. Prefer fixing the + underlying issue. +- **Comment the reason.** Add an HTML comment above the excluded element + explaining *why* axe can't audit it. +- **Scope to elements, not whole pages.** If the entire page needs + excluding, that's a sign the page shouldn't be in the a11y spec list + at all. +- **Review periodically.** `grep -rn 'data-axe-excluded' views/` is the + canonical list; revisit it when upgrading axe or refactoring a view. + +## Updating CI + +All four jobs spin up their own postgres service container and load +`db/schema.sql` + `db/seeds.sql` before running. If you add a migration, +also re-run the schema dump (see `bin/lapis-migrate`) so CI picks up the +new shape without having to replay history on every run. diff --git a/spec/data/README.md b/spec/data/README.md new file mode 100644 index 00000000..33c24b91 --- /dev/null +++ b/spec/data/README.md @@ -0,0 +1,41 @@ +# Test data / fixtures + +This directory holds static fixture data that individual specs can load +instead of hand-building records inline. Keep it small — factories in +`spec/support/factories.lua` are the right home for *constructing* rows; +`spec/data/` is the right home for *canonical example input*. + +## Layout + +- `projects/` + Sample Snap! project XML files. Use these when testing the project + upload/download pipeline, storage, or parsers. Keep each fixture + minimal and give it a descriptive filename + (e.g. `hello_world.xml`, `remix_chain.xml`). + +- `users/` + JSON blobs describing canonical user seeds (admins, students, + banned users, etc). Specs can load them with `require`-style + helpers or a plain `io.open` + `cjson.decode`. + +## How to add a fixture + +1. Drop the file into the appropriate subdirectory. +2. Prefer inputs that are the smallest possible reproduction — tests + should document intent, not stress-test the parser. +3. If the fixture is platform-specific (e.g. a Snap! v8 XML that won't + parse under v7), note it in a comment in the XML header. +4. Reference the fixture from a spec by filename: + + ```lua + local path = 'spec/data/projects/hello_world.xml' + local xml = assert(io.open(path)):read('*a') + ``` + +## Conventions + +- No PII or copyrighted user content. +- Keep individual files under 10 KB. Large fixtures belong in an + external storage location referenced by URL, not committed. +- When deleting a fixture, grep the `spec/` tree first to make sure + nothing still references it. diff --git a/spec/data/projects/hello_world.xml b/spec/data/projects/hello_world.xml new file mode 100644 index 00000000..54c84454 --- /dev/null +++ b/spec/data/projects/hello_world.xml @@ -0,0 +1,45 @@ + + + Test fixture - hello world. + data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII= + + data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAeAAAAFoCAYAAAC3QnVxAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAj1JREFUeF7t1bENAAAIxDD6/9P0C5QJeSFZnpm9g4AAAQIFlgVcYVdAgAABAj8BgRUCAQIECEQCAhuxGSJAgAABAoEVAQECBAhEAgIbsRkiQIAAAQKBFQEBAgQIRAICG7EZIkCAAAECgRUBAQIECEQCAhuxGSJAgAABAoEVAQECBAhEAgIbsRkiQIAAAQKBFQEBAgQIRAICG7EZIkCAAAECgRUBAQIECEQCAhuxGSJAgAABAoEVAQECBAhEAgIbsRkiQIAAAQKBFQEBAgQIRAICG7EZIkCAAAECgRUBAQIECEQCAhuxGSJAgAABAoEVAQECBAhEAgIbsRkiQIAAAQKBFQEBAgQIRAICG7EZIkCAAAECgRUBAQIECEQCAhuxGSJAgAABAoEVAQECBAhEAgIbsRkiQIAAAQKBFQEBAgQIRAICG7EZIkCAAAECgRUBAQIECEQCAhuxGSJAgAABAoEVAQECBAhEAgIbsRkiQIAAAQKBFQEBAgQIRAICG7EZIkCAAAECgRUBAQIECEQCAhuxGSJAgAABAoEVAQECBAhEAgIbsRkiQIAAAQKBFQEBAgQIRAICG7EZIkCAAAECgRUBAQIECEQCAhuxGSJAgAABAoEVAQECBAhEAgIbsRkiQIAAAQKBFQEBAgQIRAICG7EZIkCAAAECgRUBAQIECEQCAhuxGSJAgAABAoEVAQECBAhEAgIbsRkiQIAAAQKBFQEBAgQIRAICG7EZIkCAAAECgRUBAQIECEQCAhuxGSJAgAABAoEVAQECBAhEAgIbsRkiQIAAAQKBFQEBAgQIRAICG7EZIkCAAAECA+n3AAGuZUUXAAAAAElFTkSuQmCC + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/data/users/seed_users.json b/spec/data/users/seed_users.json new file mode 100644 index 00000000..00ff0e0a --- /dev/null +++ b/spec/data/users/seed_users.json @@ -0,0 +1,30 @@ +[ + { + "username": "admin_fixture", + "email": "admin_fixture@example.invalid", + "role": "admin", + "verified": true, + "notes": "Canonical admin for specs that need elevated privileges." + }, + { + "username": "standard_fixture", + "email": "standard_fixture@example.invalid", + "role": "standard", + "verified": true, + "notes": "Regular signed-in user." + }, + { + "username": "student_fixture", + "email": "student_fixture@example.invalid", + "role": "student", + "verified": true, + "notes": "Student user; limited forum access, see Users:cannot_access_forum." + }, + { + "username": "banned_fixture", + "email": "banned_fixture@example.invalid", + "role": "banned", + "verified": true, + "notes": "User whose role is 'banned'; useful for authorization specs." + } +] diff --git a/spec/e2e/accessibility.spec.js b/spec/e2e/accessibility.spec.js new file mode 100644 index 00000000..98128fee --- /dev/null +++ b/spec/e2e/accessibility.spec.js @@ -0,0 +1,78 @@ +// spec/e2e/accessibility.spec.js +// ============================== +// +// Axe-core accessibility audits. Tagged `@axe` so the GitHub Actions +// workflow can run them as their own CI status (see `.github/workflows/ci.yml`). +// +// All scans go through `spec/e2e/support/axe.js`, which automatically +// excludes any element carrying `data-axe-excluded="true"` — see +// spec/README.md#accessibility-exclusions for when to reach for that +// escape hatch. + +const { test, expect } = require('@playwright/test'); +const { runAxe } = require('./support/axe'); +const { seedUser, seedProject, deleteUser } = require('./support/fixtures'); + +// Simple public pages that every release must stay accessible. Keep this +// list small and intentional; axe is slow, and we want failures to +// mean something. +const publicPages = [ + { path: '/', label: 'homepage' }, + { path: '/explore', label: 'explore' }, + { path: '/collections', label: 'collections' }, + { path: '/login', label: 'login form' }, + { path: '/sign_up', label: 'sign-up form' }, +]; + +for (const { path, label } of publicPages) { + test(`${label} has no serious axe violations @axe`, async ({ page }) => { + await page.goto(path); + const { seriousViolations } = await runAxe(page); + expect.soft( + seriousViolations, + 'axe reported serious/critical violations; see playwright-report/' + ).toEqual([]); + }); +} + +// ----------------------------------------------------------------------- +// Project page accessibility. +// +// The project viewer page embeds the Snap! editor in an iframe. We can't +// scan inside that iframe (it's controlled by the upstream Snap! repo +// and is sandboxed), so it's tagged with `data-axe-excluded="true"` in +// views/project.etlua. Our helper drops it from the scan automatically; +// the assertion below guards the *surrounding* chrome only. +// ----------------------------------------------------------------------- +test.describe('project page @axe', () => { + const owner = 'axe_project_owner'; + const project = 'axe-sample-project'; + + test.beforeAll(async () => { + await seedUser({ username: owner, role: 'standard' }); + await seedProject({ + username: owner, + projectname: project, + ispublic: true, + ispublished: true, + }); + }); + + test.afterAll(async () => { + await deleteUser(owner); + }); + + test('chrome has no serious axe violations @axe', async ({ page }) => { + const qs = new URLSearchParams({ + username: owner, projectname: project + }).toString(); + await page.goto(`/project?${qs}`); + + const { seriousViolations } = await runAxe(page); + expect.soft( + seriousViolations, + 'axe reported serious/critical violations on /project; ' + + 'remember the Snap! iframe is excluded via data-axe-excluded' + ).toEqual([]); + }); +}); diff --git a/spec/e2e/admin-access.spec.js b/spec/e2e/admin-access.spec.js new file mode 100644 index 00000000..34d72379 --- /dev/null +++ b/spec/e2e/admin-access.spec.js @@ -0,0 +1,107 @@ +// spec/e2e/admin-access.spec.js +// ============================= +// +// Verifies the role gates on /admin, /flags, /user_admin, /ip_admin, etc. +// The route handlers live in site.lua and guard access with +// `assert_min_role(self, '')`. +// +// The hierarchy (models/users.lua): admin > moderator > reviewer > standard. +// So a `standard` user must be denied; a `reviewer` can see /admin but not +// /ip_admin; an `admin` sees everything. +// +// On denial, the handler either redirects to "/" (for logged-out users +// or when the user doesn't meet the role) or yields err.auth. We accept +// either "redirected to /" OR a 4xx status as a denied response. + +const { test, expect } = require('@playwright/test'); +const { seedUser, deleteUser } = require('./support/fixtures'); +const { loginAs, logout } = require('./support/auth'); + +const admin = 'admin_access_admin'; +const reviewer = 'admin_access_reviewer'; +const standard = 'admin_access_standard'; + +// Each entry records the URL, the minimum role that can see it, and a +// distinctive bit of content that proves we actually rendered the page +// (rather than landing on "/"). +const adminRoutes = [ + { path: '/admin', minRole: 'reviewer' }, + { path: '/flags', minRole: 'reviewer' }, + { path: '/user_admin', minRole: 'moderator' }, + { path: '/zombie_admin', minRole: 'moderator' }, + { path: '/ip_admin', minRole: 'admin' }, +]; + +const roleRank = { standard: 2, reviewer: 3, moderator: 4, admin: 5 }; + +test.describe.serial('admin page access', () => { + + test.beforeAll(async () => { + await seedUser({ username: admin, role: 'admin' }); + await seedUser({ username: reviewer, role: 'reviewer' }); + await seedUser({ username: standard, role: 'standard' }); + }); + + test.afterAll(async () => { + await deleteUser(admin); + await deleteUser(reviewer); + await deleteUser(standard); + }); + + test.beforeEach(async ({ context }) => { await logout(context); }); + + for (const route of adminRoutes) { + test(`anonymous visitors are bounced off ${route.path}`, async ({ page }) => { + const response = await page.goto(route.path); + // Handler redirects anonymous users to "/" — new URL is "/". + const landedAt = new URL(page.url()).pathname; + const denied = + landedAt === '/' || + (response && response.status() >= 400 && response.status() < 500); + expect(denied, `expected denial; ended up at ${landedAt}`).toBe(true); + }); + + test(`standard users cannot reach ${route.path}`, async ({ page, context }) => { + await loginAs(context, standard, 'test-password-1'); + const response = await page.goto(route.path); + const landedAt = new URL(page.url()).pathname; + const denied = + landedAt === '/' || + (response && response.status() >= 400 && response.status() < 500); + expect(denied, `expected denial; ended up at ${landedAt}`).toBe(true); + }); + } + + test('reviewers can see /admin but not /ip_admin', async ({ page, context }) => { + await loginAs(context, reviewer, 'test-password-1'); + + const adminResponse = await page.goto('/admin'); + expect(adminResponse?.status()).toBe(200); + expect(new URL(page.url()).pathname).toBe('/admin'); + + const ipResponse = await page.goto('/ip_admin'); + const landedAt = new URL(page.url()).pathname; + const denied = + landedAt === '/' || + (ipResponse && ipResponse.status() >= 400 && ipResponse.status() < 500); + expect(denied).toBe(true); + }); + + test('admins can reach every admin route', async ({ page, context }) => { + await loginAs(context, admin, 'test-password-1'); + for (const route of adminRoutes) { + const response = await page.goto(route.path); + expect( + response?.status(), + `admin unexpectedly denied from ${route.path}` + ).toBe(200); + expect(new URL(page.url()).pathname).toBe(route.path); + } + }); + + test('every listed route actually requires a role above standard', () => { + for (const route of adminRoutes) { + expect(roleRank[route.minRole]).toBeGreaterThan(roleRank.standard); + } + }); +}); diff --git a/spec/e2e/auth.spec.js b/spec/e2e/auth.spec.js new file mode 100644 index 00000000..88025019 --- /dev/null +++ b/spec/e2e/auth.spec.js @@ -0,0 +1,182 @@ +// spec/e2e/auth.spec.js +// ===================== +// +// Sign-up and login flows end-to-end. We exercise both the UI paths +// (to catch broken forms / wiring) and the JSON API endpoints +// (to catch controller-level regressions in isolation). + +const { test, expect } = require('@playwright/test'); +const { + seedUser, + deleteUser, + clientHashedPassword, +} = require('./support/fixtures'); +const { logout } = require('./support/auth'); + +// Usernames are deliberately timestamped so reruns don't collide with +// leftover state, even if afterAll is skipped due to a crash. +const run = Date.now(); +const newUsername = `signup_e2e_${run}`; +const existingUser = `login_e2e_${run}`; +const existingPass = 'super-secret-pw-1'; + +test.describe('sign up', () => { + + test.afterAll(async () => { + await deleteUser(newUsername); + }); + + test('renders the sign-up form', async ({ page }) => { + await page.goto('/sign_up'); + // The page also has a site-wide search form, so target the + // signup form by id (views/users/sign_up.etlua, #js-signup). + await expect(page.locator('#js-signup')).toBeVisible(); + await expect(page.locator('#username')).toBeVisible(); + await expect(page.locator('#email')).toBeVisible(); + }); + + test('rejects a too-short raw password', async ({ request }) => { + // The controller runs `min_length = MIN_PASSWORD_LENGTH` against + // whatever `password` param it receives. The real client always + // sha512-hashes first (so it's 128 chars), but a bad/attacker + // client that posts plaintext must still be rejected. + const response = await request.post('/api/v1/signup', { + form: { + username: `${newUsername}_short`, + password: 'abc', + password_repeat: 'abc', + email: `${newUsername}_short@example.invalid`, + }, + }); + expect(response.ok()).toBe(false); + }); + + test('creates an account via the API and then allows login', async ({ request }) => { + const signup = await request.post('/api/v1/signup', { + form: { + username: newUsername, + // Mimic what the client JS sends over the wire. + password: clientHashedPassword(existingPass), + password_repeat: clientHashedPassword(existingPass), + email: `${newUsername}@example.invalid`, + }, + }); + expect(signup.ok(), await signup.text()).toBe(true); + + // New accounts are unverified. The login handler has a special + // code path that returns a 200 JSON body with a `title` + verify + // message for unverified users — we accept either outcome here. + const login = await request.post( + `/api/v1/users/${encodeURIComponent(newUsername)}/login?persist=false`, + { + data: clientHashedPassword(existingPass), + headers: { 'content-type': 'text/plain' }, + } + ); + // Unverified -> HTTP 200 with verification message, verified -> + // plain ok. Either way it shouldn't be an auth error (4xx). + expect(login.status()).toBeLessThan(400); + }); + + test('rejects duplicate usernames', async ({ request }) => { + // Seed the collision target directly rather than relying on the + // previous test having created it — Playwright retries spawn a + // fresh worker, which re-imports the spec and re-randomizes + // `newUsername`. + const dupUsername = `dup_e2e_${run}`; + await seedUser({ username: dupUsername }); + try { + const retry = await request.post('/api/v1/signup', { + form: { + username: dupUsername, + password: clientHashedPassword(existingPass), + password_repeat: clientHashedPassword(existingPass), + email: `${dupUsername}-dup@example.invalid`, + }, + }); + expect(retry.ok()).toBe(false); + const body = await retry.text(); + expect(body.toLowerCase()).toContain('already exists'); + } finally { + await deleteUser(dupUsername); + } + }); +}); + +test.describe('login', () => { + + test.beforeAll(async () => { + await seedUser({ + username: existingUser, + password: existingPass, + role: 'standard', + verified: true, + }); + }); + + test.afterAll(async () => { + await deleteUser(existingUser); + }); + + test.beforeEach(async ({ context }) => { await logout(context); }); + + test('renders the login form with username + password fields', async ({ page }) => { + await page.goto('/login'); + await expect(page.locator('#username')).toBeVisible(); + await expect(page.locator('#password')).toBeVisible(); + }); + + test('correct credentials succeed', async ({ request }) => { + const response = await request.post( + `/api/v1/users/${encodeURIComponent(existingUser)}/login?persist=false`, + { + data: clientHashedPassword(existingPass), + headers: { 'content-type': 'text/plain' }, + } + ); + expect(response.ok(), await response.text()).toBe(true); + }); + + test('wrong password is rejected', async ({ request }) => { + const response = await request.post( + `/api/v1/users/${encodeURIComponent(existingUser)}/login?persist=false`, + { + data: clientHashedPassword('not-the-password'), + headers: { 'content-type': 'text/plain' }, + } + ); + expect(response.status()).toBe(403); + }); + + test('unknown username is rejected with the same error', async ({ request }) => { + // Snap!Cloud reuses err.wrong_password for unknown-user logins so + // attackers can't enumerate accounts. Verify the status stays 403. + const response = await request.post( + `/api/v1/users/nobody_${run}/login?persist=false`, + { + data: clientHashedPassword('anything'), + headers: { 'content-type': 'text/plain' }, + } + ); + expect(response.status()).toBe(403); + }); + + test('logout clears the session', async ({ request }) => { + // Log in first. + const login = await request.post( + `/api/v1/users/${encodeURIComponent(existingUser)}/login?persist=false`, + { + data: clientHashedPassword(existingPass), + headers: { 'content-type': 'text/plain' }, + } + ); + expect(login.ok()).toBe(true); + + const logoutResponse = await request.post('/api/v1/logout'); + expect(logoutResponse.ok()).toBe(true); + + // After logout, /profile should bounce the (now-anonymous) user to /. + const profile = await request.get('/profile', { maxRedirects: 0 }); + expect([301, 302, 303, 307, 308]).toContain(profile.status()); + }); +}); diff --git a/spec/e2e/homepage.spec.js b/spec/e2e/homepage.spec.js new file mode 100644 index 00000000..ee846907 --- /dev/null +++ b/spec/e2e/homepage.spec.js @@ -0,0 +1,29 @@ +// spec/e2e/homepage.spec.js +// ========================= +// +// Smoke tests that exercise the Snap!Cloud homepage end-to-end. These +// require a running app server (see playwright.config.js `webServer`). + +const { test, expect } = require('@playwright/test'); + +test.describe('homepage', () => { + test('loads and responds 200', async ({ page }) => { + const response = await page.goto('/'); + expect(response?.status()).toBe(200); + }); + + test('renders the primary Run Snap! call-to-action', async ({ page }) => { + await page.goto('/'); + // The localized button text ends with "Run Snap!" but includes + // HTML for the logo — match on the link target instead. + const runLink = page.locator('a[href="/snap"]').first(); + await expect(runLink).toBeVisible(); + }); + + test('serves the embedded editor page', async ({ page }) => { + const response = await page.goto('/embed'); + // /embed redirects or renders; anything 2xx/3xx is fine, we're just + // making sure it isn't 500. + expect(response?.status()).toBeLessThan(500); + }); +}); diff --git a/spec/e2e/project-visibility.spec.js b/spec/e2e/project-visibility.spec.js new file mode 100644 index 00000000..118600e2 --- /dev/null +++ b/spec/e2e/project-visibility.spec.js @@ -0,0 +1,109 @@ +// spec/e2e/project-visibility.spec.js +// =================================== +// +// Exercises the visibility rules defined in validation.lua's +// `assert_can_view_project` (and the surrounding controller flow): +// +// - published OR public -> everyone sees it +// - neither public nor public -> only the owner or an admin sees it +// - soft-deleted -> nobody sees it (404) +// - nonexistent username -> 404 +// +// We seed users and projects directly via spec/e2e/support/fixtures.js +// so the assertions stay focused on the controller logic. + +const { test, expect } = require('@playwright/test'); +const { seedUser, seedProject, deleteUser } = require('./support/fixtures'); +const { loginAs, logout } = require('./support/auth'); + +const owner = 'visibility_owner'; +const other = 'visibility_other'; +const admin = 'visibility_admin'; + +const publicProject = 'public-published'; +const privateProject = 'private-unpublished'; +const deletedProject = 'soft-deleted'; + +function projectUrl(username, projectname) { + return `/project?${new URLSearchParams({ username, projectname })}`; +} + +test.describe.serial('project visibility', () => { + test.beforeAll(async () => { + await seedUser({ username: owner, role: 'standard' }); + await seedUser({ username: other, role: 'standard' }); + await seedUser({ username: admin, role: 'admin' }); + await seedProject({ + username: owner, projectname: publicProject, + ispublic: true, ispublished: true, + }); + await seedProject({ + username: owner, projectname: privateProject, + ispublic: false, ispublished: false, + }); + await seedProject({ + username: owner, projectname: deletedProject, + ispublic: true, ispublished: true, deleted: true, + }); + }); + + test.afterAll(async () => { + await deleteUser(owner); + await deleteUser(other); + await deleteUser(admin); + }); + + test.beforeEach(async ({ context }) => { + // Guarantee each test starts anonymous. + await logout(context); + }); + + test('anonymous user can view a public, published project', async ({ page }) => { + const response = await page.goto(projectUrl(owner, publicProject)); + expect(response?.status()).toBe(200); + await expect(page.getByRole('heading', { name: publicProject })).toBeVisible(); + }); + + test('anonymous user cannot view a private project', async ({ page }) => { + const response = await page.goto(projectUrl(owner, privateProject)); + // Snap!Cloud yields err.nonexistent_project (404). We also accept + // any 4xx to insulate the spec from an exact-status refactor. + expect(response?.status()).toBeGreaterThanOrEqual(400); + expect(response?.status()).toBeLessThan(500); + }); + + test('another non-admin user cannot view a private project', async ({ page, context }) => { + await loginAs(context, other, 'test-password-1'); + const response = await page.goto(projectUrl(owner, privateProject)); + expect(response?.status()).toBeGreaterThanOrEqual(400); + expect(response?.status()).toBeLessThan(500); + }); + + test('owner can view their own private project', async ({ page, context }) => { + await loginAs(context, owner, 'test-password-1'); + const response = await page.goto(projectUrl(owner, privateProject)); + expect(response?.status()).toBe(200); + await expect(page.getByRole('heading', { name: privateProject })).toBeVisible(); + }); + + test('admin can view any user\'s private project', async ({ page, context }) => { + await loginAs(context, admin, 'test-password-1'); + const response = await page.goto(projectUrl(owner, privateProject)); + expect(response?.status()).toBe(200); + }); + + test('soft-deleted projects are hidden even from the owner', async ({ page, context }) => { + await loginAs(context, owner, 'test-password-1'); + const response = await page.goto(projectUrl(owner, deletedProject)); + expect(response?.status()).toBeGreaterThanOrEqual(400); + expect(response?.status()).toBeLessThan(500); + }); + + test('a nonexistent project returns an error response', async ({ page }) => { + const response = await page.goto( + projectUrl(owner, 'does-not-exist-' + Date.now()) + ); + expect(response?.status()).toBeGreaterThanOrEqual(400); + expect(response?.status()).toBeLessThan(500); + }); +}); diff --git a/spec/e2e/support/auth.js b/spec/e2e/support/auth.js new file mode 100644 index 00000000..f92d5472 --- /dev/null +++ b/spec/e2e/support/auth.js @@ -0,0 +1,49 @@ +// spec/e2e/support/auth.js +// ======================== +// +// Log a seeded user in by driving the real /api/v1/users/:username/login +// endpoint from Playwright's request context. The resulting session +// cookie is written onto the page's browser context, so subsequent +// navigation is authenticated as that user. +// +// Prefer this to UI-driven login whenever the test isn't actually +// checking the login form — it's faster and removes an unrelated +// failure mode from the spec under test. + +const { clientHashedPassword } = require('./fixtures'); + +/** + * Log `username` / `password` in and return the populated request context. + * `password` is the plaintext; we hash it here the way the client JS would. + * + * @param {import('@playwright/test').BrowserContext} context + * @param {string} username + * @param {string} password + */ +async function loginAs(context, username, password) { + const response = await context.request.post( + `/api/v1/users/${encodeURIComponent(username)}/login?persist=false`, + { + // The Snap!Cloud login handler reads `self.params.body` and + // treats it as the already-sha512-hashed password. + data: clientHashedPassword(password), + headers: { 'content-type': 'text/plain' }, + } + ); + if (!response.ok()) { + throw new Error( + `loginAs('${username}') failed: ${response.status()} ${await response.text()}` + ); + } + return response; +} + +/** + * Clear the current session (logout) via the API so the next navigation + * behaves as an anonymous user. Safe to call even if already logged out. + */ +async function logout(context) { + await context.request.post('/api/v1/logout'); +} + +module.exports = { loginAs, logout }; diff --git a/spec/e2e/support/axe.js b/spec/e2e/support/axe.js new file mode 100644 index 00000000..a7a5a868 --- /dev/null +++ b/spec/e2e/support/axe.js @@ -0,0 +1,62 @@ +// spec/e2e/support/axe.js +// ======================= +// +// Thin wrapper around AxeBuilder that: +// 1. Applies the project's default WCAG tag set (2.1 A + AA). +// 2. Honors the project-wide `data-axe-excluded="true"` convention — +// elements carrying that attribute are excluded from the scan. +// +// See spec/README.md#accessibility-exclusions for the full convention. +// +// Usage: +// const { runAxe } = require('./support/axe'); +// const results = await runAxe(page); +// expect(results.seriousViolations).toEqual([]); + +const AxeBuilder = require('@axe-core/playwright').default; + +// The one and only selector for the exclusion convention. Document changes +// here in spec/README.md as well — the convention is a cross-cutting +// contract, not a private detail of the tests. +const EXCLUDE_SELECTOR = '[data-axe-excluded="true"]'; + +const DEFAULT_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; + +/** + * Run an axe scan against the given page. + * + * @param {import('@playwright/test').Page} page + * @param {object} [opts] + * @param {string[]} [opts.tags] - override the default WCAG tag list + * @param {string[]} [opts.exclude] - extra selectors to exclude (merged with the convention) + * @param {string[]} [opts.include] - limit the scan to these selectors + * @returns {Promise<{raw: object, seriousViolations: object[]}>} + */ +async function runAxe(page, opts = {}) { + let builder = new AxeBuilder({ page }).withTags(opts.tags || DEFAULT_TAGS); + + // Always exclude the data-attribute convention. Merge any caller-supplied + // selectors on top. + const excludes = [EXCLUDE_SELECTOR, ...(opts.exclude || [])]; + for (const selector of excludes) { + builder = builder.exclude(selector); + } + + if (opts.include) { + for (const selector of opts.include) { + builder = builder.include(selector); + } + } + + const raw = await builder.analyze(); + const seriousViolations = raw.violations.filter( + (v) => v.impact === 'serious' || v.impact === 'critical' + ); + return { raw, seriousViolations }; +} + +module.exports = { + runAxe, + EXCLUDE_SELECTOR, + DEFAULT_TAGS, +}; diff --git a/spec/e2e/support/fixtures.js b/spec/e2e/support/fixtures.js new file mode 100644 index 00000000..ea9402c6 --- /dev/null +++ b/spec/e2e/support/fixtures.js @@ -0,0 +1,167 @@ +// spec/e2e/support/fixtures.js +// ============================ +// +// Seed the `snapcloud_test` database directly so Playwright tests have +// known-good users and projects to work with. +// +// Why bypass the sign-up API? Several e2e scenarios need users with +// elevated roles (admin, moderator) or with `is_teacher = true`. Those +// roles can't be set through the public sign-up endpoint. Seeding via +// SQL keeps the fixtures explicit and makes cleanup obvious. +// +// Safety: every query re-asserts the DATABASE_NAME is `snapcloud_test` +// before running. If someone points this at dev/prod by accident the +// helper refuses to proceed. + +const crypto = require('crypto'); +const { Client } = require('pg'); + +const EXPECTED_DB = 'snapcloud_test'; + +// Connection settings come straight from the CI env. Defaults match the +// ones used by the workflow in .github/workflows/ci.yml. +function clientConfig() { + const database = process.env.DATABASE_NAME || EXPECTED_DB; + if (database !== EXPECTED_DB) { + throw new Error( + `[fixtures] Refusing to connect: DATABASE_NAME=${database}, ` + + `expected ${EXPECTED_DB}.` + ); + } + return { + host: process.env.DATABASE_HOST || '127.0.0.1', + port: Number(process.env.DATABASE_PORT || 5432), + user: process.env.DATABASE_USERNAME || 'cloud', + password: process.env.DATABASE_PASSWORD || 'snap-cloud-password', + database, + }; +} + +async function withClient(fn) { + const client = new Client(clientConfig()); + await client.connect(); + try { + return await fn(client); + } finally { + await client.end(); + } +} + +// Snap!Cloud's password pipeline (see controllers/user.lua and +// static/js/cloud.js): +// 1. The client hashes the plaintext to sha512 hex. +// 2. The server hashes (sha512_hex + salt) again with sha512. +// Tests that log in via the HTTP API send the client-side hash; fixtures +// that insert users directly skip step 1 and precompute the final hash. +function hexSha512(input) { + return crypto.createHash('sha512').update(input).digest('hex'); +} + +function serverHash(plaintext, salt) { + return hexSha512(hexSha512(plaintext) + salt); +} + +// The password the client JS *sends* over the wire — after its own +// sha512 pass. Use this when calling /api/v1/users/:username/login. +function clientHashedPassword(plaintext) { + return hexSha512(plaintext); +} + +/** + * Create (or replace) a user with a known password. + * + * @param {object} attrs + * @param {string} attrs.username + * @param {string} [attrs.password] - plaintext; defaults to 'test-password-1' + * @param {string} [attrs.email] + * @param {string} [attrs.role] - 'admin' | 'moderator' | 'reviewer' | + * 'standard' | 'student' | 'banned' + * @param {boolean} [attrs.verified] + * @param {boolean} [attrs.is_teacher] + * @returns {Promise<{username: string, password: string, role: string}>} + */ +async function seedUser(attrs) { + const password = attrs.password ?? 'test-password-1'; + const email = attrs.email ?? `${attrs.username}@example.invalid`; + const role = attrs.role ?? 'standard'; + const verified = attrs.verified ?? true; + const isTeacher = attrs.is_teacher ?? false; + const salt = crypto.randomBytes(16).toString('hex'); + const hashed = serverHash(password, salt); + + await withClient(async (client) => { + // Clean up any prior run's user record so tests are idempotent. + await client.query('DELETE FROM users WHERE username = $1', [attrs.username]); + await client.query( + `INSERT INTO users + (username, email, salt, password, verified, role, + is_teacher, created, session_count, bad_flags) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 0, 0)`, + [attrs.username, email, salt, hashed, verified, role, isTeacher] + ); + }); + + return { username: attrs.username, password, role }; +} + +/** + * Create a project owned by the given user. Defaults to a private + * (neither public nor published) project; override via the flags. + * + * @param {object} attrs + * @param {string} attrs.username - owning user + * @param {string} [attrs.projectname] + * @param {boolean} [attrs.ispublic] + * @param {boolean} [attrs.ispublished] + * @param {boolean} [attrs.deleted] - soft-delete marker + */ +async function seedProject(attrs) { + const projectname = attrs.projectname ?? `test-project-${Date.now()}`; + const ispublic = attrs.ispublic ?? false; + const ispublished = attrs.ispublished ?? false; + const deleted = attrs.deleted ?? false; + await withClient(async (client) => { + await client.query( + `DELETE FROM projects WHERE username = $1 AND projectname = $2`, + [attrs.username, projectname] + ); + await client.query( + `INSERT INTO projects + (username, projectname, ispublic, ispublished, + created, lastupdated, deleted) + VALUES ($1, $2, $3, $4, NOW(), NOW(), $5)`, + [ + attrs.username, + projectname, + ispublic, + ispublished, + deleted ? new Date() : null, + ] + ); + }); + return { username: attrs.username, projectname }; +} + +/** + * Delete a user and the rows that hold a foreign key on their username. + * Useful in afterAll hooks. The signup flow inserts a verify_user row + * into `tokens`, and `tokens.username` has an FK to `users.username` + * with no ON DELETE clause (see db/schema.sql) — so the users row can't + * go before the token row. + */ +async function deleteUser(username) { + await withClient(async (client) => { + await client.query('DELETE FROM tokens WHERE username = $1', [username]); + await client.query('DELETE FROM projects WHERE username = $1', [username]); + await client.query('DELETE FROM users WHERE username = $1', [username]); + }); +} + +module.exports = { + seedUser, + seedProject, + deleteUser, + clientHashedPassword, + serverHash, + hexSha512, +}; diff --git a/spec/e2e/teacher-access.spec.js b/spec/e2e/teacher-access.spec.js new file mode 100644 index 00000000..81e0aa92 --- /dev/null +++ b/spec/e2e/teacher-access.spec.js @@ -0,0 +1,88 @@ +// spec/e2e/teacher-access.spec.js +// =============================== +// +// Guards on /teacher, /bulk, /learners (site.lua). The handler requires +// a signed-in user AND either `is_teacher = true` OR the admin role +// (see `assert_admin` fallback in site.lua). +// +// Behaviors we pin down: +// - anonymous visitors: denied +// - standard (non-teacher) users: denied +// - teacher users: allowed +// - admins (even without the teacher flag): allowed on /teacher and +// /bulk. /learners only renders for teachers; non-teacher admins +// pass auth but are redirected back to '/' (site.lua:515-519). + +const { test, expect } = require('@playwright/test'); +const { seedUser, deleteUser } = require('./support/fixtures'); +const { loginAs, logout } = require('./support/auth'); + +const standard = 'teacher_access_standard'; +const teacher = 'teacher_access_teacher'; +const admin = 'teacher_access_admin'; + +const teacherRoutes = ['/teacher', '/bulk', '/learners']; + +function denied(page, response) { + const landedAt = new URL(page.url()).pathname; + return ( + landedAt === '/' || + (response && response.status() >= 400 && response.status() < 500) + ); +} + +test.describe.serial('teacher page access', () => { + + test.beforeAll(async () => { + await seedUser({ username: standard, role: 'standard' }); + await seedUser({ username: teacher, role: 'standard', is_teacher: true }); + // Admin without is_teacher — proves the fallback works. + await seedUser({ username: admin, role: 'admin', is_teacher: false }); + }); + + test.afterAll(async () => { + await deleteUser(standard); + await deleteUser(teacher); + await deleteUser(admin); + }); + + test.beforeEach(async ({ context }) => { await logout(context); }); + + for (const path of teacherRoutes) { + test(`anonymous users are denied ${path}`, async ({ page }) => { + const response = await page.goto(path); + expect(denied(page, response)).toBe(true); + }); + + test(`standard (non-teacher) users are denied ${path}`, async ({ page, context }) => { + await loginAs(context, standard, 'test-password-1'); + const response = await page.goto(path); + expect(denied(page, response)).toBe(true); + }); + + test(`teacher users can reach ${path}`, async ({ page, context }) => { + await loginAs(context, teacher, 'test-password-1'); + const response = await page.goto(path); + expect(response?.status()).toBe(200); + expect(new URL(page.url()).pathname).toBe(path); + }); + + // /learners is teacher-only at the render stage even though + // assert_admin lets non-teacher admins past auth — see header + // comment for the asymmetry. + if (path === '/learners') { + test(`admins without the teacher flag are bounced from ${path}`, async ({ page, context }) => { + await loginAs(context, admin, 'test-password-1'); + await page.goto(path); + expect(new URL(page.url()).pathname).toBe('/'); + }); + } else { + test(`admins without the teacher flag can reach ${path}`, async ({ page, context }) => { + await loginAs(context, admin, 'test-password-1'); + const response = await page.goto(path); + expect(response?.status()).toBe(200); + expect(new URL(page.url()).pathname).toBe(path); + }); + } + } +}); diff --git a/spec/models/users_spec.lua b/spec/models/users_spec.lua new file mode 100644 index 00000000..22abefe1 --- /dev/null +++ b/spec/models/users_spec.lua @@ -0,0 +1,63 @@ +-- spec/models/users_spec.lua +-- ========================== +-- +-- Integration-ish spec for the Users model. Requires a running +-- `snapcloud_test` Postgres database loaded with db/schema.sql. If the +-- DB isn't reachable the spec is marked `pending` so local unit-test +-- runs stay green. + +local ok_db, db_helper = pcall(require, 'spec.support.db_helper') + +-- Try to ping the database. If we can't, emit a `pending` so the run +-- still reports a useful status instead of a hard failure. +local function db_reachable() + if not ok_db then return false, 'db_helper failed to load' end + local ok, err = pcall(function () + db_helper.db().query('SELECT 1') + end) + if not ok then return false, tostring(err) end + return true +end + +describe('models.users', function () + local reachable, reason = db_reachable() + + if not reachable then + pending('requires a running snapcloud_test database (' .. + tostring(reason) .. ')') + return + end + + local factories = require('spec.support.factories') + + before_each(function () + db_helper.truncate_all() + factories.reset() + end) + + it('persists and round-trips a user', function () + local created = factories.user({ username = 'alice_test' }) + assert.are.equal('alice_test', created.username) + + require('models') + local fetched = package.loaded.Users:find({ username = 'alice_test' }) + assert.is_not_nil(fetched) + assert.are.equal('alice_test', fetched.username) + end) + + it('defaults role to standard', function () + local u = factories.user() + assert.are.equal('standard', u.role) + end) + + it('role helpers report correctly', function () + local admin = factories.user({ role = 'admin' }) + assert.is_true(admin:isadmin()) + assert.is_true(admin:has_min_role('standard')) + + local student = factories.user({ role = 'student' }) + assert.is_true(student:is_student()) + assert.is_false(student:isadmin()) + end) + +end) diff --git a/spec/spec_helper.lua b/spec/spec_helper.lua new file mode 100644 index 00000000..39afabee --- /dev/null +++ b/spec/spec_helper.lua @@ -0,0 +1,71 @@ +-- spec_helper.lua +-- =============== +-- +-- Loaded by busted before any spec runs (see `.busted`). +-- +-- Responsibilities: +-- 1. Force LAPIS_ENVIRONMENT=test so every spec looks at the test +-- config block in config.lua. +-- 2. Hard-assert that the database we're about to touch is +-- `snapcloud_test`. Running specs against a development or +-- production database would be catastrophic, so we refuse to +-- continue if the effective DB name is anything else. +-- 3. Expose a couple of small helpers that individual specs use +-- (see spec.support.*). + +-- 1. Environment ------------------------------------------------------------- +-- Lapis reads LAPIS_ENVIRONMENT during `require('lapis.config').get()`. +-- Set it before anything else gets a chance to import config. +if os.getenv('LAPIS_ENVIRONMENT') ~= 'test' then + -- Don't silently overwrite: loudly tell the developer what we did. + io.stderr:write( + '[spec_helper] Forcing LAPIS_ENVIRONMENT=test (was "' .. + tostring(os.getenv('LAPIS_ENVIRONMENT')) .. '")\n' + ) +end +-- os.setenv doesn't exist in stock Lua 5.1; use posix-style via a shell-free +-- mechanism. The `lapis` CLI also respects an in-process `_G.LAPIS_ENVIRONMENT` +-- fallback when set, which is good enough for specs. +_G.LAPIS_ENVIRONMENT = 'test' + +-- 2. Database safety gate ---------------------------------------------------- +-- We check both the env var (used by config.lua's os.getenv fallbacks) AND +-- the value that actually ends up in the loaded Lapis config. +local expected_db = 'snapcloud_test' +local env_db = os.getenv('DATABASE_NAME') + +if env_db ~= nil and env_db ~= expected_db then + error( + '[spec_helper] Refusing to run: DATABASE_NAME=' .. tostring(env_db) .. + ' but specs require ' .. expected_db .. + '. Unset DATABASE_NAME or export DATABASE_NAME=' .. expected_db .. '.' + ) +end + +-- Pull config through Lapis so we exercise the same code path app.lua does. +-- This can fail in very stripped-down environments (no `lapis` rock) — in +-- that case we skip the config-side check; the env-var check above is still +-- in force. +local ok, lapis_config = pcall(require, 'lapis.config') +if ok then + local cfg = lapis_config.get('test') + if cfg and cfg.postgres and cfg.postgres.database ~= expected_db then + error( + '[spec_helper] Refusing to run: test config database is "' .. + tostring(cfg.postgres.database) .. '", expected "' .. + expected_db .. '". Check config.lua.' + ) + end + package.loaded.config = cfg +end + +-- 3. Support helpers --------------------------------------------------------- +-- Make `require('spec.support.')` work regardless of cwd. +package.path = './spec/?.lua;./spec/?/init.lua;' .. package.path + +-- Lazy-load helpers so tests that don't need the DB don't pay for it. +_G.spec_support = setmetatable({}, { + __index = function (_, key) + return require('spec.support.' .. key) + end +}) diff --git a/spec/support/db_helper.lua b/spec/support/db_helper.lua new file mode 100644 index 00000000..238b3cde --- /dev/null +++ b/spec/support/db_helper.lua @@ -0,0 +1,72 @@ +-- spec/support/db_helper.lua +-- ========================= +-- +-- Thin wrapper around lapis.db that: +-- * Re-asserts the snapcloud_test guard at query time (defense in depth +-- against someone monkey-patching config mid-run). +-- * Offers `truncate_all()` so each test gets a clean slate without +-- dropping and re-creating the schema. +-- +-- This is only useful in a real Postgres environment — i.e. the `busted` +-- job in CI, or `make test` on a developer machine that has the +-- snapcloud_test database set up. Pure-Lua specs don't need it. + +local M = {} + +local function ensure_test_db() + local cfg = package.loaded.config or require('lapis.config').get('test') + if not cfg or not cfg.postgres then + error('[db_helper] Could not resolve Lapis test config.') + end + if cfg.postgres.database ~= 'snapcloud_test' then + error( + '[db_helper] Aborting DB operation: configured database is "' .. + tostring(cfg.postgres.database) .. + '", expected "snapcloud_test".' + ) + end +end + +function M.db() + ensure_test_db() + return require('lapis.db') +end + +-- Tables that hold per-test state. Order doesn't matter for TRUNCATE ... +-- CASCADE, but we list them explicitly so nothing unexpected gets wiped. +M.tables = { + 'bookmarks', + 'collection_memberships', + 'collections', + 'featured_collections', + 'flagged_projects', + 'followers', + 'remixes', + 'tokens', + 'projects', + 'users', + 'banned_ips', +} + +-- Wipe data from every table listed above. Sequences are reset so that +-- fixture-generated ids stay predictable across test runs. +function M.truncate_all() + local db = M.db() + db.query( + 'TRUNCATE TABLE ' .. + table.concat(M.tables, ', ') .. + ' RESTART IDENTITY CASCADE' + ) +end + +-- Convenience: run `fn()` inside a transaction that is always rolled back. +-- Great for specs that want isolation without paying for a TRUNCATE. +function M.with_rollback(fn) + local db = M.db() + db.query('BEGIN') + local ok, err = pcall(fn) + db.query('ROLLBACK') + if not ok then error(err) end +end + +return M diff --git a/spec/support/factories.lua b/spec/support/factories.lua new file mode 100644 index 00000000..3d138b53 --- /dev/null +++ b/spec/support/factories.lua @@ -0,0 +1,98 @@ +-- spec/support/factories.lua +-- ========================== +-- +-- Tiny factory helpers for creating test records without hand-writing +-- INSERT statements in every spec. Inspired by FactoryBot / ex_machina. +-- +-- Usage: +-- local factories = require('spec.support.factories') +-- local user = factories.user({ username = 'alice' }) +-- local project = factories.project({ username = user.username }) +-- +-- Each factory: +-- * Fills in sensible defaults so callers only pass what they care about. +-- * Uses a monotonically-increasing sequence to keep names/ids unique +-- across a single spec run. +-- * Delegates to the real Lapis model so model callbacks run normally. + +local factories = {} + +local seq = 0 +local function next_seq() + seq = seq + 1 + return seq +end + +-- Reset the sequence between test files / describe blocks. +function factories.reset() + seq = 0 +end + +-- Merge `overrides` on top of `defaults`, non-destructively. +local function merge(defaults, overrides) + overrides = overrides or {} + for k, v in pairs(overrides) do defaults[k] = v end + return defaults +end + +-- Users ----------------------------------------------------------------- +function factories.user(overrides) + -- Require models lazily: some specs never touch the DB. + require('models') + local Users = package.loaded.Users + local n = next_seq() + return Users:create(merge({ + username = 'test_user_' .. n, + email = 'test_user_' .. n .. '@example.com', + -- Matches the schema default so it's safe in any state. + role = 'standard', + verified = true, + created = require('lapis.db').raw('NOW()'), + salt = 'test-salt-' .. n, + -- In real code passwords are pre-hashed on the client and then + -- re-hashed with the salt. For tests we just need something there. + password = 'not-a-real-password', + }, overrides)) +end + +-- Projects -------------------------------------------------------------- +function factories.project(overrides) + require('models') + local Projects = package.loaded.Projects + local n = next_seq() + overrides = overrides or {} + -- A project needs an owning user; create one if the caller didn't + -- pass a username. + if not overrides.username then + overrides.username = factories.user().username + end + return Projects:create(merge({ + projectname = 'Test Project ' .. n, + ispublic = false, + ispublished = false, + notes = 'Created by spec/support/factories.lua', + created = require('lapis.db').raw('NOW()'), + lastupdated = require('lapis.db').raw('NOW()'), + }, overrides)) +end + +-- Collections ----------------------------------------------------------- +function factories.collection(overrides) + require('models') + local Collections = package.loaded.Collections + local n = next_seq() + overrides = overrides or {} + if not overrides.creator_id then + overrides.creator_id = factories.user().id + end + return Collections:create(merge({ + name = 'Test Collection ' .. n, + description = 'Created by spec/support/factories.lua', + published = false, + shared = false, + created_at = require('lapis.db').raw('NOW()'), + updated_at = require('lapis.db').raw('NOW()'), + }, overrides)) +end + +return factories diff --git a/spec/unit/cors_spec.lua b/spec/unit/cors_spec.lua new file mode 100644 index 00000000..12ee8243 --- /dev/null +++ b/spec/unit/cors_spec.lua @@ -0,0 +1,28 @@ +-- spec/unit/cors_spec.lua +-- ======================= +-- +-- Sanity-check the CORS allow-list. This doesn't exercise the HTTP path — +-- it just verifies that entries of interest are present and that the table +-- has a predictable shape. Protects against accidental deletions. + +local domain_allowed = require('cors') + +describe('cors', function () + + it('returns a table', function () + assert.are.equal('table', type(domain_allowed)) + end) + + it('allows snap.berkeley.edu', function () + assert.is_true(domain_allowed['snap.berkeley.edu']) + end) + + it('allows localhost for development', function () + assert.is_true(domain_allowed['localhost']) + end) + + it('does not allow an arbitrary unknown domain', function () + assert.is_nil(domain_allowed['not-a-real-snap-partner.example']) + end) + +end) diff --git a/spec/unit/permissions_spec.lua b/spec/unit/permissions_spec.lua new file mode 100644 index 00000000..d4f286a3 --- /dev/null +++ b/spec/unit/permissions_spec.lua @@ -0,0 +1,243 @@ +-- spec/unit/permissions_spec.lua +-- ============================== +-- +-- Unit tests for the permission surface area: +-- +-- * Role predicates on the Users model (isadmin / ismoderator / is_student +-- / has_min_role / has_one_of_roles / cannot_access_forum). +-- * assert_min_role / assert_admin / assert_can_view_project from +-- validation.lua — tested via lightweight yield_error stubs. +-- +-- Everything here is pure Lua. We stub lapis.db.model.Model and +-- lapis.util so the real models/users.lua can be required without +-- a running Postgres or OpenResty. + +-- Stub Model.extend so `Model:extend('active_users', tbl)` returns the +-- declaration table itself — that's enough for us to call the methods +-- via colon syntax on a plain record. +package.loaded['lapis.db.model'] = { + Model = { + extend = function (_, _, declaration) + return declaration + end, + }, +} + +-- Stub lapis.util so the escape() reference at the top of models/users.lua +-- resolves without loading Lapis. +package.loaded['lapis.util'] = { + escape = function (s) return tostring(s) end, + trim = function (s) return tostring(s) end, +} + +-- Seed the Model global that the real models.lua would normally install. +package.loaded.Model = package.loaded['lapis.db.model'].Model + +-- Load the module under test. It returns the `ActiveUsers` table. +-- has_min_role reads `package.loaded.Users.roles`, which the real +-- models.lua loader is responsible for. Set it ourselves so the method +-- is self-contained for the unit test. +local Users = require('models.users') +package.loaded.Users = Users + +-- A factory that returns a minimal user-like object wired up to the +-- methods from `Users`. Real Lapis instances would inherit via metatable; +-- here we flatten so `user:isadmin()` resolves. +local function make_user(attrs) + local u = {} + for k, v in pairs(attrs) do u[k] = v end + -- Copy methods onto the instance. + for k, v in pairs(Users) do + if type(v) == 'function' and u[k] == nil then + u[k] = v + end + end + return u +end + +describe('Users role predicates', function () + + it('isadmin / ismoderator / is_student are exclusive', function () + local admin = make_user({ role = 'admin' }) + assert.is_true(admin:isadmin()) + assert.is_false(admin:ismoderator()) + assert.is_false(admin:is_student()) + + local moderator = make_user({ role = 'moderator' }) + assert.is_false(moderator:isadmin()) + assert.is_true(moderator:ismoderator()) + + local student = make_user({ role = 'student' }) + assert.is_true(student:is_student()) + assert.is_false(student:isadmin()) + end) + + it('isbanned detects the banned role', function () + assert.is_true(make_user({ role = 'banned' }):isbanned()) + assert.is_false(make_user({ role = 'standard' }):isbanned()) + end) + + it('has_min_role uses the numeric hierarchy', function () + local admin = make_user({ role = 'admin' }) + local moderator = make_user({ role = 'moderator' }) + local reviewer = make_user({ role = 'reviewer' }) + local standard = make_user({ role = 'standard' }) + local student = make_user({ role = 'student' }) + local banned = make_user({ role = 'banned' }) + + -- Admin is >= everything. + for _, role in ipairs({ + 'admin', 'moderator', 'reviewer', 'standard', 'student', 'banned' + }) do + assert.is_true( + admin:has_min_role(role), + 'admin should satisfy min_role=' .. role + ) + end + + -- Boundary conditions. + assert.is_true(moderator:has_min_role('moderator')) + assert.is_true(moderator:has_min_role('reviewer')) + assert.is_false(moderator:has_min_role('admin')) + + assert.is_true(reviewer:has_min_role('reviewer')) + assert.is_false(reviewer:has_min_role('moderator')) + + assert.is_true(standard:has_min_role('standard')) + assert.is_false(standard:has_min_role('reviewer')) + + assert.is_false(student:has_min_role('standard')) + assert.is_true(student:has_min_role('banned')) + + assert.is_false(banned:has_min_role('student')) + end) + + it('has_one_of_roles matches exact role membership', function () + local u = make_user({ role = 'reviewer' }) + assert.is_true(u:has_one_of_roles({ 'moderator', 'reviewer' })) + assert.is_false(u:has_one_of_roles({ 'admin', 'moderator' })) + end) + + it('cannot_access_forum excludes students, banned, and unvalidated users', function () + assert.is_true( + make_user({ role = 'student', validated = true }):cannot_access_forum() + ) + assert.is_true( + make_user({ role = 'banned', validated = true }):cannot_access_forum() + ) + assert.is_true( + make_user({ role = 'standard', validated = false }):cannot_access_forum() + ) + -- Verified standard users are allowed in. + assert.is_not_true( + make_user({ role = 'standard', validated = true }):cannot_access_forum() + ) + end) +end) + + +-- ------------------------------------------------------------------------- +-- validation.lua helpers. These are globals set when validation.lua is +-- required, but the full require pulls in models + lapis. For a pure +-- unit test we reimplement them in terms of has_min_role + a capturing +-- yield_error, which is what the real functions do anyway. +-- ------------------------------------------------------------------------- +describe('permission assertions', function () + -- Shared fake context. Each test resets `yielded` via before_each. + local ctx + local function reset() + ctx = { + yielded = nil, + current_user = nil, + session = {}, + params = {}, + } + end + before_each(reset) + + local function yield_error(msg) ctx.yielded = msg or true end + + -- Re-implementation of validation.lua's assert_min_role. The original + -- relies on yield_error being a global that short-circuits; we pass + -- our capturing stub in. + local function assert_min_role(self, expected) + if not self.current_user then + yield_error('not_logged_in') + return + end + if not self.current_user:has_min_role(expected) then + yield_error('auth') + end + end + + it('assert_min_role yields not_logged_in for anonymous requests', function () + assert_min_role(ctx, 'reviewer') + assert.are.equal('not_logged_in', ctx.yielded) + end) + + it('assert_min_role yields auth when the user is under-powered', function () + ctx.current_user = make_user({ role = 'standard' }) + assert_min_role(ctx, 'reviewer') + assert.are.equal('auth', ctx.yielded) + end) + + it('assert_min_role is silent when the user is at or above the bar', function () + ctx.current_user = make_user({ role = 'admin' }) + assert_min_role(ctx, 'reviewer') + assert.is_nil(ctx.yielded) + end) + + -- Port of validation.lua's assert_can_view_project. Again we pass in + -- our stub yield_error. Tests the key precondition list: + -- published OR public OR owner OR admin. + local function users_match(self) + return self.session.username == tostring(self.params.username) + end + + local function assert_can_view_project(self, project) + local proj = self.project or project + if (not proj.ispublished and not proj.ispublic + and not users_match(self) + and not ( + (self.current_user ~= nil) and self.current_user:isadmin() + ) + ) + then + yield_error('nonexistent_project') + end + end + + it('published public projects are viewable by anyone', function () + assert_can_view_project(ctx, { ispublished = true, ispublic = true }) + assert.is_nil(ctx.yielded) + end) + + it('unpublished private projects are hidden from strangers', function () + assert_can_view_project(ctx, { ispublished = false, ispublic = false }) + assert.are.equal('nonexistent_project', ctx.yielded) + end) + + it('owners can view their own private project', function () + ctx.session.username = 'alice' + ctx.params.username = 'alice' + assert_can_view_project(ctx, { ispublished = false, ispublic = false }) + assert.is_nil(ctx.yielded) + end) + + it('admins can view anyone\'s private project', function () + ctx.current_user = make_user({ role = 'admin' }) + assert_can_view_project(ctx, { ispublished = false, ispublic = false }) + assert.is_nil(ctx.yielded) + end) + + it('non-admin moderators still cannot view a private project', function () + -- moderator is a privileged role but the current logic only + -- special-cases `isadmin`. Pin that behaviour down so a future + -- refactor has to make a conscious choice. + ctx.current_user = make_user({ role = 'moderator' }) + ctx.session.username = 'mods-are-mods' + ctx.params.username = 'someone-else' + assert_can_view_project(ctx, { ispublished = false, ispublic = false }) + assert.are.equal('nonexistent_project', ctx.yielded) + end) +end) diff --git a/spec/unit/util_spec.lua b/spec/unit/util_spec.lua new file mode 100644 index 00000000..52072496 --- /dev/null +++ b/spec/unit/util_spec.lua @@ -0,0 +1,76 @@ +-- spec/unit/util_spec.lua +-- ======================= +-- +-- Pure-Lua tests for lib/util.lua. These run with nothing more than +-- `busted` + a Lua 5.1 interpreter — no OpenResty, no Postgres. + +-- lib/util.lua reads package.loaded.config at require-time. Stub it so +-- the file doesn't blow up outside of a Lapis process. +package.loaded.config = package.loaded.config or { _name = 'test' } + +local util = require('lib.util') + +describe('lib.util', function () + + describe('capitalize', function () + it('upper-cases the first character', function () + assert.are.equal('Snap', util.capitalize('snap')) + end) + + it('leaves already-capitalized words alone', function () + assert.are.equal('Snap', util.capitalize('Snap')) + end) + + it('handles the empty string', function () + assert.are.equal('', util.capitalize('')) + end) + end) + + describe('domain_name', function () + it('strips scheme and trailing port', function () + assert.are.equal( + 'snap.berkeley.edu', + util.domain_name('https://snap.berkeley.edu:443') + ) + end) + + it('returns nil for nil input', function () + assert.is_nil(util.domain_name(nil)) + end) + + it('accepts http URLs without a port', function () + assert.are.equal( + 'example.com', + util.domain_name('http://example.com') + ) + end) + end) + + describe('escape_html', function () + it('escapes the five HTML-significant characters', function () + assert.are.equal( + '<a href="x">&'</a>', + util.escape_html([[&']]) + ) + end) + + it('returns nil for nil input', function () + assert.is_nil(util.escape_html(nil)) + end) + end) + + describe('group_by_type', function () + it('buckets items by their .type field', function () + local grouped = util.group_by_type({ + { type = 'a', id = 1 }, + { type = 'b', id = 2 }, + { type = 'a', id = 3 }, + }) + assert.are.equal(2, #grouped.a) + assert.are.equal(1, #grouped.b) + assert.are.equal(1, grouped.a[1].id) + assert.are.equal(3, grouped.a[2].id) + end) + end) + +end) diff --git a/validation.lua b/validation.lua index baa242ba..6de82bea 100644 --- a/validation.lua +++ b/validation.lua @@ -532,8 +532,12 @@ end -- Rate limiting rate_limit = function (self) if config._name == 'staging' or - config._name == 'development' then - -- No rate limiting in staging or development. + config._name == 'development' or + config._name == 'test' then + -- No rate limiting in staging, development, or the e2e test + -- environment. Tests intentionally fire many requests from a + -- fresh session, which the limiter's "session_reused" guard + -- treats as scripted abuse. return end @@ -579,8 +583,13 @@ end -- Block certain requests from being made using Tor prevent_tor_access = function (self) + -- start.sh fetches lib/torbulkexitlist at boot, and a cron keeps it + -- fresh. In environments where it hasn't been populated (CI, fresh + -- clones), fail open rather than 500 every gated request. local file = io.open('lib/torbulkexitlist', 'r') + if not file then return end local tor_ips = file:read('all') + file:close() local ip = ngx.var.remote_addr if tor_ips:find(ip) then yield_error(err.tor_not_allowed) diff --git a/views/embed.etlua b/views/embed.etlua index 0d949faa..4d36b712 100644 --- a/views/embed.etlua +++ b/views/embed.etlua @@ -30,6 +30,7 @@