kereru-forms/backend/internal/api/auth_test.go

206 lines
7.3 KiB
Go

package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/rs/zerolog"
"kereru.forms/internal/auth"
"kereru.forms/internal/config"
"kereru.forms/internal/database"
)
const authTestSecret = "api-auth-test-secret"
func newAuthTestRouter(t *testing.T, requireSubmissionAuth bool) (*gin.Engine, database.Store) {
t.Helper()
path := filepath.Join(t.TempDir(), "auth-api-test.db")
store, err := database.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
authCfg := config.Default().Auth
authCfg.LocalPassword = "test-password"
authCfg.LocalTokenSecret = authTestSecret
authCfg.RequireSubmissionAuth = requireSubmissionAuth
service, err := auth.New(t.Context(), authCfg)
if err != nil {
t.Fatalf("new auth service: %v", err)
}
server := NewServer(store, zerolog.Nop(), "test-version", service)
router := NewRouter(server, zerolog.Nop(), RouterOptions{CORSOrigins: []string{"http://localhost:5173"}})
return router, store
}
func doAuthedRequest(t *testing.T, router *gin.Engine, method, path string, body any, token string) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
reader = bytes.NewReader(payload)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func testLocalToken(t *testing.T, roles ...string) string {
t.Helper()
now := time.Now()
claims := jwt.MapClaims{
"iss": "kereru-forms",
"sub": "test-user",
"name": "Test User",
"roles": roles,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(authTestSecret))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
func createAuthTestForm(t *testing.T, router *gin.Engine, token string) Form {
t.Helper()
body := CreateFormRequest{
Title: "Auth test form",
Questions: []QuestionInput{{
Key: "name",
Label: "Your name",
Type: Text,
Required: true,
}},
}
w := doAuthedRequest(t, router, http.MethodPost, "/forms", body, token)
if w.Code != http.StatusCreated {
t.Fatalf("create form: status %d body %s", w.Code, w.Body.String())
}
return decode[Form](t, w)
}
func TestLoginAndCurrentUser(t *testing.T) {
router, _ := newAuthTestRouter(t, false)
configResponse := doAuthedRequest(t, router, http.MethodGet, "/auth/config", nil, "")
if configResponse.Code != http.StatusOK {
t.Fatalf("auth config: status %d body %s", configResponse.Code, configResponse.Body.String())
}
authConfig := decode[AuthConfig](t, configResponse)
if authConfig.Mode != AuthConfigMode("password") {
t.Fatalf("unexpected auth mode %q", authConfig.Mode)
}
badLogin := doAuthedRequest(t, router, http.MethodPost, "/auth/login", LoginRequest{Password: "wrong"}, "")
if badLogin.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for bad password, got %d %s", badLogin.Code, badLogin.Body.String())
}
login := doAuthedRequest(t, router, http.MethodPost, "/auth/login", LoginRequest{Password: "test-password"}, "")
if login.Code != http.StatusOK {
t.Fatalf("login: status %d body %s", login.Code, login.Body.String())
}
loginBody := decode[LoginResponse](t, login)
if loginBody.Token == "" || !loginBody.User.CanAdmin || !loginBody.User.CanView {
t.Fatalf("unexpected login response: %+v", loginBody)
}
me := doAuthedRequest(t, router, http.MethodGet, "/auth/me", nil, loginBody.Token)
if me.Code != http.StatusOK {
t.Fatalf("me: status %d body %s", me.Code, me.Body.String())
}
meBody := decode[AuthMeResponse](t, me)
if !meBody.Authenticated || meBody.User == nil || meBody.User.Id != "local" {
t.Fatalf("unexpected me response: %+v", meBody)
}
anonymousMe := doAuthedRequest(t, router, http.MethodGet, "/auth/me", nil, "")
if anonymousMe.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for anonymous me, got %d", anonymousMe.Code)
}
}
func TestManagementRoleEnforcement(t *testing.T) {
router, _ := newAuthTestRouter(t, false)
adminToken := testLocalToken(t, "admin")
viewToken := testLocalToken(t, "view")
if w := doAuthedRequest(t, router, http.MethodPost, "/forms", CreateFormRequest{Title: "Anonymous"}, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", w.Code)
}
if w := doAuthedRequest(t, router, http.MethodPost, "/forms", CreateFormRequest{Title: "View only"}, viewToken); w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for view-only create, got %d %s", w.Code, w.Body.String())
}
form := createAuthTestForm(t, router, adminToken)
submissionsPath := "/forms/" + form.Id.String() + "/submissions"
if w := doAuthedRequest(t, router, http.MethodGet, submissionsPath, nil, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for anonymous submissions, got %d", w.Code)
}
if w := doAuthedRequest(t, router, http.MethodGet, submissionsPath, nil, adminToken); w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for admin-only submissions, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodGet, submissionsPath, nil, viewToken); w.Code != http.StatusOK {
t.Fatalf("expected 200 for view submissions, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodPut, "/forms/"+form.Id.String(), CreateFormRequest{Title: "Updated"}, viewToken); w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for view-only update, got %d %s", w.Code, w.Body.String())
}
}
func TestOptionalSubmissionAuthentication(t *testing.T) {
router, _ := newAuthTestRouter(t, true)
adminToken := testLocalToken(t, "admin")
if w := doAuthedRequest(t, router, http.MethodGet, "/forms", nil, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 listing forms without auth, got %d", w.Code)
}
if w := doAuthedRequest(t, router, http.MethodGet, "/forms", nil, adminToken); w.Code != http.StatusOK {
t.Fatalf("expected 200 listing forms with auth, got %d %s", w.Code, w.Body.String())
}
form := createAuthTestForm(t, router, adminToken)
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: []Answer{{
QuestionId: form.Questions[0].Id,
Value: "Ada",
}},
}
submitPath := "/forms/" + form.Id.String() + "/submissions"
if w := doAuthedRequest(t, router, http.MethodPost, submitPath, submit, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 submitting without auth, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodPost, submitPath, submit, adminToken); w.Code != http.StatusCreated {
t.Fatalf("expected admin-only user to submit as any authenticated user, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/pdf", nil, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 downloading form PDF without auth, got %d", w.Code)
}
}