initial commit
This commit is contained in:
commit
6ffc0d2c99
54 changed files with 11900 additions and 0 deletions
93
backend/internal/web/router/router.go
Normal file
93
backend/internal/web/router/router.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/security"
|
||||
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const claimsKey contextKey = "claims"
|
||||
|
||||
func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler {
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
|
||||
router.Use(cors(frontendURL))
|
||||
router.Get("/api/health", h.Health)
|
||||
router.Post("/api/auth/login", h.Login)
|
||||
router.Get("/api/interviews/{token}", h.GetInterview)
|
||||
router.Post("/api/interviews/{token}/accept", h.AcceptInterview)
|
||||
router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer)
|
||||
router.Post("/api/interviews/{token}/finish", h.FinishInterview)
|
||||
router.Group(func(protected chi.Router) {
|
||||
protected.Use(authenticate(tokens))
|
||||
protected.Get("/api/jobs", h.ListJobs)
|
||||
protected.Post("/api/jobs", h.CreateJob)
|
||||
protected.Get("/api/jobs/{id}", h.GetJob)
|
||||
protected.Patch("/api/jobs/{id}", h.UpdateJob)
|
||||
protected.Post("/api/jobs/{id}/questions/{questionId}", h.AttachQuestion)
|
||||
protected.Delete("/api/jobs/{id}/questions/{questionId}", h.DetachQuestion)
|
||||
protected.Get("/api/jobs/{id}/candidates", h.Candidates)
|
||||
protected.Get("/api/jobs/{id}/invitations", h.ListInvitations)
|
||||
protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation)
|
||||
protected.Get("/api/questions", h.ListQuestions)
|
||||
protected.Post("/api/questions", h.CreateQuestion)
|
||||
protected.Put("/api/questions/{id}", h.UpdateQuestion)
|
||||
protected.Patch("/api/questions/{id}", h.SetQuestionDeprecated)
|
||||
protected.Get("/api/participants", h.ListParticipants)
|
||||
protected.Post("/api/participants", h.CreateParticipant)
|
||||
protected.Get("/api/participants/{id}", h.GetParticipant)
|
||||
protected.Get("/api/participants/{id}/photo", h.ParticipantPhoto)
|
||||
protected.Get("/api/participants/{id}/cv", h.ParticipantCV)
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func writeUnauthorized(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scheme, raw, ok := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
writeUnauthorized(w, "missing bearer token")
|
||||
return
|
||||
}
|
||||
claims, err := tokens.Parse(raw)
|
||||
if err != nil {
|
||||
writeUnauthorized(w, "invalid bearer token")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func cors(origin string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Add("Vary", "Origin")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
112
backend/internal/web/router/router_test.go
Normal file
112
backend/internal/web/router/router_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/security"
|
||||
"github.com/danyel/go-guess/backend/internal/service"
|
||||
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||
)
|
||||
|
||||
type authStub struct{}
|
||||
|
||||
func (authStub) Login(context.Context, string, string) (string, model.User, error) {
|
||||
return "", model.User{}, service.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
type jobStub struct{}
|
||||
|
||||
func (jobStub) List(context.Context) ([]model.JobPosting, error) { return []model.JobPosting{}, nil }
|
||||
func (jobStub) Get(context.Context, uint) (model.JobPosting, error) { return model.JobPosting{}, nil }
|
||||
func (jobStub) Create(context.Context, model.JobPosting) (model.JobPosting, error) {
|
||||
return model.JobPosting{}, nil
|
||||
}
|
||||
func (jobStub) Update(context.Context, uint, string, int) (model.JobPosting, error) {
|
||||
return model.JobPosting{}, nil
|
||||
}
|
||||
func (jobStub) AttachQuestion(context.Context, uint, uint) error { return nil }
|
||||
func (jobStub) DetachQuestion(context.Context, uint, uint) error { return nil }
|
||||
func (jobStub) Candidates(context.Context, uint) ([]model.CandidateMatch, error) {
|
||||
return []model.CandidateMatch{}, nil
|
||||
}
|
||||
|
||||
type participantStub struct{}
|
||||
|
||||
func (participantStub) List(context.Context) ([]model.Participant, error) { return nil, nil }
|
||||
func (participantStub) Get(context.Context, uint) (model.Participant, error) {
|
||||
return model.Participant{}, nil
|
||||
}
|
||||
func (participantStub) Create(context.Context, model.Participant) (model.Participant, error) {
|
||||
return model.Participant{}, nil
|
||||
}
|
||||
|
||||
type questionStub struct{}
|
||||
|
||||
func (questionStub) List(context.Context, string) ([]model.Question, error) { return nil, nil }
|
||||
func (questionStub) Create(context.Context, model.Question) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
func (questionStub) Update(context.Context, uint, model.Question) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
func (questionStub) SetDeprecated(context.Context, uint, bool) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
|
||||
type invitationStub struct{}
|
||||
|
||||
func (invitationStub) List(context.Context, uint) ([]model.Invitation, error) { return nil, nil }
|
||||
func (invitationStub) Create(context.Context, uint, uint) (model.Invitation, error) {
|
||||
return model.Invitation{}, nil
|
||||
}
|
||||
func (invitationStub) GetInterview(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
func (invitationStub) Accept(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
func (invitationStub) SaveAnswer(context.Context, string, uint, string) error { return nil }
|
||||
func (invitationStub) Finish(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
|
||||
func TestHealthDoesNotRequireAuthentication(t *testing.T) {
|
||||
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
|
||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
router := New(h, tokens, "http://localhost:5173")
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedRouteRequiresValidToken(t *testing.T) {
|
||||
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
|
||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
router := New(h, tokens, "http://localhost:5173")
|
||||
|
||||
unauthorized := httptest.NewRecorder()
|
||||
router.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/jobs", nil))
|
||||
if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("expected JSON 401, got %d %q", unauthorized.Code, unauthorized.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
token, err := tokens.Issue(1, "interviewer@example.com", "interviewer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
authorized := httptest.NewRecorder()
|
||||
router.ServeHTTP(authorized, request)
|
||||
if authorized.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", authorized.Code)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue