diff --git a/packages/captcha/src/audio.test.ts b/packages/captcha/src/audio.test.ts new file mode 100644 index 0000000..800c6c3 --- /dev/null +++ b/packages/captcha/src/audio.test.ts @@ -0,0 +1,96 @@ +import express from 'express' +import session from 'express-session' +import cookieParser from 'cookie-parser' +import request from 'supertest' +import { audio, speak } from './audio' +import { join } from 'path' + +describe('speak', () => { + it('writes speech data to stdin and returns rw streams', async () => { + async function read(readable: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + readable.on('error', err => reject(err)) + readable.on('readable', () => { + let data = '' + let chunk + while (null !== (chunk = readable.read())) { + data += chunk + } + resolve(data) + }) + }) + } + + const command = { + cmd: process.argv[0], + args: [join(__dirname, 'testProcess.ts')], + contentType: 'text/plain', + } + const rw = await speak('mytest', [command]) + const data = await read(rw.stdout) + expect(data).toEqual('mytest') + }) +}) + +describe('audio', () => { + + const app = express() + app.use(cookieParser()) + app.use(session({ + saveUninitialized: false, + resave: false, + secret: 'test', + })) + app.get('/captcha/audio', audio({ + commands: [{ + cmd: process.argv[0], + args: [join(__dirname, 'testProces.ts')], + contentType: 'text/plain', + }], + size: 6, + })) + app.get('/captcha/session', (req, res) => { + res.json(req.session) + }) + + describe('/captcha/audio', () => { + it('generates a new captcha', async () => { + const res1 = await request(app) + .get('/captcha/audio') + .expect('Content-type', /text\/plain/) + .expect(200) + + const cookie = res1.header['set-cookie'][0] + + const res2 = await request(app) + .get('/captcha/session') + .set('cookie', cookie) + .expect(200) + + expect(res2.body.captcha).toEqual(jasmine.any(String)) + }) + + it('fails with error 500 when unable to generate', async () => { + const app = express() + app.use(cookieParser()) + app.use(session({ + saveUninitialized: false, + resave: false, + secret: 'test', + })) + app.get('/captcha/audio', audio({ + commands: [{ + cmd: 'non-existing-command', + args: [], + contentType: 'text/plain', + }], + size: 6, + })) + await request(app) + .get('/captcha/audio') + .expect(500) + .expect('Internal server error') + }) + }) + +}) diff --git a/packages/captcha/src/audio.ts b/packages/captcha/src/audio.ts index 358f7ec..3ebef65 100644 --- a/packages/captcha/src/audio.ts +++ b/packages/captcha/src/audio.ts @@ -1,21 +1,40 @@ -import { spawn } from 'child_process' import { Request, Response } from 'express' import { Readable } from 'stream' +import { run, ReadableProcess, ReadableWritable, Command } from './run' +import SVGCaptcha from 'svg-captcha' -export async function audio(req: Request, res: Response) { - // TODO generate random string - const captcha = 'test' +export interface AudioConfig { + commands: Command[] + size: number +} + +export const audio = (config: AudioConfig) => async ( + req: Request, + res: Response, +) => { + const { commands, size } = config + const captcha = SVGCaptcha.randomText(size) req.session!.captcha = captcha - const speech = await speak('test') + let speech: ReadableProcess + try { + speech = await speak('test', commands) + } catch (err) { + res.status(500) + res.send('Internal server error') + return + } res.type(speech.contentType) speech.stdout.pipe(res) } -async function speak(text: string) { - const streams: ReadableWritable[] = [ - await espeak(), - await opus(), - ] +export async function speak( + text: string, + commands: Command[], +): Promise { + const streams: ReadableWritable[] = [] + for (const command of commands) { + streams.push(await run(command)) + } const last = streams.reduce((prev, proc) => { prev.stdout.pipe(proc.stdin) @@ -25,31 +44,6 @@ async function speak(text: string) { return last } -interface ReadableProcess { - stdout: NodeJS.ReadableStream - contentType: string -} - -interface WritableProcess { - stdin: NodeJS.WritableStream -} - -interface ReadableWritable extends ReadableProcess, WritableProcess { - -} - -function espeak() { - return run( - 'espeak', - ['-k', '2', '-s', '90', '--stdin', '--stdout'], - 'audio/wav', - ) -} - -async function opus() { - return run('opusenc', ['-', '-'], 'audio/opus') -} - class TextStream extends Readable { constructor(text: string) { super() @@ -65,20 +59,3 @@ function createTextStream(text: string): ReadableProcess { contentType: 'text/plain', } } - -async function run( - cmd: string, args: string[], contentType: string, -): Promise { - return new Promise((resolve, reject) => { - const p = spawn(cmd, args) - - p.once('error', err => { - console.error(err.stack) - reject(err) - }) - - if (p.pid) { - resolve({ stdin: p.stdin, stdout: p.stdout, contentType }) - } - }) -} diff --git a/packages/captcha/src/commands.test.ts b/packages/captcha/src/commands.test.ts new file mode 100644 index 0000000..7992f62 --- /dev/null +++ b/packages/captcha/src/commands.test.ts @@ -0,0 +1,21 @@ +import { espeak, opusenc } from './commands' + +describe('espeak', () => { + it('returns espeak arguments', () => { + expect(espeak({})).toEqual({ + cmd: 'espeak', + args: ['-k', '2', '-s', '90', '--stdin', '--stdout'], + contentType: 'audio/wav', + }) + }) +}) + +describe('opus', () => { + it('returns opusenc arguments', () => { + expect(opusenc({})).toEqual({ + cmd: 'opusenc', + args: ['-', '-'], + contentType: 'audio/opus', + }) + }) +}) diff --git a/packages/captcha/src/commands.ts b/packages/captcha/src/commands.ts new file mode 100644 index 0000000..abcbb52 --- /dev/null +++ b/packages/captcha/src/commands.ts @@ -0,0 +1,22 @@ +export interface ESpeakOptions { + +} + +export interface OpusOptions { +} + +export function espeak(config: ESpeakOptions) { + return { + cmd: 'espeak', + args: ['-k', '2', '-s', '90', '--stdin', '--stdout'], + contentType: 'audio/wav', + } +} + +export function opusenc(config: OpusOptions) { + return { + cmd: 'opusenc', + args: ['-', '-'], + contentType: 'audio/opus', + } +} diff --git a/packages/captcha/src/espeak.ts b/packages/captcha/src/espeak.ts new file mode 100644 index 0000000..e94a162 --- /dev/null +++ b/packages/captcha/src/espeak.ts @@ -0,0 +1,15 @@ +export function espeak() { + return { + cmd: 'espeak', + args: ['-k', '2', '-s', '90', '--stdin', '--stdout'], + contentType: 'audio/wav', + } +} + +export function opus() { + return { + cmd: 'opusenc', + args: ['-', '-'], + contentType: 'audio/opus', + } +} diff --git a/packages/captcha/src/image.test.ts b/packages/captcha/src/image.test.ts new file mode 100644 index 0000000..3243f5c --- /dev/null +++ b/packages/captcha/src/image.test.ts @@ -0,0 +1,39 @@ +import express from 'express' +import session from 'express-session' +import cookieParser from 'cookie-parser' +import request from 'supertest' +import { image } from './image' + +describe('image', () => { + + const app = express() + app.use(cookieParser()) + app.use(session({ + saveUninitialized: false, + resave: false, + secret: 'test', + })) + app.get('/captcha/image', image({ size: 6 })) + app.get('/captcha/session', (req, res) => { + res.json(req.session) + }) + + describe('/captcha/image', () => { + it('generates a new captcha', async () => { + const res1 = await request(app) + .get('/captcha/image') + .expect('Content-type', /svg/) + .expect(200) + + const cookie = res1.header['set-cookie'][0] + + const res2 = await request(app) + .get('/captcha/session') + .set('cookie', cookie) + .expect(200) + + expect(res2.body.captcha).toEqual(jasmine.any(String)) + }) + }) + +}) diff --git a/packages/captcha/src/image.ts b/packages/captcha/src/image.ts index 2bdb179..018cd0a 100644 --- a/packages/captcha/src/image.ts +++ b/packages/captcha/src/image.ts @@ -1,8 +1,14 @@ import SVGCaptcha from 'svg-captcha' import { Request, Response } from 'express' -export function image(req: Request, res: Response) { - const { text, data } = SVGCaptcha.create() +export interface ImageConfig { + size: number +} + +export const image = (config: ImageConfig) => (req: Request, res: Response) => { + const { text, data } = SVGCaptcha.create({ + size: config.size, + }) req.session!.captcha = text res.type('svg') res.status(200) diff --git a/packages/captcha/src/index.ts b/packages/captcha/src/index.ts index 781c991..715a574 100644 --- a/packages/captcha/src/index.ts +++ b/packages/captcha/src/index.ts @@ -1,2 +1,3 @@ export * from './audio' export * from './image' +export * from './commands' diff --git a/packages/captcha/src/run.test.ts b/packages/captcha/src/run.test.ts new file mode 100644 index 0000000..a829b3a --- /dev/null +++ b/packages/captcha/src/run.test.ts @@ -0,0 +1,43 @@ +import { run } from './run' +import { getError } from '@rondo.dev/test-utils' +import { join } from 'path' + +describe('run', () => { + + async function read(readable: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + readable.on('error', err => reject(err)) + readable.on('readable', () => { + let data = '' + let chunk + while (null !== (chunk = readable.read())) { + data += chunk + } + resolve(data) + }) + }) + } + + + it('runs a process and returns stdin/stdout/contentType', async () => { + const result = await run({ + cmd: process.argv[0], + args: [join(__dirname, 'testProcess.ts')], + contentType: 'text/plain', + }) + expect(result.contentType).toBe('text/plain') + result.stdin.write('test') + const text = await read(result.stdout) + expect(text.trim()).toBe('test') + }) + + it('rejects when command is invalid', async () => { + const error = await getError(run({ + cmd: 'invalid-command', + args: ['test'], + contentType: 'text/plain', + })) + expect(error.message).toMatch(/ENOENT/) + }) + +}) diff --git a/packages/captcha/src/run.ts b/packages/captcha/src/run.ts new file mode 100644 index 0000000..2dc81c9 --- /dev/null +++ b/packages/captcha/src/run.ts @@ -0,0 +1,35 @@ +import { spawn } from 'child_process' + +export interface ReadableProcess { + stdout: NodeJS.ReadableStream + contentType: string +} + +export interface WritableProcess { + stdin: NodeJS.WritableStream +} + +export interface ReadableWritable extends ReadableProcess, WritableProcess { + +} + +export interface Command { + cmd: string + args: string[] + contentType: string +} + +export async function run(command: Command): Promise { + const { cmd, args, contentType } = command + return new Promise((resolve, reject) => { + const p = spawn(cmd, args) + + p.once('error', err => { + reject(err) + }) + + if (p.pid) { + resolve({ stdin: p.stdin, stdout: p.stdout, contentType }) + } + }) +} diff --git a/packages/captcha/src/testProcess.ts b/packages/captcha/src/testProcess.ts new file mode 100644 index 0000000..8ad57c3 --- /dev/null +++ b/packages/captcha/src/testProcess.ts @@ -0,0 +1,4 @@ +process.stdin.on('data', data => { + process.stdout.write(data) + process.exit(0) +}) diff --git a/packages/captcha/tsconfig.esm.json b/packages/captcha/tsconfig.esm.json index 6e2aaa7..a4e06cf 100644 --- a/packages/captcha/tsconfig.esm.json +++ b/packages/captcha/tsconfig.esm.json @@ -4,5 +4,8 @@ "outDir": "esm" }, "references": [ + { + "path": "../test-utils" + } ] } diff --git a/packages/captcha/tsconfig.json b/packages/captcha/tsconfig.json index 94e864b..703c5c1 100644 --- a/packages/captcha/tsconfig.json +++ b/packages/captcha/tsconfig.json @@ -5,5 +5,8 @@ "rootDir": "src" }, "references": [ + { + "path": "../test-utils" + } ] }