2026-05-27 14:21:32 -04:00
import { test } from 'node:test' ;
import assert from 'node:assert/strict' ;
import { isTestDbConfigured , setupTestDb } from '../helpers/setup-db.js' ;
import express from 'express' ;
2026-05-27 14:24:56 -04:00
import session from 'express-session' ;
2026-05-27 14:21:32 -04:00
import authRouter from '../../src/routes/auth.js' ;
2026-05-27 14:28:18 -04:00
import { hashPassword } from '../../src/auth/passwords.js' ;
import { requireAuth } from '../../src/middleware/auth.js' ;
2026-05-27 14:21:32 -04:00
async function appWithAuth ( pool ) {
process . env . DATABASE _URL = process . env . TEST _DATABASE _URL ;
const app = express ( ) ;
app . use ( express . json ( ) ) ;
app . use ( '/api/v1/auth' , authRouter ) ;
return new Promise ( r => {
const srv = app . listen ( 0 , '127.0.0.1' , ( ) => {
r ( { baseUrl : 'http://127.0.0.1:' + srv . address ( ) . port , close : ( ) => new Promise ( rs => srv . close ( rs ) ) } ) ;
} ) ;
} ) ;
}
test ( 'GET /auth/setup-required returns { required: true } on empty users (modulo dev seed)' , { skip : ! isTestDbConfigured ( ) && 'TEST_DATABASE_URL not set' } , async ( ) => {
const pool = await setupTestDb ( ) ;
const { baseUrl , close } = await appWithAuth ( pool ) ;
try {
const res = await fetch ( baseUrl + '/api/v1/auth/setup-required' ) ;
assert . equal ( res . status , 200 ) ;
assert . deepEqual ( await res . json ( ) , { required : true } ) ;
} finally { await close ( ) ; await pool . end ( ) ; }
} ) ;
test ( 'GET /auth/setup-required returns { required: false } once a real user exists' , { skip : ! isTestDbConfigured ( ) && 'TEST_DATABASE_URL not set' } , async ( ) => {
const pool = await setupTestDb ( ) ;
await pool . query ( ` INSERT INTO users (username, password_hash) VALUES ('admin', 'x') ` ) ;
const { baseUrl , close } = await appWithAuth ( pool ) ;
try {
const res = await fetch ( baseUrl + '/api/v1/auth/setup-required' ) ;
assert . deepEqual ( await res . json ( ) , { required : false } ) ;
} finally { await close ( ) ; await pool . end ( ) ; }
} ) ;
2026-05-27 14:24:56 -04:00
async function appWithSession ( pool ) {
process . env . DATABASE _URL = process . env . TEST _DATABASE _URL ;
process . env . SESSION _SECRET = 'test' ;
process . env . AUTH _ENABLED = 'true' ;
const ConnectPg = ( await import ( 'connect-pg-simple' ) ) . default ( session ) ;
const app = express ( ) ;
app . use ( express . json ( ) ) ;
app . use ( session ( {
store : new ConnectPg ( { pool , tableName : 'sessions' } ) ,
secret : 'test' , name : 'dragonflight.sid' ,
cookie : { httpOnly : true , sameSite : 'lax' , secure : false , maxAge : 8 * 3600 * 1000 } ,
rolling : false , resave : false , saveUninitialized : false ,
} ) ) ;
app . use ( '/api/v1/auth' , authRouter ) ;
return new Promise ( r => {
const srv = app . listen ( 0 , '127.0.0.1' , ( ) => {
r ( { baseUrl : 'http://127.0.0.1:' + srv . address ( ) . port , close : ( ) => new Promise ( rs => srv . close ( rs ) ) } ) ;
} ) ;
} ) ;
}
test ( 'POST /auth/setup creates the first admin and returns a session cookie' , { skip : ! isTestDbConfigured ( ) && 'TEST_DATABASE_URL not set' } , async ( ) => {
const pool = await setupTestDb ( ) ;
const { baseUrl , close } = await appWithSession ( pool ) ;
try {
const res = await fetch ( baseUrl + '/api/v1/auth/setup' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { username : 'admin' , password : 'correct-horse-battery' } ) ,
} ) ;
assert . equal ( res . status , 200 ) ;
const body = await res . json ( ) ;
assert . equal ( body . user . username , 'admin' ) ;
assert . match ( res . headers . get ( 'set-cookie' ) || '' , /dragonflight\.sid=/ ) ;
const { rows } = await pool . query ( ` SELECT COUNT(*)::int AS n FROM users WHERE username='admin' ` ) ;
assert . equal ( rows [ 0 ] . n , 1 ) ;
} finally { await close ( ) ; await pool . end ( ) ; }
} ) ;
test ( 'POST /auth/setup is 409 once a real user exists' , { skip : ! isTestDbConfigured ( ) && 'TEST_DATABASE_URL not set' } , async ( ) => {
const pool = await setupTestDb ( ) ;
await pool . query ( ` INSERT INTO users (username, password_hash) VALUES ('existing', 'x') ` ) ;
const { baseUrl , close } = await appWithSession ( pool ) ;
try {
const res = await fetch ( baseUrl + '/api/v1/auth/setup' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { username : 'admin' , password : 'correct-horse-battery' } ) ,
} ) ;
assert . equal ( res . status , 409 ) ;
assert . equal ( ( await res . json ( ) ) . error , 'setup already complete' ) ;
} finally { await close ( ) ; await pool . end ( ) ; }
} ) ;
test ( 'POST /auth/setup rejects passwords shorter than 12 chars' , { skip : ! isTestDbConfigured ( ) && 'TEST_DATABASE_URL not set' } , async ( ) => {
const pool = await setupTestDb ( ) ;
const { baseUrl , close } = await appWithSession ( pool ) ;
try {
const res = await fetch ( baseUrl + '/api/v1/auth/setup' , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { username : 'admin' , password : 'short' } ) ,
} ) ;
assert . equal ( res . status , 400 ) ;
} finally { await close ( ) ; await pool . end ( ) ; }
} ) ;
2026-05-27 14:28:18 -04:00
async function appWithSessionAndMe ( pool ) {
// Same as appWithSession but also mounts a tiny /me endpoint behind requireAuth
// so we can exercise the round-trip: login → cookie sent → /me 200.
process . env . DATABASE _URL = process . env . TEST _DATABASE _URL ;
process . env . AUTH _ENABLED = 'true' ;
const ConnectPg = ( await import ( 'connect-pg-simple' ) ) . default ( session ) ;
const app = express ( ) ;
app . use ( express . json ( ) ) ;
app . use ( session ( {
store : new ConnectPg ( { pool , tableName : 'sessions' } ) ,
secret : 'test' , name : 'dragonflight.sid' ,
cookie : { httpOnly : true , sameSite : 'lax' , secure : false , maxAge : 8 * 3600 * 1000 } ,
rolling : false , resave : false , saveUninitialized : false ,
} ) ) ;
app . use ( '/api/v1/auth' , authRouter ) ;
app . get ( '/api/v1/protected/me' , requireAuth , ( req , res ) => res . json ( { user : req . user } ) ) ;
return new Promise ( r => {
const srv = app . listen ( 0 , '127.0.0.1' , ( ) => {
r ( { baseUrl : 'http://127.0.0.1:' + srv . address ( ) . port , close : ( ) => new Promise ( rs => srv . close ( rs ) ) } ) ;
} ) ;
} ) ;
}
test ( 'POST /auth/login with valid creds → 200 + cookie, and the cookie unlocks subsequent requests (regression: redirect loop)' , { skip : ! isTestDbConfigured ( ) && 'TEST_DATABASE_URL not set' } , async ( ) => {
const pool = await setupTestDb ( ) ;
const hash = await hashPassword ( 'correct-horse-battery' ) ;
await pool . query ( ` INSERT INTO users (username, password_hash, display_name) VALUES ('alice', $ 1, 'Alice') ` , [ hash ] ) ;
const { baseUrl , close } = await appWithSessionAndMe ( pool ) ;
try {
// 1. Login.
const loginRes = await fetch ( baseUrl + '/api/v1/auth/login' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { username : 'alice' , password : 'correct-horse-battery' } ) ,
} ) ;
assert . equal ( loginRes . status , 200 ) ;
const setCookie = loginRes . headers . get ( 'set-cookie' ) ;
assert . match ( setCookie || '' , /dragonflight\.sid=/ , 'expected Set-Cookie with dragonflight.sid' ) ;
// 2. The SAME cookie must unlock the next request. This is the bug that
// produced the original redirect loop — login returned 200 but no cookie
// was persisted, so the next request 401'd and the SPA bounced.
const meRes = await fetch ( baseUrl + '/api/v1/protected/me' , {
headers : { cookie : setCookie . split ( ';' ) [ 0 ] } ,
} ) ;
assert . equal ( meRes . status , 200 , 'POST /login returned 200 but the cookie did not unlock /me — this is the regression' ) ;
assert . equal ( ( await meRes . json ( ) ) . user . username , 'alice' ) ;
} finally { await close ( ) ; await pool . end ( ) ; }
} ) ;
test ( 'POST /auth/login with wrong password → 401 + generic message (no enumeration)' , { skip : ! isTestDbConfigured ( ) && 'TEST_DATABASE_URL not set' } , async ( ) => {
const pool = await setupTestDb ( ) ;
const hash = await hashPassword ( 'correct-horse-battery' ) ;
await pool . query ( ` INSERT INTO users (username, password_hash) VALUES ('alice', $ 1) ` , [ hash ] ) ;
const { baseUrl , close } = await appWithSessionAndMe ( pool ) ;
try {
const r1 = await fetch ( baseUrl + '/api/v1/auth/login' , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { username : 'alice' , password : 'wrong' } ) ,
} ) ;
const r2 = await fetch ( baseUrl + '/api/v1/auth/login' , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { username : 'nobody' , password : 'whatever-long-enough' } ) ,
} ) ;
assert . equal ( r1 . status , 401 ) ;
assert . equal ( r2 . status , 401 ) ;
const e1 = ( await r1 . json ( ) ) . error , e2 = ( await r2 . json ( ) ) . error ;
assert . equal ( e1 , 'invalid credentials' ) ;
assert . equal ( e2 , 'invalid credentials' ) ; // identical message — no enumeration
} finally { await close ( ) ; await pool . end ( ) ; }
} ) ;
2026-05-27 14:38:05 -04:00
test ( 'POST /auth/logout destroys the session row and the cookie no longer unlocks /me' , { skip : ! isTestDbConfigured ( ) && 'TEST_DATABASE_URL not set' } , async ( ) => {
const pool = await setupTestDb ( ) ;
const hash = await hashPassword ( 'correct-horse-battery' ) ;
await pool . query ( ` INSERT INTO users (username, password_hash) VALUES ('alice', $ 1) ` , [ hash ] ) ;
const { baseUrl , close } = await appWithSessionAndMe ( pool ) ;
try {
const loginRes = await fetch ( baseUrl + '/api/v1/auth/login' , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { username : 'alice' , password : 'correct-horse-battery' } ) ,
} ) ;
const cookie = ( loginRes . headers . get ( 'set-cookie' ) || '' ) . split ( ';' ) [ 0 ] ;
const logoutRes = await fetch ( baseUrl + '/api/v1/auth/logout' , {
method : 'POST' , headers : { cookie } ,
} ) ;
assert . equal ( logoutRes . status , 204 ) ;
const meRes = await fetch ( baseUrl + '/api/v1/protected/me' , { headers : { cookie } } ) ;
assert . equal ( meRes . status , 401 ) ;
} finally { await close ( ) ; await pool . end ( ) ; }
} ) ;