Skip to content

Commit e0bea6c

Browse files
authored
Merge pull request #49 from lightninglabs/auth
Add authentication
2 parents 5db443b + dc51ee4 commit e0bea6c

38 files changed

+661
-83
lines changed

app/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ const App = () => {
1414
<ThemeProvider>
1515
<Layout>
1616
<Pages />
17-
<AlertContainer />
1817
</Layout>
18+
<AlertContainer />
1919
</ThemeProvider>
2020
</StoreProvider>
2121
);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from 'react';
2+
import AuthPage from 'components/auth/AuthPage';
3+
import { Layout } from 'components/layout';
4+
5+
export default {
6+
title: 'Pages/Auth',
7+
component: AuthPage,
8+
};
9+
10+
export const Default = () => {
11+
return (
12+
<Layout>
13+
<AuthPage />
14+
</Layout>
15+
);
16+
};

app/src/__stories__/StoryWrapper.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { CSSProperties, useMemo } from 'react';
22
import { observer } from 'mobx-react-lite';
33
import { BalanceMode, Unit } from 'util/constants';
4+
import { AuthenticationError } from 'util/errors';
45
import { sampleApiResponses } from 'util/tests/sampleData';
56
import { createStore, StoreProvider } from 'store';
67
import { PersistentSettings } from 'store/stores/settingsStore';
@@ -9,7 +10,10 @@ import { ThemeProvider } from 'components/theme';
910

1011
// mock the GRPC client to return sample data instead of making an actual request
1112
const grpc = {
12-
request: (methodDescriptor: any) => {
13+
request: (methodDescriptor: any, opts: any, metadata: any) => {
14+
// fail any authenticated requests to simulate incorrect login attempts
15+
if (metadata && metadata.authorization) throw new AuthenticationError();
16+
1317
const endpoint = `${methodDescriptor.service.serviceName}.${methodDescriptor.methodName}`;
1418
const data = sampleApiResponses[endpoint] || {};
1519
// the calling function expects the return value to have a `toObject` function
@@ -26,11 +30,17 @@ class StoryAppStorage {
2630
unit: Unit.sats,
2731
balanceMode: BalanceMode.receive,
2832
});
33+
setSession = () => undefined;
34+
getSession = () => '';
2935
}
3036

3137
// Create a store that pulls data from the mock GRPC and doesn't use
3238
// the real localStorage to save settings
33-
const createStoryStore = () => createStore(grpc, new StoryAppStorage());
39+
const createStoryStore = () => {
40+
const store = createStore(grpc, new StoryAppStorage());
41+
store.fetchAllData();
42+
return store;
43+
};
3444

3545
/**
3646
* This component is used to wrap every story. It provides the app theme

app/src/__tests__/App.spec.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import React from 'react';
22
import { render } from '@testing-library/react';
3+
import * as config from 'config';
34
import App from '../App';
45

56
describe('App Component', () => {
67
const renderApp = () => {
78
return render(<App />);
89
};
910

10-
it('should render the App', () => {
11-
const { getByText } = renderApp();
12-
const linkElement = getByText('Node Status');
13-
expect(linkElement).toBeInTheDocument();
11+
it('should render the App', async () => {
12+
// ensure init is called in the store so the UI is displayed
13+
Object.defineProperty(config, 'IS_TEST', { get: () => false });
14+
const { findByText } = renderApp();
15+
expect(await findByText('Shushtar')).toBeInTheDocument();
16+
expect(await findByText('logo.svg')).toBeInTheDocument();
17+
// revert IS_DEV
18+
Object.defineProperty(config, 'IS_TEST', { get: () => true });
1419
});
1520
});

app/src/__tests__/Pages.spec.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,37 @@
11
import React from 'react';
22
import { renderWithProviders } from 'util/tests';
3+
import { createStore } from 'store';
34
import Pages from 'components/Pages';
45

56
describe('Pages Component', () => {
6-
const render = () => {
7-
return renderWithProviders(<Pages />);
7+
const render = async () => {
8+
const store = createStore();
9+
await store.init();
10+
return renderWithProviders(<Pages />, store);
811
};
912

10-
it('should display the Loop page by default', () => {
11-
const { getByText, store } = render();
13+
it('should display the Auth page by default', async () => {
14+
const { getByText, store } = await render();
15+
expect(getByText('Shushtar')).toBeInTheDocument();
16+
expect(store.uiStore.page).toBe('auth');
17+
});
18+
19+
it('should display the Loop page', async () => {
20+
const { getByText, store } = await render();
21+
store.uiStore.goToLoop();
1222
expect(getByText('Lightning Loop')).toBeInTheDocument();
1323
expect(store.uiStore.page).toBe('loop');
1424
});
1525

16-
it('should display the History page', () => {
17-
const { getByText, store } = render();
26+
it('should display the History page', async () => {
27+
const { getByText, store } = await render();
1828
store.uiStore.goToHistory();
1929
expect(getByText('Loop History')).toBeInTheDocument();
2030
expect(store.uiStore.page).toBe('history');
2131
});
2232

23-
it('should display the Settings page', () => {
24-
const { getByText, store } = render();
33+
it('should display the Settings page', async () => {
34+
const { getByText, store } = await render();
2535
store.uiStore.goToSettings();
2636
expect(getByText('Settings')).toBeInTheDocument();
2737
expect(store.uiStore.page).toBe('settings');
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React from 'react';
2+
import { grpc } from '@improbable-eng/grpc-web';
3+
import { fireEvent } from '@testing-library/react';
4+
import { renderWithProviders } from 'util/tests';
5+
import { createStore, Store } from 'store';
6+
import AuthPage from 'components/auth/AuthPage';
7+
8+
const grpcMock = grpc as jest.Mocked<typeof grpc>;
9+
10+
describe('AuthPage ', () => {
11+
let store: Store;
12+
13+
beforeEach(async () => {
14+
store = createStore();
15+
await store.init();
16+
});
17+
18+
const render = () => {
19+
return renderWithProviders(<AuthPage />, store);
20+
};
21+
22+
it('should display the title', () => {
23+
const { getByText } = render();
24+
expect(getByText('Shushtar')).toBeInTheDocument();
25+
});
26+
27+
it('should display the password field', () => {
28+
const { getByLabelText } = render();
29+
expect(getByLabelText('Enter your password in the field above')).toBeInTheDocument();
30+
});
31+
32+
it('should display the submit button', () => {
33+
const { getByText } = render();
34+
expect(getByText('Submit')).toBeInTheDocument();
35+
});
36+
37+
it('should display nothing when the store is not initialized', () => {
38+
const { getByText, queryByText } = render();
39+
expect(getByText('Shushtar')).toBeInTheDocument();
40+
store.initialized = false;
41+
expect(queryByText('Shushtar')).not.toBeInTheDocument();
42+
});
43+
44+
it('should display an error when submitting an empty password', async () => {
45+
const { getByText, findByText } = render();
46+
fireEvent.click(getByText('Submit'));
47+
expect(await findByText('oops, password is required')).toBeInTheDocument();
48+
});
49+
50+
it('should display an error when submitting an invalid password', async () => {
51+
grpcMock.unary.mockImplementationOnce(desc => {
52+
if (desc.methodName === 'GetInfo') throw new Error('test-err');
53+
return undefined as any;
54+
});
55+
56+
const { getByText, getByLabelText, findByText } = render();
57+
const input = getByLabelText('Enter your password in the field above');
58+
fireEvent.change(input, { target: { value: 'test-pw' } });
59+
fireEvent.click(getByText('Submit'));
60+
expect(await findByText('oops, that password is incorrect')).toBeInTheDocument();
61+
});
62+
});

app/src/__tests__/components/layout/Layout.spec.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import React from 'react';
22
import { fireEvent } from '@testing-library/react';
33
import { renderWithProviders } from 'util/tests';
4+
import { createStore } from 'store';
45
import Layout from 'components/layout/Layout';
56

67
describe('Layout component', () => {
78
const render = () => {
8-
return renderWithProviders(<Layout />);
9+
const store = createStore();
10+
store.uiStore.page = 'loop';
11+
return renderWithProviders(<Layout />, store);
912
};
1013

1114
it('should display the hamburger menu', () => {
@@ -41,7 +44,7 @@ describe('Layout component', () => {
4144
expect(getByText('Lightning Loop').parentElement).toHaveClass('active');
4245
});
4346

44-
it('should navigate back to the Settings page', () => {
47+
it('should navigate to the Settings page', () => {
4548
const { getByText, store } = render();
4649
expect(store.uiStore.page).toBe('loop');
4750
fireEvent.click(getByText('Settings'));
@@ -51,4 +54,11 @@ describe('Layout component', () => {
5154
expect(store.uiStore.page).toBe('loop');
5255
expect(getByText('Lightning Loop').parentElement).toHaveClass('active');
5356
});
57+
58+
it('should not display the sidebar on the auth page', () => {
59+
const { getByText, queryByText, store } = render();
60+
expect(getByText('menu.svg')).toBeInTheDocument();
61+
store.uiStore.page = 'auth';
62+
expect(queryByText('menu.svg')).not.toBeInTheDocument();
63+
});
5464
});

app/src/__tests__/components/loop/LoopHistory.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe('LoopHistory component', () => {
1010

1111
beforeEach(async () => {
1212
store = createStore();
13-
await store.init();
13+
await store.fetchAllData();
1414

1515
// remove all but one swap to prevent `getByText` from
1616
// complaining about multiple elements in tests

app/src/__tests__/components/loop/LoopPage.spec.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('LoopPage component', () => {
1616

1717
beforeEach(async () => {
1818
store = createStore();
19-
await store.init();
19+
await store.fetchAllData();
2020
});
2121

2222
const render = () => {
@@ -74,8 +74,8 @@ describe('LoopPage component', () => {
7474
const { getByText } = render();
7575
expect(getByText('Loop')).toBeInTheDocument();
7676
fireEvent.click(getByText('Loop'));
77-
expect(getByText('Loop out')).toBeInTheDocument();
78-
expect(getByText('Loop in')).toBeInTheDocument();
77+
expect(getByText('Loop Out')).toBeInTheDocument();
78+
expect(getByText('Loop In')).toBeInTheDocument();
7979
});
8080

8181
it('should display swap wizard when Loop out is clicked', async () => {
@@ -85,7 +85,7 @@ describe('LoopPage component', () => {
8585
store.channelStore.sortedChannels.slice(0, 3).forEach(c => {
8686
store.buildSwapStore.toggleSelectedChannel(c.chanId);
8787
});
88-
fireEvent.click(getByText('Loop out'));
88+
fireEvent.click(getByText('Loop Out'));
8989
expect(getByText('Step 1 of 2')).toBeInTheDocument();
9090
});
9191

@@ -96,7 +96,7 @@ describe('LoopPage component', () => {
9696
store.channelStore.sortedChannels.slice(0, 1).forEach(c => {
9797
store.buildSwapStore.toggleSelectedChannel(c.chanId);
9898
});
99-
fireEvent.click(getByText('Loop in'));
99+
fireEvent.click(getByText('Loop In'));
100100
expect(getByText('Step 1 of 2')).toBeInTheDocument();
101101
});
102102

@@ -107,7 +107,7 @@ describe('LoopPage component', () => {
107107
store.channelStore.sortedChannels.slice(0, 1).forEach(c => {
108108
store.buildSwapStore.toggleSelectedChannel(c.chanId);
109109
});
110-
fireEvent.click(getByText('Loop in'));
110+
fireEvent.click(getByText('Loop In'));
111111
expect(getByText('Step 1 of 2')).toBeInTheDocument();
112112
fireEvent.click(getByText('arrow-left.svg'));
113113
expect(getByText('Loop History')).toBeInTheDocument();
@@ -120,7 +120,7 @@ describe('LoopPage component', () => {
120120
store.channelStore.sortedChannels.slice(0, 3).forEach(c => {
121121
store.buildSwapStore.toggleSelectedChannel(c.chanId);
122122
});
123-
fireEvent.click(getByText('Loop out'));
123+
fireEvent.click(getByText('Loop Out'));
124124
expect(getByText('Step 1 of 2')).toBeInTheDocument();
125125
fireEvent.click(getByText('Next'));
126126
expect(getByText('Step 2 of 2')).toBeInTheDocument();
@@ -141,7 +141,7 @@ describe('LoopPage component', () => {
141141
store.channelStore.sortedChannels.slice(0, 3).forEach(c => {
142142
store.buildSwapStore.toggleSelectedChannel(c.chanId);
143143
});
144-
fireEvent.click(getByText('Loop out'));
144+
fireEvent.click(getByText('Loop Out'));
145145
expect(getByText('Step 1 of 2')).toBeInTheDocument();
146146
fireEvent.click(getByText('Next'));
147147
expect(getByText('Step 2 of 2')).toBeInTheDocument();
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { grpc } from '@improbable-eng/grpc-web';
2+
import AppStorage from 'util/appStorage';
3+
import { AuthStore, createStore, Store } from 'store';
4+
import { PersistentSettings } from 'store/stores/settingsStore';
5+
6+
const grpcMock = grpc as jest.Mocked<typeof grpc>;
7+
const appStorageMock = AppStorage as jest.Mock<AppStorage<PersistentSettings>>;
8+
9+
describe('AuthStore', () => {
10+
let rootStore: Store;
11+
let store: AuthStore;
12+
13+
beforeEach(() => {
14+
rootStore = createStore();
15+
store = rootStore.authStore;
16+
});
17+
18+
it('should set credentials', () => {
19+
expect(store.credentials).toBe('');
20+
store.setCredentials('test');
21+
expect(store.credentials).toEqual('test');
22+
store.setCredentials('');
23+
expect(store.credentials).toEqual('');
24+
});
25+
26+
it('should login successfully', async () => {
27+
await store.login('test-pw');
28+
expect(store.credentials).toBe('dGVzdC1wdzp0ZXN0LXB3');
29+
});
30+
31+
it('should fail to login with a blank password', async () => {
32+
await expect(store.login('')).rejects.toThrow('oops, password is required');
33+
expect(store.credentials).toBe('');
34+
});
35+
36+
it('should fail to login with an invalid password', async () => {
37+
grpcMock.unary.mockImplementationOnce(desc => {
38+
if (desc.methodName === 'GetInfo') throw new Error('test-err');
39+
return undefined as any;
40+
});
41+
await expect(store.login('test-pw')).rejects.toThrow(
42+
'oops, that password is incorrect',
43+
);
44+
expect(store.credentials).toBe('');
45+
});
46+
47+
it('should load credentials from session storage', async () => {
48+
const getMock = appStorageMock.mock.instances[0].getSession as jest.Mock;
49+
getMock.mockReturnValue('test-creds');
50+
await store.init();
51+
expect(store.credentials).toBe('test-creds');
52+
});
53+
54+
it('should not store invalid credentials from session storage', async () => {
55+
grpcMock.unary.mockImplementationOnce((desc, opts) => {
56+
if (desc.methodName === 'GetInfo') {
57+
opts.onEnd({
58+
status: grpc.Code.Unauthenticated,
59+
} as any);
60+
}
61+
return undefined as any;
62+
});
63+
const getMock = appStorageMock.mock.instances[0].getSession as jest.Mock;
64+
getMock.mockReturnValue('test-creds');
65+
await store.init();
66+
expect(store.credentials).toBe('');
67+
});
68+
});

0 commit comments

Comments
 (0)