Files
openfsd/test/users_api_test.go
Reese Norris 57d54d6705 v0.1.0-alpha
Changes:
- Implement bootstrapping library for managing several concurrent internal services
- Refactor concurrency model for connections/logical clients and their associated I/O
- Refactor server context singleton
- Refactor error handling
    - Most errors are now gracefully sent to the FSD client directly encoded as an $ER packet,
      enhancing visibility and debugging
    - Most errors are now rightfully treated as non-fatal
- Refactor package/dependency graph
- Refactor calling conventions/interfaces for many packages
- Refactor database package
- Refactor post office

Features:
- Add VATSIM-esque HTTP/JSON "data feed"
- Add ephemeral in-memory database option
- Add user management REST API
- Add improved web interface
- Add MySQL support (drop SQLite support)
2024-10-07 12:50:39 -07:00

284 lines
8.9 KiB
Go

package test
import (
"bytes"
"context"
"encoding/json"
"errors"
"github.com/renorris/openfsd/auth"
"github.com/renorris/openfsd/bootstrap"
"github.com/renorris/openfsd/protocol"
"github.com/renorris/openfsd/servercontext"
"github.com/renorris/openfsd/web"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/renorris/openfsd/database"
"github.com/stretchr/testify/assert"
)
// MockVerifier is a mock implementation of JWTVerifier for testing
type MockVerifier struct {
Claims *auth.FSDJWTClaims
Err error
}
func (mv *MockVerifier) VerifyJWT(tokenStr string) (token *jwt.Token, err error) {
if mv.Err != nil {
return nil, mv.Err
}
if mv.Claims != nil {
// Construct the token using the mock claims
now := time.Now()
t := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.FSDJWTCustomClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "openfsd",
Subject: strconv.Itoa(mv.Claims.CID()),
Audience: mv.Claims.Audience(),
ExpiresAt: jwt.NewNumericDate(now.Add(420 * time.Second)),
NotBefore: jwt.NewNumericDate(now.Add(-120 * time.Second)),
IssuedAt: jwt.NewNumericDate(now),
ID: "randomrandomrandom",
},
ControllerRating: int(mv.Claims.ControllerRating()),
PilotRating: int(mv.Claims.PilotRating()),
})
if tokenStr, err = t.SignedString(servercontext.JWTKey()); err != nil {
panic(err)
}
if token, err = jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
return servercontext.JWTKey(), nil
}, jwt.WithValidMethods([]string{"HS256"})); err != nil {
panic(err)
}
return token, nil
}
return nil, errors.New("invalid token")
}
func TestAPIV1UsersHandler(t *testing.T) {
if err := os.Setenv("IN_MEMORY_DB", "true"); err != nil {
t.Fatal(err)
}
// Start the server
ctx, cancelCtx := context.WithCancel(context.Background())
b := bootstrap.NewDefaultBootstrap()
if err := b.Start(ctx); err != nil {
t.Fatal(err)
}
// Add demo user
demoRecord := database.FSDUserRecord{
Email: "example@mail.com",
FirstName: "Test user 666",
LastName: "Test user 666 lastname",
Password: "12345",
FSDPassword: "54321",
NetworkRating: 1,
PilotRating: 0,
}
// Insert it
var err error
if demoRecord.CID, err = demoRecord.Insert(servercontext.DB()); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
method string
authorization string
requestBody web.APIV1UsersRequest
expectedStatus int
expectedErrorMsg string
verifier *MockVerifier
}{
{
name: "Missing Authorization",
method: "POST",
expectedStatus: http.StatusBadRequest,
expectedErrorMsg: "authorization header missing",
},
{
name: "Invalid Bearer Token Format",
method: "POST",
authorization: "Basic token",
expectedStatus: http.StatusBadRequest,
expectedErrorMsg: "invalid authorization header format",
},
{
name: "Invalid Token",
method: "POST",
authorization: "Bearer invalid_token",
expectedStatus: http.StatusForbidden,
expectedErrorMsg: "invalid token",
verifier: &MockVerifier{Claims: nil, Err: nil},
},
{
name: "Successful User Creation",
method: "POST",
authorization: "Bearer valid_token",
requestBody: web.APIV1UsersRequest{User: database.FSDUserRecord{CID: 1, NetworkRating: 1}},
expectedStatus: http.StatusOK,
verifier: &MockVerifier{
Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"dashboard"}),
},
},
{
name: "Successful User Load",
method: "GET",
authorization: "Bearer valid_token",
requestBody: web.APIV1UsersRequest{CID: demoRecord.CID},
expectedStatus: http.StatusOK,
verifier: &MockVerifier{
Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"dashboard"}),
},
},
{
name: "Successful User Update",
method: "PUT",
authorization: "Bearer valid_token",
requestBody: web.APIV1UsersRequest{User: database.FSDUserRecord{
CID: demoRecord.CID,
Email: "newemail@example.com",
FirstName: "new first name",
LastName: "new last name",
Password: "newpassword",
FSDPassword: "newfsdpassword",
NetworkRating: 2,
PilotRating: 0,
}},
expectedStatus: http.StatusOK,
verifier: &MockVerifier{
Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"dashboard"}),
},
},
{
name: "Successful User Creation, but request is too long (> 8192 bytes)",
method: "POST",
authorization: "Bearer valid_token",
requestBody: web.APIV1UsersRequest{User: database.FSDUserRecord{CID: 1, NetworkRating: 1, Email: strings.Repeat("b", 16384)}},
expectedStatus: http.StatusInternalServerError,
expectedErrorMsg: "error reading request body",
verifier: &MockVerifier{
Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"dashboard"}),
},
},
{
name: "Forbidden User Creation by Non-Supervisor",
method: "POST",
authorization: "Bearer valid_token",
requestBody: web.APIV1UsersRequest{User: database.FSDUserRecord{CID: 2, NetworkRating: 3}}, // Not enough rating to create user
expectedStatus: http.StatusForbidden,
expectedErrorMsg: "must be at least Supervisor to create user",
verifier: &MockVerifier{
Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingS1, protocol.PilotRatingPPL, []string{"dashboard"}), // Not a Supervisor
},
},
{
name: "Forbidden User Load by Non-Supervisor",
method: "GET",
authorization: "Bearer valid_token",
requestBody: web.APIV1UsersRequest{CID: 2}, // Not enough rating to read user
expectedStatus: http.StatusForbidden,
expectedErrorMsg: "must be at least Supervisor to read users",
verifier: &MockVerifier{
Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingS1, protocol.PilotRatingPPL, []string{"dashboard"}), // Not a Supervisor
},
},
{
name: "Forbidden User Creation of administrator by supervisor",
method: "POST",
authorization: "Bearer valid_token",
requestBody: web.APIV1UsersRequest{User: database.FSDUserRecord{CID: 322, NetworkRating: 12}}, // Not enough rating to create user
expectedStatus: http.StatusForbidden,
expectedErrorMsg: "created user must be below supervisor rating",
verifier: &MockVerifier{
Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingSUP, protocol.PilotRatingPPL, []string{"dashboard"}),
},
},
{
name: "Forbidden User Delete - lower than supervisor",
method: "DELETE",
authorization: "Bearer valid_token",
requestBody: web.APIV1UsersRequest{CID: demoRecord.CID},
expectedStatus: http.StatusForbidden,
verifier: &MockVerifier{
Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingI3, protocol.PilotRatingPPL, []string{"dashboard"}),
},
},
{
name: "Successful User Delete",
method: "DELETE",
authorization: "Bearer valid_token",
requestBody: web.APIV1UsersRequest{CID: demoRecord.CID},
expectedStatus: http.StatusOK,
verifier: &MockVerifier{
Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"dashboard"}),
},
},
{
name: "invalid audience",
method: "DELETE",
authorization: "Bearer valid_token",
requestBody: web.APIV1UsersRequest{CID: demoRecord.CID},
expectedStatus: http.StatusForbidden,
verifier: &MockVerifier{
Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"fsd"}),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := new(bytes.Buffer)
json.NewEncoder(body).Encode(tt.requestBody)
var req *http.Request
if tt.method == "GET" {
req = httptest.NewRequest(tt.method, "/api/v1/users/"+strconv.Itoa(tt.requestBody.CID), nil)
} else {
req = httptest.NewRequest(tt.method, "/api/v1/users", body)
}
if tt.authorization != "" {
req.Header.Set("Authorization", tt.authorization)
}
rr := httptest.NewRecorder()
handler := func(w http.ResponseWriter, r *http.Request) {
web.APIV1UsersHandler(w, r, tt.verifier) // Inject the verifier
}
handler(rr, req)
assert.Equal(t, tt.expectedStatus, rr.Code)
if rr.Header().Get("Content-Type") != "application/json" {
t.Fatal("response type not application/json")
}
if tt.expectedErrorMsg != "" {
var resp web.APIV1UsersResponse
if err := json.NewDecoder(rr.Body).Decode(&resp); err == nil { // Check for decode errors
assert.Equal(t, tt.expectedErrorMsg, resp.StatusMessage)
}
}
})
}
cancelCtx()
<-b.Done
}