Files
Erupe/server/api/routing_test.go
Houmgaor 6f7852cc12 feat(api): add GET /v2/server/info endpoint for launcher compatibility
Exposes the server's configured client mode in a form that mhf-outpost
and other launcher tools can consume to warn users when their local game
version does not match what the server expects.

Returns clientMode (raw, e.g. "G9.1") and manifestId (normalized for
mhf-outpost: lowercase, dots stripped, e.g. "g91"). No auth required.
2026-03-23 19:55:55 +01:00

516 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")
v2.HandleFunc("/server/info", s.ServerInfo).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)
}
}