package mysqlserver import ( "context" "fmt" "net" "net/http/httptest" "strings" "testing" "time" "mysql-tunnel/internal/navicat" "mysql-tunnel/internal/tunnel" ) type stubBackend struct{} func (stubBackend) Query(_ context.Context, _ string, sqls ...string) (*navicat.QueryResponse, error) { var results []navicat.Result for _, sql := range sqls { s := strings.TrimSpace(sql) u := strings.ToUpper(s) switch { case strings.HasPrefix(u, "SET NAMES"), strings.HasPrefix(u, "USE "): results = append(results, navicat.Result{}) case u == "SELECT 1" || strings.HasPrefix(u, "SELECT 1 "): results = append(results, navicat.Result{ NumFields: 1, NumRows: 1, Fields: []navicat.Field{{Name: "1", Type: 8, Length: 20}}, Rows: [][]navicat.Cell{{{Bytes: []byte("1")}}}, }) case strings.Contains(u, "SELECT NULL"): results = append(results, navicat.Result{ NumFields: 1, NumRows: 1, Fields: []navicat.Field{{Name: "n", Type: 6}}, Rows: [][]navicat.Cell{{{Null: true}}}, }) case strings.Contains(strings.ToLower(s), "bigint_val"): results = append(results, navicat.Result{ NumFields: 1, NumRows: 1, Fields: []navicat.Field{{Name: "bigint_val", Type: 8, Flags: 32, Length: 20}}, Rows: [][]navicat.Cell{{{Bytes: []byte("18446744073709551615")}}}, }) case strings.Contains(strings.ToLower(s), "dec_val"): results = append(results, navicat.Result{ NumFields: 1, NumRows: 1, Fields: []navicat.Field{{Name: "dec_val", Type: 246, Length: 32}}, Rows: [][]navicat.Cell{{{Bytes: []byte("1234567890.123456789")}}}, }) case strings.Contains(s, "你好") || strings.Contains(strings.ToLower(s), "zh_val"): results = append(results, navicat.Result{ NumFields: 1, NumRows: 1, Fields: []navicat.Field{{Name: "zh_val", Type: 253, Length: 12}}, Rows: [][]navicat.Cell{{{Bytes: []byte("你好世界")}}}, }) case strings.HasPrefix(u, "UPDATE"), strings.HasPrefix(u, "INSERT"): results = append(results, navicat.Result{AffectedRows: 2, InsertID: 7}) case strings.Contains(u, "SYNTAX"): results = append(results, navicat.Result{Errno: 1064, Message: "You have an error in your SQL syntax"}) default: results = append(results, navicat.Result{Errno: 1064, Message: "stub: unhandled SQL"}) } } return &navicat.QueryResponse{Results: results}, nil } func startServer(t *testing.T, backend Backend, user, pass string) (addr string, cancel context.CancelFunc) { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) s := New(Options{ Host: "127.0.0.1", Port: 1, Username: user, Password: pass, DefaultDB: "app", MaxConnections: 5, Timeout: 5 * time.Second, }, backend, nil) errCh := make(chan error, 1) go func() { errCh <- s.Serve(ctx, ln) }() t.Cleanup(func() { cancel() _ = ln.Close() }) return ln.Addr().String(), cancel } type testClient struct { pc *packetConn caps uint32 } func dialTest(t *testing.T, addr, user, pass, db string) *testClient { t.Helper() c, err := net.DialTimeout("tcp", addr, 3*time.Second) if err != nil { t.Fatal(err) } pc := newPacketConn(c, 5*time.Second) hs, err := pc.readPacket() if err != nil { t.Fatal(err) } scramble, err := scrambleFromHandshake(hs) if err != nil { t.Fatal(err) } caps := clientProtocol41 | clientSecureConnection | clientPluginAuth | clientConnectWithDB | clientPluginAuthLenencClientData | clientDeprecateEOF | clientMultiResults token := nativePasswordToken(pass, scramble) if err := pc.writePacket(buildClientAuth(user, token, db, caps)); err != nil { t.Fatal(err) } ok, err := pc.readPacket() if err != nil { t.Fatal(err) } if len(ok) == 0 || ok[0] != markerOK { t.Fatalf("auth failed: %x %q", ok, ok) } return &testClient{pc: pc, caps: caps} } func scrambleFromHandshake(payload []byte) ([]byte, error) { if len(payload) < 20 || payload[0] != ProtocolVersion { return nil, fmt.Errorf("bad handshake") } off := 1 _, off, err := readNUL(payload, off) if err != nil { return nil, err } off += 4 // conn id part1 := payload[off : off+8] off += 8 + 1 + 2 + 1 + 2 + 2 + 1 + 10 if off+13 > len(payload) { return nil, fmt.Errorf("short scramble") } part2 := payload[off : off+12] s := append(append([]byte{}, part1...), part2...) return s, nil } func (c *testClient) query(t *testing.T, sql string) (cols []string, rows [][]*string, affected, lastID uint64, errno int, errMsg string) { t.Helper() c.pc.resetSeq() payload := append([]byte{ComQuery}, []byte(sql)...) if err := c.pc.writePacket(payload); err != nil { t.Fatal(err) } pkt, err := c.pc.readPacket() if err != nil { t.Fatal(err) } if len(pkt) == 0 { t.Fatal("empty") } if pkt[0] == markerERR { errno = int(pkt[1]) | int(pkt[2])<<8 if len(pkt) > 9 { errMsg = string(pkt[9:]) } return } if pkt[0] == markerOK { n, off, _ := readLenencInt(pkt, 1) id, _, _ := readLenencInt(pkt, off) return nil, nil, n, id, 0, "" } nCols, _, err := readLenencInt(pkt, 0) if err != nil { t.Fatal(err) } for i := uint64(0); i < nCols; i++ { colPkt, err := c.pc.readPacket() if err != nil { t.Fatal(err) } name := columnName(colPkt) cols = append(cols, name) } // metadata end OK/EOF if _, err := c.pc.readPacket(); err != nil { t.Fatal(err) } for { rowPkt, err := c.pc.readPacket() if err != nil { t.Fatal(err) } if len(rowPkt) == 0 { break } if rowPkt[0] == markerERR { errno = int(rowPkt[1]) | int(rowPkt[2])<<8 return } if rowPkt[0] == markerEOF && len(rowPkt) < 9 { break } if rowPkt[0] == markerOK && len(rowPkt) >= 7 { break } off := 0 row := make([]*string, nCols) for i := uint64(0); i < nCols; i++ { if off >= len(rowPkt) { t.Fatal("short row") } if rowPkt[off] == markerNull { row[i] = nil off++ continue } b, nOff, err := readLenencString(rowPkt, off) if err != nil { t.Fatal(err) } s := string(b) row[i] = &s off = nOff } rows = append(rows, row) } return } func (c *testClient) ping(t *testing.T) { t.Helper() c.pc.resetSeq() if err := c.pc.writePacket([]byte{ComPing}); err != nil { t.Fatal(err) } pkt, err := c.pc.readPacket() if err != nil { t.Fatal(err) } if pkt[0] != markerOK { t.Fatalf("ping %x", pkt) } } func (c *testClient) quit() { c.pc.resetSeq() _ = c.pc.writePacket([]byte{ComQuit}) _ = c.pc.close() } func columnName(pkt []byte) string { off := 0 skip := func() { _, n, err := readLenencString(pkt, off) if err != nil { return } off = n } skip() // catalog skip() // schema skip() // table skip() // org table b, _, err := readLenencString(pkt, off) if err != nil { return "" } return string(b) } func TestServerAuthPingQueryQuit(t *testing.T) { addr, _ := startServer(t, stubBackend{}, "root", "123456") cl := dialTest(t, addr, "root", "123456", "app") cl.ping(t) _, rows, _, _, errno, msg := cl.query(t, "SELECT 1") if errno != 0 { t.Fatalf("errno %d %s", errno, msg) } if len(rows) != 1 || rows[0][0] == nil || *rows[0][0] != "1" { t.Fatalf("rows=%v", rows) } cl.quit() } func TestServerBadPassword(t *testing.T) { addr, _ := startServer(t, stubBackend{}, "root", "123456") c, err := net.DialTimeout("tcp", addr, 3*time.Second) if err != nil { t.Fatal(err) } defer c.Close() pc := newPacketConn(c, 5*time.Second) hs, err := pc.readPacket() if err != nil { t.Fatal(err) } scramble, err := scrambleFromHandshake(hs) if err != nil { t.Fatal(err) } caps := clientProtocol41 | clientSecureConnection | clientPluginAuth | clientPluginAuthLenencClientData tok := nativePasswordToken("wrong", scramble) if err := pc.writePacket(buildClientAuth("root", tok, "", caps)); err != nil { t.Fatal(err) } pkt, err := pc.readPacket() if err != nil { t.Fatal(err) } if pkt[0] != markerERR { t.Fatalf("want ERR got %x", pkt) } errno := int(pkt[1]) | int(pkt[2])<<8 if errno != int(errAccessDenied) { t.Fatalf("errno %d", errno) } } func TestServerResultTypes(t *testing.T) { addr, _ := startServer(t, stubBackend{}, "root", "p") cl := dialTest(t, addr, "root", "p", "app") defer cl.quit() _, rows, _, _, errno, _ := cl.query(t, "SELECT NULL AS n") if errno != 0 || rows[0][0] != nil { t.Fatalf("null errno=%d row=%v", errno, rows) } _, rows, _, _, errno, _ = cl.query(t, "SELECT bigint_val") if errno != 0 || *rows[0][0] != "18446744073709551615" { t.Fatalf("bigint %v errno=%d", rows, errno) } _, rows, _, _, errno, _ = cl.query(t, "SELECT dec_val") if errno != 0 || *rows[0][0] != "1234567890.123456789" { t.Fatalf("dec %v", rows) } _, rows, _, _, errno, _ = cl.query(t, "SELECT zh_val") if errno != 0 || *rows[0][0] != "你好世界" { t.Fatalf("zh %v", rows) } _, _, affected, lastID, errno, msg := cl.query(t, "UPDATE t SET x=1") if errno != 0 { t.Fatal(msg) } if affected != 2 || lastID != 7 { t.Fatalf("affected=%d id=%d", affected, lastID) } _, _, _, _, errno, msg = cl.query(t, "SELECT SYNTAX") if errno != 1064 || !strings.Contains(msg, "syntax") { t.Fatalf("errno=%d msg=%s", errno, msg) } } func TestServerAuthSwitch(t *testing.T) { addr, _ := startServer(t, stubBackend{}, "root", "p") c, err := net.DialTimeout("tcp", addr, 3*time.Second) if err != nil { t.Fatal(err) } defer c.Close() pc := newPacketConn(c, 5*time.Second) hs, err := pc.readPacket() if err != nil { t.Fatal(err) } scramble, err := scrambleFromHandshake(hs) if err != nil { t.Fatal(err) } caps := clientProtocol41 | clientSecureConnection | clientPluginAuth | clientPluginAuthLenencClientData b := appendU32(nil, caps) b = appendU32(b, MaxPacketSize) b = append(b, charsetUTF8MB4) b = append(b, make([]byte, 23)...) b = appendNUL(b, "root") b = appendLenencInt(b, 0) // empty, will switch b = appendNUL(b, AuthPluginSHA2) if err := pc.writePacket(b); err != nil { t.Fatal(err) } sw, err := pc.readPacket() if err != nil { t.Fatal(err) } if sw[0] != markerEOF { t.Fatalf("want auth switch %x", sw) } tok := nativePasswordToken("p", scramble) if err := pc.writePacket(tok); err != nil { t.Fatal(err) } ok, err := pc.readPacket() if err != nil { t.Fatal(err) } if ok[0] != markerOK { t.Fatalf("auth after switch %x", ok) } } func TestPhase4NavicatStack(t *testing.T) { mock := httptest.NewServer(navicat.NewMockHandler(navicat.WithToken("t"))) t.Cleanup(mock.Close) httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second}) n := navicat.NewClient(httpClient, navicat.ClientOptions{ URL: mock.URL + "?token=t", Target: navicat.Target{ Host: "mysql", Port: 3306, User: "remote", Password: "rp", Database: "app", Charset: "utf8mb4", }, }) addr, _ := startServer(t, n, "root", "local") cl := dialTest(t, addr, "root", "local", "app") defer cl.quit() _, rows, _, _, errno, msg := cl.query(t, "SELECT 1") if errno != 0 { t.Fatal(msg) } if *rows[0][0] != "1" { t.Fatalf("%v", rows) } }