206 lines
7.3 KiB
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)
|
|
}
|
|
}
|