Files
SansanandClaude Sonnet 5 384c4a7217 Restrict platform-list edits to admins
Anyone authenticated can still read the shared platform list, but only an
admin may add, rename or delete from it.

- users.is_admin boolean, defaulting to false. The very first account ever
  registered becomes an admin automatically (CreateUser's INSERT uses
  NOT EXISTS (SELECT 1 FROM users) for is_admin), so there's always at
  least one without manual setup; promoting anyone beyond that is a manual
  UPDATE for now (documented in the migration's comment on the column).
- GetSessionAndUser, GetUserByEmail and GetUserByID now also select
  is_admin, and it flows through to model.User (and so to
  /api/auth/{register,login,me}'s response) and to the auth middleware's
  authedUser.
- New requireAdminMiddleware + addAdminAuth, layered on top of the existing
  auth middleware. Wired onto create/update/delete-platform; list/get stay
  on plain addAuth, since reading the list only requires being logged in.

Verified end to end: the first registered user comes back is_admin:true,
the second false; the non-admin gets 403 on create/update/delete-platform
but 200 on list; the admin can do all of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 11:39:21 +02:00

274 lines
9.3 KiB
Go

package api
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"completed/internal/auth"
"completed/internal/db"
)
// newTestAPI wires mockDB into a fresh Huma/Echo instance exactly as
// cmd/api/main.go does, so tests exercise routing and middleware, not just
// the handler bodies.
func newTestAPI(mockDB db.Querier) (*echo.Echo, huma.API) {
e := echo.New()
api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0"))
RegisterRoutes(api, mockDB)
return e, api
}
// a fixed valid session, returned by GetSessionAndUserFunc so authed
// requests don't need a real database.
var testUserID = uuid.New()
func authedMock() *MockQuerier {
return &MockQuerier{
GetSessionAndUserFunc: func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice"}, nil
},
TouchSessionFunc: func(ctx context.Context, tokenHash []byte) error { return nil },
}
}
// adminMock is authedMock for a session belonging to an admin, for the
// platform-mutation routes that require one.
func adminMock() *MockQuerier {
m := authedMock()
m.GetSessionAndUserFunc = func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice", IsAdmin: true}, nil
}
return m
}
func authedRequest(method, path, body string) *http.Request {
var req *http.Request
if body == "" {
req = httptest.NewRequest(method, path, nil)
} else {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer any-token-the-mock-accepts")
return req
}
func TestListGamesRequiresAuth(t *testing.T) {
e, _ := newTestAPI(&MockQuerier{})
req := httptest.NewRequest(http.MethodGet, "/api/games", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestListGames(t *testing.T) {
mockDB := authedMock()
mockDB.ListGamesFunc = func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) {
assert.Equal(t, testUserID, userID, "handler must scope the query to the authenticated user")
return []db.ListGamesRow{
{ID: uuid.New(), Name: "Test Game", Score: 90, PlatformName: "PC"},
}, nil
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games", ""))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Test Game")
assert.Contains(t, rec.Body.String(), `"platform_name":"PC"`)
}
func TestGetGameNotFoundForOtherUsersGame(t *testing.T) {
// A game that exists, but belongs to someone else: the query is scoped
// by user_id, so sqlc/postgres report it as not found, same as a
// genuinely missing id. The handler must turn that into 404, not 500.
mockDB := authedMock()
mockDB.GetGameFunc = func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) {
assert.Equal(t, testUserID, arg.UserID, "handler must scope the lookup to the authenticated user")
return db.GetGameRow{}, pgx.ErrNoRows
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games/"+uuid.New().String(), ""))
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestDeleteGameNotFoundWhenNoRowsAffected(t *testing.T) {
mockDB := authedMock()
mockDB.DeleteGameFunc = func(ctx context.Context, arg db.DeleteGameParams) (int64, error) {
return 0, nil
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/games/"+uuid.New().String(), ""))
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) {
// Soft delete means the FK is never actually checked by Postgres (see
// db/query.sql's DeletePlatform comment), so the handler has to notice
// the 0-rows result itself and turn it into 409, not just pass through
// whatever error (if any) the query returns.
mockDB := adminMock()
mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
return db.GetPlatformRow{ID: id, Name: "PC"}, nil
}
mockDB.DeletePlatformFunc = func(ctx context.Context, id uuid.UUID) (int64, error) {
return 0, nil
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), ""))
assert.Equal(t, http.StatusConflict, rec.Code)
}
func TestDeletePlatformNotFound(t *testing.T) {
mockDB := adminMock()
mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
return db.GetPlatformRow{}, pgx.ErrNoRows
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), ""))
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestCreateGameRejectsScoreOutOfRange(t *testing.T) {
mockDB := authedMock()
// No CreateGameFunc set: validation must reject the request before the
// handler ever calls the database, or this test would panic.
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
body := `{"name":"Bad","platform_id":"` + uuid.New().String() + `","score":150,"release_date":"2020-01-01"}`
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/games", body))
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
}
func TestCreatePlatform(t *testing.T) {
mockDB := adminMock()
mockDB.CreatePlatformFunc = func(ctx context.Context, name string) (db.CreatePlatformRow, error) {
return db.CreatePlatformRow{ID: uuid.New(), Name: name}, nil
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Sega Genesis")
}
func TestCreatePlatformForbiddenForNonAdmin(t *testing.T) {
mockDB := authedMock()
// No CreatePlatformFunc set: the admin check must reject the request
// before the handler ever calls the database, or this test would panic.
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`))
assert.Equal(t, http.StatusForbidden, rec.Code)
}
func TestListPlatformsIsSharedAcrossUsers(t *testing.T) {
// Unlike games, the platform list has no user_id to scope by: every
// authenticated user sees the same rows. ListPlatformsFunc taking no
// user id (beyond ctx) is itself part of what's being checked here -
// this test would fail to compile if a user_id parameter crept back in.
calls := 0
mockDB := authedMock()
mockDB.ListPlatformsFunc = func(ctx context.Context) ([]db.ListPlatformsRow, error) {
calls++
return []db.ListPlatformsRow{{ID: uuid.New(), Name: "PC"}}, nil
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/platforms", ""))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "PC")
assert.Equal(t, 1, calls)
}
func TestRegisterDuplicateEmailConflicts(t *testing.T) {
mockDB := &MockQuerier{
CreateUserFunc: func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) {
return db.CreateUserRow{}, &pgconn.PgError{Code: pgCodeUniqueViolation}
},
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
body := `{"email":"a@example.com","password":"password123","name":"Alice"}`
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/register", body))
assert.Equal(t, http.StatusConflict, rec.Code)
}
func TestLoginWrongPasswordUnauthorized(t *testing.T) {
hash, err := auth.HashPassword("correct-password")
require.NoError(t, err)
mockDB := &MockQuerier{
GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) {
return db.GetUserByEmailRow{ID: testUserID, Email: email, PasswordHash: hash}, nil
},
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
body := `{"email":"a@example.com","password":"wrong-password"}`
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body))
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestLoginSuccessSetsSessionCookie(t *testing.T) {
hash, err := auth.HashPassword("correct-password")
require.NoError(t, err)
mockDB := &MockQuerier{
GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) {
return db.GetUserByEmailRow{ID: testUserID, Email: email, Name: "Alice", PasswordHash: hash}, nil
},
CreateSessionFunc: func(ctx context.Context, arg db.CreateSessionParams) error {
assert.Equal(t, testUserID, arg.UserID)
return nil
},
}
e, _ := newTestAPI(mockDB)
rec := httptest.NewRecorder()
body := `{"email":"a@example.com","password":"correct-password"}`
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Header().Get("Set-Cookie"), "session=")
}