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.
250 lines
7.8 KiB
Go
250 lines
7.8 KiB
Go
package db_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/m/projax/db"
|
|
)
|
|
|
|
// skipMigrate reports whether the test process should skip ApplyMigrations.
|
|
// Useful when the live deploy or another process is concurrently migrating
|
|
// against the same DB — Postgres serialises CREATE / ALTER OWNER and the
|
|
// loser deadlocks. Set PROJAX_SKIP_MIGRATE=1 to opt out.
|
|
func skipMigrate() bool { return os.Getenv("PROJAX_SKIP_MIGRATE") == "1" }
|
|
|
|
// connect returns a pool or skips the test if no DB is configured.
|
|
// Honours PROJAX_DB_URL first, then SUPABASE_DATABASE_URL.
|
|
func connect(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
url := os.Getenv("PROJAX_DB_URL")
|
|
if url == "" {
|
|
url = os.Getenv("SUPABASE_DATABASE_URL")
|
|
}
|
|
if url == "" {
|
|
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping integration test")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
pool, err := pgxpool.New(ctx, url)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
t.Skipf("DB unreachable: %v", err)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
func TestMigrationsAreIdempotent(t *testing.T) {
|
|
if skipMigrate() {
|
|
t.Skip("PROJAX_SKIP_MIGRATE=1 — schema assumed already applied")
|
|
}
|
|
pool := connect(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
|
|
// Apply twice; second run must not fail.
|
|
if err := db.ApplyMigrations(ctx, pool); err != nil {
|
|
t.Fatalf("first apply: %v", err)
|
|
}
|
|
if err := db.ApplyMigrations(ctx, pool); err != nil {
|
|
t.Fatalf("second apply: %v", err)
|
|
}
|
|
|
|
var n int
|
|
if err := pool.QueryRow(ctx, `select count(*) from projax.items where 'area' = any(kind) and parent_id is null`).Scan(&n); err != nil {
|
|
t.Fatalf("count areas: %v", err)
|
|
}
|
|
if n < 7 {
|
|
t.Fatalf("expected at least 7 seeded areas, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestPathTriggerNestAndRename(t *testing.T) {
|
|
pool := connect(t)
|
|
defer pool.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// Get the 'home' area id.
|
|
var homeID string
|
|
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
|
|
t.Fatalf("read home: %v", err)
|
|
}
|
|
|
|
// Insert child project under home.
|
|
var parentPath string
|
|
if err := tx.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], $1, $2, $3) returning path`,
|
|
"Spring clean", "spring-clean", homeID,
|
|
).Scan(&parentPath); err != nil {
|
|
t.Fatalf("insert spring-clean: %v", err)
|
|
}
|
|
if parentPath != "home.spring-clean" {
|
|
t.Fatalf("expected path 'home.spring-clean', got %q", parentPath)
|
|
}
|
|
|
|
// Insert grandchild.
|
|
var childPath string
|
|
if err := tx.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id)
|
|
select array['project']::text[], 'Bathroom', 'bathroom', id from projax.items where path='home.spring-clean'
|
|
returning path`).Scan(&childPath); err != nil {
|
|
t.Fatalf("insert bathroom: %v", err)
|
|
}
|
|
if childPath != "home.spring-clean.bathroom" {
|
|
t.Fatalf("expected path 'home.spring-clean.bathroom', got %q", childPath)
|
|
}
|
|
|
|
// Rename middle: descendants must be rewritten.
|
|
if _, err := tx.Exec(ctx, `update projax.items set slug='big-clean' where path='home.spring-clean'`); err != nil {
|
|
t.Fatalf("rename: %v", err)
|
|
}
|
|
var renamedChild string
|
|
if err := tx.QueryRow(ctx, `select path from projax.items where slug='bathroom' and parent_id=(select id from projax.items where slug='big-clean')`).Scan(&renamedChild); err != nil {
|
|
t.Fatalf("read child after rename: %v", err)
|
|
}
|
|
if renamedChild != "home.big-clean.bathroom" {
|
|
t.Fatalf("expected child path 'home.big-clean.bathroom', got %q", renamedChild)
|
|
}
|
|
}
|
|
|
|
func TestPathTriggerReparent(t *testing.T) {
|
|
pool := connect(t)
|
|
defer pool.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var homeID, devID string
|
|
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
|
|
t.Fatalf("home: %v", err)
|
|
}
|
|
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
|
|
// Create project under home, then move it to dev.
|
|
var pid string
|
|
if err := tx.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'X', 'mover', $1) returning id`, homeID,
|
|
).Scan(&pid); err != nil {
|
|
t.Fatalf("insert mover: %v", err)
|
|
}
|
|
// Child of mover.
|
|
if _, err := tx.Exec(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'X.child', 'child', $1)`, pid,
|
|
); err != nil {
|
|
t.Fatalf("insert child: %v", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$2`, devID, pid); err != nil {
|
|
t.Fatalf("reparent: %v", err)
|
|
}
|
|
|
|
var p1, p2 string
|
|
if err := tx.QueryRow(ctx, `select path from projax.items where id=$1`, pid).Scan(&p1); err != nil {
|
|
t.Fatalf("read mover path: %v", err)
|
|
}
|
|
if p1 != "dev.mover" {
|
|
t.Fatalf("mover path = %q, want dev.mover", p1)
|
|
}
|
|
if err := tx.QueryRow(ctx, `select path from projax.items where parent_id=$1`, pid).Scan(&p2); err != nil {
|
|
t.Fatalf("read child path: %v", err)
|
|
}
|
|
if p2 != "dev.mover.child" {
|
|
t.Fatalf("child path = %q, want dev.mover.child", p2)
|
|
}
|
|
}
|
|
|
|
func TestStructuralRules(t *testing.T) {
|
|
pool := connect(t)
|
|
defer pool.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
sql string
|
|
args []any
|
|
}{
|
|
{
|
|
name: "area with parent rejected",
|
|
sql: `insert into projax.items (kind, title, slug, parent_id) values (array['area']::text[], 'bad', $1, (select id from projax.items where slug='home' and parent_id is null))`,
|
|
args: []any{fmt.Sprintf("bad-area-%d", time.Now().UnixNano())},
|
|
},
|
|
{
|
|
name: "project at root rejected",
|
|
sql: `insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'orphan', $1, null)`,
|
|
args: []any{fmt.Sprintf("orphan-%d", time.Now().UnixNano())},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
if _, err := tx.Exec(ctx, tc.sql, tc.args...); err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCycleRejected(t *testing.T) {
|
|
pool := connect(t)
|
|
defer pool.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var homeID string
|
|
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
|
|
t.Fatalf("home: %v", err)
|
|
}
|
|
var aID, bID string
|
|
if err := tx.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'A', 'cyc-a', $1) returning id`, homeID,
|
|
).Scan(&aID); err != nil {
|
|
t.Fatalf("a: %v", err)
|
|
}
|
|
if err := tx.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'B', 'cyc-b', $1) returning id`, aID,
|
|
).Scan(&bID); err != nil {
|
|
t.Fatalf("b: %v", err)
|
|
}
|
|
// Now try to make A a child of B -> cycle.
|
|
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$2`, bID, aID); err == nil {
|
|
t.Fatalf("expected cycle rejection, got nil error")
|
|
}
|
|
|
|
// Also: self-parent.
|
|
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$1`, aID); err == nil {
|
|
t.Fatalf("expected self-parent rejection, got nil error")
|
|
}
|
|
}
|