diff --git a/assets/info.plist b/assets/info.plist index 2efe9ee6..9e2b060c 100644 --- a/assets/info.plist +++ b/assets/info.plist @@ -96,7 +96,7 @@ argumenttype 1 escaping - 36 + 102 keyword todos queuedelaycustom @@ -119,8 +119,7 @@ . ./check-node.sh -escaped="$(echo "{query}" | sed 's/"/\\"/g')" -node $node_flags alfred-workflow-todoist.js "{\"name\": \"read\", \"args\": \"$escaped\"}" +node $node_flags alfred-workflow-todoist.js "{\"name\": \"read\", \"args\": \"{query}\"}" scriptargtype 0 @@ -219,7 +218,7 @@ node $node_flags alfred-workflow-todoist.js "{query}" argumenttype 0 escaping - 36 + 102 keyword todo queuedelaycustom @@ -242,8 +241,7 @@ node $node_flags alfred-workflow-todoist.js "{query}" . ./check-node.sh -escaped="$(echo "{query}" | sed 's/"/\\"/g')" -node $node_flags alfred-workflow-todoist.js "{\"name\": \"parse\", \"args\": \"$escaped\"}" +node $node_flags alfred-workflow-todoist.js "{\"name\": \"parse\", \"args\": \"{query}\"}" scriptargtype 0 @@ -294,7 +292,7 @@ node $node_flags alfred-workflow-todoist.js "{\"name\": \"parse\", \"args\": \"$ argumenttype 1 escaping - 38 + 102 keyword todo:setting queuedelaycustom @@ -317,8 +315,7 @@ node $node_flags alfred-workflow-todoist.js "{\"name\": \"parse\", \"args\": \"$ . ./check-node.sh -escaped="$(echo "{query}" | sed 's/"/\\"/g')" -node $node_flags alfred-workflow-todoist.js "{\"name\": \"readSettings\", \"args\": \"$escaped\"}" +node $node_flags alfred-workflow-todoist.js "{\"name\": \"readSettings\", \"args\": \"{query}\"}" scriptargtype 0 @@ -412,7 +409,7 @@ node $node_flags alfred-workflow-todoist.js "{\"name\": \"readSettings\", \"args version - 0.0.0-development + 6.0.0-alpha.8 webaddress https://github.com/moranje/alfred-workflow-todoist diff --git a/jest.config.js b/jest.config.js index 3c6497f2..e58bd6f9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -33,7 +33,6 @@ module.exports = { '/dist/', '/tools/', 'alfred-workflow-todoist.ts', - 'cli-args', ], // A list of reporter names that Jest uses when writing coverage reports diff --git a/src/lib/cli-args.ts b/src/lib/cli-args.ts index adb80123..6ce6760a 100755 --- a/src/lib/cli-args.ts +++ b/src/lib/cli-args.ts @@ -1,9 +1,10 @@ -import { isPrimitive } from 'util'; import { TodoistTask, TodoistTaskOptions } from 'todoist-rest-api'; +import { isPrimitive } from 'util'; -import { ResourceName } from './todoist/local-rest-adapter'; import { Settings } from '@/lib/stores/settings-store'; +import { ResourceName } from './todoist/local-rest-adapter'; + type Arg = | string | TodoistTaskOptions @@ -39,6 +40,16 @@ export type Call = }; const [, , ...argv] = process.argv; +export const callNames: { [key: string]: boolean } = { + parse: true, + read: true, + readSettings: true, + openUrl: true, + create: true, + remove: true, + writeSetting: true, + refreshCache: true, +}; function assertValidArgs(args: Arg): asserts args is Arg { if (args == null) { @@ -49,13 +60,22 @@ function assertValidArgs(args: Arg): asserts args is Arg { } function assertValidCall(call: Call): asserts call is Call { + // istanbul ignore next: shouldn't be possible in codebase if (!call || isPrimitive(call)) { throw new TypeError(`The call should be a an object, was ${call}`); } if (!call.name || typeof call.name !== 'string') { throw new TypeError( - `Expected call.name to be a string was ${call.name} (${typeof call.name})` + `Expected call.name to be a string was ${ + call.name + } (of type ${typeof call.name})` + ); + } + + if (!callNames[call.name.toString()]) { + throw new TypeError( + `Expected call.name to be one of parse, read, readSettings, openUrl, create, remove, writeSetting, refreshCache, was ${call.name}` ); } @@ -68,22 +88,49 @@ function serialize(call: Call): string | never { return JSON.stringify(call); } +function escape(string: string): string { + // @ts-ignore: is valid + return String(string).replace(/["\\\b\f\n\r\t]/g, char => { + switch (char) { + case '"': + return '\\"'; + case '\b': + return '\\b'; + case '\f': + return '\\f'; + case '\n': + return '\\n'; + case '\r': + return '\\r'; + case '\t': + return '\\t'; + default: + return char; + } + }); +} + function deserialize(serialized: string): Call | never { - if (typeof serialized !== 'string') { - throw new TypeError( - `Expected a string in deserialize, got ${serialized} (${typeof serialized})` - ); - } + const escaped = serialized.replace( + /"args": "([\s\S]+?)"}/, + (match, input) => { + return `"args": "${escape(input)}"}`; + } + ); try { - const call = JSON.parse(serialized) as Call; + const call = JSON.parse(escaped) as Call; assertValidCall(call); return call; } catch (error) { - throw new TypeError( - `Expected a JSON string, got '${serialized}' (${typeof serialized})` - ); + if (error instanceof SyntaxError) { + throw new TypeError( + `Expected a JSON string, got '${escaped}' (${typeof escaped})` + ); + } + + throw new TypeError(error.message); } } @@ -116,7 +163,7 @@ export function isUserFacingCall(): boolean { let call; try { call = getCurrentCall(); - } catch { + } catch (error) { // Err on the side of caution. return true; } diff --git a/src/lib/tests/cli-args.ts b/src/lib/tests/cli-args.ts new file mode 100644 index 00000000..b783daed --- /dev/null +++ b/src/lib/tests/cli-args.ts @@ -0,0 +1,176 @@ +import mockArgv from 'mock-argv'; +// import { createCall } from '@/lib/cli-args'; + +type CallName = + | 'parse' + | 'read' + | 'readSettings' + | 'openUrl' + | 'create' + | 'remove' + | 'writeSetting' + | 'refreshCache' + | 'anything'; // for testing purposes + +function escape(string: string): string { + return String(string).replace(/[\\]/g, function(char) { + switch (char) { + // case '"': + // return '\\"'; + case '\\': + return '\\' + char; + } + }); +} + +function cliArgs(callName: CallName, input: any): string { + return `{"name": "${callName}", "args": "${escape(input.toString())}"}`; +} + +describe('unit: CLI', () => { + beforeEach(() => jest.resetModules()); + + it('should escape characters invalid to JSON during deserialization', async () => { + expect.assertions(1); + + const characters = '\\ " \t \n \r \b \f'; + await mockArgv([cliArgs('read', characters)], async () => { + const { getCurrentCall } = await import('@/lib/cli-args'); + + const cli = getCurrentCall(); + + expect(cli.args).toBe(characters); + }); + }); + + it('should throw if call is not a valid JSON string', async () => { + expect.assertions(1); + + await mockArgv([{}], async () => { + const { getCurrentCall } = await import('@/lib/cli-args'); + + expect(() => { + const cli = getCurrentCall(); + }).toThrow(/Expected a JSON string/); + }); + }); + + it("should throw if call doesn't have a name property", async () => { + expect.assertions(1); + + await mockArgv(['{}'], async () => { + const { getCurrentCall } = await import('@/lib/cli-args'); + + expect(() => { + const cli = getCurrentCall(); + }).toThrow(/Expected call.name to be a string/); + }); + }); + + it("should throw if call doesn't have an args property", async () => { + expect.assertions(1); + + await mockArgv(['{"name": "read"}'], async () => { + const { getCurrentCall } = await import('@/lib/cli-args'); + + expect(() => { + const cli = getCurrentCall(); + }).toThrow(/Property args should not be null or undefined/); + }); + }); + + it('should serialize to a JSON parsable string', async () => { + expect.assertions(1); + + const { createCall } = await import('@/lib/cli-args'); + + const characters = '\\ " \t \n \r \b \f'; + const call = createCall({ + name: 'create', + args: characters, + }); + + expect(JSON.parse(call).args).toBe(characters); + }); + + it('should label "parse" as a user facing call', async () => { + expect.assertions(1); + + await mockArgv([cliArgs('parse', '')], async () => { + const { isUserFacingCall } = await import('@/lib/cli-args'); + expect(isUserFacingCall()).toBe(true); + }); + }); + + it('should label "read" as a user facing call', async () => { + expect.assertions(1); + + await mockArgv([cliArgs('read', '')], async () => { + const { isUserFacingCall } = await import('@/lib/cli-args'); + expect(isUserFacingCall()).toBe(true); + }); + }); + + it('should label "readSettings" as a user facing call', async () => { + expect.assertions(1); + + await mockArgv([cliArgs('readSettings', '')], async () => { + const { isUserFacingCall } = await import('@/lib/cli-args'); + expect(isUserFacingCall()).toBe(true); + }); + }); + + it('should label "openUrl" as non-user facing call', async () => { + expect.assertions(1); + + await mockArgv([cliArgs('openUrl', '')], async () => { + const { isUserFacingCall } = await import('@/lib/cli-args'); + expect(isUserFacingCall()).toBe(false); + }); + }); + + it('should label "create" as non-user facing call', async () => { + expect.assertions(1); + + await mockArgv([cliArgs('create', '')], async () => { + const { isUserFacingCall } = await import('@/lib/cli-args'); + expect(isUserFacingCall()).toBe(false); + }); + }); + + it('should label "remove" as non-user facing call', async () => { + expect.assertions(1); + + await mockArgv([cliArgs('remove', '')], async () => { + const { isUserFacingCall } = await import('@/lib/cli-args'); + expect(isUserFacingCall()).toBe(false); + }); + }); + + it('should label "writeSetting" as non-user facing call', async () => { + expect.assertions(1); + + await mockArgv([cliArgs('writeSetting', '')], async () => { + const { isUserFacingCall } = await import('@/lib/cli-args'); + expect(isUserFacingCall()).toBe(false); + }); + }); + + it('should label "refreshCache" as non-user facing call', async () => { + expect.assertions(1); + + await mockArgv([cliArgs('refreshCache', '')], async () => { + const { isUserFacingCall } = await import('@/lib/cli-args'); + expect(isUserFacingCall()).toBe(false); + }); + }); + + it('should label unknown calls as user facing', async () => { + expect.assertions(1); + + await mockArgv([cliArgs('anything', '')], async () => { + const { isUserFacingCall } = await import('@/lib/cli-args'); + expect(isUserFacingCall()).toBe(true); + }); + }); +});