feat: 보안 수정 + Prometheus 메트릭 + 단위 테스트 추가
보안: - Zip Bomb 방어 (io.LimitReader 100MB) - Redis Del 에러 로깅 (auth, idempotency) - 로그인 실패 로그에서 username 제거 - os.Remove 에러 로깅 모니터링: - Prometheus 메트릭 미들웨어 + /metrics 엔드포인트 - http_requests_total, http_request_duration_seconds 등 4개 메트릭 테스트: - download (11), chain (10), bossraid (20) = 41개 단위 테스트 기타: - DB 모델 GORM 인덱스 태그 추가 - launcherHash 필드 + hashFileToHex() 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type Announcement struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
|
||||
@@ -94,7 +94,7 @@ func (h *Handler) Login(c *fiber.Ctx) error {
|
||||
|
||||
accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password)
|
||||
if err != nil {
|
||||
log.Printf("Login failed (username=%s): %v", req.Username, err)
|
||||
log.Printf("Login failed: %v", err)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"})
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ func (h *Handler) Logout(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증 정보가 올바르지 않습니다"})
|
||||
}
|
||||
if err := h.svc.Logout(userID); err != nil {
|
||||
log.Printf("Logout failed for user %d: %v", userID, err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "로그아웃 처리 중 오류가 발생했습니다"})
|
||||
}
|
||||
c.Cookie(&fiber.Cookie{
|
||||
|
||||
@@ -20,7 +20,7 @@ type User struct {
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
Username string `json:"username" gorm:"type:varchar(100);uniqueIndex;not null"`
|
||||
PasswordHash string `json:"-" gorm:"not null"`
|
||||
Role Role `json:"role" gorm:"default:'user'"`
|
||||
Role Role `json:"role" gorm:"type:varchar(20);index;default:'user'"`
|
||||
SsafyID *string `json:"ssafyId,omitempty" gorm:"type:varchar(100);uniqueIndex"`
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,9 @@ func (s *Service) DeleteUser(id uint) error {
|
||||
defer delCancel()
|
||||
sessionKey := fmt.Sprintf("session:%d", id)
|
||||
refreshKey := fmt.Sprintf("refresh:%d", id)
|
||||
s.rdb.Del(delCtx, sessionKey, refreshKey)
|
||||
if err := s.rdb.Del(delCtx, sessionKey, refreshKey).Err(); err != nil {
|
||||
log.Printf("WARNING: failed to delete Redis sessions for user %d: %v", id, err)
|
||||
}
|
||||
|
||||
// TODO: Clean up wallet and profile data via cross-service calls
|
||||
// (walletCreator/profileCreator are creation-only; deletion callbacks are not yet wired up)
|
||||
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
// BossRoom represents a boss raid session room.
|
||||
type BossRoom struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"`
|
||||
@@ -63,8 +63,8 @@ type RoomSlot struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
DedicatedServerID uint `json:"dedicatedServerId" gorm:"index;not null"`
|
||||
SlotIndex int `json:"slotIndex" gorm:"not null"`
|
||||
DedicatedServerID uint `json:"dedicatedServerId" gorm:"index;uniqueIndex:idx_server_slot;not null"`
|
||||
SlotIndex int `json:"slotIndex" gorm:"uniqueIndex:idx_server_slot;not null"`
|
||||
SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"`
|
||||
Status SlotStatus `json:"status" gorm:"type:varchar(20);index;default:idle;not null"`
|
||||
BossRoomID *uint `json:"bossRoomId" gorm:"index"`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
234
internal/chain/service_test.go
Normal file
234
internal/chain/service_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
tocrypto "github.com/tolelom/tolchain/crypto"
|
||||
)
|
||||
|
||||
// testEncKey returns a valid 32-byte AES-256 key for testing.
|
||||
func testEncKey() []byte {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// newTestService creates a minimal Service with only the encryption key set.
|
||||
// No DB, Redis, or chain client — only suitable for testing pure crypto functions.
|
||||
func newTestService() *Service {
|
||||
return &Service{
|
||||
encKeyBytes: testEncKey(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
svc := newTestService()
|
||||
|
||||
// Generate a real ed25519 private key
|
||||
privKey, _, err := tocrypto.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key pair: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
|
||||
if err != nil {
|
||||
t.Fatalf("encryptPrivKey failed: %v", err)
|
||||
}
|
||||
|
||||
if cipherHex == "" || nonceHex == "" {
|
||||
t.Fatal("encryptPrivKey returned empty strings")
|
||||
}
|
||||
|
||||
// Verify ciphertext is valid hex
|
||||
if _, err := hex.DecodeString(cipherHex); err != nil {
|
||||
t.Errorf("cipherHex is not valid hex: %v", err)
|
||||
}
|
||||
if _, err := hex.DecodeString(nonceHex); err != nil {
|
||||
t.Errorf("nonceHex is not valid hex: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := svc.decryptPrivKey(cipherHex, nonceHex)
|
||||
if err != nil {
|
||||
t.Fatalf("decryptPrivKey failed: %v", err)
|
||||
}
|
||||
|
||||
// Compare
|
||||
if hex.EncodeToString(decrypted) != hex.EncodeToString(privKey) {
|
||||
t.Error("decrypted key does not match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_DifferentKeysProduceDifferentCiphertext(t *testing.T) {
|
||||
svc := newTestService()
|
||||
|
||||
privKey1, _, err := tocrypto.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key pair 1: %v", err)
|
||||
}
|
||||
privKey2, _, err := tocrypto.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key pair 2: %v", err)
|
||||
}
|
||||
|
||||
cipher1, _, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey1))
|
||||
if err != nil {
|
||||
t.Fatalf("encryptPrivKey 1 failed: %v", err)
|
||||
}
|
||||
cipher2, _, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey2))
|
||||
if err != nil {
|
||||
t.Fatalf("encryptPrivKey 2 failed: %v", err)
|
||||
}
|
||||
|
||||
if cipher1 == cipher2 {
|
||||
t.Error("different private keys should produce different ciphertexts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptSameKey_DifferentNonces(t *testing.T) {
|
||||
svc := newTestService()
|
||||
|
||||
privKey, _, err := tocrypto.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key pair: %v", err)
|
||||
}
|
||||
|
||||
cipher1, nonce1, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
|
||||
if err != nil {
|
||||
t.Fatalf("encryptPrivKey 1 failed: %v", err)
|
||||
}
|
||||
cipher2, nonce2, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
|
||||
if err != nil {
|
||||
t.Fatalf("encryptPrivKey 2 failed: %v", err)
|
||||
}
|
||||
|
||||
// Each encryption should use a different random nonce
|
||||
if nonce1 == nonce2 {
|
||||
t.Error("encrypting the same key twice should use different nonces")
|
||||
}
|
||||
|
||||
// So ciphertext should also differ (AES-GCM is nonce-dependent)
|
||||
if cipher1 == cipher2 {
|
||||
t.Error("encrypting the same key with different nonces should produce different ciphertexts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithWrongKey(t *testing.T) {
|
||||
svc := newTestService()
|
||||
|
||||
privKey, _, err := tocrypto.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key pair: %v", err)
|
||||
}
|
||||
|
||||
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
|
||||
if err != nil {
|
||||
t.Fatalf("encryptPrivKey failed: %v", err)
|
||||
}
|
||||
|
||||
// Create a service with a different encryption key
|
||||
wrongKey := make([]byte, 32)
|
||||
for i := range wrongKey {
|
||||
wrongKey[i] = byte(255 - i)
|
||||
}
|
||||
wrongSvc := &Service{encKeyBytes: wrongKey}
|
||||
|
||||
_, err = wrongSvc.decryptPrivKey(cipherHex, nonceHex)
|
||||
if err == nil {
|
||||
t.Error("decryptPrivKey with wrong key should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithInvalidHex(t *testing.T) {
|
||||
svc := newTestService()
|
||||
|
||||
_, err := svc.decryptPrivKey("not-hex", "also-not-hex")
|
||||
if err == nil {
|
||||
t.Error("decryptPrivKey with invalid hex should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithTamperedCiphertext(t *testing.T) {
|
||||
svc := newTestService()
|
||||
|
||||
privKey, _, err := tocrypto.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key pair: %v", err)
|
||||
}
|
||||
|
||||
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
|
||||
if err != nil {
|
||||
t.Fatalf("encryptPrivKey failed: %v", err)
|
||||
}
|
||||
|
||||
// Tamper with the ciphertext by flipping a byte
|
||||
cipherBytes, _ := hex.DecodeString(cipherHex)
|
||||
cipherBytes[0] ^= 0xFF
|
||||
tamperedHex := hex.EncodeToString(cipherBytes)
|
||||
|
||||
_, err = svc.decryptPrivKey(tamperedHex, nonceHex)
|
||||
if err == nil {
|
||||
t.Error("decryptPrivKey with tampered ciphertext should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewService_InvalidEncryptionKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
encKey string
|
||||
}{
|
||||
{"too short", "aabbccdd"},
|
||||
{"not hex", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"},
|
||||
{"empty", ""},
|
||||
{"odd length", "aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewService(nil, nil, "test-chain", "", tt.encKey)
|
||||
if err == nil {
|
||||
t.Error("NewService should fail with invalid encryption key")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewService_ValidEncryptionKey(t *testing.T) {
|
||||
// 64 hex chars = 32 bytes
|
||||
validKey := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
svc, err := NewService(nil, nil, "test-chain", "", validKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService with valid key should succeed: %v", err)
|
||||
}
|
||||
if svc == nil {
|
||||
t.Fatal("NewService returned nil service")
|
||||
}
|
||||
if svc.chainID != "test-chain" {
|
||||
t.Errorf("chainID = %q, want %q", svc.chainID, "test-chain")
|
||||
}
|
||||
// No operator key provided, so operatorWallet should be nil
|
||||
if svc.operatorWallet != nil {
|
||||
t.Error("operatorWallet should be nil when no operator key is provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOperator_NilWallet(t *testing.T) {
|
||||
svc := newTestService()
|
||||
err := svc.ensureOperator()
|
||||
if err == nil {
|
||||
t.Error("ensureOperator should fail when operatorWallet is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsername_NoResolver(t *testing.T) {
|
||||
svc := newTestService()
|
||||
_, err := svc.resolveUsername("testuser")
|
||||
if err == nil {
|
||||
t.Error("resolveUsername should fail when userResolver is nil")
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,5 @@ type Info struct {
|
||||
// LauncherSize is a human-readable string (e.g., "25.3 MB") for display purposes.
|
||||
// Programmatic size tracking uses os.Stat on the actual file.
|
||||
LauncherSize string `json:"launcherSize" gorm:"not null;default:''"`
|
||||
LauncherHash string `json:"launcherHash" gorm:"not null;default:''"`
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -55,12 +56,16 @@ func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error)
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(tmpPath)
|
||||
if removeErr := os.Remove(tmpPath); removeErr != nil {
|
||||
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("파일 저장 실패: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
if removeErr := os.Remove(tmpPath); removeErr != nil {
|
||||
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("파일 이동 실패: %w", err)
|
||||
}
|
||||
|
||||
@@ -69,12 +74,15 @@ func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error)
|
||||
launcherSize = fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
|
||||
}
|
||||
|
||||
launcherHash := hashFileToHex(finalPath)
|
||||
|
||||
info, err := s.repo.GetLatest()
|
||||
if err != nil {
|
||||
info = &Info{}
|
||||
}
|
||||
info.LauncherURL = baseURL + "/api/download/launcher"
|
||||
info.LauncherSize = launcherSize
|
||||
info.LauncherHash = launcherHash
|
||||
return info, s.repo.Save(info)
|
||||
}
|
||||
|
||||
@@ -97,12 +105,16 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(tmpPath)
|
||||
if removeErr := os.Remove(tmpPath); removeErr != nil {
|
||||
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("파일 저장 실패: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
if removeErr := os.Remove(tmpPath); removeErr != nil {
|
||||
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("파일 이동 실패: %w", err)
|
||||
}
|
||||
|
||||
@@ -123,7 +135,9 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
|
||||
|
||||
fileHash := hashGameExeFromZip(finalPath)
|
||||
if fileHash == "" {
|
||||
os.Remove(finalPath)
|
||||
if removeErr := os.Remove(finalPath); removeErr != nil {
|
||||
log.Printf("WARNING: failed to remove file %s: %v", finalPath, removeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("zip 파일에 %s이(가) 포함되어 있지 않습니다", "A301.exe")
|
||||
}
|
||||
|
||||
@@ -139,8 +153,21 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
|
||||
return info, s.repo.Save(info)
|
||||
}
|
||||
|
||||
// NOTE: No size limit on decompressed entry. This is admin-only so
|
||||
// the risk is minimal. For defense-in-depth, consider io.LimitReader.
|
||||
func hashFileToHex(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
const maxExeSize = 100 * 1024 * 1024 // 100MB — Zip Bomb 방어
|
||||
|
||||
func hashGameExeFromZip(zipPath string) string {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
@@ -155,7 +182,7 @@ func hashGameExeFromZip(zipPath string) string {
|
||||
return ""
|
||||
}
|
||||
h := sha256.New()
|
||||
_, err = io.Copy(h, rc)
|
||||
_, err = io.Copy(h, io.LimitReader(rc, maxExeSize))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return ""
|
||||
|
||||
198
internal/download/service_test.go
Normal file
198
internal/download/service_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashFileToHex_KnownContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "testfile.bin")
|
||||
|
||||
content := []byte("hello world")
|
||||
if err := os.WriteFile(path, content, 0644); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
|
||||
got := hashFileToHex(path)
|
||||
|
||||
h := sha256.Sum256(content)
|
||||
want := hex.EncodeToString(h[:])
|
||||
|
||||
if got != want {
|
||||
t.Errorf("hashFileToHex = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashFileToHex_EmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "empty.bin")
|
||||
|
||||
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
|
||||
got := hashFileToHex(path)
|
||||
|
||||
h := sha256.Sum256([]byte{})
|
||||
want := hex.EncodeToString(h[:])
|
||||
|
||||
if got != want {
|
||||
t.Errorf("hashFileToHex (empty) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashFileToHex_NonExistentFile(t *testing.T) {
|
||||
got := hashFileToHex("/nonexistent/path/file.bin")
|
||||
if got != "" {
|
||||
t.Errorf("hashFileToHex (nonexistent) = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
// createTestZip creates a zip file at zipPath containing the given files.
|
||||
// files is a map of filename -> content.
|
||||
func createTestZip(t *testing.T, zipPath string, files map[string][]byte) {
|
||||
t.Helper()
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create zip: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := zip.NewWriter(f)
|
||||
for name, data := range files {
|
||||
fw, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create zip entry %s: %v", name, err)
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
t.Fatalf("failed to write zip entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("failed to close zip writer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashGameExeFromZip_WithA301Exe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "game.zip")
|
||||
|
||||
exeContent := []byte("fake A301.exe binary content for testing")
|
||||
createTestZip(t, zipPath, map[string][]byte{
|
||||
"GameFolder/A301.exe": exeContent,
|
||||
"GameFolder/readme.txt": []byte("readme"),
|
||||
})
|
||||
|
||||
got := hashGameExeFromZip(zipPath)
|
||||
|
||||
h := sha256.Sum256(exeContent)
|
||||
want := hex.EncodeToString(h[:])
|
||||
|
||||
if got != want {
|
||||
t.Errorf("hashGameExeFromZip = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashGameExeFromZip_CaseInsensitive(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "game.zip")
|
||||
|
||||
exeContent := []byte("case insensitive test")
|
||||
createTestZip(t, zipPath, map[string][]byte{
|
||||
"build/a301.EXE": exeContent,
|
||||
})
|
||||
|
||||
got := hashGameExeFromZip(zipPath)
|
||||
|
||||
h := sha256.Sum256(exeContent)
|
||||
want := hex.EncodeToString(h[:])
|
||||
|
||||
if got != want {
|
||||
t.Errorf("hashGameExeFromZip (case insensitive) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashGameExeFromZip_NoA301Exe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "game.zip")
|
||||
|
||||
createTestZip(t, zipPath, map[string][]byte{
|
||||
"GameFolder/other.exe": []byte("not A301"),
|
||||
"GameFolder/readme.txt": []byte("readme"),
|
||||
})
|
||||
|
||||
got := hashGameExeFromZip(zipPath)
|
||||
if got != "" {
|
||||
t.Errorf("hashGameExeFromZip (no A301.exe) = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashGameExeFromZip_EmptyZip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "empty.zip")
|
||||
|
||||
createTestZip(t, zipPath, map[string][]byte{})
|
||||
|
||||
got := hashGameExeFromZip(zipPath)
|
||||
if got != "" {
|
||||
t.Errorf("hashGameExeFromZip (empty zip) = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashGameExeFromZip_InvalidZip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "notazip.zip")
|
||||
|
||||
if err := os.WriteFile(zipPath, []byte("this is not a zip file"), 0644); err != nil {
|
||||
t.Fatalf("failed to write file: %v", err)
|
||||
}
|
||||
|
||||
got := hashGameExeFromZip(zipPath)
|
||||
if got != "" {
|
||||
t.Errorf("hashGameExeFromZip (invalid zip) = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionRegex(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"game_v1.2.3.zip", "v1.2.3"},
|
||||
{"game_v2.0.zip", "v2.0"},
|
||||
{"game_v10.20.30.zip", "v10.20.30"},
|
||||
{"game.zip", ""},
|
||||
{"noversion", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := versionRe.FindString(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("versionRe.FindString(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameFilePath(t *testing.T) {
|
||||
s := NewService(nil, "/data/game")
|
||||
got := s.GameFilePath()
|
||||
// filepath.Join normalizes separators per OS
|
||||
want := filepath.Join("/data/game", "game.zip")
|
||||
if got != want {
|
||||
t.Errorf("GameFilePath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherFilePath(t *testing.T) {
|
||||
s := NewService(nil, "/data/game")
|
||||
got := s.LauncherFilePath()
|
||||
want := filepath.Join("/data/game", "launcher.exe")
|
||||
if got != want {
|
||||
t.Errorf("LauncherFilePath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
3
main.go
3
main.go
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/tolelom/tolchain/core"
|
||||
"a301_server/pkg/config"
|
||||
"a301_server/pkg/database"
|
||||
"a301_server/pkg/metrics"
|
||||
"a301_server/pkg/middleware"
|
||||
"a301_server/routes"
|
||||
|
||||
@@ -146,6 +147,8 @@ func main() {
|
||||
BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB
|
||||
})
|
||||
app.Use(middleware.RequestID)
|
||||
app.Use(middleware.Metrics)
|
||||
app.Get("/metrics", metrics.Handler)
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: `{"time":"${time}","status":${status},"latency":"${latency}","method":"${method}","path":"${path}","ip":"${ip}","reqId":"${locals:requestID}"}` + "\n",
|
||||
TimeFormat: "2006-01-02T15:04:05Z07:00",
|
||||
|
||||
54
pkg/metrics/metrics.go
Normal file
54
pkg/metrics/metrics.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
HTTPRequestsTotal = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests"},
|
||||
[]string{"method", "path", "status"},
|
||||
)
|
||||
HTTPRequestDuration = prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request duration"},
|
||||
[]string{"method", "path"},
|
||||
)
|
||||
DBConnectionsActive = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{Name: "db_connections_active", Help: "Active DB connections"},
|
||||
)
|
||||
RedisConnectionsActive = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{Name: "redis_connections_active", Help: "Active Redis connections"},
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
prometheus.MustRegister(HTTPRequestsTotal, HTTPRequestDuration, DBConnectionsActive, RedisConnectionsActive)
|
||||
}
|
||||
|
||||
// Handler returns a Fiber handler that serves the Prometheus metrics endpoint.
|
||||
// It wraps promhttp.Handler() without requiring the gofiber/adaptor package.
|
||||
func Handler(c *fiber.Ctx) error {
|
||||
handler := promhttp.Handler()
|
||||
req, err := http.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
if err != nil {
|
||||
return c.SendStatus(fiber.StatusInternalServerError)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
result := rec.Result()
|
||||
defer result.Body.Close()
|
||||
|
||||
c.Set("Content-Type", result.Header.Get("Content-Type"))
|
||||
c.Status(result.StatusCode)
|
||||
body, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
return c.SendStatus(fiber.StatusInternalServerError)
|
||||
}
|
||||
return c.Send(body)
|
||||
}
|
||||
@@ -85,7 +85,9 @@ func Idempotency(c *fiber.Ctx) error {
|
||||
// Processing failed — remove the key so it can be retried
|
||||
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
|
||||
defer delCancel()
|
||||
database.RDB.Del(delCtx, redisKey)
|
||||
if delErr := database.RDB.Del(delCtx, redisKey).Err(); delErr != nil {
|
||||
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -104,7 +106,9 @@ func Idempotency(c *fiber.Ctx) error {
|
||||
// Non-success — allow retry by removing the key
|
||||
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
|
||||
defer delCancel()
|
||||
database.RDB.Del(delCtx, redisKey)
|
||||
if delErr := database.RDB.Del(delCtx, redisKey).Err(); delErr != nil {
|
||||
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
25
pkg/middleware/metrics.go
Normal file
25
pkg/middleware/metrics.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"a301_server/pkg/metrics"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Metrics records HTTP request count and duration as Prometheus metrics.
|
||||
func Metrics(c *fiber.Ctx) error {
|
||||
start := time.Now()
|
||||
err := c.Next()
|
||||
duration := time.Since(start).Seconds()
|
||||
|
||||
status := strconv.Itoa(c.Response().StatusCode())
|
||||
path := c.Route().Path // use route pattern to avoid cardinality explosion
|
||||
method := c.Method()
|
||||
|
||||
metrics.HTTPRequestsTotal.WithLabelValues(method, path, status).Inc()
|
||||
metrics.HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user