Files
a301_launcher/main_test.go
tolelom 281a365952
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / release (push) Has been cancelled
refactor: 코드 가독성 개선 및 버그 수정
- main.go를 main()만 남기고 함수 분리 (game.go, protocol.go, ui.go)
- 재시도 로직을 retryWithBackoff 공통 함수로 통합
- redeemTicketFrom 별도 HTTP 클라이언트 → apiClient 사용으로 통일
- doDownload에서 resumeOffset 이중 계산 제거
- extractZip에서 stripTopDir/extractFile 함수 분리
- downloadWithProgress에서 createProgressWindow 함수 분리
- DLL 선언을 DLL별로 그룹화, 상수를 역할별로 분리
- 전체 주석 한국어 통일 및 섹션 구분 추가

버그 수정:
- ensureLauncher가 설치 경로 대신 실행 중인 경로를 해시하던 문제 수정
- uninstall 시 실행 중인 exe 삭제 실패 → 백그라운드 cmd로 대체
- moveContents에서 os.Remove 에러를 무시하던 문제 수정
- install/uninstall 메시지 통일, exitWithError 헬퍼 추가
- .gitignore에 *.exe 통일, ANALYSIS.md 삭제
- 빌드 명령에 git 태그 기반 버전 주입 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 02:17:51 +09:00

414 lines
11 KiB
Go

package main
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
)
// ── extractZip tests ─────────────────────────────────────────────────────────
// createTestZip creates a zip file at zipPath with the given entries.
// Each entry is a path → content pair. Directories have empty content and end with "/".
func createTestZip(t *testing.T, zipPath string, entries map[string]string) {
t.Helper()
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
for name, content := range entries {
fw, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
if content != "" {
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
}
func TestExtractZip_Normal(t *testing.T) {
tmpDir := t.TempDir()
zipPath := filepath.Join(tmpDir, "test.zip")
destDir := filepath.Join(tmpDir, "out")
os.MkdirAll(destDir, 0755)
// zip 내 최상위 디렉토리 제거 동작 검증 (A301/hello.txt → hello.txt)
createTestZip(t, zipPath, map[string]string{
"A301/hello.txt": "world",
"A301/sub/nested.txt": "deep",
})
if err := extractZip(zipPath, destDir); err != nil {
t.Fatal(err)
}
// hello.txt가 destDir에 직접 존재해야 함
content, err := os.ReadFile(filepath.Join(destDir, "hello.txt"))
if err != nil {
t.Fatalf("hello.txt 읽기 실패: %v", err)
}
if string(content) != "world" {
t.Errorf("hello.txt 내용 불일치: got %q, want %q", string(content), "world")
}
content, err = os.ReadFile(filepath.Join(destDir, "sub", "nested.txt"))
if err != nil {
t.Fatalf("sub/nested.txt 읽기 실패: %v", err)
}
if string(content) != "deep" {
t.Errorf("sub/nested.txt 내용 불일치: got %q, want %q", string(content), "deep")
}
}
func TestExtractZip_FlatZip(t *testing.T) {
tmpDir := t.TempDir()
zipPath := filepath.Join(tmpDir, "flat.zip")
destDir := filepath.Join(tmpDir, "out")
os.MkdirAll(destDir, 0755)
// 디렉토리 없이 최상위에 직접 파일이 있는 zip
createTestZip(t, zipPath, map[string]string{
"readme.txt": "flat file",
})
if err := extractZip(zipPath, destDir); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(filepath.Join(destDir, "readme.txt"))
if err != nil {
t.Fatalf("readme.txt 읽기 실패: %v", err)
}
if string(content) != "flat file" {
t.Errorf("내용 불일치: got %q", string(content))
}
}
func TestExtractZip_ZipSlip(t *testing.T) {
tmpDir := t.TempDir()
zipPath := filepath.Join(tmpDir, "evil.zip")
destDir := filepath.Join(tmpDir, "out")
os.MkdirAll(destDir, 0755)
// Zip Slip: 경로 탈출 시도
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
// A301/../../../etc/passwd → 최상위 제거 후 ../../etc/passwd
fw, _ := w.Create("A301/../../../etc/passwd")
fw.Write([]byte("evil"))
w.Close()
f.Close()
err = extractZip(zipPath, destDir)
if err == nil {
t.Fatal("Zip Slip 공격이 차단되지 않음")
}
}
func TestExtractZip_NTFS_ADS(t *testing.T) {
tmpDir := t.TempDir()
zipPath := filepath.Join(tmpDir, "ads.zip")
destDir := filepath.Join(tmpDir, "out")
os.MkdirAll(destDir, 0755)
// NTFS ADS: 콜론 포함 경로
createTestZip(t, zipPath, map[string]string{
"A301/file.txt:hidden": "ads data",
})
err := extractZip(zipPath, destDir)
if err == nil {
t.Fatal("NTFS ADS 공격이 차단되지 않음")
}
}
func TestExtractZip_Empty(t *testing.T) {
tmpDir := t.TempDir()
zipPath := filepath.Join(tmpDir, "empty.zip")
destDir := filepath.Join(tmpDir, "out")
os.MkdirAll(destDir, 0755)
// 빈 zip
createTestZip(t, zipPath, map[string]string{})
if err := extractZip(zipPath, destDir); err != nil {
t.Fatalf("빈 zip 처리 실패: %v", err)
}
// destDir에 아무것도 없어야 함
entries, _ := os.ReadDir(destDir)
if len(entries) != 0 {
t.Errorf("빈 zip인데 파일이 추출됨: %d개", len(entries))
}
}
func TestExtractZip_NestedDirs(t *testing.T) {
tmpDir := t.TempDir()
zipPath := filepath.Join(tmpDir, "nested.zip")
destDir := filepath.Join(tmpDir, "out")
os.MkdirAll(destDir, 0755)
createTestZip(t, zipPath, map[string]string{
"root/a/b/c/deep.txt": "deep content",
"root/a/b/mid.txt": "mid content",
})
if err := extractZip(zipPath, destDir); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(filepath.Join(destDir, "a", "b", "c", "deep.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "deep content" {
t.Errorf("deep.txt 내용 불일치: got %q", string(content))
}
}
func TestExtractZip_AbsolutePath(t *testing.T) {
tmpDir := t.TempDir()
zipPath := filepath.Join(tmpDir, "abs.zip")
destDir := filepath.Join(tmpDir, "out")
os.MkdirAll(destDir, 0755)
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
// 절대 경로 시도
fw, _ := w.Create("A301/C:\\Windows\\evil.txt")
fw.Write([]byte("evil"))
w.Close()
f.Close()
err = extractZip(zipPath, destDir)
// Windows에서 C: 포함은 ADS로도 잡히지만, 절대 경로로도 잡혀야 함
if err == nil {
t.Fatal("절대 경로 공격이 차단되지 않음")
}
}
// ── hashFile tests ───────────────────────────────────────────────────────────
func TestHashFile_Normal(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.bin")
content := []byte("hello world")
os.WriteFile(path, content, 0644)
got, err := hashFile(path)
if err != nil {
t.Fatal(err)
}
h := sha256.Sum256(content)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("해시 불일치: got %s, want %s", got, want)
}
}
func TestHashFile_Empty(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "empty.bin")
os.WriteFile(path, []byte{}, 0644)
got, err := hashFile(path)
if err != nil {
t.Fatal(err)
}
h := sha256.Sum256([]byte{})
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("빈 파일 해시 불일치: got %s, want %s", got, want)
}
}
func TestHashFile_NotExist(t *testing.T) {
_, err := hashFile("/nonexistent/path/to/file")
if err == nil {
t.Fatal("존재하지 않는 파일에 에러가 발생하지 않음")
}
}
// ── redeemTicket tests (httptest) ────────────────────────────────────────────
func TestRedeemTicket_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("예상 메서드 POST, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Content-Type이 application/json이 아님")
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"token":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abc123"}`)
}))
defer srv.Close()
origURL := redeemTicketURL
redeemTicketURL = srv.URL
defer func() { redeemTicketURL = origURL }()
token, err := redeemTicket("test-ticket")
if err != nil {
t.Fatal(err)
}
if token != "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abc123" {
t.Errorf("토큰 불일치: got %s", token)
}
}
func TestRedeemTicket_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, `{"error":"invalid ticket"}`)
}))
defer srv.Close()
origURL := redeemTicketURL
redeemTicketURL = srv.URL
defer func() { redeemTicketURL = origURL }()
_, err := redeemTicket("bad-ticket")
if err == nil {
t.Fatal("서버 에러 시 에러가 반환되지 않음")
}
}
func TestRedeemTicket_InvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `not json`)
}))
defer srv.Close()
origURL := redeemTicketURL
redeemTicketURL = srv.URL
defer func() { redeemTicketURL = origURL }()
_, err := redeemTicket("ticket")
if err == nil {
t.Fatal("잘못된 JSON에 에러가 반환되지 않음")
}
}
func TestRedeemTicket_EmptyToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"token":""}`)
}))
defer srv.Close()
origURL := redeemTicketURL
redeemTicketURL = srv.URL
defer func() { redeemTicketURL = origURL }()
_, err := redeemTicket("ticket")
if err == nil {
t.Fatal("빈 토큰에 에러가 반환되지 않음")
}
}
func TestRedeemTicket_Unreachable(t *testing.T) {
origURL := redeemTicketURL
redeemTicketURL = "http://127.0.0.1:1"
defer func() { redeemTicketURL = origURL }()
_, err := redeemTicket("ticket")
if err == nil {
t.Fatal("연결 불가 시 에러가 반환되지 않음")
}
}
// ── URL parsing tests ────────────────────────────────────────────────────────
func TestParseURI_ValidToken(t *testing.T) {
raw := "a301://launch?token=test-ticket-123"
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if parsed.Scheme != protocolName {
t.Errorf("스킴 불일치: got %s, want %s", parsed.Scheme, protocolName)
}
token := parsed.Query().Get("token")
if token != "test-ticket-123" {
t.Errorf("토큰 불일치: got %s", token)
}
}
func TestParseURI_MissingToken(t *testing.T) {
raw := "a301://launch"
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
token := parsed.Query().Get("token")
if token != "" {
t.Errorf("토큰이 비어있어야 함: got %s", token)
}
}
func TestParseURI_WrongScheme(t *testing.T) {
raw := "http://launch?token=xxx"
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if parsed.Scheme == protocolName {
t.Error("잘못된 스킴이 허용됨")
}
}
func TestParseURI_EncodedToken(t *testing.T) {
// URL 인코딩된 토큰
raw := "a301://launch?token=abc%2Bdef%3Dghi"
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
token := parsed.Query().Get("token")
if token != "abc+def=ghi" {
t.Errorf("URL 디코딩 불일치: got %s, want abc+def=ghi", token)
}
}
func TestParseURI_MultipleParams(t *testing.T) {
raw := "a301://launch?token=myticket&extra=ignored"
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
token := parsed.Query().Get("token")
if token != "myticket" {
t.Errorf("토큰 불일치: got %s", token)
}
}