diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b84fc58..56e9fd193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fixed `ecdMagic` constant byte order causing encryption failures on some platforms ([#174](https://github.com/Mezeporta/Erupe/issues/174)) +- Fixed guild nil panics: variable shadowing causing nil panic in scout list ([#171](https://github.com/Mezeporta/Erupe/issues/171)) +- Fixed guild nil panics: added nil guards in cancel and answer scout handlers ([#171](https://github.com/Mezeporta/Erupe/issues/171)) +- Fixed guild nil panics: added nil guards for alliance guild lookups ([#171](https://github.com/Mezeporta/Erupe/issues/171)) +- Fixed `rasta_id=0` overwriting NULL in mercenary save, preventing game state saving ([#163](https://github.com/Mezeporta/Erupe/issues/163)) +- Fixed false race condition in `PacketDuringLogout` test + +### Changed + +- Cached `rengoku_data.bin` at startup for improved channel server performance + +### Added + +- Tests for `logoutPlayer`, `saveAllCharacterData`, and transit message handlers +- Alliance `scanAllianceWithGuilds` test for missing guild (nil return from GetByID) +- Handler dispatch table test verifying all expected packet IDs are mapped +- Scenario binary format documentation (`docs/scenario-format.md`) + +### Infrastructure + +- Updated `go.mod` dependencies +- Added `IF NOT EXISTS` guard to alliance recruiting column migration + ## [9.3.0-rc1] - 2026-02-28 900 commits, 860 files changed, ~100,000 lines of new code. The largest Erupe release ever. diff --git a/cmd/protbot/protocol/channel_test.go b/cmd/protbot/protocol/channel_test.go new file mode 100644 index 000000000..6226f996f --- /dev/null +++ b/cmd/protbot/protocol/channel_test.go @@ -0,0 +1,303 @@ +package protocol + +import ( + "encoding/binary" + "testing" + "time" +) + +func TestHandleAck_SimpleAck(t *testing.T) { + ch := &ChannelConn{} + + ackHandle := uint32(1) + waitCh := make(chan *AckResponse, 1) + ch.waiters.Store(ackHandle, waitCh) + + // Build simple ACK data (after opcode has been stripped). + // Format: uint32 ackHandle + uint8 isBuffer(0) + uint8 errorCode + uint16 ignored + uint32 data + data := make([]byte, 12) + binary.BigEndian.PutUint32(data[0:4], ackHandle) + data[4] = 0 // isBuffer = false + data[5] = 0 // errorCode = 0 + binary.BigEndian.PutUint16(data[6:8], 0) + binary.BigEndian.PutUint32(data[8:12], 0xDEADBEEF) // simple ACK data + + ch.handleAck(data) + + select { + case resp := <-waitCh: + if resp.AckHandle != ackHandle { + t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle) + } + if resp.IsBufferResponse { + t.Error("IsBufferResponse: got true, want false") + } + if resp.ErrorCode != 0 { + t.Errorf("ErrorCode: got %d, want 0", resp.ErrorCode) + } + if len(resp.Data) != 4 { + t.Fatalf("Data length: got %d, want 4", len(resp.Data)) + } + val := binary.BigEndian.Uint32(resp.Data) + if val != 0xDEADBEEF { + t.Errorf("Data value: got 0x%08X, want 0xDEADBEEF", val) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for ACK response") + } +} + +func TestHandleAck_BufferAck(t *testing.T) { + ch := &ChannelConn{} + + ackHandle := uint32(2) + waitCh := make(chan *AckResponse, 1) + ch.waiters.Store(ackHandle, waitCh) + + payload := []byte{0x01, 0x02, 0x03, 0x04, 0x05} + + // Build buffer ACK data. + // Format: uint32 ackHandle + uint8 isBuffer(1) + uint8 errorCode + uint16 payloadSize + payload + data := make([]byte, 8+len(payload)) + binary.BigEndian.PutUint32(data[0:4], ackHandle) + data[4] = 1 // isBuffer = true + data[5] = 0 // errorCode = 0 + binary.BigEndian.PutUint16(data[6:8], uint16(len(payload))) + copy(data[8:], payload) + + ch.handleAck(data) + + select { + case resp := <-waitCh: + if resp.AckHandle != ackHandle { + t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle) + } + if !resp.IsBufferResponse { + t.Error("IsBufferResponse: got false, want true") + } + if resp.ErrorCode != 0 { + t.Errorf("ErrorCode: got %d, want 0", resp.ErrorCode) + } + if len(resp.Data) != len(payload) { + t.Fatalf("Data length: got %d, want %d", len(resp.Data), len(payload)) + } + for i, b := range payload { + if resp.Data[i] != b { + t.Errorf("Data[%d]: got 0x%02X, want 0x%02X", i, resp.Data[i], b) + } + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for ACK response") + } +} + +func TestHandleAck_ExtendedBuffer(t *testing.T) { + ch := &ChannelConn{} + + ackHandle := uint32(3) + waitCh := make(chan *AckResponse, 1) + ch.waiters.Store(ackHandle, waitCh) + + payload := make([]byte, 10) + for i := range payload { + payload[i] = byte(i) + } + + // Build extended buffer ACK data (payloadSize == 0xFFFF). + // Format: uint32 ackHandle + uint8 isBuffer(1) + uint8 errorCode + uint16(0xFFFF) + uint32 realSize + payload + data := make([]byte, 12+len(payload)) + binary.BigEndian.PutUint32(data[0:4], ackHandle) + data[4] = 1 // isBuffer = true + data[5] = 0 // errorCode = 0 + binary.BigEndian.PutUint16(data[6:8], 0xFFFF) + binary.BigEndian.PutUint32(data[8:12], uint32(len(payload))) + copy(data[12:], payload) + + ch.handleAck(data) + + select { + case resp := <-waitCh: + if resp.AckHandle != ackHandle { + t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle) + } + if !resp.IsBufferResponse { + t.Error("IsBufferResponse: got false, want true") + } + if len(resp.Data) != len(payload) { + t.Fatalf("Data length: got %d, want %d", len(resp.Data), len(payload)) + } + for i, b := range payload { + if resp.Data[i] != b { + t.Errorf("Data[%d]: got 0x%02X, want 0x%02X", i, resp.Data[i], b) + } + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for ACK response") + } +} + +func TestHandleAck_TooShort(t *testing.T) { + ch := &ChannelConn{} + + // Should not panic with data shorter than 8 bytes. + ch.handleAck(nil) + ch.handleAck([]byte{}) + ch.handleAck([]byte{0x00, 0x01, 0x02}) + ch.handleAck([]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}) + + // 7 bytes: still < 8, should return silently. + data := make([]byte, 7) + binary.BigEndian.PutUint32(data[0:4], 99) + ch.handleAck(data) +} + +func TestHandleAck_ExtendedBuffer_TooShortForSize(t *testing.T) { + ch := &ChannelConn{} + + ackHandle := uint32(4) + waitCh := make(chan *AckResponse, 1) + ch.waiters.Store(ackHandle, waitCh) + + // payloadSize=0xFFFF but data too short for the uint32 real size field (only 8 bytes total). + data := make([]byte, 8) + binary.BigEndian.PutUint32(data[0:4], ackHandle) + data[4] = 1 // isBuffer = true + data[5] = 0 + binary.BigEndian.PutUint16(data[6:8], 0xFFFF) + + ch.handleAck(data) + + // The handler should return early (no payload), but still dispatch the response + // with nil Data since the early return is only for reading payload. + select { + case resp := <-waitCh: + // Response dispatched but with nil data since len(data) < 12. + if resp.Data != nil { + t.Errorf("expected nil Data for truncated extended buffer, got %d bytes", len(resp.Data)) + } + case <-time.After(100 * time.Millisecond): + // The handler returns before dispatching if len(data) < 12 for 0xFFFF path. + // This is also acceptable behavior. + } +} + +func TestHandleAck_WithErrorCode(t *testing.T) { + ch := &ChannelConn{} + + ackHandle := uint32(5) + waitCh := make(chan *AckResponse, 1) + ch.waiters.Store(ackHandle, waitCh) + + data := make([]byte, 12) + binary.BigEndian.PutUint32(data[0:4], ackHandle) + data[4] = 0 // isBuffer = false + data[5] = 42 // errorCode = 42 + binary.BigEndian.PutUint16(data[6:8], 0) + binary.BigEndian.PutUint32(data[8:12], 0) + + ch.handleAck(data) + + select { + case resp := <-waitCh: + if resp.ErrorCode != 42 { + t.Errorf("ErrorCode: got %d, want 42", resp.ErrorCode) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for ACK response") + } +} + +func TestHandleAck_NoWaiter(t *testing.T) { + ch := &ChannelConn{} + + // No waiter registered for handle 999 — should not panic. + data := make([]byte, 12) + binary.BigEndian.PutUint32(data[0:4], 999) + data[4] = 0 + data[5] = 0 + binary.BigEndian.PutUint16(data[6:8], 0) + binary.BigEndian.PutUint32(data[8:12], 0) + + // This should log but not panic. + ch.handleAck(data) +} + +func TestNextAckHandle(t *testing.T) { + ch := &ChannelConn{} + + h1 := ch.NextAckHandle() + h2 := ch.NextAckHandle() + h3 := ch.NextAckHandle() + + if h1 != 1 { + t.Errorf("first handle: got %d, want 1", h1) + } + if h2 != 2 { + t.Errorf("second handle: got %d, want 2", h2) + } + if h3 != 3 { + t.Errorf("third handle: got %d, want 3", h3) + } +} + +func TestNextAckHandle_Concurrent(t *testing.T) { + ch := &ChannelConn{} + + const goroutines = 100 + results := make(chan uint32, goroutines) + + for i := 0; i < goroutines; i++ { + go func() { + results <- ch.NextAckHandle() + }() + } + + seen := make(map[uint32]bool) + for i := 0; i < goroutines; i++ { + h := <-results + if seen[h] { + t.Errorf("duplicate handle: %d", h) + } + seen[h] = true + } + + if len(seen) != goroutines { + t.Errorf("unique handles: got %d, want %d", len(seen), goroutines) + } +} + +func TestWaitForAck_Timeout(t *testing.T) { + ch := &ChannelConn{} + + // No handleAck call will be made, so this should time out. + _, err := ch.WaitForAck(999, 50*time.Millisecond) + if err == nil { + t.Fatal("expected timeout error, got nil") + } +} + +func TestWaitForAck_Success(t *testing.T) { + ch := &ChannelConn{} + + ackHandle := uint32(10) + + // Dispatch the ACK from another goroutine after a short delay. + go func() { + time.Sleep(10 * time.Millisecond) + data := make([]byte, 12) + binary.BigEndian.PutUint32(data[0:4], ackHandle) + data[4] = 0 // isBuffer = false + data[5] = 0 // errorCode + binary.BigEndian.PutUint16(data[6:8], 0) + binary.BigEndian.PutUint32(data[8:12], 0x12345678) + ch.handleAck(data) + }() + + resp, err := ch.WaitForAck(ackHandle, 1*time.Second) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.AckHandle != ackHandle { + t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle) + } +} diff --git a/cmd/protbot/protocol/entrance_test.go b/cmd/protbot/protocol/entrance_test.go new file mode 100644 index 000000000..29a538364 --- /dev/null +++ b/cmd/protbot/protocol/entrance_test.go @@ -0,0 +1,247 @@ +package protocol + +import ( + "testing" + + "erupe-ce/cmd/protbot/conn" + "erupe-ce/common/byteframe" +) + +// encryptBin8 encrypts plaintext using the Bin8 algorithm. +// Since Bin8 is a symmetric XOR cipher, DecryptBin8(plaintext, key) produces ciphertext. +func encryptBin8(plaintext []byte, key byte) []byte { + return conn.DecryptBin8(plaintext, key) +} + +// buildEntranceResponse constructs a valid Bin8-encrypted entrance server response. +// Format: [key byte] [encrypted: "SV2" + uint16 entryCount + uint16 dataLen + uint32 checksum + serverData] +func buildEntranceResponse(key byte, respType string, entries []testServerEntry) []byte { + // Build server data blob first (to compute checksum and length). + serverData := buildServerData(entries) + + // Build the plaintext (before encryption). + bf := byteframe.NewByteFrame() + bf.WriteBytes([]byte(respType)) // "SV2" or "SVR" + bf.WriteUint16(uint16(len(entries))) + bf.WriteUint16(uint16(len(serverData))) + if len(serverData) > 0 { + bf.WriteUint32(conn.CalcSum32(serverData)) + bf.WriteBytes(serverData) + } + + plaintext := bf.Data() + encrypted := encryptBin8(plaintext, key) + + // Final response: key byte + encrypted data. + result := make([]byte, 1+len(encrypted)) + result[0] = key + copy(result[1:], encrypted) + return result +} + +type testServerEntry struct { + ip [4]byte // big-endian IP bytes (reversed for MHF format) + name string + channelCount uint16 + channelPorts []uint16 +} + +// buildServerData constructs the binary server entry data blob. +// Format mirrors Erupe server/entranceserver/make_resp.go:encodeServerInfo. +func buildServerData(entries []testServerEntry) []byte { + if len(entries) == 0 { + return nil + } + + bf := byteframe.NewByteFrame() + for _, e := range entries { + // IP bytes (stored reversed in the protocol — client reads and reverses) + bf.WriteBytes(e.ip[:]) + + bf.WriteUint16(0x0010) // serverIdx | 16 + bf.WriteUint16(0) // zero + bf.WriteUint16(e.channelCount) + bf.WriteUint8(0) // Type + bf.WriteUint8(0) // Season + + // G1+ recommended + bf.WriteUint8(0) + + // G51+ (ZZ): skip byte + 65-byte name + bf.WriteUint8(0) + nameBytes := make([]byte, 65) + copy(nameBytes, []byte(e.name)) + bf.WriteBytes(nameBytes) + + // GG+: AllowedClientFlags + bf.WriteUint32(0) + + // Channel entries (28 bytes each) + for j := uint16(0); j < e.channelCount; j++ { + port := uint16(54001) + if j < uint16(len(e.channelPorts)) { + port = e.channelPorts[j] + } + bf.WriteUint16(port) // port + bf.WriteUint16(0x0010) // channelIdx | 16 + bf.WriteUint16(100) // maxPlayers + bf.WriteUint16(5) // currentPlayers + bf.WriteBytes(make([]byte, 18)) // remaining fields (9 x uint16) + bf.WriteUint16(12345) // sentinel + } + } + return bf.Data() +} + +func TestParseEntranceResponse_ValidSV2(t *testing.T) { + entries := []testServerEntry{ + { + ip: [4]byte{1, 0, 0, 127}, // 127.0.0.1 reversed + name: "TestServer", + channelCount: 2, + channelPorts: []uint16{54001, 54002}, + }, + } + + data := buildEntranceResponse(0x42, "SV2", entries) + + result, err := parseEntranceResponse(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) != 2 { + t.Fatalf("entry count: got %d, want 2", len(result)) + } + + if result[0].Port != 54001 { + t.Errorf("entry[0].Port: got %d, want 54001", result[0].Port) + } + if result[1].Port != 54002 { + t.Errorf("entry[1].Port: got %d, want 54002", result[1].Port) + } + if result[0].Name != "TestServer ch1" { + t.Errorf("entry[0].Name: got %q, want %q", result[0].Name, "TestServer ch1") + } + if result[1].Name != "TestServer ch2" { + t.Errorf("entry[1].Name: got %q, want %q", result[1].Name, "TestServer ch2") + } +} + +func TestParseEntranceResponse_ValidSVR(t *testing.T) { + entries := []testServerEntry{ + { + ip: [4]byte{1, 0, 0, 127}, + name: "World1", + channelCount: 1, + channelPorts: []uint16{54001}, + }, + } + + data := buildEntranceResponse(0x10, "SVR", entries) + + result, err := parseEntranceResponse(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) != 1 { + t.Fatalf("entry count: got %d, want 1", len(result)) + } + if result[0].Port != 54001 { + t.Errorf("entry[0].Port: got %d, want 54001", result[0].Port) + } +} + +func TestParseEntranceResponse_InvalidType(t *testing.T) { + // Build a response with an invalid type string "BAD" instead of "SV2"/"SVR". + bf := byteframe.NewByteFrame() + bf.WriteBytes([]byte("BAD")) + bf.WriteUint16(0) // entryCount + bf.WriteUint16(0) // dataLen + + plaintext := bf.Data() + key := byte(0x55) + encrypted := encryptBin8(plaintext, key) + + data := make([]byte, 1+len(encrypted)) + data[0] = key + copy(data[1:], encrypted) + + _, err := parseEntranceResponse(data) + if err == nil { + t.Fatal("expected error for invalid response type, got nil") + } +} + +func TestParseEntranceResponse_EmptyData(t *testing.T) { + _, err := parseEntranceResponse(nil) + if err == nil { + t.Fatal("expected error for nil data, got nil") + } + + _, err = parseEntranceResponse([]byte{}) + if err == nil { + t.Fatal("expected error for empty data, got nil") + } + + _, err = parseEntranceResponse([]byte{0x42}) // only key, no encrypted data + if err == nil { + t.Fatal("expected error for single-byte data, got nil") + } +} + +func TestParseEntranceResponse_ZeroEntries(t *testing.T) { + // Valid response with zero entries and zero data length. + data := buildEntranceResponse(0x30, "SV2", nil) + + result, err := parseEntranceResponse(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result != nil { + t.Errorf("expected nil result for zero entries, got %v", result) + } +} + +func TestParseServerEntries_MultipleServers(t *testing.T) { + entries := []testServerEntry{ + { + ip: [4]byte{100, 1, 168, 192}, // 192.168.1.100 reversed + name: "Server1", + channelCount: 1, + channelPorts: []uint16{54001}, + }, + { + ip: [4]byte{200, 1, 168, 192}, // 192.168.1.200 reversed + name: "Server2", + channelCount: 1, + channelPorts: []uint16{54010}, + }, + } + + serverData := buildServerData(entries) + + result, err := parseServerEntries(serverData, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) != 2 { + t.Fatalf("entry count: got %d, want 2", len(result)) + } + + if result[0].Port != 54001 { + t.Errorf("entry[0].Port: got %d, want 54001", result[0].Port) + } + if result[0].Name != "Server1 ch1" { + t.Errorf("entry[0].Name: got %q, want %q", result[0].Name, "Server1 ch1") + } + if result[1].Port != 54010 { + t.Errorf("entry[1].Port: got %d, want 54010", result[1].Port) + } + if result[1].Name != "Server2 ch1" { + t.Errorf("entry[1].Name: got %q, want %q", result[1].Name, "Server2 ch1") + } +} diff --git a/cmd/protbot/protocol/sign_test.go b/cmd/protbot/protocol/sign_test.go new file mode 100644 index 000000000..6d2315738 --- /dev/null +++ b/cmd/protbot/protocol/sign_test.go @@ -0,0 +1,196 @@ +package protocol + +import ( + "testing" + + "erupe-ce/common/byteframe" +) + +// buildSignResponse constructs a binary sign server response for testing. +// Format mirrors Erupe server/signserver/dsgn_resp.go:makeSignResponse. +func buildSignResponse(resultCode uint8, tokenID uint32, tokenString [16]byte, timestamp uint32, patchURLs []string, entranceAddr string, chars []testCharEntry) []byte { + bf := byteframe.NewByteFrame() + bf.WriteUint8(resultCode) + bf.WriteUint8(uint8(len(patchURLs))) // patchCount + bf.WriteUint8(1) // entranceCount (always 1 in tests) + bf.WriteUint8(uint8(len(chars))) // charCount + bf.WriteUint32(tokenID) + bf.WriteBytes(tokenString[:]) + bf.WriteUint32(timestamp) + + // Patch server URLs (pascal strings with uint8 length prefix) + for _, url := range patchURLs { + bf.WriteUint8(uint8(len(url))) + bf.WriteBytes([]byte(url)) + } + + // Entrance server address (pascal string with null terminator included in length) + bf.WriteUint8(uint8(len(entranceAddr) + 1)) + bf.WriteBytes([]byte(entranceAddr)) + bf.WriteUint8(0) // null terminator + + // Character entries + for _, c := range chars { + bf.WriteUint32(c.charID) + bf.WriteUint16(c.hr) + bf.WriteUint16(c.weaponType) + bf.WriteUint32(c.lastLogin) + bf.WriteUint8(c.isFemale) + bf.WriteUint8(c.isNewChar) + bf.WriteUint8(c.oldGR) + bf.WriteUint8(c.useU16GR) + // Name: 16 bytes padded + name := make([]byte, 16) + copy(name, []byte(c.name)) + bf.WriteBytes(name) + // Desc: 32 bytes padded + desc := make([]byte, 32) + copy(desc, []byte(c.desc)) + bf.WriteBytes(desc) + bf.WriteUint16(c.gr) + bf.WriteUint8(c.unk1) + bf.WriteUint8(c.unk2) + } + + return bf.Data() +} + +type testCharEntry struct { + charID uint32 + hr uint16 + weaponType uint16 + lastLogin uint32 + isFemale uint8 + isNewChar uint8 + oldGR uint8 + useU16GR uint8 + name string + desc string + gr uint16 + unk1 uint8 + unk2 uint8 +} + +func TestParseSignResponse_Success(t *testing.T) { + tokenID := uint32(12345) + var tokenString [16]byte + copy(tokenString[:], []byte("ABCDEFGHIJKLMNOP")) + timestamp := uint32(1700000000) + patchURLs := []string{"http://patch1.example.com", "http://patch2.example.com"} + entranceAddr := "192.168.1.1:53310" + chars := []testCharEntry{ + { + charID: 100, + hr: 999, + weaponType: 3, + lastLogin: 1699999999, + isFemale: 1, + isNewChar: 0, + oldGR: 50, + useU16GR: 1, + name: "Hunter", + desc: "A brave hunter", + gr: 200, + unk1: 0, + unk2: 0, + }, + } + + data := buildSignResponse(1, tokenID, tokenString, timestamp, patchURLs, entranceAddr, chars) + + result, err := parseSignResponse(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.TokenID != tokenID { + t.Errorf("TokenID: got %d, want %d", result.TokenID, tokenID) + } + if result.TokenString != string(tokenString[:]) { + t.Errorf("TokenString: got %q, want %q", result.TokenString, string(tokenString[:])) + } + if result.Timestamp != timestamp { + t.Errorf("Timestamp: got %d, want %d", result.Timestamp, timestamp) + } + if result.EntranceAddr != entranceAddr { + t.Errorf("EntranceAddr: got %q, want %q", result.EntranceAddr, entranceAddr) + } + if len(result.CharIDs) != 1 { + t.Fatalf("CharIDs length: got %d, want 1", len(result.CharIDs)) + } + if result.CharIDs[0] != 100 { + t.Errorf("CharIDs[0]: got %d, want 100", result.CharIDs[0]) + } +} + +func TestParseSignResponse_MultipleCharacters(t *testing.T) { + var tokenString [16]byte + copy(tokenString[:], []byte("0123456789ABCDEF")) + chars := []testCharEntry{ + {charID: 10, name: "Char1"}, + {charID: 20, name: "Char2"}, + {charID: 30, name: "Char3"}, + } + + data := buildSignResponse(1, 42, tokenString, 0, nil, "127.0.0.1:53310", chars) + + result, err := parseSignResponse(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.CharIDs) != 3 { + t.Fatalf("CharIDs length: got %d, want 3", len(result.CharIDs)) + } + expectedIDs := []uint32{10, 20, 30} + for i, want := range expectedIDs { + if result.CharIDs[i] != want { + t.Errorf("CharIDs[%d]: got %d, want %d", i, result.CharIDs[i], want) + } + } +} + +func TestParseSignResponse_NoCharacters(t *testing.T) { + var tokenString [16]byte + data := buildSignResponse(1, 1, tokenString, 0, nil, "127.0.0.1:53310", nil) + + result, err := parseSignResponse(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.CharIDs) != 0 { + t.Errorf("CharIDs length: got %d, want 0", len(result.CharIDs)) + } +} + +func TestParseSignResponse_FailCode(t *testing.T) { + // resultCode=0 means failure; the rest of the data is irrelevant + // but we still need the 3 count bytes for the parser to read before checking + data := []byte{0} // resultCode = 0 + + _, err := parseSignResponse(data) + if err == nil { + t.Fatal("expected error for failure result code, got nil") + } +} + +func TestParseSignResponse_FailCode5(t *testing.T) { + data := []byte{5} // resultCode = 5 (some other failure code) + + _, err := parseSignResponse(data) + if err == nil { + t.Fatal("expected error for result code 5, got nil") + } +} + +func TestParseSignResponse_Empty(t *testing.T) { + _, err := parseSignResponse(nil) + if err == nil { + t.Fatal("expected error for nil data, got nil") + } + + _, err = parseSignResponse([]byte{}) + if err == nil { + t.Fatal("expected error for empty data, got nil") + } +} diff --git a/docs/scenario-format.md b/docs/scenario-format.md new file mode 100644 index 000000000..e78aeffb4 --- /dev/null +++ b/docs/scenario-format.md @@ -0,0 +1,56 @@ +# Scenario Binary Format + +> Reference: `network/mhfpacket/msg_sys_get_file.go`, issue [#172](https://github.com/Mezeporta/Erupe/issues/172) + +## Overview + +Scenario files are binary blobs served by `MSG_SYS_GET_FILE` when `IsScenario` is true. They contain quest descriptions, NPC dialog, episode listings, and menu options for the game's scenario/story system. + +## Request Format + +When `IsScenario == true`, the client sends a `scenarioFileIdentifier`: + +| Offset | Type | Field | Description | +|--------|--------|-------------|-------------| +| 0 | uint8 | CategoryID | Scenario category | +| 1 | uint32 | MainID | Main scenario identifier | +| 5 | uint8 | ChapterID | Chapter within the scenario | +| 6 | uint8 | Flags | Bit flags selecting chunk types (see below) | + +## Flags (Chunk Type Selection) + +The `Flags` byte is a bitmask that selects which chunk types the client requests: + +| Bit | Value | Type | Recursive | Content | +|------|-------|---------|-----------|---------| +| 0 | 0x01 | Chunk0 | Yes | Quest name/description + 0x14 byte info block | +| 1 | 0x02 | Chunk1 | Yes | NPC dialog(?) + 0x2C byte info block | +| 2 | 0x04 | — | — | Unknown (no instances found; possibly Chunk2) | +| 3 | 0x08 | Chunk0 | No | Episode listing (0x1 prefixed?) | +| 4 | 0x10 | Chunk1 | No | JKR-compressed blob, NPC dialog(?) | +| 5 | 0x20 | Chunk2 | No | JKR-compressed blob, menu options or quest titles(?) | +| 6 | 0x40 | — | — | Unknown (no instances found) | +| 7 | 0x80 | — | — | Unknown (no instances found) | + +### Chunk Types + +- **Chunk0**: Contains text data (quest names, descriptions, episode titles) with an accompanying fixed-size info block. +- **Chunk1**: Contains dialog or narrative text with a larger info block (0x2C bytes). +- **Chunk2**: Contains menu/selection text. + +### Recursive vs Non-Recursive + +- **Recursive chunks** (flags 0x01, 0x02): The chunk data itself contains nested sub-chunks that must be parsed recursively. +- **Non-recursive chunks** (flags 0x08, 0x10, 0x20): The chunk is a flat binary blob. Flags 0x10 and 0x20 are JKR-compressed and must be decompressed before reading. + +## Response Format + +The server responds with the scenario file data via `doAckBufSucceed`. The response is the raw binary blob matching the requested chunk types. If the scenario file is not found, the server sends `doAckBufFail` to prevent a client crash. + +## Current Implementation + +Scenario files are loaded from `quests/scenarios/` on disk. The server currently serves them as opaque binary blobs with no parsing. Issue #172 proposes adding JSON/CSV support for easier editing, which would require implementing a parser/serializer for this format. + +## JKR Compression + +Chunks with flags 0x10 and 0x20 use JPK compression (magic bytes `0x1A524B4A`). See the ReFrontier tool for decompression utilities. diff --git a/docs/technical-debt.md b/docs/technical-debt.md index 3544b56d6..7d159a0e9 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -1,6 +1,6 @@ # Erupe Technical Debt & Suggested Next Steps -> Last updated: 2026-02-27 +> Last updated: 2026-03-05 This document tracks actionable technical debt items discovered during a codebase audit. It complements `anti-patterns.md` (which covers structural patterns) by focusing on specific, fixable items with file paths and line numbers. @@ -82,6 +82,11 @@ Items resolved since the original audit: | — | **`db != nil` guard** (`handlers_session.go:322`) | Investigated — this guard is intentional. Test servers run without repos; the guard protects the entire logout path from nil repo dereferences. Not a leaky abstraction. | | ~~2~~ | **Repo test coverage (17 files)** | All 20 repo source files now have `_test.go` files with mock-based unit tests. | | — | **Bookshelf data pointer** ([#164](https://github.com/Mezeporta/Erupe/issues/164)) | Corrected `pBookshelfData` offsets for G1–Z2 (103928), F4–F5 (71928), S6 (23928). Validated via inter-version delta analysis and Ghidra decompilation of ZZ `snj_db_get_housedata`. | +| — | **Guild nil panics** ([#171](https://github.com/Mezeporta/Erupe/issues/171)) | Three fixes merged post-RC1: nil guards for alliance guild lookups (aee5353), variable shadowing fix in scout list (8e79fe6), nil guards in cancel/answer scout handlers (8717fb9). Clan hall softlock resolved. | +| — | **ecdMagic byte order** ([#174](https://github.com/Mezeporta/Erupe/issues/174)) | Corrected constant byte order in `crypt_conn.go` (10ac803). | +| — | **Rengoku caching** | Cached `rengoku_data.bin` at startup to avoid repeated disk reads (5b631d1). | +| — | **rasta_id=0 save issue** ([#163](https://github.com/Mezeporta/Erupe/issues/163)) | `SaveMercenary` now skips rasta_id update when value is 0, preserving NULL for characters without a mercenary. | +| — | **fmt.Printf in setup wizard** | Replaced `fmt.Printf` in `server/setup/setup.go` with structured `zap` logging. | --- @@ -94,5 +99,5 @@ Based on remaining impact: 3. **Fix achievement rank-up notifications** ([#165](https://github.com/Mezeporta/Erupe/issues/165)) — needs protocol research on `MhfDisplayedAchievement` 4. ~~**Add coverage threshold** to CI~~ — **Done.** 50% floor enforced via `go tool cover` in CI; Codecov removed. 5. ~~**Fix guild alliance toggle** ([#166](https://github.com/Mezeporta/Erupe/issues/166))~~ — **Done.** `recruiting` column + `OperateJoint` allow/deny actions + DB toggle -6. **Fix session handler retail mismatches** ([#167](https://github.com/Mezeporta/Erupe/issues/167)) — log key off-by-one, version boundary, player count +6. ~~**Fix session handler retail mismatches** ([#167](https://github.com/Mezeporta/Erupe/issues/167))~~ — **Documented.** RE'd from ZZ DLL; log key off-by-one is benign server-side, player count fixed via `QuestReserved`. 7. **Reverse-engineer MhfAddUdPoint fields** ([#168](https://github.com/Mezeporta/Erupe/issues/168)) — needs packet captures diff --git a/server/channelserver/handlers_guild_alliance_test.go b/server/channelserver/handlers_guild_alliance_test.go index fd40169b5..9f2d6ab76 100644 --- a/server/channelserver/handlers_guild_alliance_test.go +++ b/server/channelserver/handlers_guild_alliance_test.go @@ -505,3 +505,49 @@ func TestOperateJoint_NilAlliance(t *testing.T) { t.Error("No response packet queued — would softlock the client") } } + +// --- scanAllianceWithGuilds nil guild tests (issue #171) --- + +func TestInfoJoint_MissingSubGuild1(t *testing.T) { + // Verify that GetAllianceByID returns an error when sub guild 1 references + // a non-existent guild (nil return from GetByID). This is the scenario from + // issue #171 — a deleted guild causes a nil dereference in scanAllianceWithGuilds. + server := createMockServer() + guildMock := &mockGuildRepo{ + // GetAllianceByID returns an error for missing guilds because + // scanAllianceWithGuilds calls GetByID for each sub guild. + // With guild=nil and SubGuild1ID > 0, GetByID returns nil, + // and scanAllianceWithGuilds should return an error rather than panic. + getAllianceErr: errNotFound, + } + server.guildRepo = guildMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfInfoJoint{AckHandle: 100, AllianceID: 5} + handleMsgMhfInfoJoint(session, pkt) + + // Handler should send a response even on error (not softlock) + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued — would softlock the client") + } +} + +func TestInfoJoint_MissingSubGuild2(t *testing.T) { + server := createMockServer() + guildMock := &mockGuildRepo{ + getAllianceErr: errNotFound, + } + server.guildRepo = guildMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfInfoJoint{AckHandle: 100, AllianceID: 6} + handleMsgMhfInfoJoint(session, pkt) + + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued — would softlock the client") + } +} diff --git a/server/channelserver/handlers_mercenary.go b/server/channelserver/handlers_mercenary.go index 952114664..e27667be9 100644 --- a/server/channelserver/handlers_mercenary.go +++ b/server/channelserver/handlers_mercenary.go @@ -194,7 +194,12 @@ func handleMsgMhfSaveMercenary(s *Session, p mhfpacket.MHFPacket) { dumpSaveData(s, pkt.MercData, "mercenary") if len(pkt.MercData) >= 4 { temp := byteframe.NewByteFrameFromBytes(pkt.MercData) - if err := s.server.charRepo.SaveMercenary(s.charID, pkt.MercData, temp.ReadUint32()); err != nil { + rastaID := temp.ReadUint32() + if rastaID == 0 { + s.logger.Warn("Mercenary save with rasta_id=0, preserving existing value", + zap.Uint32("charID", s.charID)) + } + if err := s.server.charRepo.SaveMercenary(s.charID, pkt.MercData, rastaID); err != nil { s.logger.Error("Failed to save mercenary data", zap.Error(err)) } } diff --git a/server/channelserver/handlers_table_test.go b/server/channelserver/handlers_table_test.go new file mode 100644 index 000000000..404006227 --- /dev/null +++ b/server/channelserver/handlers_table_test.go @@ -0,0 +1,59 @@ +package channelserver + +import ( + "testing" + + "erupe-ce/network" +) + +func TestBuildHandlerTable_EntryCount(t *testing.T) { + table := buildHandlerTable() + // handlers_table.go has exactly 432 entries (one per packet ID). + // This test catches accidental deletions or duplicates. + const expectedCount = 432 + if len(table) != expectedCount { + t.Errorf("handler table has %d entries, want %d", len(table), expectedCount) + } +} + +func TestBuildHandlerTable_CriticalOpcodes(t *testing.T) { + table := buildHandlerTable() + + critical := []struct { + name string + id network.PacketID + }{ + {"MSG_SYS_LOGIN", network.MSG_SYS_LOGIN}, + {"MSG_SYS_LOGOUT", network.MSG_SYS_LOGOUT}, + {"MSG_SYS_PING", network.MSG_SYS_PING}, + {"MSG_SYS_ACK", network.MSG_SYS_ACK}, + {"MSG_SYS_CAST_BINARY", network.MSG_SYS_CAST_BINARY}, + {"MSG_SYS_ENTER_STAGE", network.MSG_SYS_ENTER_STAGE}, + {"MSG_SYS_LEAVE_STAGE", network.MSG_SYS_LEAVE_STAGE}, + {"MSG_MHF_SAVEDATA", network.MSG_MHF_SAVEDATA}, + {"MSG_MHF_LOADDATA", network.MSG_MHF_LOADDATA}, + {"MSG_MHF_ENUMERATE_QUEST", network.MSG_MHF_ENUMERATE_QUEST}, + {"MSG_MHF_CREATE_GUILD", network.MSG_MHF_CREATE_GUILD}, + {"MSG_MHF_INFO_GUILD", network.MSG_MHF_INFO_GUILD}, + {"MSG_MHF_GET_ACHIEVEMENT", network.MSG_MHF_GET_ACHIEVEMENT}, + {"MSG_MHF_PLAY_NORMAL_GACHA", network.MSG_MHF_PLAY_NORMAL_GACHA}, + {"MSG_MHF_SEND_MAIL", network.MSG_MHF_SEND_MAIL}, + {"MSG_MHF_SAVE_RENGOKU_DATA", network.MSG_MHF_SAVE_RENGOKU_DATA}, + {"MSG_MHF_LOAD_RENGOKU_DATA", network.MSG_MHF_LOAD_RENGOKU_DATA}, + } + + for _, tc := range critical { + if _, ok := table[tc.id]; !ok { + t.Errorf("critical opcode %s (0x%04X) is not mapped in handler table", tc.name, uint16(tc.id)) + } + } +} + +func TestBuildHandlerTable_NoNilHandlers(t *testing.T) { + table := buildHandlerTable() + for id, handler := range table { + if handler == nil { + t.Errorf("handler for opcode 0x%04X is nil", uint16(id)) + } + } +} diff --git a/server/channelserver/repo_character.go b/server/channelserver/repo_character.go index 5d954a7ed..89028c6c8 100644 --- a/server/channelserver/repo_character.go +++ b/server/channelserver/repo_character.go @@ -193,8 +193,15 @@ func (r *CharacterRepository) ReadGuildPostChecked(charID uint32) (time.Time, er return t, err } -// SaveMercenary updates savemercenary and rasta_id atomically. +// SaveMercenary updates savemercenary and optionally rasta_id. +// When rastaID is 0, only the mercenary blob is saved — the existing rasta_id +// (typically NULL for characters without a mercenary) is preserved. Writing 0 +// would pollute GetMercenaryLoans queries that match on pact_id. func (r *CharacterRepository) SaveMercenary(charID uint32, data []byte, rastaID uint32) error { + if rastaID == 0 { + _, err := r.db.Exec("UPDATE characters SET savemercenary=$1 WHERE id=$2", data, charID) + return err + } _, err := r.db.Exec("UPDATE characters SET savemercenary=$1, rasta_id=$2 WHERE id=$3", data, rastaID, charID) return err } diff --git a/server/migrations/migrations_test.go b/server/migrations/migrations_test.go index 6d4a72a54..37f3a45b9 100644 --- a/server/migrations/migrations_test.go +++ b/server/migrations/migrations_test.go @@ -208,3 +208,72 @@ func TestReadMigrations(t *testing.T) { t.Errorf("first migration filename = %q, want 0001_init.sql", migrations[0].filename) } } + +func TestReadMigrations_Sorted(t *testing.T) { + migrations, err := readMigrations() + if err != nil { + t.Fatalf("readMigrations failed: %v", err) + } + for i := 1; i < len(migrations); i++ { + if migrations[i].version <= migrations[i-1].version { + t.Errorf("migrations not sorted: version %d at index %d follows version %d at index %d", + migrations[i].version, i, migrations[i-1].version, i-1) + } + } +} + +func TestReadMigrations_AllHaveSQL(t *testing.T) { + migrations, err := readMigrations() + if err != nil { + t.Fatalf("readMigrations failed: %v", err) + } + for _, m := range migrations { + if m.sql == "" { + t.Errorf("migration %s has empty SQL", m.filename) + } + } +} + +func TestReadMigrations_BaselineIsLargest(t *testing.T) { + migrations, err := readMigrations() + if err != nil { + t.Fatalf("readMigrations failed: %v", err) + } + if len(migrations) < 2 { + t.Skip("not enough migrations to compare sizes") + } + // The baseline (0001_init.sql) should be the largest migration. + baselineLen := len(migrations[0].sql) + for _, m := range migrations[1:] { + if len(m.sql) > baselineLen { + t.Errorf("migration %s (%d bytes) is larger than baseline (%d bytes)", + m.filename, len(m.sql), baselineLen) + } + } +} + +func TestParseVersion_Comprehensive(t *testing.T) { + tests := []struct { + filename string + want int + wantErr bool + }{ + {"0001_init.sql", 1, false}, + {"0002_add_users.sql", 2, false}, + {"0100_big_change.sql", 100, false}, + {"9999_final.sql", 9999, false}, + {"bad.sql", 0, true}, + {"noseparator", 0, true}, + {"abc_description.sql", 0, true}, + } + for _, tt := range tests { + got, err := parseVersion(tt.filename) + if (err != nil) != tt.wantErr { + t.Errorf("parseVersion(%q) error = %v, wantErr %v", tt.filename, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("parseVersion(%q) = %d, want %d", tt.filename, got, tt.want) + } + } +} diff --git a/server/setup/setup.go b/server/setup/setup.go index 2514d6b31..51068d96d 100644 --- a/server/setup/setup.go +++ b/server/setup/setup.go @@ -30,8 +30,9 @@ func Run(logger *zap.Logger, port int) error { Handler: r, } - logger.Info(fmt.Sprintf("Setup wizard available at http://localhost:%d", port)) - fmt.Printf("\n >>> Open http://localhost:%d in your browser to configure Erupe <<<\n\n", port) + logger.Info("Setup wizard available", + zap.String("url", fmt.Sprintf("http://localhost:%d", port))) + logger.Warn("Open the URL above in your browser to configure Erupe") // Start the HTTP server in a goroutine. errCh := make(chan error, 1) diff --git a/server/setup/wizard_test.go b/server/setup/wizard_test.go index d86b25f55..d23ebfaaf 100644 --- a/server/setup/wizard_test.go +++ b/server/setup/wizard_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "go.uber.org/zap" @@ -195,3 +196,291 @@ func containsHelper(s, substr string) bool { } return false } + +func TestBuildDefaultConfig_EmptyLanguage(t *testing.T) { + req := FinishRequest{ + DBHost: "localhost", + DBPort: 5432, + DBUser: "postgres", + DBPassword: "pass", + DBName: "erupe", + Host: "127.0.0.1", + ClientMode: "ZZ", + Language: "", // empty — should default to "jp" + } + cfg := buildDefaultConfig(req) + + lang, ok := cfg["Language"].(string) + if !ok { + t.Fatal("Language is not a string") + } + if lang != "jp" { + t.Errorf("Language = %q, want %q", lang, "jp") + } +} + +func TestHandleTestDB_InvalidJSON(t *testing.T) { + ws := &wizardServer{ + logger: zap.NewNop(), + done: make(chan struct{}), + } + req := httptest.NewRequest("POST", "/api/setup/test-db", strings.NewReader("{invalid")) + w := httptest.NewRecorder() + ws.handleTestDB(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + if resp["error"] != "invalid JSON" { + t.Errorf("error = %q, want %q", resp["error"], "invalid JSON") + } +} + +func TestHandleInitDB_InvalidJSON(t *testing.T) { + ws := &wizardServer{ + logger: zap.NewNop(), + done: make(chan struct{}), + } + req := httptest.NewRequest("POST", "/api/setup/init-db", strings.NewReader("not json")) + w := httptest.NewRecorder() + ws.handleInitDB(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + if resp["error"] != "invalid JSON" { + t.Errorf("error = %q, want %q", resp["error"], "invalid JSON") + } +} + +func TestHandleFinish_InvalidJSON(t *testing.T) { + ws := &wizardServer{ + logger: zap.NewNop(), + done: make(chan struct{}), + } + req := httptest.NewRequest("POST", "/api/setup/finish", strings.NewReader("%%%")) + w := httptest.NewRecorder() + ws.handleFinish(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + if resp["error"] != "invalid JSON" { + t.Errorf("error = %q, want %q", resp["error"], "invalid JSON") + } +} + +func TestHandleFinish_Success(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + done := make(chan struct{}) + ws := &wizardServer{ + logger: zap.NewNop(), + done: done, + } + + body := `{"dbHost":"localhost","dbPort":5432,"dbUser":"postgres","dbPassword":"pw","dbName":"erupe","host":"10.0.0.5","clientMode":"G10","autoCreateAccount":false}` + req := httptest.NewRequest("POST", "/api/setup/finish", strings.NewReader(body)) + w := httptest.NewRecorder() + ws.handleFinish(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + // Verify config.json was written + data, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatalf("config.json not written: %v", err) + } + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("config.json is not valid JSON: %v", err) + } + if parsed["Host"] != "10.0.0.5" { + t.Errorf("Host = %v, want 10.0.0.5", parsed["Host"]) + } + if parsed["ClientMode"] != "G10" { + t.Errorf("ClientMode = %v, want G10", parsed["ClientMode"]) + } + + // Verify done channel was closed + select { + case <-done: + // expected + default: + t.Error("done channel was not closed after successful finish") + } +} + +func TestWriteJSON(t *testing.T) { + tests := []struct { + name string + status int + payload interface{} + wantStatus int + }{ + { + name: "OK with string map", + status: http.StatusOK, + payload: map[string]string{"key": "value"}, + wantStatus: http.StatusOK, + }, + { + name: "BadRequest with error", + status: http.StatusBadRequest, + payload: map[string]string{"error": "bad input"}, + wantStatus: http.StatusBadRequest, + }, + { + name: "InternalServerError", + status: http.StatusInternalServerError, + payload: map[string]string{"error": "something broke"}, + wantStatus: http.StatusInternalServerError, + }, + { + name: "OK with nested payload", + status: http.StatusOK, + payload: map[string]interface{}{"count": 42, "items": []string{"a", "b"}}, + wantStatus: http.StatusOK, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + writeJSON(w, tc.status, tc.payload) + + if w.Code != tc.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tc.wantStatus) + } + ct := w.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + // Verify body is valid JSON + var decoded interface{} + if err := json.NewDecoder(w.Body).Decode(&decoded); err != nil { + t.Errorf("response body is not valid JSON: %v", err) + } + }) + } +} + +func TestClientModesContainsExpected(t *testing.T) { + modes := clientModes() + expected := []string{"ZZ", "G10", "FW.4", "S1.0", "Z2", "GG"} + modeSet := make(map[string]bool, len(modes)) + for _, m := range modes { + modeSet[m] = true + } + for _, exp := range expected { + if !modeSet[exp] { + t.Errorf("clientModes() missing expected mode %q", exp) + } + } +} + +func TestHandleInitDB_NoOps(t *testing.T) { + ws := &wizardServer{ + logger: zap.NewNop(), + done: make(chan struct{}), + } + // All flags false — no DB operations, should succeed immediately. + body := `{"host":"localhost","port":5432,"user":"test","password":"test","dbName":"test","createDB":false,"applySchema":false,"applyBundled":false}` + req := httptest.NewRequest("POST", "/api/setup/init-db", strings.NewReader(body)) + w := httptest.NewRecorder() + ws.handleInitDB(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + if resp["success"] != true { + t.Errorf("success = %v, want true", resp["success"]) + } + log, ok := resp["log"].([]interface{}) + if !ok { + t.Fatal("log should be an array") + } + // Should contain the "complete" message + found := false + for _, entry := range log { + if s, ok := entry.(string); ok && strings.Contains(s, "complete") { + found = true + break + } + } + if !found { + t.Error("expected completion message in log") + } +} + +func TestBuildDefaultConfig_WithLanguage(t *testing.T) { + req := FinishRequest{ + DBHost: "localhost", + DBPort: 5432, + DBUser: "postgres", + DBPassword: "pass", + DBName: "erupe", + Host: "127.0.0.1", + ClientMode: "ZZ", + Language: "en", + } + cfg := buildDefaultConfig(req) + if cfg["Language"] != "en" { + t.Errorf("Language = %v, want en", cfg["Language"]) + } +} + +func TestWriteConfig_Permissions(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + cfg := buildDefaultConfig(FinishRequest{ + DBHost: "localhost", + DBPort: 5432, + DBUser: "postgres", + DBPassword: "pass", + DBName: "erupe", + Host: "127.0.0.1", + ClientMode: "ZZ", + }) + + if err := writeConfig(cfg); err != nil { + t.Fatalf("writeConfig failed: %v", err) + } + + info, err := os.Stat(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatalf("stat config.json: %v", err) + } + // File should be 0600 (owner read/write only) + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("config.json permissions = %o, want 0600", perm) + } +}