Add table-driven unit tests for unlock() that exercise success and error paths, cover flag and arg handling.
662 lines
16 KiB
Go
662 lines
16 KiB
Go
package commands
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/lightningnetwork/lnd/lnrpc"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/urfave/cli"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/metadata"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// fakeClientStream implements grpc.ClientStream for tests.
|
|
type fakeClientStream struct{}
|
|
|
|
// Header returns empty metadata for the fake client stream.
|
|
func (fakeClientStream) Header() (metadata.MD, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// Trailer returns empty metadata for the fake client stream.
|
|
func (fakeClientStream) Trailer() metadata.MD {
|
|
return nil
|
|
}
|
|
|
|
// CloseSend is a no-op for the fake client stream.
|
|
func (fakeClientStream) CloseSend() error {
|
|
return nil
|
|
}
|
|
|
|
// Context returns a background context for the fake client stream.
|
|
func (fakeClientStream) Context() context.Context {
|
|
return context.Background()
|
|
}
|
|
|
|
// SendMsg is a no-op for the fake client stream.
|
|
func (fakeClientStream) SendMsg(interface{}) error {
|
|
return nil
|
|
}
|
|
|
|
// RecvMsg is a no-op for the fake client stream.
|
|
func (fakeClientStream) RecvMsg(interface{}) error {
|
|
return nil
|
|
}
|
|
|
|
// stateStreamSpec describes the scripted responses for a state stream.
|
|
type stateStreamSpec struct {
|
|
states []lnrpc.WalletState
|
|
err error
|
|
}
|
|
|
|
// fakeStateStream implements State_SubscribeStateClient with scripted states.
|
|
type fakeStateStream struct {
|
|
fakeClientStream
|
|
states []lnrpc.WalletState
|
|
err error
|
|
idx int
|
|
}
|
|
|
|
// Recv returns the next scripted wallet state or the configured error.
|
|
func (f *fakeStateStream) Recv() (*lnrpc.SubscribeStateResponse, error) {
|
|
if f.idx < len(f.states) {
|
|
state := f.states[f.idx]
|
|
f.idx++
|
|
|
|
return &lnrpc.SubscribeStateResponse{
|
|
State: state,
|
|
}, nil
|
|
}
|
|
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
|
|
return nil, io.EOF
|
|
}
|
|
|
|
// fakeStateClient implements lnrpc.StateClient with scripted streams.
|
|
type fakeStateClient struct {
|
|
streams []stateStreamSpec
|
|
subscribeCalls int
|
|
subscribeInputs []*lnrpc.SubscribeStateRequest
|
|
}
|
|
|
|
// SubscribeState returns a scripted stream for the fake state client.
|
|
func (f *fakeStateClient) SubscribeState(_ context.Context,
|
|
in *lnrpc.SubscribeStateRequest,
|
|
_ ...grpc.CallOption) (lnrpc.State_SubscribeStateClient, error) {
|
|
|
|
f.subscribeCalls++
|
|
f.subscribeInputs = append(f.subscribeInputs, in)
|
|
|
|
if f.subscribeCalls > len(f.streams) {
|
|
return nil, errors.New("unexpected SubscribeState call")
|
|
}
|
|
|
|
streamSpec := f.streams[f.subscribeCalls-1]
|
|
|
|
return &fakeStateStream{
|
|
states: streamSpec.states,
|
|
err: streamSpec.err,
|
|
}, nil
|
|
}
|
|
|
|
// GetState is unused in tests and returns a sentinel error.
|
|
func (f *fakeStateClient) GetState(_ context.Context,
|
|
_ *lnrpc.GetStateRequest,
|
|
_ ...grpc.CallOption) (*lnrpc.GetStateResponse, error) {
|
|
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
|
|
// Ensure fakeStateClient satisfies the lnrpc.StateClient interface.
|
|
var _ lnrpc.StateClient = (*fakeStateClient)(nil)
|
|
|
|
// errNotImplemented is returned by fake methods that are unused in tests.
|
|
var errNotImplemented = errors.New("not implemented")
|
|
|
|
// fakeUnlockerClient implements lnrpc.WalletUnlockerClient for tests.
|
|
type fakeUnlockerClient struct {
|
|
unlockCalls int
|
|
lastReq *lnrpc.UnlockWalletRequest
|
|
unlockErr error
|
|
}
|
|
|
|
// GenSeed is unused in tests and returns a sentinel error.
|
|
func (f *fakeUnlockerClient) GenSeed(_ context.Context, _ *lnrpc.GenSeedRequest,
|
|
_ ...grpc.CallOption) (*lnrpc.GenSeedResponse, error) {
|
|
|
|
return nil, errNotImplemented
|
|
}
|
|
|
|
// InitWallet is unused in tests and returns a sentinel error.
|
|
func (f *fakeUnlockerClient) InitWallet(_ context.Context,
|
|
_ *lnrpc.InitWalletRequest,
|
|
_ ...grpc.CallOption) (*lnrpc.InitWalletResponse, error) {
|
|
|
|
return nil, errNotImplemented
|
|
}
|
|
|
|
// UnlockWallet records the request and returns the configured response.
|
|
func (f *fakeUnlockerClient) UnlockWallet(_ context.Context,
|
|
in *lnrpc.UnlockWalletRequest,
|
|
_ ...grpc.CallOption) (*lnrpc.UnlockWalletResponse, error) {
|
|
|
|
f.unlockCalls++
|
|
f.lastReq = in
|
|
|
|
if f.unlockErr != nil {
|
|
return nil, f.unlockErr
|
|
}
|
|
|
|
return &lnrpc.UnlockWalletResponse{}, nil
|
|
}
|
|
|
|
// ChangePassword is unused in tests and returns a sentinel error.
|
|
func (f *fakeUnlockerClient) ChangePassword(_ context.Context,
|
|
_ *lnrpc.ChangePasswordRequest,
|
|
_ ...grpc.CallOption) (*lnrpc.ChangePasswordResponse, error) {
|
|
|
|
return nil, errNotImplemented
|
|
}
|
|
|
|
// Ensure fakeUnlockerClient satisfies the lnrpc.WalletUnlockerClient interface.
|
|
var _ lnrpc.WalletUnlockerClient = (*fakeUnlockerClient)(nil)
|
|
|
|
// newUnlockContext builds a cli.Context with unlock flags parsed.
|
|
func newUnlockContext(t *testing.T, args []string) *cli.Context {
|
|
t.Helper()
|
|
|
|
flagSet := flag.NewFlagSet("unlock", flag.ContinueOnError)
|
|
flagSet.SetOutput(io.Discard)
|
|
flagSet.Bool("stdin", false, "")
|
|
flagSet.Int64("recovery_window", 0, "")
|
|
flagSet.Bool("stateless_init", false, "")
|
|
|
|
err := flagSet.Parse(args)
|
|
require.NoError(t, err)
|
|
|
|
app := cli.NewApp()
|
|
|
|
return cli.NewContext(app, flagSet, nil)
|
|
}
|
|
|
|
// TestUnlock exercises wallet unlock command across success and error paths.
|
|
func TestUnlock(t *testing.T) {
|
|
// Shortcut for a long name.
|
|
const waitingToString = lnrpc.WalletState_WAITING_TO_START
|
|
|
|
// Define table-driven cases for unlockWithDeps behavior and inputs.
|
|
testCases := []struct {
|
|
name string
|
|
args []string
|
|
stdinInput string
|
|
readPasswordRet []byte
|
|
readPasswordErr error
|
|
stateStreams []stateStreamSpec
|
|
unlockerErr error
|
|
expectErr string
|
|
expectReadPasswordCalls int
|
|
expectUnlockCalls int
|
|
expectSubscribeCalls int
|
|
expectReq *lnrpc.UnlockWalletRequest
|
|
}{
|
|
// Succeeds by waiting for locked then RPC active.
|
|
{
|
|
name: "success_default",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
waitingToString,
|
|
lnrpc.WalletState_LOCKED,
|
|
},
|
|
},
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_RPC_ACTIVE,
|
|
},
|
|
},
|
|
},
|
|
expectReadPasswordCalls: 1,
|
|
expectUnlockCalls: 1,
|
|
expectSubscribeCalls: 2,
|
|
expectReq: &lnrpc.UnlockWalletRequest{
|
|
WalletPassword: []byte("pw"),
|
|
RecoveryWindow: 0,
|
|
StatelessInit: false,
|
|
},
|
|
},
|
|
|
|
// Uses stdin, stateless init, and recovery window flag.
|
|
{
|
|
name: "success_stdin_flag_recovery_stateless",
|
|
args: []string{
|
|
"--stdin", "--stateless_init",
|
|
"--recovery_window=50",
|
|
},
|
|
stdinInput: "secret\n",
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_LOCKED,
|
|
},
|
|
},
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_UNLOCKED,
|
|
},
|
|
},
|
|
},
|
|
expectReadPasswordCalls: 0,
|
|
expectUnlockCalls: 1,
|
|
expectSubscribeCalls: 2,
|
|
expectReq: &lnrpc.UnlockWalletRequest{
|
|
WalletPassword: []byte("secret"),
|
|
RecoveryWindow: 50,
|
|
StatelessInit: true,
|
|
},
|
|
},
|
|
|
|
// Uses positional recovery window argument.
|
|
{
|
|
name: "success_arg_recovery_window",
|
|
args: []string{"25"},
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_LOCKED,
|
|
},
|
|
},
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_SERVER_ACTIVE,
|
|
},
|
|
},
|
|
},
|
|
expectReadPasswordCalls: 1,
|
|
expectUnlockCalls: 1,
|
|
expectSubscribeCalls: 2,
|
|
expectReq: &lnrpc.UnlockWalletRequest{
|
|
WalletPassword: []byte("pw"),
|
|
RecoveryWindow: 25,
|
|
StatelessInit: false,
|
|
},
|
|
},
|
|
|
|
// Propagates password read errors.
|
|
{
|
|
name: "read_password_error",
|
|
readPasswordErr: errors.New("read fail"),
|
|
expectErr: "read fail",
|
|
expectReadPasswordCalls: 1,
|
|
},
|
|
|
|
// Fails when positional recovery window is not an int.
|
|
{
|
|
name: "bad_recovery_arg",
|
|
args: []string{"not-int"},
|
|
readPasswordRet: []byte("pw"),
|
|
expectErr: "invalid syntax",
|
|
expectReadPasswordCalls: 1,
|
|
},
|
|
|
|
// EOF while waiting for locked state returns a descriptive
|
|
// error.
|
|
{
|
|
name: "wait_locked_eof",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
err: io.EOF,
|
|
},
|
|
},
|
|
expectErr: "lnd shut down before reaching expected " +
|
|
"wallet state",
|
|
expectReadPasswordCalls: 1,
|
|
expectSubscribeCalls: 1,
|
|
},
|
|
|
|
// Unimplemented StateService skips lock wait then succeeds.
|
|
{
|
|
name: "wait_locked_unimplemented",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
err: status.Error(
|
|
codes.Unimplemented, "no state",
|
|
),
|
|
},
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_UNLOCKED,
|
|
},
|
|
},
|
|
},
|
|
expectReadPasswordCalls: 1,
|
|
expectUnlockCalls: 1,
|
|
expectSubscribeCalls: 2,
|
|
expectReq: &lnrpc.UnlockWalletRequest{
|
|
WalletPassword: []byte("pw"),
|
|
RecoveryWindow: 0,
|
|
StatelessInit: false,
|
|
},
|
|
},
|
|
|
|
// Unavailable StateService skips lock wait then succeeds.
|
|
{
|
|
name: "wait_locked_unavailable",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
err: status.Error(
|
|
codes.Unavailable, "no state",
|
|
),
|
|
},
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_RPC_ACTIVE,
|
|
},
|
|
},
|
|
},
|
|
expectReadPasswordCalls: 1,
|
|
expectUnlockCalls: 1,
|
|
expectSubscribeCalls: 2,
|
|
expectReq: &lnrpc.UnlockWalletRequest{
|
|
WalletPassword: []byte("pw"),
|
|
RecoveryWindow: 0,
|
|
StatelessInit: false,
|
|
},
|
|
},
|
|
|
|
// NON_EXISTING during lock wait fails before unlock.
|
|
{
|
|
name: "wait_locked_non_existing",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_NON_EXISTING,
|
|
},
|
|
},
|
|
},
|
|
expectErr: "wallet is not initialized - please run " +
|
|
"'lncli create'",
|
|
expectReadPasswordCalls: 1,
|
|
expectSubscribeCalls: 1,
|
|
},
|
|
|
|
// Already unlocked during lock wait fails before unlock.
|
|
{
|
|
name: "wait_locked_already_unlocked",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_UNLOCKED,
|
|
},
|
|
},
|
|
},
|
|
expectErr: "wallet is already unlocked",
|
|
expectReadPasswordCalls: 1,
|
|
expectSubscribeCalls: 1,
|
|
},
|
|
|
|
// Unlock RPC error is returned after lock wait succeeds.
|
|
{
|
|
name: "unlocker_error",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_LOCKED,
|
|
},
|
|
},
|
|
},
|
|
unlockerErr: errors.New("unlock failed"),
|
|
expectErr: "unlock failed",
|
|
expectReadPasswordCalls: 1,
|
|
expectUnlockCalls: 1,
|
|
expectSubscribeCalls: 1,
|
|
expectReq: &lnrpc.UnlockWalletRequest{
|
|
WalletPassword: []byte("pw"),
|
|
RecoveryWindow: 0,
|
|
StatelessInit: false,
|
|
},
|
|
},
|
|
|
|
// EOF while waiting for unlocked state returns a descriptive
|
|
// error.
|
|
{
|
|
name: "wait_unlocked_eof",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_LOCKED,
|
|
},
|
|
},
|
|
{
|
|
err: io.EOF,
|
|
},
|
|
},
|
|
expectErr: "lnd shut down before reaching expected " +
|
|
"wallet state",
|
|
expectReadPasswordCalls: 1,
|
|
expectUnlockCalls: 1,
|
|
expectSubscribeCalls: 2,
|
|
expectReq: &lnrpc.UnlockWalletRequest{
|
|
WalletPassword: []byte("pw"),
|
|
RecoveryWindow: 0,
|
|
StatelessInit: false,
|
|
},
|
|
},
|
|
|
|
// NON_EXISTING during unlocked wait fails after unlock attempt.
|
|
{
|
|
name: "wait_unlocked_non_existing",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_LOCKED,
|
|
},
|
|
},
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_NON_EXISTING,
|
|
},
|
|
},
|
|
},
|
|
expectErr: "wallet is not initialized - please run " +
|
|
"'lncli create'",
|
|
expectReadPasswordCalls: 1,
|
|
expectUnlockCalls: 1,
|
|
expectSubscribeCalls: 2,
|
|
expectReq: &lnrpc.UnlockWalletRequest{
|
|
WalletPassword: []byte("pw"),
|
|
RecoveryWindow: 0,
|
|
StatelessInit: false,
|
|
},
|
|
},
|
|
|
|
// Unimplemented StateService skips unlock wait.
|
|
{
|
|
name: "wait_unlocked_unimplemented",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_LOCKED,
|
|
},
|
|
},
|
|
{
|
|
err: status.Error(
|
|
codes.Unimplemented, "no state",
|
|
),
|
|
},
|
|
},
|
|
expectReadPasswordCalls: 1,
|
|
expectUnlockCalls: 1,
|
|
expectSubscribeCalls: 2,
|
|
expectReq: &lnrpc.UnlockWalletRequest{
|
|
WalletPassword: []byte("pw"),
|
|
RecoveryWindow: 0,
|
|
StatelessInit: false,
|
|
},
|
|
},
|
|
|
|
// Unavailable StateService skips unlock wait.
|
|
{
|
|
name: "wait_unlocked_unavailable",
|
|
readPasswordRet: []byte("pw"),
|
|
stateStreams: []stateStreamSpec{
|
|
{
|
|
states: []lnrpc.WalletState{
|
|
lnrpc.WalletState_LOCKED,
|
|
},
|
|
},
|
|
{
|
|
err: status.Error(
|
|
codes.Unavailable, "no state",
|
|
),
|
|
},
|
|
},
|
|
expectReadPasswordCalls: 1,
|
|
expectUnlockCalls: 1,
|
|
expectSubscribeCalls: 2,
|
|
expectReq: &lnrpc.UnlockWalletRequest{
|
|
WalletPassword: []byte("pw"),
|
|
RecoveryWindow: 0,
|
|
StatelessInit: false,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Build the CLI context with unlock flags and args.
|
|
ctx := newUnlockContext(t, tc.args)
|
|
|
|
// Create fake clients with scripted responses.
|
|
unlocker := &fakeUnlockerClient{
|
|
unlockErr: tc.unlockerErr,
|
|
}
|
|
stateClient := &fakeStateClient{
|
|
streams: tc.stateStreams,
|
|
}
|
|
|
|
// Track cleanup for the unlocker client.
|
|
unlockerCleaned := false
|
|
getUnlockerClient := func(
|
|
*cli.Context) (lnrpc.WalletUnlockerClient,
|
|
func()) {
|
|
|
|
return unlocker, func() {
|
|
unlockerCleaned = true
|
|
}
|
|
}
|
|
|
|
// Track cleanup for the state client.
|
|
stateCleaned := false
|
|
getStateClient := func(*cli.Context) (lnrpc.StateClient,
|
|
func()) {
|
|
|
|
return stateClient, func() {
|
|
stateCleaned = true
|
|
}
|
|
}
|
|
|
|
// Capture password prompt and inject return values.
|
|
readPrompt := ""
|
|
readCalls := 0
|
|
readPasswordFn := func(prompt string) ([]byte, error) {
|
|
readPrompt = prompt
|
|
readCalls++
|
|
|
|
return tc.readPasswordRet, tc.readPasswordErr
|
|
}
|
|
|
|
// Provide a deterministic context without signal
|
|
// handling.
|
|
contextCalls := 0
|
|
getContextFn := func() context.Context {
|
|
contextCalls++
|
|
|
|
return t.Context()
|
|
}
|
|
|
|
// Provide stdin input via injected reader.
|
|
stdin := strings.NewReader(tc.stdinInput)
|
|
|
|
// Execute unlockWithDeps with injected dependencies.
|
|
err := unlockWithDeps(
|
|
ctx, readPasswordFn, getUnlockerClient,
|
|
getStateClient, getContextFn, stdin,
|
|
)
|
|
|
|
// Assert error behavior.
|
|
if tc.expectErr != "" {
|
|
require.ErrorContains(t, err, tc.expectErr)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Verify password prompt usage.
|
|
require.Equal(t, tc.expectReadPasswordCalls, readCalls)
|
|
if readCalls > 0 {
|
|
require.Equal(
|
|
t, "Input wallet password: ",
|
|
readPrompt,
|
|
)
|
|
}
|
|
|
|
// Verify client usage and cleanup behavior.
|
|
require.Equal(
|
|
t, tc.expectUnlockCalls, unlocker.unlockCalls,
|
|
)
|
|
require.Equal(
|
|
t, tc.expectSubscribeCalls,
|
|
stateClient.subscribeCalls,
|
|
)
|
|
require.True(t, unlockerCleaned)
|
|
require.True(t, stateCleaned)
|
|
require.Equal(t, 1, contextCalls)
|
|
|
|
// Verify the unlock request fields when applicable.
|
|
if tc.expectReq != nil {
|
|
require.NotNil(t, unlocker.lastReq)
|
|
require.Equal(t, tc.expectReq.WalletPassword,
|
|
unlocker.lastReq.WalletPassword)
|
|
require.Equal(t, tc.expectReq.RecoveryWindow,
|
|
unlocker.lastReq.RecoveryWindow)
|
|
require.Equal(t, tc.expectReq.StatelessInit,
|
|
unlocker.lastReq.StatelessInit)
|
|
} else {
|
|
require.Nil(t, unlocker.lastReq)
|
|
}
|
|
|
|
// Verify SubscribeState requests were well-formed.
|
|
require.Len(
|
|
t, stateClient.subscribeInputs,
|
|
stateClient.subscribeCalls,
|
|
)
|
|
for _, req := range stateClient.subscribeInputs {
|
|
require.NotNil(t, req)
|
|
require.Equal(
|
|
t, &lnrpc.SubscribeStateRequest{}, req,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|