Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[eas-cli] use google service key detection in credentials service #660

Merged
merged 3 commits into from
Sep 30, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { promptAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
import { CredentialsContext } from '../../context';
import { GoogleServiceAccountKey } from '../credentials';
import { readAndValidateServiceAccountKey } from '../utils/googleServiceAccountKey';
import {
detectGoogleServiceAccountKeyPathAsync,
readAndValidateServiceAccountKey,
} from '../utils/googleServiceAccountKey';

export class CreateGoogleServiceAccountKey {
constructor(private account: Account) {}
Expand All @@ -13,7 +16,7 @@ export class CreateGoogleServiceAccountKey {
if (ctx.nonInteractive) {
throw new Error(`New Google Service Account Key cannot be created in non-interactive mode.`);
}
const jsonKeyObject = await this.provideAsync();
const jsonKeyObject = await this.provideAsync(ctx);
const gsaKeyFragment = await ctx.android.createGoogleServiceAccountKeyAsync(
this.account,
jsonKeyObject
Expand All @@ -22,20 +25,30 @@ export class CreateGoogleServiceAccountKey {
return gsaKeyFragment;
}

private async provideAsync(): Promise<GoogleServiceAccountKey> {
private async provideAsync(ctx: CredentialsContext): Promise<GoogleServiceAccountKey> {
try {
const { keyJsonPath } = await promptAsync([
{
type: 'text',
name: 'keyJsonPath',
message: 'Path to Google Service Account Key JSON file:',
validate: (value: string) => value.length > 0 || "Path can't be empty",
},
]);
const keyJsonPath = await this.provideKeyJsonPathAsync(ctx);
return readAndValidateServiceAccountKey(keyJsonPath);
} catch (e) {
Log.error(e);
return await this.provideAsync();
return await this.provideAsync(ctx);
}
}

private async provideKeyJsonPathAsync(ctx: CredentialsContext): Promise<string> {
const detectedPath = await detectGoogleServiceAccountKeyPathAsync(ctx.projectDir);
if (detectedPath) {
return detectedPath;
}

const { keyJsonPath } = await promptAsync([
{
type: 'text',
name: 'keyJsonPath',
message: 'Path to Google Service Account Key JSON file:',
validate: (value: string) => value.length > 0 || "Path can't be empty",
},
]);
return keyJsonPath;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ beforeEach(() => {
describe(CreateGoogleServiceAccountKey, () => {
it('creates a Google Service Account Key in Interactive Mode', async () => {
vol.fromJSON({
'/google-service-account-key.json': JSON.stringify({ private_key: 'super secret' }),
'/google-service-account-key.json': JSON.stringify({
type: 'service_account',
private_key: 'super secret',
}),
});

const ctx = createCtxMock({ nonInteractive: false });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ describe(SetupGoogleServiceAccountKey, () => {
});
it('sets up a Google Service Account Key when there is none already setup', async () => {
vol.fromJSON({
'/google-service-account-key.json': JSON.stringify({ private_key: 'super secret' }),
'/google-service-account-key.json': JSON.stringify({
type: 'service_account',
private_key: 'super secret',
}),
});
const ctx = createCtxMock({
nonInteractive: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { vol } from 'memfs';

import { asMock } from '../../../../__tests__/utils';
import { promptAsync } from '../../../../prompts';
import { detectGoogleServiceAccountKeyPathAsync } from '../googleServiceAccountKey';

jest.mock('fs');
jest.mock('../../../../prompts');

beforeAll(() => {
const mockDetectableServiceAccountJson = JSON.stringify({
type: 'service_account',
private_key: 'super secret',
});

vol.fromJSON({
'/project_dir/subdir/service-account.json': mockDetectableServiceAccountJson,
'/project_dir/another-service-account.json': mockDetectableServiceAccountJson,
'/other_dir/invalid_file.txt': 'this is not even a JSON',
});
});
afterAll(() => {
vol.reset();
});

afterEach(() => {
asMock(promptAsync).mockClear();
});

describe('Google Service Account Key path detection', () => {
it('detects a single google-services file and prompts for confirmation', async () => {
asMock(promptAsync).mockResolvedValueOnce({
confirmed: true,
});
const serviceAccountPath = await detectGoogleServiceAccountKeyPathAsync('/project_dir/subdir');

expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ type: 'confirm' }));
expect(serviceAccountPath).toBe('/project_dir/subdir/service-account.json');
});

it('returns null, when no valid files are found in the dir', async () => {
const serviceAccountPath = await detectGoogleServiceAccountKeyPathAsync('/other_dir'); // no valid files in that dir
expect(promptAsync).not.toHaveBeenCalled();
expect(serviceAccountPath).toBe(null);
});

it('returns null, when user rejects to use detected file', async () => {
asMock(promptAsync).mockResolvedValueOnce({
confirmed: false,
});

const serviceAccountPath = await detectGoogleServiceAccountKeyPathAsync('/project_dir/subdir');

expect(promptAsync).toHaveBeenCalledTimes(1);
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ type: 'confirm' }));
expect(serviceAccountPath).toBe(null);
});

it('displays a chooser, when multiple files are found', async () => {
asMock(promptAsync).mockResolvedValueOnce({
selectedPath: '/project_dir/another-service-account.json',
});

const serviceAccountPath = await detectGoogleServiceAccountKeyPathAsync('/project_dir'); // should find 2 files here

const expectedChoices = expect.arrayContaining([
expect.objectContaining({ value: '/project_dir/another-service-account.json' }),
expect.objectContaining({ value: '/project_dir/subdir/service-account.json' }),
expect.objectContaining({ value: false }),
]);

expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
type: 'select',
choices: expectedChoices,
})
);
expect(serviceAccountPath).toBe('/project_dir/another-service-account.json');
});

it('returns null, when user selects the "None of above"', async () => {
asMock(promptAsync).mockResolvedValueOnce({
selectedPath: false,
});

const serviceAccountPath = await detectGoogleServiceAccountKeyPathAsync('/project_dir'); // should find 2 files here

expect(promptAsync).toHaveBeenCalledTimes(1);
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ type: 'select' }));
expect(serviceAccountPath).toBe(null);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import JsonFile from '@expo/json-file';
import chalk from 'chalk';
import glob from 'fast-glob';
import Joi from 'joi';
import path from 'path';

import { GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
Expand All @@ -9,9 +11,19 @@ import { fromNow } from '../../../utils/date';
import { GoogleServiceAccountKey } from '../credentials';

export const MinimalGoogleServiceAccountKeySchema = Joi.object({
type: Joi.string().required(),
private_key: Joi.string().required(),
});

function fileIsServiceAccountKey(keyJsonPath: string): boolean {
try {
readAndValidateServiceAccountKey(keyJsonPath);
return true;
} catch (err: any) {
return false;
}
}

export function readAndValidateServiceAccountKey(keyJsonPath: string): GoogleServiceAccountKey {
try {
const jsonKeyObject = JsonFile.read(keyJsonPath);
Expand Down Expand Up @@ -77,3 +89,73 @@ function formatGoogleServiceAccountKey({
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago,`);
return line;
}

export async function detectGoogleServiceAccountKeyPathAsync(
projectDir: string
): Promise<string | null> {
const foundFilePaths = await glob('**/*.json', {
cwd: projectDir,
ignore: ['app.json', 'package*.json', 'tsconfig.json', 'node_modules'],
});

const googleServiceFiles = foundFilePaths
.map(file => path.join(projectDir, file))
.filter(fileIsServiceAccountKey);

if (googleServiceFiles.length > 1) {
const selectedPath = await displayPathChooserAsync(googleServiceFiles, projectDir);

if (selectedPath !== false) {
return selectedPath;
}
} else if (googleServiceFiles.length === 1) {
const [detectedPath] = googleServiceFiles;

if (await confirmDetectedPathAsync(detectedPath)) {
return detectedPath;
}
}

return null;
}

async function displayPathChooserAsync(
paths: string[],
projectDir: string
): Promise<string | false> {
quinlanj marked this conversation as resolved.
Show resolved Hide resolved
const choices = paths.map<{ title: string; value: string | false }>(f => ({
value: f,
title: f.startsWith(projectDir) ? path.relative(projectDir, f) : f,
}));

choices.push({
title: 'None of the above',
value: false,
});

Log.log(
'Multiple Google Service Account JSON keys have been found inside your project directory.'
);
const { selectedPath } = await promptAsync({
name: 'selectedPath',
type: 'select',
message: 'Choose the key you want to use:',
choices,
});

Log.addNewLineIfNone();
return selectedPath;
}

async function confirmDetectedPathAsync(path: string): Promise<boolean> {
Log.log(`A Google Service Account JSON key has been found at\n ${chalk.underline(path)}`);
const { confirmed } = await promptAsync({
quinlanj marked this conversation as resolved.
Show resolved Hide resolved
name: 'confirmed',
type: 'confirm',
message: 'Would you like to use this file?',
initial: true,
});

Log.addNewLineIfNone();
return confirmed;
}