Files
lnd/cmd/commands/cmd_walletunlocker_test.go
Boris Nagaev 6cd785c751 lncli unlock: add unit tests
Add table-driven unit tests for unlock() that exercise success and error paths,
cover flag and arg handling.
2026-02-04 15:20:03 -05:00

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,
)
}
})
}
}