mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 23:54:33 +01:00
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.
515 lines
14 KiB
Go
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)
|
|
}
|
|
}
|