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