Skip to content

Commit

Permalink
fix: No longer requiring password input through CLI or env variable, …
Browse files Browse the repository at this point in the history
…but can be provided via stdin

Fixes #341
  • Loading branch information
Frank Steiler committed Sep 19, 2023
1 parent 8a80a8a commit 63eba6c
Show file tree
Hide file tree
Showing 7 changed files with 79 additions and 18 deletions.
1 change: 1 addition & 0 deletions .vscode/icloud-photos-sync.cspell
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ Symbolication
templating
testparam
testpassword
thundernetworkrad
timepicker
tplrgn
truevision
Expand Down
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"--runInBand",
"--config", "jest.config.json",
//"--detectOpenHandles",
"test/unit/icloud.test.ts"
"test/unit/app.test.ts"
],
"env": {
"NODE_NO_WARNINGS": "1"
Expand Down
9 changes: 9 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
},
"dependencies": {
"@backtrace-labs/node": "^0.0.1",
"@thundernetworkrad/readline-sync": "^2023.3.13-1",
"ajv": "^8.12.0",
"axios": "^1.2.2",
"axios-har-tracker": "^0.5.1",
Expand Down
39 changes: 33 additions & 6 deletions app/src/app/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Cron from "croner";
import {TokenApp, SyncApp, ArchiveApp, iCPSApp, DaemonApp} from "./icloud-app.js";
import {Resources} from "../lib/resources/main.js";
import {LogLevel} from "./event/log.js";
import {question, BasicOptions} from '@thundernetworkrad/readline-sync';

/**
* This function can be used as a commander argParser. It will try to parse the value as a positive integer and throw an invalid argument error in case it fails
Expand Down Expand Up @@ -56,6 +57,21 @@ function commanderParseCron(value: string, _dummyPrevious?: unknown): string {
}
}

/**
* This function reads a value that can be optionally empty and asks for user input to provide the actual value
* @param questionFct - Function to query for input - parameterized for testability
* @param value - The string literal, read from the CLI
* @param _dummyPrevious - Conforming to the interface - unused
* @returns The original string, or user provided input, in case the input was undefined or empty
*/
function commanderParseOptionalAndReadFromStdin(questionFct: (query?: any, options?: BasicOptions) => string, value: string, _dummyPrevious?: unknown): string {
if (value && value.length > 0) {
return value;
}

return questionFct(`Please enter your iCloud password: `, {hideEchoBack: true});
}

/**
* This function can be used as a commander argParser. It will try to parse the value as an interval with the format \<numberOfRequests|Infinity\>/\<timeInMs\>.
* @param value - The string literal, read from the CLI
Expand Down Expand Up @@ -111,9 +127,10 @@ export type iCPSAppOptions = {
/**
* Creates the argument parser for the CLI and environment variables
* @param callback - A callback function that will be called with the created app, based on the provided options.
* @param questionFct - The function to query for input from the CLI - parameterized for testing purposes
* @returns The commander command object, awaiting .parse() to be called
*/
export function argParser(callback: (res: iCPSApp) => void): Command {
export function argParser(callback: (res: iCPSApp) => void, questionFct: (query?: any, options?: BasicOptions) => string): Command {
// Overwriting commander\`s _exit function, because exitOverwrite will still call process.exit
(Command.prototype as any)._exit = (exitCode: number, code: string, message: string) => {
throw new CommanderError(exitCode, code, message); // Function needs to return \`never\`, otherwise errors will be ignored
Expand All @@ -126,9 +143,11 @@ export function argParser(callback: (res: iCPSApp) => void): Command {
.addOption(new Option(`-u, --username <string>`, `AppleID username.`)
.env(`APPLE_ID_USER`)
.makeOptionMandatory(true))
.addOption(new Option(`-p, --password <string>`, `AppleID password.`)
.addOption(new Option(`-p, --password [string]`, `AppleID password. Omitting the option or providing an empty string will result in the CLI to ask for user input before startup.`)
.preset('')
.makeOptionMandatory()
.env(`APPLE_ID_PWD`)
.makeOptionMandatory(true))
.argParser(commanderParseOptionalAndReadFromStdin.bind(this, questionFct)))
.addOption(new Option(`-T, --trust-token <string>`, `The trust token for authentication. If not provided, the trust token is read from the \`.icloud-photos-sync\` resource file in data dir. If no stored trust token could be loaded, a new trust token will be acquired (requiring the input of an MFA code).`)
.env(`TRUST_TOKEN`))
.addOption(new Option(`-d, --data-dir <string>`, `Directory to store local copy of library.`)
Expand Down Expand Up @@ -215,7 +234,7 @@ export function argParser(callback: (res: iCPSApp) => void): Command {
.description(`Fetches the remote state and persist it to the local disk once.`);

program.command(`archive`)
.action((archivePath, _, command) => {
.action(async (archivePath, _, command) => {
Resources.setup(command.parent.opts());
callback(new ArchiveApp(archivePath));
})
Expand All @@ -228,14 +247,22 @@ export function argParser(callback: (res: iCPSApp) => void): Command {
/**
* This function will parse the provided string array and environment variables and return the correct application object.
* @param argv - The argument vector to be parsed
* @param questionFct - The function to query for input from the CLI - parameterized for testing purposes, readline-sync used per default
* @returns - A promise that resolves to the correct application object. Once the promise resolves, the global resource singleton will also be available. If the program is not able to parse the options, or required options are missing, an error message is printed to stderr and the promise rejects with a CommanderError.
*/
export async function appFactory(argv: string[]): Promise<iCPSApp> {
export async function appFactory(argv: string[], questionFct: (query?: any, options?: BasicOptions) => string = question): Promise<iCPSApp> {
return new Promise((resolve, reject) => {
try {
argParser((res: iCPSApp) => {
resolve(res);
}).parse(argv);
}, questionFct).parse(
argv.indexOf('-p') >=0
? argv
: [
...argv,
'-p' // making sure -p is present to trigger commander preset
]
);
} catch (err) {
reject(err);
}
Expand Down
12 changes: 1 addition & 11 deletions app/test/_helpers/app-factory.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,8 @@ export const rejectOptions = [
`/home/icloud-photos-sync/main.js`,
`token`,
],
_desc: `Missing username & password`,
_desc: `Missing username`,
expected: `error: required option '-u, --username <string>' not specified`,
}, {
options: [
`/usr/bin/node`,
`/home/icloud-photos-sync/main.js`,
`-u`,
`test@icloud.com`,
`token`,
],
_desc: `Missing password`,
expected: `error: required option '-p, --password <string>' not specified`,
}, {
options: [
`/usr/bin/node`,
Expand Down
33 changes: 33 additions & 0 deletions app/test/unit/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {prepareResources, spyOnEvent} from '../_helpers/_general';
import path from 'path';
import {iCPSEventApp, iCPSEventCloud, iCPSEventRuntimeError} from '../../src/lib/resources/events-types';
import {Resources} from '../../src/lib/resources/main';
import { BasicOptions } from '@thundernetworkrad/readline-sync'

beforeEach(() => {
mockfs();
Expand Down Expand Up @@ -61,6 +62,38 @@ describe(`App Factory`, () => {
});
});

// Currently don't know how to test this...
test.each([
{
desc: 'No parameter',
parameter: []
},{
desc: 'Empty parameter',
parameter: [
`-p`,
``,
]
}
])(`Asking user to provide password: $desc`, async ({parameter}) => {
const setupSpy = jest.spyOn(Resources, `setup`);
const questionFct = jest.fn<(query?: any, options?: BasicOptions) => string>().mockReturnValue('testPass')
const app = await appFactory(
[
`/usr/bin/node`,
`/home/icloud-photos-sync/main.js`,
`-u`,
`test@icloud.com`,
...parameter,
`token`,
],
questionFct
);

expect(app).toBeInstanceOf(TokenApp);
expect(setupSpy).toHaveBeenCalledWith(Config.defaultConfig);
expect(questionFct).toHaveBeenCalled();
});

test(`Create Token App`, async () => {
const tokenApp = await appFactory(validOptions.token) as TokenApp;

Expand Down

0 comments on commit 63eba6c

Please sign in to comment.