feat: add Docker infrastructure, migrations, CI/CD client, session cleanup, tests

Docker & Deployment:
- Add Dockerfile (multi-stage, alpine, non-root)
- Add docker-compose.yml (caddy, backend, postgres, watchtower)
- Add Caddyfile (TLS, file_server, reverse proxy)
- Add .env.example

Database:
- Add migrations/001_init.sql (all tables + indexes)

CI/CD:
- Add cmd/ci-release/main.go (launcher binary upload tool)

Session management:
- Add internal/session/cleanup.go (background expired session cleanup)
- Integrate cleanup worker into main.go

Bug fixes:
- Fix launcherLatest download URL to include version segment
- Fix serveLauncherAsset path to match route pattern
- Add Content-Type detection from file extension in CAS serveFile
- Add empty-field validation in webLogin
- Format string fix in ci-release (%d → %s for resp.Status)

Tests:
- Add internal/auth/auth_test.go (8 tests)
- Add internal/cas/cas_test.go (7 tests)
- Add internal/session/cleanup_test.go (1 test)
- Add internal/api/api_test.go (5 tests)
- All tests passing, go vet clean

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 20:09:00 +03:00
parent 81c42e1a9a
commit 5fba2e78d5
14 changed files with 986 additions and 4 deletions

117
internal/auth/auth_test.go Normal file
View File

@@ -0,0 +1,117 @@
package auth
import (
"strings"
"testing"
)
func TestGenerateToken(t *testing.T) {
tok := GenerateToken()
if len(tok) != 32 {
t.Errorf("expected 32-char token, got %d chars: %s", len(tok), tok)
}
// Must be hex.
for _, c := range tok {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("token contains non-hex char: %c", c)
}
}
}
func TestGenerateToken_Uniqueness(t *testing.T) {
// Two tokens should never collide.
t1 := GenerateToken()
t2 := GenerateToken()
if t1 == t2 {
t.Error("two generated tokens are identical")
}
}
func TestGenerateUUID(t *testing.T) {
uuid := GenerateUUID()
// Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars).
if len(uuid) != 36 {
t.Errorf("expected 36-char UUID, got %d: %s", len(uuid), uuid)
}
// Check dashes at correct positions.
for _, pos := range []int{8, 13, 18, 23} {
if uuid[pos] != '-' {
t.Errorf("expected dash at position %d, got %c", pos, uuid[pos])
}
}
// Version 4: char at position 14 should be '4'.
if uuid[14] != '4' {
t.Errorf("expected version 4 at position 14, got %c", uuid[14])
}
}
func TestGenerateUUID_Uniqueness(t *testing.T) {
u1 := GenerateUUID()
u2 := GenerateUUID()
if u1 == u2 {
t.Error("two generated UUIDs are identical")
}
}
func TestHashPassword(t *testing.T) {
hash, err := HashPassword("testpassword")
if err != nil {
t.Fatalf("HashPassword failed: %v", err)
}
if !strings.HasPrefix(hash, "$2a$") {
t.Errorf("expected bcrypt hash starting with $2a$, got: %s", hash[:4])
}
}
func TestVerifyPassword(t *testing.T) {
hash, err := HashPassword("minecraft123")
if err != nil {
t.Fatalf("HashPassword failed: %v", err)
}
if !VerifyPassword("minecraft123", hash) {
t.Error("VerifyPassword returned false for correct password")
}
if VerifyPassword("wrongpassword", hash) {
t.Error("VerifyPassword returned true for wrong password")
}
}
func TestIsBcryptHash(t *testing.T) {
tests := []struct {
hash string
want bool
}{
{"$2a$10$abcdefghijklmnopqrstuuxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"$2b$10$abcdefghijklmnopqrstuuxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"$2y$10$abcdefghijklmnopqrstuuxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", false},
{"", false},
{"plaintext", false},
}
for _, tt := range tests {
got := IsBcryptHash(tt.hash)
if got != tt.want {
t.Errorf("IsBcryptHash(%q) = %v, want %v", tt.hash, got, tt.want)
}
}
}
func TestExtractBearer(t *testing.T) {
tests := []struct {
header string
want string
}{
{"Bearer abc123", "abc123"},
{"Bearer ", ""},
{"abc123", ""},
{"", ""},
{"Basic abc123", ""},
}
for _, tt := range tests {
got := ExtractBearer(tt.header)
if got != tt.want {
t.Errorf("ExtractBearer(%q) = %q, want %q", tt.header, got, tt.want)
}
}
}