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) } }