feat(server): ghost-session guard — JWTs for deleted users stop authenticating

The JWT middleware validated signature + expiry and fabricated the
request user purely from token claims, so a signature-valid token for a
DELETED user kept full API access until token expiry — observed live on
hardware (a phone session survived the test suite's user-table
recreation and kept serving its true empty sets; open handoff bug 1).

An ensureUserExistsMiddleware already existed but was only wired on the
frontend SSR group (frontend.go) — every /api group used bare
createJWTMiddleware. The existence check now lives INSIDE
createJWTMiddleware's SuccessHandler so every protected group (SSR and
API alike) gets it:

- After claim validation, GetUser by the token's user_id; a missing row
  clears the token cookie and answers 401 user_not_found for API calls
  (the app's TokenAuthenticator then re-logins with stored credentials)
  or redirects to /login?session=invalid for page requests — same
  shape-aware split the error handler already uses.
- Cost: one primary-key lookup per authenticated request (~0.2ms local);
  instant revocation, no TTL semantics. The redundant SSR-only
  middleware is deleted (its redirect behavior is preserved inside the
  guard).

The guard exposed two processing-issues tests that were unknowingly
RELYING on ghost semantics: they nested a second setupTestServer whose
bootstrap deleted the first setup's regular user, then asserted 403
from the dead token. De-nested: a createLibraryDirect helper inserts
the library under the live setup's user, no second bootstrap.

New TestDeletedUserTokenRejected pins the contract: token works while
the user exists -> 401 user_not_found after deletion. Full suite: 219
groups, zero failures.
This commit is contained in:
John O'Keefe committed 2026-10-09 22:33:30 -04:00
1 parent 4f05a232fe
commit 9cd6356892
5 files changed
+125 -57

No files matched your search

+9 -18
View File
@@ -16,8 +16,7 @@ func TestProcessingIssuesListAuthentication(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Auth Library", "manga")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Auth Library", "manga")
t.Run("GET /api/libraries/:id/issues/list - No authentication", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
@@ -55,8 +54,7 @@ func TestProcessingIssuesListResponseStructure(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Structure Library", "comics")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Structure Library", "comics")
t.Run("Returns JSON array for empty library", func(t *testing.T) {
token := setup.Token
@@ -163,8 +161,7 @@ func TestProcessingIssueStatsAuthentication(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Auth Library", "manga")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Auth Library", "manga")
t.Run("GET /api/libraries/:id/issues/stats - No authentication", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
@@ -202,8 +199,7 @@ func TestProcessingIssueStatsResponseStructure(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Structure Library", "comics")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Structure Library", "comics")
t.Run("Returns correct structure for empty library", func(t *testing.T) {
token := setup.Token
@@ -343,11 +339,9 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
setup := setupTestServer(t)
// Create two different libraries with different types
deviceSetup1 := setupDeviceTest(t)
library1ID := deviceSetup1.CreateLibrary(t, "Isolation Test Library 1", "manga")
library1ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 1", "manga")
deviceSetup2 := setupDeviceTest(t)
library2ID := deviceSetup2.CreateLibrary(t, "Isolation Test Library 2", "comics")
library2ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 2", "comics")
token := setup.Token
@@ -431,8 +425,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
for _, lt := range libraryTypes {
t.Run(lt.name+" - list endpoint works", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -448,8 +441,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
})
t.Run(lt.name+" - stats endpoint works", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -474,8 +466,7 @@ func TestProcessingIssuesInvalidTokens(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Token Library", "ebooks")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Token Library", "ebooks")
t.Run("List endpoint rejects invalid token", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)