Browse Source

Use mock for all tests

Julien Laffaye 6 năm trước cách đây
mục cha
commit
55546487cf
7 tập tin đã thay đổi với 393 bổ sung383 xóa
  1. 1 5
      .travis.yml
  2. 0 19
      .travis/prepare.sh
  3. 0 9
      .travis/proftpd.conf
  4. 0 15
      .travis/vsftpd.conf
  5. 0 107
      client_multiline_test.go
  6. 57 228
      client_test.go
  7. 335 0
      conn_test.go

+ 1 - 5
.travis.yml

@@ -1,14 +1,10 @@
 language: go
-dist: trusty
 sudo: required
+dist: xenial
 go:
   - 1.10.x
   - 1.11.x
-env:
-  - FTP_SERVER=vsftpd
-  - FTP_SERVER=proftpd
 before_install:
-- sudo $TRAVIS_BUILD_DIR/.travis/prepare.sh "$FTP_SERVER"
 - sudo sysctl net.ipv6.conf.lo.disable_ipv6=0
 - go get github.com/mattn/goveralls
 - go get github.com/golang/lint/golint

+ 0 - 19
.travis/prepare.sh

@@ -1,19 +0,0 @@
-#!/bin/sh -e
-
-case "$1" in
-  proftpd)
-    mkdir -p /etc/proftpd/conf.d/
-    cp $TRAVIS_BUILD_DIR/.travis/proftpd.conf /etc/proftpd/conf.d/
-    ;;
-  vsftpd)
-    cp $TRAVIS_BUILD_DIR/.travis/vsftpd.conf /etc/vsftpd.conf
-    ;;
-  *)
-    echo "unknown software: $1"
-    exit 1
-esac
-
-mkdir --mode 0777 -p /var/ftp/incoming
-
-apt-get update -qq
-apt-get install -qq "$1"

+ 0 - 9
.travis/proftpd.conf

@@ -1,9 +0,0 @@
-<Anonymous /var/ftp>
-  User        ftp
-  Group       nogroup
-  MaxClients  2
-  # We want clients to be able to login with "anonymous" as well as "ftp"
-  UserAlias   anonymous ftp
-
-  RequireValidShell  off
-</Anonymous>

+ 0 - 15
.travis/vsftpd.conf

@@ -1,15 +0,0 @@
-# Used by Travis CI
-
-listen=NO
-listen_ipv6=YES
-
-write_enable=YES
-dirmessage_enable=YES
-secure_chroot_dir=/var/run/vsftpd/empty
-
-anonymous_enable=YES
-anon_root=/var/ftp
-anon_upload_enable=YES
-anon_mkdir_write_enable=YES
-anon_other_write_enable=YES
-anon_umask=022

+ 0 - 107
client_multiline_test.go

@@ -1,107 +0,0 @@
-package ftp
-
-import (
-	"net"
-	"net/textproto"
-	"reflect"
-	"strings"
-	"sync"
-	"testing"
-)
-
-type ftpMock struct {
-	listener net.Listener
-	commands []string // list of received commands
-	sync.WaitGroup
-}
-
-func newFtpMock(t *testing.T, addresss string) *ftpMock {
-	var err error
-	mock := &ftpMock{}
-	mock.listener, err = net.Listen("tcp", addresss)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	go func() {
-		// Listen for an incoming connection.
-		conn, err := mock.listener.Accept()
-		if err != nil {
-			t.Fatal(err)
-		}
-
-		mock.Add(1)
-		defer mock.Done()
-		defer conn.Close()
-
-		proto := textproto.NewConn(conn)
-		proto.Writer.PrintfLine("220 FTP Server ready.")
-
-		for {
-			command, _ := proto.ReadLine()
-
-			// Strip the arguments
-			if i := strings.Index(command, " "); i > 0 {
-				command = command[:i]
-			}
-
-			// Append to list of received commands
-			mock.commands = append(mock.commands, command)
-
-			// At least one command must have a multiline response
-			switch command {
-			case "FEAT":
-				proto.Writer.PrintfLine("211-Features:\r\nFEAT\r\nPASV\r\nSIZE\r\n211 End")
-			case "USER":
-				proto.Writer.PrintfLine("331 Please send your password")
-			case "PASS":
-				proto.Writer.PrintfLine("230-Hey,\r\nWelcome to my FTP\r\n230 Access granted")
-			case "TYPE":
-				proto.Writer.PrintfLine("200 Type set ok")
-			case "QUIT":
-				proto.Writer.PrintfLine("221 Goodbye.")
-				return
-			default:
-				t.Fatal("unknown command:", command)
-			}
-		}
-	}()
-
-	return mock
-}
-
-// Closes the listening socket
-func (mock *ftpMock) Close() {
-	mock.listener.Close()
-}
-
-// ftp.mozilla.org uses multiline 220 response
-func TestMultiline(t *testing.T) {
-	if testing.Short() {
-		t.Skip("skipping test in short mode.")
-	}
-
-	address := "localhost:2121"
-	mock := newFtpMock(t, address)
-	defer mock.Close()
-
-	c, err := Dial(address)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	err = c.Login("anonymous", "anonymous")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	c.Quit()
-
-	// Wait for the connection to close
-	mock.Wait()
-
-	expected := []string{"FEAT", "USER", "PASS", "TYPE", "QUIT"}
-	if !reflect.DeepEqual(mock.commands, expected) {
-		t.Fatal("unexpected sequence of commands:", mock.commands, "expected:", expected)
-	}
-}

+ 57 - 228
client_test.go

@@ -23,21 +23,15 @@ func TestConnEPSV(t *testing.T) {
 }
 
 func testConn(t *testing.T, disableEPSV bool) {
-	if testing.Short() {
-		t.Skip("skipping test in short mode.")
-	}
 
-	c, err := DialTimeout("localhost:21", 5*time.Second)
-	if err != nil {
-		t.Fatal(err)
-	}
+	mock, c := openConn(t, "127.0.0.1")
 
 	if disableEPSV {
 		delete(c.features, "EPSV")
 		c.DisableEPSV = true
 	}
 
-	err = c.Login("anonymous", "anonymous")
+	err := c.Login("anonymous", "anonymous")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -52,6 +46,15 @@ func testConn(t *testing.T, disableEPSV bool) {
 		t.Error(err)
 	}
 
+	dir, err := c.CurrentDir()
+	if err != nil {
+		t.Error(err)
+	} else {
+		if dir != "/incoming" {
+			t.Error("Wrong dir: " + dir)
+		}
+	}
+
 	data := bytes.NewBufferString(testData)
 	err = c.Stor("test", data)
 	if err != nil {
@@ -115,26 +118,12 @@ func testConn(t *testing.T, disableEPSV bool) {
 		r.Close()
 	}
 
-	fileSize, err := c.FileSize("tset")
+	fileSize, err := c.FileSize("magic-file")
 	if err != nil {
 		t.Error(err)
 	}
-	if fileSize != 14 {
-		t.Errorf("file size %q, expected %q", fileSize, 14)
-	}
-
-	data = bytes.NewBufferString("")
-	err = c.Stor("tset", data)
-	if err != nil {
-		t.Error(err)
-	}
-
-	fileSize, err = c.FileSize("tset")
-	if err != nil {
-		t.Error(err)
-	}
-	if fileSize != 0 {
-		t.Errorf("file size %q, expected %q", fileSize, 0)
+	if fileSize != 42 {
+		t.Errorf("file size %q, expected %q", fileSize, 42)
 	}
 
 	_, err = c.FileSize("not-found")
@@ -157,15 +146,6 @@ func testConn(t *testing.T, disableEPSV bool) {
 		t.Error(err)
 	}
 
-	dir, err := c.CurrentDir()
-	if err != nil {
-		t.Error(err)
-	} else {
-		if dir != "/incoming/"+testDir {
-			t.Error("Wrong dir: " + dir)
-		}
-	}
-
 	err = c.ChangeDirToParent()
 	if err != nil {
 		t.Error(err)
@@ -195,7 +175,12 @@ func testConn(t *testing.T, disableEPSV bool) {
 		}
 	}
 
-	c.Quit()
+	if err := c.Quit(); err != nil {
+		t.Fatal(err)
+	}
+
+	// Wait for the connection to close
+	mock.Wait()
 
 	err = c.NoOp()
 	if err == nil {
@@ -203,41 +188,23 @@ func testConn(t *testing.T, disableEPSV bool) {
 	}
 }
 
-func TestConnIPv6(t *testing.T) {
-	if testing.Short() {
-		t.Skip("skipping test in short mode.")
-	}
-
-	c, err := DialTimeout("[::1]:21", 5*time.Second)
+// TestConnect tests the legacy Connect function
+func TestConnect(t *testing.T) {
+	mock, err := newFtpMock(t, "127.0.0.1")
 	if err != nil {
 		t.Fatal(err)
 	}
+	defer mock.Close()
 
-	err = c.Login("anonymous", "anonymous")
+	c, err := Connect(mock.Addr())
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	_, err = c.List(".")
-	if err != nil {
-		t.Error(err)
-	}
-
-	c.Quit()
-}
-
-// TestConnect tests the legacy Connect function
-func TestConnect(t *testing.T) {
-	if testing.Short() {
-		t.Skip("skipping test in short mode.")
-	}
-
-	c, err := Connect("localhost:21")
-	if err != nil {
+	if err := c.Quit(); err != nil {
 		t.Fatal(err)
 	}
-
-	c.Quit()
+	mock.Wait()
 }
 
 func TestTimeout(t *testing.T) {
@@ -253,11 +220,13 @@ func TestTimeout(t *testing.T) {
 }
 
 func TestWrongLogin(t *testing.T) {
-	if testing.Short() {
-		t.Skip("skipping test in short mode.")
+	mock, err := newFtpMock(t, "127.0.0.1")
+	if err != nil {
+		t.Fatal(err)
 	}
+	defer mock.Close()
 
-	c, err := DialTimeout("localhost:21", 5*time.Second)
+	c, err := DialTimeout(mock.Addr(), 5*time.Second)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -270,189 +239,49 @@ func TestWrongLogin(t *testing.T) {
 }
 
 func TestDeleteDirRecur(t *testing.T) {
-	if testing.Short() {
-		t.Skip("skipping test in short mode.")
-	}
-	c, err := DialTimeout("localhost:21", 5*time.Second)
-	if err != nil {
-		t.Fatal(err)
-	}
+	mock, c := openConn(t, "127.0.0.1")
 
-	err = c.Login("anonymous", "anonymous")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	err = c.NoOp()
-	if err != nil {
-		t.Error(err)
-	}
-
-	err = c.ChangeDir("incoming")
-	if err != nil {
-		t.Error(err)
-	}
-
-	err = c.MakeDir("testDir")
-	if err != nil {
-		t.Error(err)
-	}
-
-	err = c.ChangeDir("testDir")
-	if err != nil {
-		t.Error(err)
-	}
-
-	err = c.MakeDir("anotherDir")
-	if err != nil {
-		t.Error(err)
-	}
-
-	data := bytes.NewBufferString("test text")
-	err = c.Stor("fileTest", data)
-	if err != nil {
-		t.Error(err)
-	}
-
-	err = c.ChangeDirToParent()
-	if err != nil {
-		t.Error(err)
-	}
-	err = c.RemoveDirRecur("testDir")
-	if err != nil {
-		t.Error(err)
-	}
-	dir, err := c.CurrentDir()
+	err := c.RemoveDirRecur("testDir")
 	if err != nil {
 		t.Error(err)
-	} else {
-		if dir != "/incoming" {
-			t.Error("Wrong dir: " + dir)
-		}
-	}
-
-	err = c.ChangeDir("testDir")
-	if err == nil {
-		t.Fatal("expected error, got nil")
-	}
-
-	err = c.Logout()
-	if err != nil {
-		if protoErr := err.(*textproto.Error); protoErr != nil {
-			if protoErr.Code != StatusNotImplemented {
-				t.Error(err)
-			}
-		} else {
-			t.Error(err)
-		}
 	}
 
-	c.Quit()
-}
-
-func TestFileDeleteDirRecur(t *testing.T) {
-	if testing.Short() {
-		t.Skip("skipping test in short mode.")
-	}
-
-	c, err := DialTimeout("localhost:21", 5*time.Second)
-	if err != nil {
+	if err := c.Quit(); err != nil {
 		t.Fatal(err)
 	}
 
-	err = c.Login("anonymous", "anonymous")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	err = c.ChangeDir("incoming")
-	if err != nil {
-		t.Error(err)
-	}
-
-	data := bytes.NewBufferString(testData)
-	err = c.Stor("testFile", data)
-	if err != nil {
-		t.Error(err)
-	}
-
-	err = c.RemoveDirRecur("testFile")
-	if err == nil {
-		t.Fatal("expected error got nill")
-	}
+	// Wait for the connection to close
+	mock.Wait()
+}
 
-	dir, err := c.CurrentDir()
-	if err != nil {
-		t.Error(err)
-	} else {
-		if dir != "/incoming" {
-			t.Error("Wrong dir: " + dir)
-		}
-	}
+// func TestFileDeleteDirRecur(t *testing.T) {
+// 	mock, c := openConn(t, "127.0.0.1")
 
-	err = c.Delete("testFile")
-	if err != nil {
-		t.Error(err)
-	}
+// 	err := c.RemoveDirRecur("testFile")
+// 	if err == nil {
+// 		t.Fatal("expected error got nil")
+// 	}
 
-	err = c.Logout()
-	if err != nil {
-		if protoErr := err.(*textproto.Error); protoErr != nil {
-			if protoErr.Code != StatusNotImplemented {
-				t.Error(err)
-			}
-		} else {
-			t.Error(err)
-		}
-	}
+// 	if err := c.Quit(); err != nil {
+// 		t.Fatal(err)
+// 	}
 
-	c.Quit()
-}
+// 	// Wait for the connection to close
+// 	mock.Wait()
+// }
 
 func TestMissingFolderDeleteDirRecur(t *testing.T) {
-	if testing.Short() {
-		t.Skip("skipping test in short mode.")
-	}
+	mock, c := openConn(t, "127.0.0.1")
 
-	c, err := DialTimeout("localhost:21", 5*time.Second)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	err = c.Login("anonymous", "anonymous")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	err = c.ChangeDir("incoming")
-	if err != nil {
-		t.Error(err)
-	}
-
-	err = c.RemoveDirRecur("test")
+	err := c.RemoveDirRecur("missing-dir")
 	if err == nil {
-		t.Fatal("expected error got nill")
+		t.Fatal("expected error got nil")
 	}
 
-	dir, err := c.CurrentDir()
-	if err != nil {
-		t.Error(err)
-	} else {
-		if dir != "/incoming" {
-			t.Error("Wrong dir: " + dir)
-		}
-	}
-
-	err = c.Logout()
-	if err != nil {
-		if protoErr := err.(*textproto.Error); protoErr != nil {
-			if protoErr.Code != StatusNotImplemented {
-				t.Error(err)
-			}
-		} else {
-			t.Error(err)
-		}
+	if err := c.Quit(); err != nil {
+		t.Fatal(err)
 	}
 
-	c.Quit()
+	// Wait for the connection to close
+	mock.Wait()
 }

+ 335 - 0
conn_test.go

@@ -0,0 +1,335 @@
+package ftp
+
+import (
+	"errors"
+	"io"
+	"io/ioutil"
+	"net"
+	"net/textproto"
+	"reflect"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+)
+
+type ftpMock struct {
+	address  string
+	listener *net.TCPListener
+	proto    *textproto.Conn
+	commands []string // list of received commands
+	rest     int
+	dataConn *mockDataConn
+	sync.WaitGroup
+}
+
+// newFtpMock returns a mock implementation of a FTP server
+// For simplication, a mock instance only accepts a signle connection and terminates afer
+func newFtpMock(t *testing.T, address string) (*ftpMock, error) {
+	var err error
+	mock := &ftpMock{address: address}
+
+	l, err := net.Listen("tcp", address+":0")
+	if err != nil {
+		return nil, err
+	}
+
+	tcpListener, ok := l.(*net.TCPListener)
+	if !ok {
+		return nil, errors.New("listener is not a net.TCPListener")
+	}
+	mock.listener = tcpListener
+
+	go mock.listen(t)
+
+	return mock, nil
+}
+
+func (mock *ftpMock) listen(t *testing.T) {
+	// Listen for an incoming connection.
+	conn, err := mock.listener.Accept()
+	if err != nil {
+		t.Errorf("can not accept: %s", err)
+		return
+	}
+
+	// Do not accept incoming connections anymore
+	mock.listener.Close()
+
+	mock.Add(1)
+	defer mock.Done()
+	defer conn.Close()
+
+	mock.proto = textproto.NewConn(conn)
+	mock.proto.Writer.PrintfLine("220 FTP Server ready.")
+
+	for {
+		fullCommand, _ := mock.proto.ReadLine()
+
+		cmdParts := strings.Split(fullCommand, " ")
+
+		// Append to list of received commands
+		mock.commands = append(mock.commands, cmdParts[0])
+
+		// At least one command must have a multiline response
+		switch cmdParts[0] {
+		case "FEAT":
+			mock.proto.Writer.PrintfLine("211-Features:\r\n FEAT\r\n PASV\r\n EPSV\r\n SIZE\r\n211 End")
+		case "USER":
+			if cmdParts[1] == "anonymous" {
+				mock.proto.Writer.PrintfLine("331 Please send your password")
+			} else {
+				mock.proto.Writer.PrintfLine("530 This FTP server is anonymous only")
+			}
+		case "PASS":
+			mock.proto.Writer.PrintfLine("230-Hey,\r\nWelcome to my FTP\r\n230 Access granted")
+		case "TYPE":
+			mock.proto.Writer.PrintfLine("200 Type set ok")
+		case "CWD":
+			if cmdParts[1] == "missing-dir" {
+				mock.proto.Writer.PrintfLine("550 %s: No such file or directory", cmdParts[1])
+			} else {
+				mock.proto.Writer.PrintfLine("250 Directory successfully changed.")
+			}
+		case "DELE":
+			mock.proto.Writer.PrintfLine("250 File successfully removed.")
+		case "MKD":
+			mock.proto.Writer.PrintfLine("257 Directory successfully created.")
+		case "RMD":
+			if cmdParts[1] == "missing-dir" {
+				mock.proto.Writer.PrintfLine("550 No such file or directory")
+			} else {
+				mock.proto.Writer.PrintfLine("250 Directory successfully removed.")
+			}
+		case "PWD":
+			mock.proto.Writer.PrintfLine("257 \"/incoming\"")
+		case "CDUP":
+			mock.proto.Writer.PrintfLine("250 CDUP command successful")
+		case "SIZE":
+			if cmdParts[1] == "magic-file" {
+				mock.proto.Writer.PrintfLine("213 42")
+			} else {
+				mock.proto.Writer.PrintfLine("550 Could not get file size.")
+			}
+		case "PASV":
+			p, err := mock.listenDataConn()
+			if err != nil {
+				mock.proto.Writer.PrintfLine("451 %s.", err)
+				break
+			}
+
+			p1 := int(p / 256)
+			p2 := p % 256
+
+			mock.proto.Writer.PrintfLine("227 Entering Passive Mode (127,0,0,1,%d,%d).", p1, p2)
+		case "EPSV":
+			p, err := mock.listenDataConn()
+			if err != nil {
+				mock.proto.Writer.PrintfLine("451 %s.", err)
+				break
+			}
+			mock.proto.Writer.PrintfLine("229 Entering Extended Passive Mode (|||%d|)", p)
+		case "STOR":
+			if mock.dataConn == nil {
+				mock.proto.Writer.PrintfLine("425 Unable to build data connection: Connection refused")
+				break
+			}
+			mock.proto.Writer.PrintfLine("150 please send")
+			mock.recvDataConn()
+		case "LIST":
+			if mock.dataConn == nil {
+				mock.proto.Writer.PrintfLine("425 Unable to build data connection: Connection refused")
+				break
+			}
+
+			mock.dataConn.Wait()
+			mock.proto.Writer.PrintfLine("150 Opening ASCII mode data connection for file list")
+			mock.dataConn.conn.Write([]byte("-rw-r--r--   1 ftp      wheel           0 Jan 29 10:29 lo"))
+			mock.proto.Writer.PrintfLine("226 Transfer complete")
+			mock.closeDataConn()
+		case "NLST":
+			if mock.dataConn == nil {
+				mock.proto.Writer.PrintfLine("425 Unable to build data connection: Connection refused")
+				break
+			}
+
+			mock.dataConn.Wait()
+			mock.proto.Writer.PrintfLine("150 Opening ASCII mode data connection for file list")
+			mock.dataConn.conn.Write([]byte("/incoming"))
+			mock.proto.Writer.PrintfLine("226 Transfer complete")
+			mock.closeDataConn()
+		case "RETR":
+			if mock.dataConn == nil {
+				mock.proto.Writer.PrintfLine("425 Unable to build data connection: Connection refused")
+				break
+			}
+
+			mock.dataConn.Wait()
+			mock.proto.Writer.PrintfLine("150 Opening ASCII mode data connection for file list")
+			mock.dataConn.conn.Write([]byte(testData[mock.rest:]))
+			mock.rest = 0
+			mock.proto.Writer.PrintfLine("226 Transfer complete")
+			mock.closeDataConn()
+		case "RNFR":
+			mock.proto.Writer.PrintfLine("350 File or directory exists, ready for destination name")
+		case "RNTO":
+			mock.proto.Writer.PrintfLine("250 Rename successful")
+		case "REST":
+			if len(cmdParts) != 2 {
+				mock.proto.Writer.PrintfLine("500 wrong number of arguments")
+				break
+			}
+			rest, err := strconv.Atoi(cmdParts[1])
+			if err != nil {
+				mock.proto.Writer.PrintfLine("500 REST: %s", err)
+				break
+			}
+			mock.rest = rest
+			mock.proto.Writer.PrintfLine("350 Restarting at %s. Send STORE or RETRIEVE to initiate transfer", cmdParts[1])
+		case "NOOP":
+			mock.proto.Writer.PrintfLine("200 NOOP ok.")
+		case "REIN":
+			mock.proto.Writer.PrintfLine("220 Logged out")
+		case "QUIT":
+			mock.proto.Writer.PrintfLine("221 Goodbye.")
+			return
+		default:
+			mock.proto.Writer.PrintfLine("500 Unknown command %s.", cmdParts[0])
+		}
+	}
+}
+
+func (mock *ftpMock) closeDataConn() (err error) {
+	if mock.dataConn != nil {
+		err = mock.dataConn.Close()
+		mock.dataConn = nil
+	}
+	return
+}
+
+type mockDataConn struct {
+	listener *net.TCPListener
+	conn     net.Conn
+	// WaitGroup is done when conn is accepted and stored
+	sync.WaitGroup
+}
+
+func (d *mockDataConn) Close() (err error) {
+	if d.listener != nil {
+		err = d.listener.Close()
+	}
+	if d.conn != nil {
+		err = d.conn.Close()
+	}
+	return
+}
+
+func (mock *ftpMock) listenDataConn() (int64, error) {
+	mock.closeDataConn()
+
+	l, err := net.Listen("tcp", mock.address+":0")
+	if err != nil {
+		return 0, err
+	}
+
+	tcpListener, ok := l.(*net.TCPListener)
+	if !ok {
+		return 0, errors.New("listener is not a net.TCPListener")
+	}
+
+	addr := tcpListener.Addr().String()
+
+	_, port, err := net.SplitHostPort(addr)
+	if err != nil {
+		return 0, err
+	}
+
+	p, err := strconv.ParseInt(port, 10, 32)
+	if err != nil {
+		return 0, err
+	}
+
+	dataConn := &mockDataConn{listener: tcpListener}
+	dataConn.Add(1)
+
+	go func() {
+		// Listen for an incoming connection.
+		conn, err := dataConn.listener.Accept()
+		if err != nil {
+			// t.Errorf("can not accept: %s", err)
+			return
+		}
+
+		dataConn.conn = conn
+		dataConn.Done()
+	}()
+
+	mock.dataConn = dataConn
+	return p, nil
+}
+
+func (mock *ftpMock) recvDataConn() {
+	mock.dataConn.Wait()
+	io.Copy(ioutil.Discard, mock.dataConn.conn)
+	mock.proto.Writer.PrintfLine("226 Transfer Complete")
+	mock.closeDataConn()
+}
+
+func (mock *ftpMock) Addr() string {
+	return mock.listener.Addr().String()
+}
+
+// Closes the listening socket
+func (mock *ftpMock) Close() {
+	mock.listener.Close()
+}
+
+// Helper to return a client connected to a mock server
+func openConn(t *testing.T, addr string) (*ftpMock, *ServerConn) {
+	mock, err := newFtpMock(t, addr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer mock.Close()
+
+	c, err := Dial(mock.Addr())
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = c.Login("anonymous", "anonymous")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	return mock, c
+
+}
+
+// Helper to close a client connected to a mock server
+func closeConn(t *testing.T, mock *ftpMock, c *ServerConn, commands []string) {
+	expected := []string{"FEAT", "USER", "PASS", "TYPE"}
+	expected = append(expected, commands...)
+	expected = append(expected, "QUIT")
+
+	if err := c.Quit(); err != nil {
+		t.Fatal(err)
+	}
+
+	// Wait for the connection to close
+	mock.Wait()
+
+	if !reflect.DeepEqual(mock.commands, expected) {
+		t.Fatal("unexpected sequence of commands:", mock.commands, "expected:", expected)
+	}
+}
+
+func TestConn4(t *testing.T) {
+	mock, c := openConn(t, "127.0.0.1")
+	closeConn(t, mock, c, nil)
+}
+
+func TestConn6(t *testing.T) {
+	mock, c := openConn(t, "[::1]")
+	closeConn(t, mock, c, nil)
+}