Files
Erupe/server/api/routing_test.go
Houmgaor 3ad2836088 feat(api): add DELETE /v2/characters/{id} route, v2 test coverage, and OpenAPI spec
Add REST-idiomatic DELETE method as alias for POST .../delete.
Add 8 router-level tests exercising Bearer auth, invalid tokens,
soft delete, export body decoding, and MaxLauncherHR capping.
Create OpenAPI 3.1.0 specification covering all v2 endpoints.
2026-02-27 12:58:31 +01:00

515 lines
14 KiB
Go

package api
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
"golang.org/x/crypto/bcrypt"
)
// newTestRouter builds the same mux router as Start() without starting an HTTP server.
func newTestRouter(s *APIServer) *mux.Router {
r := mux.NewRouter()
// Legacy routes
r.HandleFunc("/launcher", s.Launcher)
r.HandleFunc("/login", s.Login)
r.HandleFunc("/register", s.Register)
r.HandleFunc("/character/create", s.CreateCharacter)
r.HandleFunc("/character/delete", s.DeleteCharacter)
r.HandleFunc("/character/export", s.ExportSave)
r.HandleFunc("/health", s.Health)
r.HandleFunc("/version", s.Version)
// V2 routes
v2 := r.PathPrefix("/v2").Subrouter()
v2.HandleFunc("/login", s.Login).Methods("POST")
v2.HandleFunc("/register", s.Register).Methods("POST")
v2.HandleFunc("/launcher", s.Launcher).Methods("GET")
v2.HandleFunc("/version", s.Version).Methods("GET")
v2.HandleFunc("/health", s.Health).Methods("GET")
// V2 authenticated routes
v2Auth := v2.PathPrefix("").Subrouter()
v2Auth.Use(s.AuthMiddleware)
v2Auth.HandleFunc("/characters", s.CreateCharacter).Methods("POST")
v2Auth.HandleFunc("/characters/{id}/delete", s.DeleteCharacter).Methods("POST")
v2Auth.HandleFunc("/characters/{id}", s.DeleteCharacter).Methods("DELETE")
v2Auth.HandleFunc("/characters/{id}/export", s.ExportSave).Methods("GET")
v2.HandleFunc("/server/status", s.ServerStatus).Methods("GET")
return r
}
func TestV2LoginRoute(t *testing.T) {
logger := NewTestLogger(t)
hash, _ := bcrypt.GenerateFromPassword([]byte("pass"), bcrypt.MinCost)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
userRepo: &mockAPIUserRepo{
credentialsID: 1,
credentialsPassword: string(hash),
credentialsRights: 30,
lastLogin: time.Now(),
returnExpiry: time.Now().Add(time.Hour * 24 * 30),
},
sessionRepo: &mockAPISessionRepo{createTokenID: 1},
charRepo: &mockAPICharacterRepo{characters: []Character{}},
}
router := newTestRouter(server)
body, _ := json.Marshal(map[string]string{"username": "user", "password": "pass"})
req := httptest.NewRequest("POST", "/v2/login", bytes.NewReader(body))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("POST /v2/login: status = %d, want 200", rec.Code)
}
}
func TestV2LoginRejectsGET(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
}
router := newTestRouter(server)
req := httptest.NewRequest("GET", "/v2/login", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// gorilla/mux subrouters return 404 for method mismatches (not 405)
if rec.Code == http.StatusOK {
t.Error("GET /v2/login should not succeed (POST only)")
}
}
func TestV2HealthRoute(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
db: nil,
}
router := newTestRouter(server)
req := httptest.NewRequest("GET", "/v2/health", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// 503 because db is nil, but it should route correctly
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("GET /v2/health: status = %d, want 503", rec.Code)
}
}
func TestV2VersionRoute(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
}
router := newTestRouter(server)
req := httptest.NewRequest("GET", "/v2/version", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET /v2/version: status = %d, want 200", rec.Code)
}
}
func TestV2CharactersRequiresAuth(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{
userIDErr: sql.ErrNoRows,
},
}
router := newTestRouter(server)
// No auth header
req := httptest.NewRequest("POST", "/v2/characters", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("POST /v2/characters (no auth): status = %d, want 401", rec.Code)
}
}
func TestV2CharactersWithAuth(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{userID: 1},
charRepo: &mockAPICharacterRepo{
newCharacter: Character{ID: 5, Name: "NewChar"},
},
}
router := newTestRouter(server)
req := httptest.NewRequest("POST", "/v2/characters", nil)
req.Header.Set("Authorization", "Bearer valid-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("POST /v2/characters (with auth): status = %d, want 200", rec.Code)
}
}
func TestV2DeleteCharacterRoute(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{userID: 1},
charRepo: &mockAPICharacterRepo{isNewResult: true},
}
router := newTestRouter(server)
req := httptest.NewRequest("POST", "/v2/characters/5/delete", nil)
req.Header.Set("Authorization", "Bearer valid-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("POST /v2/characters/5/delete: status = %d, want 200", rec.Code)
}
}
func TestV2ExportCharacterRoute(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{userID: 1},
charRepo: &mockAPICharacterRepo{
exportResult: map[string]interface{}{"name": "Hunter"},
},
}
router := newTestRouter(server)
req := httptest.NewRequest("GET", "/v2/characters/1/export", nil)
req.Header.Set("Authorization", "Bearer valid-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET /v2/characters/1/export: status = %d, want 200", rec.Code)
}
}
func TestV2ServerStatusRoute_NoEventRepo(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
}
router := newTestRouter(server)
req := httptest.NewRequest("GET", "/v2/server/status", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET /v2/server/status: status = %d, want 200", rec.Code)
}
var resp ServerStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode error: %v", err)
}
if resp.MezFes == nil {
t.Error("MezFes should not be nil")
}
if resp.FeaturedWeapon != nil {
t.Error("FeaturedWeapon should be nil without event repo")
}
if resp.Events.FestaActive || resp.Events.DivaActive {
t.Error("events should be inactive without event repo")
}
}
func TestV2ServerStatusRoute_WithEvents(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
eventRepo: &mockAPIEventRepo{
featureWeapon: &FeatureWeaponRow{
StartTime: time.Now(),
ActiveFeatures: 0xFF,
},
events: []EventRow{{ID: 1, StartTime: time.Now().Unix()}},
},
}
router := newTestRouter(server)
req := httptest.NewRequest("GET", "/v2/server/status", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
var resp ServerStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode error: %v", err)
}
if resp.FeaturedWeapon == nil {
t.Fatal("FeaturedWeapon should not be nil")
}
if resp.FeaturedWeapon.ActiveFeatures != 0xFF {
t.Errorf("ActiveFeatures = %d, want 255", resp.FeaturedWeapon.ActiveFeatures)
}
// Both festa and diva use the same mock events slice, so both are active
if !resp.Events.FestaActive {
t.Error("FestaActive should be true")
}
if !resp.Events.DivaActive {
t.Error("DivaActive should be true")
}
}
func TestV2CreateCharacter_InvalidToken(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{
userIDErr: sql.ErrNoRows,
},
}
router := newTestRouter(server)
req := httptest.NewRequest("POST", "/v2/characters", nil)
req.Header.Set("Authorization", "Bearer bad-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("POST /v2/characters (bad token): status = %d, want 401", rec.Code)
}
}
func TestV2DeleteCharacter_InvalidToken(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{
userIDErr: sql.ErrNoRows,
},
}
router := newTestRouter(server)
req := httptest.NewRequest("POST", "/v2/characters/5/delete", nil)
req.Header.Set("Authorization", "Bearer bad-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("POST /v2/characters/5/delete (bad token): status = %d, want 401", rec.Code)
}
}
func TestV2DeleteCharacter_DELETE(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{userID: 1},
charRepo: &mockAPICharacterRepo{isNewResult: true},
}
router := newTestRouter(server)
req := httptest.NewRequest("DELETE", "/v2/characters/5", nil)
req.Header.Set("Authorization", "Bearer valid-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("DELETE /v2/characters/5: status = %d, want 200", rec.Code)
}
}
func TestV2DeleteCharacter_DELETE_InvalidToken(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{
userIDErr: sql.ErrNoRows,
},
}
router := newTestRouter(server)
req := httptest.NewRequest("DELETE", "/v2/characters/5", nil)
req.Header.Set("Authorization", "Bearer bad-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("DELETE /v2/characters/5 (bad token): status = %d, want 401", rec.Code)
}
}
func TestV2DeleteCharacter_Finalized(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{userID: 1},
charRepo: &mockAPICharacterRepo{isNewResult: false},
}
router := newTestRouter(server)
req := httptest.NewRequest("POST", "/v2/characters/5/delete", nil)
req.Header.Set("Authorization", "Bearer valid-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("POST /v2/characters/5/delete (finalized): status = %d, want 200", rec.Code)
}
}
func TestV2ExportSave_InvalidToken(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{
userIDErr: sql.ErrNoRows,
},
}
router := newTestRouter(server)
req := httptest.NewRequest("GET", "/v2/characters/1/export", nil)
req.Header.Set("Authorization", "Bearer bad-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("GET /v2/characters/1/export (bad token): status = %d, want 401", rec.Code)
}
}
func TestV2ExportSave_VerifyBody(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
sessionRepo: &mockAPISessionRepo{userID: 1},
charRepo: &mockAPICharacterRepo{
exportResult: map[string]interface{}{"name": "Hunter", "hr": float64(99)},
},
}
router := newTestRouter(server)
req := httptest.NewRequest("GET", "/v2/characters/1/export", nil)
req.Header.Set("Authorization", "Bearer valid-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /v2/characters/1/export: status = %d, want 200", rec.Code)
}
var export ExportData
if err := json.NewDecoder(rec.Body).Decode(&export); err != nil {
t.Fatalf("decode error: %v", err)
}
if export.Character["name"] != "Hunter" {
t.Errorf("character name = %v, want Hunter", export.Character["name"])
}
}
func TestV2CreateCharacter_DebugHR(t *testing.T) {
logger := NewTestLogger(t)
conf := NewTestConfig()
conf.DebugOptions.MaxLauncherHR = true
server := &APIServer{
logger: logger,
erupeConfig: conf,
sessionRepo: &mockAPISessionRepo{userID: 1},
charRepo: &mockAPICharacterRepo{
newCharacter: Character{ID: 5, Name: "NewChar", HR: 999},
},
}
router := newTestRouter(server)
req := httptest.NewRequest("POST", "/v2/characters", nil)
req.Header.Set("Authorization", "Bearer valid-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("POST /v2/characters: status = %d, want 200", rec.Code)
}
var char Character
if err := json.NewDecoder(rec.Body).Decode(&char); err != nil {
t.Fatalf("decode error: %v", err)
}
if char.HR != 7 {
t.Errorf("HR = %d, want 7 (capped by MaxLauncherHR)", char.HR)
}
}
func TestLegacyRoutesStillWork(t *testing.T) {
logger := NewTestLogger(t)
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
}
router := newTestRouter(server)
// Legacy /version should work with GET
req := httptest.NewRequest("GET", "/version", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET /version (legacy): status = %d, want 200", rec.Code)
}
}