1import { getUserStatePath } from '@expo/config/build/getUserState';
2import { fs, vol } from 'memfs';
3import nock from 'nock';
4
5import {
6  DevelopmentCodeSigningInfoFile,
7  getDevelopmentCodeSigningDirectory,
8} from '../../../utils/codesigning';
9import { getExpoApiBaseUrl } from '../../endpoint';
10import UserSettings from '../UserSettings';
11import { getSessionUsingBrowserAuthFlowAsync } from '../expoSsoLauncher';
12import {
13  Actor,
14  getActorDisplayName,
15  getUserAsync,
16  loginAsync,
17  logoutAsync,
18  ssoLoginAsync,
19} from '../user';
20
21jest.mock('../expoSsoLauncher', () => ({
22  getSessionUsingBrowserAuthFlowAsync: jest.fn(),
23}));
24
25jest.mock('../../../log');
26jest.unmock('../UserSettings');
27jest.mock('../../graphql/client', () => ({
28  graphqlClient: {
29    query: () => {
30      return {
31        toPromise: () =>
32          Promise.resolve({ data: { meUserActor: { id: 'USER_ID', username: 'USERNAME' } } }),
33      };
34    },
35  },
36}));
37jest.mock('../../graphql/queries/UserQuery', () => ({
38  UserQuery: {
39    currentUserAsync: async () => ({ __typename: 'User', username: 'USERNAME', id: 'USER_ID' }),
40  },
41}));
42
43beforeEach(() => {
44  vol.reset();
45});
46
47const userStub: Actor = {
48  __typename: 'User',
49  id: 'userId',
50  username: 'username',
51  accounts: [],
52  isExpoAdmin: false,
53};
54
55const ssoUserStub: Actor = {
56  __typename: 'SSOUser',
57  id: 'userId',
58  username: 'username',
59  accounts: [],
60  isExpoAdmin: false,
61};
62
63const robotStub: Actor = {
64  __typename: 'Robot',
65  id: 'userId',
66  firstName: 'GLaDOS',
67  accounts: [],
68  isExpoAdmin: false,
69};
70
71function mockLoginRequest() {
72  nock(getExpoApiBaseUrl())
73    .post('/v2/auth/loginAsync')
74    .reply(200, { data: { sessionSecret: 'SESSION_SECRET' } });
75}
76
77function resetEnv() {
78  beforeEach(() => {
79    delete process.env.EXPO_OFFLINE;
80    delete process.env.EXPO_TOKEN;
81  });
82  afterAll(() => {
83    delete process.env.EXPO_OFFLINE;
84    delete process.env.EXPO_TOKEN;
85  });
86}
87
88resetEnv();
89
90describe(getUserAsync, () => {
91  resetEnv();
92  it('skips fetching user without access token or session secret', async () => {
93    expect(await getUserAsync()).toBeUndefined();
94  });
95
96  it('fetches user when access token is defined', async () => {
97    process.env.EXPO_TOKEN = 'accesstoken';
98    expect(await getUserAsync()).toMatchObject({ __typename: 'User' });
99  });
100
101  it('fetches user when session secret is defined', async () => {
102    mockLoginRequest();
103
104    await loginAsync({ username: 'USERNAME', password: 'PASSWORD' });
105    expect(await getUserAsync()).toMatchObject({ __typename: 'User' });
106  });
107
108  it('skips fetching user when running in offline mode', async () => {
109    jest.resetModules();
110    process.env.EXPO_OFFLINE = '1';
111    const { getUserAsync } = require('../user');
112
113    process.env.EXPO_TOKEN = 'accesstoken';
114    await expect(getUserAsync()).resolves.toBeUndefined();
115  });
116});
117
118describe(loginAsync, () => {
119  resetEnv();
120  it('saves user data to ~/.expo/state.json', async () => {
121    mockLoginRequest();
122    await loginAsync({ username: 'USERNAME', password: 'PASSWORD' });
123
124    expect(await fs.promises.readFile(getUserStatePath(), 'utf8')).toMatchInlineSnapshot(`
125      "{
126        "auth": {
127          "sessionSecret": "SESSION_SECRET",
128          "userId": "USER_ID",
129          "username": "USERNAME",
130          "currentConnection": "Username-Password-Authentication"
131        }
132      }
133      "
134    `);
135  });
136});
137
138describe(ssoLoginAsync, () => {
139  it('saves user data to ~/.expo/state.json', async () => {
140    jest.mocked(getSessionUsingBrowserAuthFlowAsync).mockResolvedValue('SESSION_SECRET');
141
142    await ssoLoginAsync();
143
144    expect(await fs.promises.readFile(getUserStatePath(), 'utf8')).toMatchInlineSnapshot(`
145      "{
146        "auth": {
147          "sessionSecret": "SESSION_SECRET",
148          "userId": "USER_ID",
149          "username": "USERNAME",
150          "currentConnection": "Browser-Flow-Authentication"
151        }
152      }
153      "
154    `);
155  });
156});
157
158describe(logoutAsync, () => {
159  resetEnv();
160  it('removes the session secret', async () => {
161    mockLoginRequest();
162    await loginAsync({ username: 'USERNAME', password: 'PASSWORD' });
163    expect(UserSettings.getSession()?.sessionSecret).toBe('SESSION_SECRET');
164
165    await logoutAsync();
166    expect(UserSettings.getSession()?.sessionSecret).toBeUndefined();
167  });
168
169  it('removes code signing data', async () => {
170    mockLoginRequest();
171    await loginAsync({ username: 'USERNAME', password: 'PASSWORD' });
172
173    await DevelopmentCodeSigningInfoFile.setAsync('test', {});
174    expect(fs.existsSync(getDevelopmentCodeSigningDirectory())).toBe(true);
175
176    await logoutAsync();
177    expect(fs.existsSync(getDevelopmentCodeSigningDirectory())).toBe(false);
178  });
179});
180
181describe(getActorDisplayName, () => {
182  resetEnv();
183  it('returns anonymous for unauthenticated users', () => {
184    expect(getActorDisplayName()).toBe('anonymous');
185  });
186
187  it('returns username for user actors', () => {
188    expect(getActorDisplayName(userStub)).toBe(userStub.username);
189  });
190
191  it('returns username for SSO user actors', () => {
192    expect(getActorDisplayName(userStub)).toBe(ssoUserStub.username);
193  });
194
195  it('returns firstName with robot prefix for robot actors', () => {
196    expect(getActorDisplayName(robotStub)).toBe(`${robotStub.firstName} (robot)`);
197  });
198
199  it('returns robot prefix only for robot actors without firstName', () => {
200    expect(getActorDisplayName({ ...robotStub, firstName: undefined })).toBe('robot');
201  });
202});
203