kilabit.info
Build | GitHub | Mastodon | SourceHut |

This journal is part of series,

In awwan[1] application, there is a functionality to run a command using sudo. In this journal we will take a notes on how to create integration tests for it, is it possible or not.

Environment

We will do editing on the host and running the test in the container.

In the container we have user “awwan” with password “awwan” and with following sudoers configuration,

awwan ALL=(ALL:ALL) ALL
awwanssh ALL=(ALL:ALL) NOPASSWD: ALL

## Always ask for password.
Defaults:awwan timestamp_timeout=0,passwd_tries=1

The "Defaults" has option "timestamp_timeout=0" to always ask for password instead of caching it, and "passwd_tries=1" to only ask for password once, not three times (as per default).

Lets boot up our container first,

$ make setup-mkosi
>>> Creating symlinks to simplify binding ...
ln -sf /home/ms/.cache/go-build _mkosi/mkosi.cache/gocache
ln -sf /home/ms/go/pkg/mod _mkosi/mkosi.cache/gomodcache
>>> Booting awwan-test container ...
sudo mkosi --directory=_mkosi/ boot
[sudo] password for ms:
systemd 254.5-1-arch running in system mode (+PAM +AUDIT -SELINUX -APPARMOR
-IMA +SMACK +SECCOMP +GCRYPT +GNUTLS +OPENSSL +ACL +BLKID +CURL +ELFUTILS
+FIDO2 +IDN2 -IDN +IPTC +KMOD +LIBCRYPTSETUP +LIBFDISK +PCRE2 -PWQUALITY
+P11KIT -QRENCODE +TPM2 +BZIP2 +LZ4 +XZ +ZLIB +ZSTD +BPF_FRAMEWORK
+XKBCOMMON +UTMP -SYSVINIT default-hierarchy=unified)
Detected virtualization systemd-nspawn.
Detected architecture x86-64.
Received regular credentials: agetty.autologin, firstboot.locale,
firstboot.timezone, login.noauth
Acquired 4 regular credentials, 0 untrusted credentials.

Welcome to Arch Linux!

<TRUNCATED>

Code to be tested

Testing "sudo" with Go means, we execute a command "sudo" using os/exec.Cmd.

In awwan, we have a "ExecLocal" function to execute it,

// ExecLocal execute the command with its arguments in local environment
// where the output and error send to os.Stdout and os.Stderr respectively.
func ExecLocal(req *Request, rawCmd string) (err error) {
    var cmd = exec.Command(`/bin/sh`, `-c`, rawCmd)

    cmd.Stdin = req.stdin
    cmd.Stdout = req.stdout
    cmd.Stderr = req.stderr

    err = cmd.Run()
    if err != nil {
        return fmt.Errorf(`ExecLocal: %w`, err)
    }
    return nil
}

The "*Request" is a struct that contains fields to overwrite default "exec.Cmd" Stdin, Stdout, and Stderr.

// Request for executing local or remote script.
// Each request define the Mode of execution, Script file to be executed,
// and the lineRange -- list of line numbers to be executed.
type Request struct {
    // Each request may set the Writer where the command read input from
    // or output and error will be written.
    // If its nil, it will default to os.DevNull (default os
    // [exec/Cmd]), os.Stdout, and os.Stderr, respectively.
    stdin  io.Reader
    stdout io.Writer
    stderr io.Writer

    <TRUNCATED>
}

Test case 1: sudo with valid password

Lets create a test that run sudo first and see what would happened,

//go:build integration

package awwan

import (
	"testing"
)

func TestExecLocal_sudo(t *testing.T) {
	var (
		req    = &Request{}
		rawCmd = `sudo echo "hello sudo"`

		err error
	)

	err = ExecLocal(req, rawCmd)
	if err != nil {
		t.Fatal(err)
	}
}

Inside the container we run the test,

[awwan@awwan-test src]$ go test -v -tags=integration -run=ExecLocal_sudo
=== RUN   TestExecLocal_sudo
[sudo] password for awwan:
signal: interrupt
FAIL    git.sr.ht/~shulhan/awwan        10.799s

The test wait for password, as expected, so we force stop it using CTRL+C.

Lets mock the stdin and fill the password to be read by sudo. To be able to do this we need to add "-S" option to sudo [2]

-S, —stdin

Write the prompt to the standard error and read the password from the standard input instead of using the terminal device.

Not only we need to mock stdin, we also need to mock stderr.

 package awwan

 import (
+	"bytes"
 	"testing"
 )

 func TestExecLocal_sudo(t *testing.T) {
 	var (
-		req    = &Request{}
-		rawCmd = `sudo ls -al`
+		mockin  = &bytes.Buffer{}
+		mockout = &bytes.Buffer{}
+		req     = &Request{
+			stdin:  mockin,
+			stdout: mockout,
+			stderr: mockout,
+		}
+		rawCmd = `sudo -S echo "hello sudo"`

 		err error
 	)

+	mockin.WriteString("awwan\n")
+
 	err = ExecLocal(req, rawCmd)
 	if err != nil {
 		t.Fatal(err)
 	}

Run the test again,

[awwan@awwan-test src]$ go test -v -tags=integration -run=ExecLocal_sudo
=== RUN   TestExecLocal_sudo
    sudo_test.go:30: Stdout+Stderr: [sudo] password for awwan: hello sudo
--- PASS: TestExecLocal_sudo (0.12s)
PASS
ok      git.sr.ht/~shulhan/awwan        0.123s

It works. But does the output correct? Lets check the output in mockout.

 import (
 	"bytes"
 	"testing"
+
+	"github.com/shuLhan/share/lib/test"
 )

 func TestExecLocal_sudo(t *testing.T) {
 	var (
@@ -29,5 +31,7 @@ func TestExecLocal_sudo(t *testing.T) {
 	err = ExecLocal(req, rawCmd)
 	if err != nil {
 		t.Fatal(err)
 	}
+
+	test.Assert(t, "stdout", `hello sudo`, mockout.String())
 }

Run the test again,

[awwan@awwan-test src]$ go test -v -tags=integration -run=ExecLocal_sudo
=== RUN   TestExecLocal_sudo
    test.go:43:
        !!! ERR git.sr.ht/~shulhan/awwan.TestExecLocal_sudo(0xc00026c000)
                /home/awwan/src/sudo_test.go:36 +0x18e

    test.go:113: !!! Assert: stdout: expecting string(hello sudo), got string([sudo] password for awwan: hello sudo
        )
--- FAIL: TestExecLocal_sudo (0.12s)
FAIL
exit status 1
FAIL    git.sr.ht/~shulhan/awwan        0.120s

OK its really working, lets fix the test code to match the output,

 	if err != nil {
 		t.Fatal(err)
 	}

-	test.Assert(t, "stdout", `hello sudo`, mockout.String())
+	var exp = "[sudo] password for awwan: hello sudo\n"
+	test.Assert(t, "stdout", exp, mockout.String())
 }

Run the test again,

[awwan@awwan-test src]$ go test -v -tags=integration -run=ExecLocal_sudo
=== RUN   TestExecLocal_sudo
--- PASS: TestExecLocal_sudo (0.12s)
PASS
ok      git.sr.ht/~shulhan/awwan        0.120s

That is one test case. Lets move to another one.

Test case 2: multiple sudo

What happened if our command call sudo multiple times, for example twice?

Lets rewrite our test code first to accommodates multiple test cases,

//go:build integration

package awwan

import (
	"bytes"
	"testing"

	"github.com/shuLhan/share/lib/test"
)

func TestExecLocal_sudo(t *testing.T) {
	type testCase struct {
		desc      string
		sudoPass  string
		expOutput string
		listCmd   []string
	}

	var (
		mockin  = &bytes.Buffer{}
		mockout = &bytes.Buffer{}
		req     = &Request{
			stdin:  mockin,
			stdout: mockout,
			stderr: mockout,
		}
		err error
	)

	var cases = []testCase{{
		desc:      `SingleSudo`,
		listCmd:   []string{`sudo -S echo "hello sudo"`},
		sudoPass:  "awwan\n",
		expOutput: "[sudo] password for awwan: hello sudo\n",
	}}

	var c testCase

	for _, c = range cases {
		t.Log(c.desc)

		mockout.Reset()
		mockin.Reset()
		mockin.WriteString(c.sudoPass)

		for _, rawCmd := range c.listCmd {
			err = ExecLocal(req, rawCmd)
			if err != nil {
				t.Log(mockout.String())
				t.Fatal(err)
			}
		}

		test.Assert(t, c.desc+` output`, c.expOutput, mockout.String())
	}
}

Run it,

[awwan@awwan-test src]$ go test -v -tags=integration -run=ExecLocal_sudo
=== RUN   TestExecLocal_sudo
    sudo_test.go:44: SingleSudo
--- PASS: TestExecLocal_sudo (0.12s)
PASS
ok      git.sr.ht/~shulhan/awwan        0.120s

And then add a second case,

 		desc:      `SingleSudo`,
 		listCmd:   []string{`sudo -S echo "hello sudo"`},
 		sudoPass:  "awwan\n",
 		expOutput: "[sudo] password for awwan: hello sudo\n",
+	}, {
+		desc: `MultipleSudo`,
+		listCmd: []string{
+			`sudo -S echo "hello sudo #1"`,
+			`sudo -S echo "hello sudo #2"`,
+		},
+		sudoPass:  "awwan\nawwan\n",
+		expOutput: "[sudo] password for awwan: hello sudo #1\n[sudo] password for awwan: hello sudo #2",
 	}}

 	var c testCase

By logic if we call sudo twice, the password should be asked twice too. Lets test it,

[awwan@awwan-test src]$ go test -v -tags=integration -run=ExecLocal_sudo
=== RUN   TestExecLocal_sudo
    sudo_test.go:52: SingleSudo
    sudo_test.go:52: MultipleSudo
    sudo_test.go:61: [sudo] password for awwan: hello sudo #1
        [sudo] password for awwan:
        sudo: no password was provided
        sudo: a password is required

    sudo_test.go:62: ExecLocal: exit status 1
--- FAIL: TestExecLocal_sudo (1.84s)
FAIL
exit status 1
FAIL    git.sr.ht/~shulhan/awwan        1.841s

The first sudo works, but the second is not. What happened? The only way to find out is by creating our own io.Reader that overwrite the Read method, so we can check which password is read by sudo,

 import (
 	"bytes"
+	"log"
 	"testing"

 	"github.com/shuLhan/share/lib/test"
 )

+type mockStdin struct {
+	buf bytes.Buffer
+}
+
+func (in *mockStdin) Read(pass []byte) (n int, err error) {
+	log.Printf(`Read: len=%d`, len(pass))
+	n, err = in.buf.Read(pass)
+	log.Printf(`Read: pass=%s n=%d err=%v`, pass, n, err)
+	return n, err
+}
+
 func TestExecLocal_sudo(t *testing.T) {
 	type testCase struct {
 		desc      string
 		sudoPass  string
@@ -20,9 +32,9 @@ func TestExecLocal_sudo(t *testing.T) {
 		listCmd   []string
 	}

 	var (
-		mockin  = &bytes.Buffer{}
+		mockin  = &mockStdin{}
 		mockout = &bytes.Buffer{}
 		req     = &Request{
 			stdin:  mockin,
 			stdout: mockout,
@@ -51,9 +63,9 @@ func TestExecLocal_sudo(t *testing.T) {
 	for _, c = range cases {
 		t.Log(c.desc)

 		mockout.Reset()
-		mockin.Reset()
+		mockin.buf.Reset()
 		mockin.WriteString(c.sudoPass)

 		for _, rawCmd := range c.listCmd {
 			err = ExecLocal(req, rawCmd)

Lets test it again,

[awwan@awwan-test src]$ go test -v -tags=integration -run=ExecLocal_sudo
=== RUN   TestExecLocal_sudo
    sudo_test.go:64: SingleSudo
2023/10/14 15:03:58 Read: len=32768
2023/10/14 15:03:58 Read: pass=awwan
 n=6 err=<nil>
2023/10/14 15:03:58 Read: len=32768
2023/10/14 15:03:58 Read: pass=awwan
 n=0 err=EOF
    sudo_test.go:64: MultipleSudo
2023/10/14 15:03:58 Read: len=32768
2023/10/14 15:03:58 Read: pass=awwan
awwan
 n=12 err=<nil>
2023/10/14 15:03:58 Read: len=32768
2023/10/14 15:03:58 Read: pass=awwan
awwan
 n=0 err=EOF
2023/10/14 15:03:58 Read: len=32768
2023/10/14 15:03:58 Read: pass= n=0 err=EOF
    sudo_test.go:73: [sudo] password for awwan: hello sudo #1
        [sudo] password for awwan:
        sudo: no password was provided
        sudo: a password is required

    sudo_test.go:74: ExecLocal: exit status 1
--- FAIL: TestExecLocal_sudo (2.70s)
FAIL
exit status 1
FAIL    git.sr.ht/~shulhan/awwan        2.705s

In SingleSudo, the Read method called twice, one with err=<nil> and another with err=EOF.

In MultipleSudo, all of the stdin content is readed until it return EOF, so the next sudo statement Read an empty password. Lets check the exec.Cmd documentation on Stdin,

...
// Stdin specifies the process's standard input.
//
// If Stdin is nil, the process reads from the null device (os.DevNull).
//
// If Stdin is an *os.File, the process's standard input is connected
// directly to that file.
//
// Otherwise, during the execution of the command a separate
// goroutine reads from Stdin and delivers that data to the command
// over a pipe. In this case, Wait does not complete until the goroutine
// stops copying, either because it has reached the end of Stdin
// (EOF or a read error), or because writing to the pipe returned an
// error,
// or because a nonzero WaitDelay was set and expired.
Stdin io.Reader
...

Since we are not using "*os.File", we fall into clause "…​ Wait does not complete until the goroutine stops copying, either because it has reached the end of Stdin (EOF or a read error), …​"

The only way to stop Read is by returning EOF. Lets try by modifying our mockStdin Reader,

 package awwan

 import (
 	"bytes"
+	"io"
 	"log"
 	"testing"

 	"github.com/shuLhan/share/lib/test"
@@ -18,9 +19,21 @@ type mockStdin struct {
 }

 func (in *mockStdin) Read(pass []byte) (n int, err error) {
 	log.Printf(`Read: len=%d`, len(pass))
-	n, err = in.buf.Read(pass)
+	var b = make([]byte, 1)
+	for n < len(pass) {
+		_, err = in.buf.Read(b)
+		if err != nil {
+			return n, err
+		}
+		if b[0] == '\n' {
+			err = io.EOF
+			break
+		}
+		pass[n] = b[0]
+		n++
+	}
 	log.Printf(`Read: pass=%s n=%d err=%v`, pass, n, err)
 	return n, err
 }

In Read method, we read one byte at the time, then when we found new line "\n" character, we set the error as "io.EOF" and return it to caller. Lets test it.

[awwan@awwan-test src]$ go test -v -tags=integration -run=ExecLocal_sudo
=== RUN   TestExecLocal_sudo
    sudo_test.go:77: SingleSudo
2023/10/14 15:17:48 Read: len=32768
2023/10/14 15:17:48 Read: pass=awwan n=5 err=EOF
    sudo_test.go:77: MultipleSudo
2023/10/14 15:17:49 Read: len=32768
2023/10/14 15:17:49 Read: pass=awwan n=5 err=EOF
2023/10/14 15:17:49 Read: len=32768
2023/10/14 15:17:49 Read: pass=awwan n=5 err=EOF
--- PASS: TestExecLocal_sudo (0.35s)
PASS
ok      git.sr.ht/~shulhan/awwan        0.354s

Its works my man!

Refactoring

Remember our test command always run sudo with "-S" option? It should not. It should only run with "-S" if during testing or when stdin from Request instance is set. Lets refactoring our ExecLocal function first.

// ExecLocal execute the command with its arguments in local environment
// where the output and error send to os.Stdout and os.Stderr respectively.
//
// If the statement command is "sudo" and stdin is non-nil, sudo will run
// with "-S" option to read password from stdin instead of from terminal.
func ExecLocal(req *Request, stmt *Statement) (err error) {
	if stmt.cmd == `sudo` {
		if req.stdin != nil {
			var newArgs = make([]string, len(stmt.args)+1)
			newArgs = append(newArgs, `-S`)
			newArgs = append(newArgs, stmt.args...)
			stmt.args = newArgs
		}
	}

	var (
		rawcmd = fmt.Sprintf(`%s %s`, stmt.cmd, strings.Join(stmt.args, ` `))
		cmd    = exec.Command(`/bin/sh`, `-c`, rawcmd)
	)

	cmd.Stdin = req.stdin
	cmd.Stdout = req.stdout
	cmd.Stderr = req.stderr

	err = cmd.Run()
	if err != nil {
		return fmt.Errorf(`ExecLocal: %w`, err)
	}
	return nil
}

Then we changes our test code to use Statement instead of string,

@@ -41,9 +41,9 @@ func TestExecLocal_sudo(t *testing.T) {
 	type testCase struct {
 		desc      string
 		sudoPass  string
 		expOutput string
-		listCmd   []string
+		listStmt  []Statement
 	}

 	var (
 		mockin  = &mockStdin{}
@@ -56,33 +56,42 @@ func TestExecLocal_sudo(t *testing.T) {
 		err error
 	)

 	var cases = []testCase{{
-		desc:      `SingleSudo`,
-		listCmd:   []string{`sudo -S echo "hello sudo"`},
+		desc: `SingleSudo`,
+		listStmt: []Statement{{
+			cmd:  `sudo`,
+			args: []string{`echo "hello sudo"`},
+		}},
 		sudoPass:  "awwan\n",
 		expOutput: "[sudo] password for awwan: hello sudo\n",
 	}, {
 		desc: `MultipleSudo`,
-		listCmd: []string{
-			`sudo -S echo "hello sudo #1"`,
-			`sudo -S echo "hello sudo #2"`,
-		},
+		listStmt: []Statement{{
+			cmd:  `sudo`,
+			args: []string{`echo "hello sudo #1"`},
+		}, {
+			cmd:  `sudo`,
+			args: []string{`echo "hello sudo #2"`},
+		}},
 		sudoPass:  "awwan\nawwan\n",
 		expOutput: "[sudo] password for awwan: hello sudo #1\n[sudo] password for awwan: hello sudo #2",
 	}}

-	var c testCase
+	var (
+		c    testCase
+		stmt Statement
+	)

 	for _, c = range cases {
 		t.Log(c.desc)

 		mockout.Reset()
 		mockin.buf.Reset()
 		mockin.buf.WriteString(c.sudoPass)

-		for _, rawCmd := range c.listCmd {
-			err = ExecLocal(req, rawCmd)
+		for _, stmt = range c.listStmt {
+			err = ExecLocal(req, &stmt)
 			if err != nil {
 				t.Log(mockout.String())
 				t.Fatal(err)
 			}

If we run the test again it should PASS,

[awwan@awwan-test src]$ go test -v -tags=integration -run=ExecLocal_sudo
=== RUN   TestExecLocal_sudo
    sudo_test.go:86: SingleSudo
2023/10/14 16:07:36 Read: len=32768
2023/10/14 16:07:36 Read: pass=awwan n=5 err=EOF
    sudo_test.go:86: MultipleSudo
2023/10/14 16:07:36 Read: len=32768
2023/10/14 16:07:36 Read: pass=awwan n=5 err=EOF
2023/10/14 16:07:36 Read: len=32768
2023/10/14 16:07:36 Read: pass=awwan n=5 err=EOF
--- PASS: TestExecLocal_sudo (0.35s)
PASS
ok      git.sr.ht/~shulhan/awwan        0.355s

Test case 3: invalid password

What would its look like if we pass invalid password during sudo? Lets see it.

First, we add new test case where we pass invalid password,

@@ -41,8 +41,9 @@ func TestExecLocal_sudo(t *testing.T) {
 	type testCase struct {
 		desc      string
 		sudoPass  string
 		expOutput string
+		expError  string
 		listStmt  []Statement
 	}

 	var (
@@ -74,8 +75,17 @@ func TestExecLocal_sudo(t *testing.T) {
 			args: []string{`echo "hello sudo #2"`},
 		}},
 		sudoPass:  "awwan\nawwan\n",
 		expOutput: "[sudo] password for awwan: hello sudo #1\n[sudo] password for awwan: hello sudo #2",
+	}, {
+		desc: `WithInvalidPassword`,
+		listStmt: []Statement{{
+			cmd:  `sudo`,
+			args: []string{`echo "hello sudo"`},
+		}},
+		sudoPass:  "invalid\n",
+		expError:  `ExecLocal: exit status 1`,
+		expOutput: "[sudo] password for awwan: sudo: 1 incorrect password attempt\n",
 	}}

 	var (
 		c    testCase
@@ -92,9 +102,9 @@ func TestExecLocal_sudo(t *testing.T) {
 		for _, stmt = range c.listStmt {
 			err = ExecLocal(req, &stmt)
 			if err != nil {
 				t.Log(mockout.String())
-				t.Fatal(err)
+				test.Assert(t, `error`, c.expError, err.Error())
 			}
 		}

 		test.Assert(t, c.desc+` output`, c.expOutput, mockout.String())

In this changes we add new field to our testCase, expError, which contains the expected error when sudo failed. Instead of calling "t.Fatal", we then compare the error returned by ExecLocal with the value of expError.

Lets test it,

[awwan@awwan-test src]$ go test -v -tags=integration -run=ExecLocal_sudo
=== RUN   TestExecLocal_sudo
    sudo_test.go:96: SingleSudo
2023/10/14 16:35:15 Read: len=32768
2023/10/14 16:35:15 Read: pass=awwan n=5 err=EOF
    sudo_test.go:96: MultipleSudo
2023/10/14 16:35:15 Read: len=32768
2023/10/14 16:35:15 Read: pass=awwan n=5 err=EOF
2023/10/14 16:35:16 Read: len=32768
2023/10/14 16:35:16 Read: pass=awwan n=5 err=EOF
    sudo_test.go:96: WithInvalidPassword
2023/10/14 16:35:16 Read: len=32768
2023/10/14 16:35:16 Read: pass=invalid n=7 err=EOF
    sudo_test.go:105: [sudo] password for awwan: sudo: 1 incorrect password attempt

--- PASS: TestExecLocal_sudo (2.63s)
PASS
ok      git.sr.ht/~shulhan/awwan        2.630s

Nice.

Test case 4: multiple sudo, one with invalid password

This is the last test case, similar to test case #2, where we run multiple sudo statements, but we make one of the statement use invalid password.

@@ -18,5 +18,5 @@ func TestExecLocal_sudo(t *testing.T) {
 		sudoPass  string
 		expOutput string
-		expError  string
+		expError  []string
 		listStmt  []Statement
 	}
@@ -59,6 +59,21 @@ func TestExecLocal_sudo(t *testing.T) {
 		}},
 		sudoPass:  "invalid\n",
-		expError:  `ExecLocal: exit status 1`,
+		expError:  []string{`ExecLocal: exit status 1`},
 		expOutput: "[sudo] password for awwan: sudo: 1 incorrect password attempt\n",
+	}, {
+		desc: `MultipleSudoOneInvalid`,
+		listStmt: []Statement{{
+			cmd:  `sudo`,
+			args: []string{`echo "hello sudo #1"`},
+		}, {
+			cmd:  `sudo`,
+			args: []string{`echo "hello sudo #2"`},
+		}},
+		sudoPass: "awwan\ninvalid\n",
+		expError: []string{
+			``,
+			`ExecLocal: exit status 1`,
+		},
+		expOutput: "[sudo] password for awwan: hello sudo #1\n[sudo] password for awwan: sudo: 1 incorrect password attempt\n",
 	}}

@@ -66,4 +81,5 @@ func TestExecLocal_sudo(t *testing.T) {
 		c    testCase
 		stmt Statement
+		x    int
 	)

@@ -75,9 +91,10 @@ func TestExecLocal_sudo(t *testing.T) {
 		mockin.buf.WriteString(c.sudoPass)

-		for _, stmt = range c.listStmt {
+		for x, stmt = range c.listStmt {
 			err = ExecLocal(req, &stmt)
 			if err != nil {
 				t.Log(mockout.String())
-				test.Assert(t, `error`, c.expError, err.Error())
+				var expError = c.expError[x]
+				test.Assert(t, `error`, expError, err.Error())
 			}
 		}

Since the expError is tied to each statement, we need to changes it to slice of string. Lets test it,

[awwan@awwan-test src]$ go test -tags=integration -v -run=ExecLocal
=== RUN   TestExecLocal_sudo
    sudo_test.go:87: SingleSudo
2023/10/14 16:53:53 Read: len=32768
2023/10/14 16:53:53 Read: pass=awwan n=5 err=EOF
    sudo_test.go:87: MultipleSudo
2023/10/14 16:53:53 Read: len=32768
2023/10/14 16:53:53 Read: pass=awwan n=5 err=EOF
2023/10/14 16:53:53 Read: len=32768
2023/10/14 16:53:53 Read: pass=awwan n=5 err=EOF
    sudo_test.go:87: WithInvalidPassword
2023/10/14 16:53:53 Read: len=32768
2023/10/14 16:53:53 Read: pass=invalid n=7 err=EOF
    sudo_test.go:96: [sudo] password for awwan: sudo: 1 incorrect password attempt

    sudo_test.go:87: MultipleSudoOneInvalid
2023/10/14 16:53:55 Read: len=32768
2023/10/14 16:53:55 Read: pass=awwan n=5 err=EOF
2023/10/14 16:53:55 Read: len=32768
2023/10/14 16:53:55 Read: pass=invalid n=7 err=EOF
    sudo_test.go:96: [sudo] password for awwan: hello sudo #1
        [sudo] password for awwan: sudo: 1 incorrect password attempt

--- PASS: TestExecLocal_sudo (4.33s)
PASS
ok      git.sr.ht/~shulhan/awwan        4.329s

Cleaning it up

Now that our test are finally works, we can remove all logging statements.

@@ -9,5 +9,4 @@ import (
 	"bytes"
 	"io"
-	"log"
 )

@@ -18,5 +17,4 @@ type mockStdin struct {

 func (in *mockStdin) Read(pass []byte) (n int, err error) {
-	log.Printf(`Read: len=%d`, len(pass))
 	var b = make([]byte, 1)
 	for n < len(pass) {
@@ -32,5 +30,4 @@ func (in *mockStdin) Read(pass []byte) (n int, err error) {
 		n++
 	}
-	log.Printf(`Read: pass=%s n=%d err=%v`, pass, n, err)
 	return n, err
 }

And then run all integration tests,

[awwan@awwan-test src]$ go test -tags=integration .
ok      git.sr.ht/~shulhan/awwan        7.498s