Files
projax/web/server_test.go
mAi 840c1760c9 feat(auth): federate with mgmt.msbls.de via Supabase cookies
projax was deployed publicly through Dokploy/Traefik with a Let's
Encrypt cert; the earlier "Tailscale-only" claim was never true. Gate
every request at the application layer using the same Supabase JWT
cookie pair that mgmt.msbls.de issues, so projax inherits SSO without
running its own login.

Middleware (web/auth.go):
- GET <SUPABASE_URL>/auth/v1/user with the access_token cookie or a
  Bearer header. On 2xx → pass through.
- On expiry, swap the refresh_token via /auth/v1/token?grant_type=
  refresh_token and rotate both cookies (Domain=msbls.de, HttpOnly,
  Secure, SameSite=Lax, Path=/, Max-Age=1y). Cookie attributes match
  mgmt/auth.ts verbatim — refreshed sessions stay drop-in compatible
  with the rest of the .msbls.de fleet.
- Anything still invalid → 302 to <PROJAX_LOGIN_URL>?redirectTo=
  <original-absolute-url>. mgmt's safeRedirect() rejects absolute URLs
  and falls back to /, so after login the user lands on mgmt; manual
  click back to projax then succeeds with the fresh cookie. UX is
  rough but functional; broadening mgmt's safeRedirect is parked for a
  separate PR.
- /healthz remains ungated so Dokploy/Traefik probes don't hit the
  redirect.

main.go: enable the middleware only when SUPABASE_URL is set; require
SUPABASE_ANON_KEY when it is (refuse to start otherwise). New env
overrides: PROJAX_LOGIN_URL (default https://mgmt.msbls.de/login),
PROJAX_COOKIE_DOMAIN (default msbls.de). Local dev with no env stays
fully anonymous.

Tests (7 cases, no DB needed): stub Supabase via httptest covers
healthz-open, anonymous-redirect, bad-cookie-redirect, good-cookie
pass-through, Bearer-pass-through, stale-but-refreshable rotation
(verifies cookie Domain/HttpOnly/Secure/SameSite), final fail
redirect.

DB-backed integration tests now honour PROJAX_SKIP_MIGRATE=1 so they
don't deadlock against the live container's auto-migrate during a
deploy window.

README + dokploy.yaml: kill the Tailscale-only claim, document the
federated-auth trust model and the new SUPABASE_* env contract.
2026-05-15 14:58:43 +02:00

193 lines
5.2 KiB
Go

package web_test
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/m/projax/db"
"github.com/m/projax/store"
"github.com/m/projax/web"
)
var (
migrateOnce sync.Once
migrateErr error
)
func mustServer(t *testing.T) (*web.Server, *pgxpool.Pool) {
t.Helper()
dbURL := os.Getenv("PROJAX_DB_URL")
if dbURL == "" {
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
}
if dbURL == "" {
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping HTTP integration test")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
t.Fatalf("pool: %v", err)
}
if err := pool.Ping(ctx); err != nil {
t.Skipf("DB unreachable: %v", err)
}
if os.Getenv("PROJAX_SKIP_MIGRATE") != "1" {
migrateOnce.Do(func() { migrateErr = db.ApplyMigrations(ctx, pool) })
if migrateErr != nil {
t.Fatalf("migrate: %v", migrateErr)
}
}
srv, err := web.New(store.New(pool), slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("server: %v", err)
}
return srv, pool
}
func get(t *testing.T, h http.Handler, url string) (int, string) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
body, _ := io.ReadAll(w.Result().Body)
return w.Result().StatusCode, string(body)
}
func TestTreeRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/")
if code != 200 {
t.Fatalf("GET / status %d body=%s", code, body)
}
for _, want := range []string{"<h1>Tree</h1>", "/i/dev", "/i/home", "/admin/classify"} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
}
func TestHealthz(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/healthz")
if code != 200 || strings.TrimSpace(body) != "ok" {
t.Fatalf("healthz: %d %q", code, body)
}
}
func TestDetailProjaxNativeEditable(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/i/dev")
if code != 200 {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
t.Errorf("editable form missing for /i/dev")
}
}
func TestDetailMaiProjectsReadOnly(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/i/mai.dotfiles")
if code != 200 {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "Promote to projax") {
t.Errorf("Promote section missing for mai.projects row")
}
if !strings.Contains(body, `action="/i/mai.dotfiles/promote"`) {
t.Errorf("promote form missing")
}
}
func TestClassifyListsOrphans(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/admin/classify")
if code != 200 {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "unclassified rows") {
t.Errorf("classify missing summary")
}
}
func TestPromoteRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// Pick an orphan to promote.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var maiID, maiPath string
if err := pool.QueryRow(ctx,
`select source_ref_id, path from projax.items_unified where source='mai.projects' limit 1`,
).Scan(&maiID, &maiPath); err != nil {
t.Fatalf("pick orphan: %v", err)
}
if maiID == "" {
t.Skip("no mai.projects orphans available")
}
var devID string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
t.Fatalf("dev: %v", err)
}
promoSlug := "test-promo-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
form := url.Values{}
form.Set("parent_id", devID)
form.Set("slug", promoSlug)
form.Set("title", "Promo "+maiID)
req := httptest.NewRequest(http.MethodPost, "/i/"+maiPath+"/promote", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusSeeOther {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("promote status %d body=%s", w.Result().StatusCode, body)
}
loc := w.Result().Header.Get("Location")
wantLoc := "/i/dev." + promoSlug
if loc != wantLoc {
t.Errorf("redirect Location = %q, want %q", loc, wantLoc)
}
// The mai row should be hidden from items_unified now.
var still int
if err := pool.QueryRow(ctx,
`select count(*) from projax.items_unified where source='mai.projects' and source_ref_id=$1`, maiID,
).Scan(&still); err != nil {
t.Fatalf("post-promote count: %v", err)
}
if still != 0 {
t.Errorf("expected mai source row hidden after promote, got count=%d", still)
}
// Clean up to keep test idempotent.
if _, err := pool.Exec(ctx, `delete from projax.item_links where ref_type='mai-project' and ref_id=$1`, maiID); err != nil {
t.Fatalf("cleanup link: %v", err)
}
if _, err := pool.Exec(ctx, `delete from projax.items where slug=$1 and parent_id=$2`, promoSlug, devID); err != nil {
t.Fatalf("cleanup item: %v", err)
}
}