aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-01-09 15:14:29 +0100
committerChocobozzz <me@florianbigard.com>2019-01-10 11:32:37 +0100
commita4101923e699e49ceb9ff36e971c75417fafc9f0 (patch)
treec098a87ac5a85e1bc7454facbb59ecbd6c7dac82 /server
parent8d00889b6038c38d9c86cbeca88a9f3c23962c48 (diff)
downloadPeerTube-a4101923e699e49ceb9ff36e971c75417fafc9f0.tar.gz
PeerTube-a4101923e699e49ceb9ff36e971c75417fafc9f0.tar.zst
PeerTube-a4101923e699e49ceb9ff36e971c75417fafc9f0.zip
Implement contact form on server side
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/config.ts55
-rw-r--r--server/controllers/api/server/contact.ts28
-rw-r--r--server/controllers/api/server/index.ts2
-rw-r--r--server/helpers/core-utils.ts20
-rw-r--r--server/helpers/custom-validators/servers.ts11
-rw-r--r--server/helpers/utils.ts6
-rw-r--r--server/initializers/constants.ts11
-rw-r--r--server/lib/emailer.ts23
-rw-r--r--server/lib/job-queue/handlers/email.ts3
-rw-r--r--server/lib/redis.ts24
-rw-r--r--server/middlewares/validators/config.ts19
-rw-r--r--server/middlewares/validators/server.ts49
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/check-params/contact-form.ts92
-rw-r--r--server/tests/api/check-params/index.ts2
-rw-r--r--server/tests/api/server/config.ts19
-rw-r--r--server/tests/api/server/contact-form.ts84
-rw-r--r--server/tests/api/server/handle-down.ts11
-rw-r--r--server/tests/api/server/index.ts1
-rw-r--r--server/tests/helpers/core-utils.ts52
20 files changed, 467 insertions, 48 deletions
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index c75002aaf..43b20e078 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -1,5 +1,5 @@
1import * as express from 'express' 1import * as express from 'express'
2import { omit } from 'lodash' 2import { omit, snakeCase } from 'lodash'
3import { ServerConfig, UserRight } from '../../../shared' 3import { ServerConfig, UserRight } from '../../../shared'
4import { About } from '../../../shared/models/server/about.model' 4import { About } from '../../../shared/models/server/about.model'
5import { CustomConfig } from '../../../shared/models/server/custom-config.model' 5import { CustomConfig } from '../../../shared/models/server/custom-config.model'
@@ -12,6 +12,8 @@ import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '..
12import { remove, writeJSON } from 'fs-extra' 12import { remove, writeJSON } from 'fs-extra'
13import { getServerCommit } from '../../helpers/utils' 13import { getServerCommit } from '../../helpers/utils'
14import { Emailer } from '../../lib/emailer' 14import { Emailer } from '../../lib/emailer'
15import { isNumeric } from 'validator'
16import { objectConverter } from '../../helpers/core-utils'
15 17
16const packageJSON = require('../../../../package.json') 18const packageJSON = require('../../../../package.json')
17const configRouter = express.Router() 19const configRouter = express.Router()
@@ -65,6 +67,9 @@ async function getConfig (req: express.Request, res: express.Response) {
65 email: { 67 email: {
66 enabled: Emailer.Instance.isEnabled() 68 enabled: Emailer.Instance.isEnabled()
67 }, 69 },
70 contactForm: {
71 enabled: CONFIG.CONTACT_FORM.ENABLED
72 },
68 serverVersion: packageJSON.version, 73 serverVersion: packageJSON.version,
69 serverCommit, 74 serverCommit,
70 signup: { 75 signup: {
@@ -154,34 +159,10 @@ async function deleteCustomConfig (req: express.Request, res: express.Response,
154} 159}
155 160
156async function updateCustomConfig (req: express.Request, res: express.Response, next: express.NextFunction) { 161async function updateCustomConfig (req: express.Request, res: express.Response, next: express.NextFunction) {
157 const toUpdate: CustomConfig = req.body
158 const oldCustomConfigAuditKeys = new CustomConfigAuditView(customConfig()) 162 const oldCustomConfigAuditKeys = new CustomConfigAuditView(customConfig())
159 163
160 // Force number conversion 164 // camelCase to snake_case key + Force number conversion
161 toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10) 165 const toUpdateJSON = convertCustomConfigBody(req.body)
162 toUpdate.cache.captions.size = parseInt('' + toUpdate.cache.captions.size, 10)
163 toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10)
164 toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10)
165 toUpdate.user.videoQuotaDaily = parseInt('' + toUpdate.user.videoQuotaDaily, 10)
166 toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10)
167
168 // camelCase to snake_case key
169 const toUpdateJSON = omit(
170 toUpdate,
171 'user.videoQuota',
172 'instance.defaultClientRoute',
173 'instance.shortDescription',
174 'cache.videoCaptions',
175 'signup.requiresEmailVerification',
176 'transcoding.allowAdditionalExtensions'
177 )
178 toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
179 toUpdateJSON.user['video_quota_daily'] = toUpdate.user.videoQuotaDaily
180 toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
181 toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
182 toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy
183 toUpdateJSON.signup['requires_email_verification'] = toUpdate.signup.requiresEmailVerification
184 toUpdateJSON.transcoding['allow_additional_extensions'] = toUpdate.transcoding.allowAdditionalExtensions
185 166
186 await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 }) 167 await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
187 168
@@ -243,6 +224,9 @@ function customConfig (): CustomConfig {
243 admin: { 224 admin: {
244 email: CONFIG.ADMIN.EMAIL 225 email: CONFIG.ADMIN.EMAIL
245 }, 226 },
227 contactForm: {
228 enabled: CONFIG.CONTACT_FORM.ENABLED
229 },
246 user: { 230 user: {
247 videoQuota: CONFIG.USER.VIDEO_QUOTA, 231 videoQuota: CONFIG.USER.VIDEO_QUOTA,
248 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY 232 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
@@ -271,3 +255,20 @@ function customConfig (): CustomConfig {
271 } 255 }
272 } 256 }
273} 257}
258
259function convertCustomConfigBody (body: CustomConfig) {
260 function keyConverter (k: string) {
261 // Transcoding resolutions exception
262 if (/^\d{3,4}p$/.exec(k)) return k
263
264 return snakeCase(k)
265 }
266
267 function valueConverter (v: any) {
268 if (isNumeric(v + '')) return parseInt('' + v, 10)
269
270 return v
271 }
272
273 return objectConverter(body, keyConverter, valueConverter)
274}
diff --git a/server/controllers/api/server/contact.ts b/server/controllers/api/server/contact.ts
new file mode 100644
index 000000000..b1144c94e
--- /dev/null
+++ b/server/controllers/api/server/contact.ts
@@ -0,0 +1,28 @@
1import * as express from 'express'
2import { asyncMiddleware, contactAdministratorValidator } from '../../../middlewares'
3import { Redis } from '../../../lib/redis'
4import { Emailer } from '../../../lib/emailer'
5import { ContactForm } from '../../../../shared/models/server'
6
7const contactRouter = express.Router()
8
9contactRouter.post('/contact',
10 asyncMiddleware(contactAdministratorValidator),
11 asyncMiddleware(contactAdministrator)
12)
13
14async function contactAdministrator (req: express.Request, res: express.Response) {
15 const data = req.body as ContactForm
16
17 await Emailer.Instance.addContactFormJob(data.fromEmail, data.fromName, data.body)
18
19 await Redis.Instance.setContactFormIp(req.ip)
20
21 return res.status(204).end()
22}
23
24// ---------------------------------------------------------------------------
25
26export {
27 contactRouter
28}
diff --git a/server/controllers/api/server/index.ts b/server/controllers/api/server/index.ts
index c08192a8c..814248e5f 100644
--- a/server/controllers/api/server/index.ts
+++ b/server/controllers/api/server/index.ts
@@ -3,6 +3,7 @@ import { serverFollowsRouter } from './follows'
3import { statsRouter } from './stats' 3import { statsRouter } from './stats'
4import { serverRedundancyRouter } from './redundancy' 4import { serverRedundancyRouter } from './redundancy'
5import { serverBlocklistRouter } from './server-blocklist' 5import { serverBlocklistRouter } from './server-blocklist'
6import { contactRouter } from './contact'
6 7
7const serverRouter = express.Router() 8const serverRouter = express.Router()
8 9
@@ -10,6 +11,7 @@ serverRouter.use('/', serverFollowsRouter)
10serverRouter.use('/', serverRedundancyRouter) 11serverRouter.use('/', serverRedundancyRouter)
11serverRouter.use('/', statsRouter) 12serverRouter.use('/', statsRouter)
12serverRouter.use('/', serverBlocklistRouter) 13serverRouter.use('/', serverBlocklistRouter)
14serverRouter.use('/', contactRouter)
13 15
14// --------------------------------------------------------------------------- 16// ---------------------------------------------------------------------------
15 17
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index 84e33c0e9..3fb824e36 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -11,6 +11,25 @@ import * as pem from 'pem'
11import { URL } from 'url' 11import { URL } from 'url'
12import { truncate } from 'lodash' 12import { truncate } from 'lodash'
13import { exec } from 'child_process' 13import { exec } from 'child_process'
14import { isArray } from './custom-validators/misc'
15
16const objectConverter = (oldObject: any, keyConverter: (e: string) => string, valueConverter: (e: any) => any) => {
17 if (!oldObject || typeof oldObject !== 'object') {
18 return valueConverter(oldObject)
19 }
20
21 if (isArray(oldObject)) {
22 return oldObject.map(e => objectConverter(e, keyConverter, valueConverter))
23 }
24
25 const newObject = {}
26 Object.keys(oldObject).forEach(oldKey => {
27 const newKey = keyConverter(oldKey)
28 newObject[ newKey ] = objectConverter(oldObject[ oldKey ], keyConverter, valueConverter)
29 })
30
31 return newObject
32}
14 33
15const timeTable = { 34const timeTable = {
16 ms: 1, 35 ms: 1,
@@ -235,6 +254,7 @@ export {
235 isTestInstance, 254 isTestInstance,
236 isProdInstance, 255 isProdInstance,
237 256
257 objectConverter,
238 root, 258 root,
239 escapeHTML, 259 escapeHTML,
240 pageToStartAndCount, 260 pageToStartAndCount,
diff --git a/server/helpers/custom-validators/servers.ts b/server/helpers/custom-validators/servers.ts
index d5021bf38..18c80ec8f 100644
--- a/server/helpers/custom-validators/servers.ts
+++ b/server/helpers/custom-validators/servers.ts
@@ -3,6 +3,7 @@ import 'express-validator'
3 3
4import { isArray, exists } from './misc' 4import { isArray, exists } from './misc'
5import { isTestInstance } from '../core-utils' 5import { isTestInstance } from '../core-utils'
6import { CONSTRAINTS_FIELDS } from '../../initializers'
6 7
7function isHostValid (host: string) { 8function isHostValid (host: string) {
8 const isURLOptions = { 9 const isURLOptions = {
@@ -26,9 +27,19 @@ function isEachUniqueHostValid (hosts: string[]) {
26 }) 27 })
27} 28}
28 29
30function isValidContactBody (value: any) {
31 return exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.CONTACT_FORM.BODY)
32}
33
34function isValidContactFromName (value: any) {
35 return exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.CONTACT_FORM.FROM_NAME)
36}
37
29// --------------------------------------------------------------------------- 38// ---------------------------------------------------------------------------
30 39
31export { 40export {
41 isValidContactBody,
42 isValidContactFromName,
32 isEachUniqueHostValid, 43 isEachUniqueHostValid,
33 isHostValid 44 isHostValid
34} 45}
diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts
index 9b89e3e61..3c3406e38 100644
--- a/server/helpers/utils.ts
+++ b/server/helpers/utils.ts
@@ -7,6 +7,7 @@ import { join } from 'path'
7import { Instance as ParseTorrent } from 'parse-torrent' 7import { Instance as ParseTorrent } from 'parse-torrent'
8import { remove } from 'fs-extra' 8import { remove } from 'fs-extra'
9import * as memoizee from 'memoizee' 9import * as memoizee from 'memoizee'
10import { isArray } from './custom-validators/misc'
10 11
11function deleteFileAsync (path: string) { 12function deleteFileAsync (path: string) {
12 remove(path) 13 remove(path)
@@ -19,10 +20,7 @@ async function generateRandomString (size: number) {
19 return raw.toString('hex') 20 return raw.toString('hex')
20} 21}
21 22
22interface FormattableToJSON { 23interface FormattableToJSON { toFormattedJSON (args?: any) }
23 toFormattedJSON (args?: any)
24}
25
26function getFormattedObjects<U, T extends FormattableToJSON> (objects: T[], objectsTotal: number, formattedArg?: any) { 24function getFormattedObjects<U, T extends FormattableToJSON> (objects: T[], objectsTotal: number, formattedArg?: any) {
27 const formattedObjects: U[] = [] 25 const formattedObjects: U[] = []
28 26
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 91e74f6c7..4a88aef87 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -231,6 +231,9 @@ const CONFIG = {
231 ADMIN: { 231 ADMIN: {
232 get EMAIL () { return config.get<string>('admin.email') } 232 get EMAIL () { return config.get<string>('admin.email') }
233 }, 233 },
234 CONTACT_FORM: {
235 get ENABLED () { return config.get<boolean>('contact_form.enabled') }
236 },
234 SIGNUP: { 237 SIGNUP: {
235 get ENABLED () { return config.get<boolean>('signup.enabled') }, 238 get ENABLED () { return config.get<boolean>('signup.enabled') },
236 get LIMIT () { return config.get<number>('signup.limit') }, 239 get LIMIT () { return config.get<number>('signup.limit') },
@@ -394,6 +397,10 @@ let CONSTRAINTS_FIELDS = {
394 }, 397 },
395 VIDEO_SHARE: { 398 VIDEO_SHARE: {
396 URL: { min: 3, max: 2000 } // Length 399 URL: { min: 3, max: 2000 } // Length
400 },
401 CONTACT_FORM: {
402 FROM_NAME: { min: 1, max: 120 }, // Length
403 BODY: { min: 3, max: 5000 } // Length
397 } 404 }
398} 405}
399 406
@@ -409,6 +416,8 @@ const RATES_LIMIT = {
409} 416}
410 417
411let VIDEO_VIEW_LIFETIME = 60000 * 60 // 1 hour 418let VIDEO_VIEW_LIFETIME = 60000 * 60 // 1 hour
419let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
420
412const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = { 421const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
413 MIN: 10, 422 MIN: 10,
414 AVERAGE: 30, 423 AVERAGE: 30,
@@ -685,6 +694,7 @@ if (isTestInstance() === true) {
685 REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1 694 REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1
686 695
687 VIDEO_VIEW_LIFETIME = 1000 // 1 second 696 VIDEO_VIEW_LIFETIME = 1000 // 1 second
697 CONTACT_FORM_LIFETIME = 1000 // 1 second
688 698
689 JOB_ATTEMPTS['email'] = 1 699 JOB_ATTEMPTS['email'] = 1
690 700
@@ -756,6 +766,7 @@ export {
756 HTTP_SIGNATURE, 766 HTTP_SIGNATURE,
757 VIDEO_IMPORT_STATES, 767 VIDEO_IMPORT_STATES,
758 VIDEO_VIEW_LIFETIME, 768 VIDEO_VIEW_LIFETIME,
769 CONTACT_FORM_LIFETIME,
759 buildLanguages 770 buildLanguages
760} 771}
761 772
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index 3429498e7..9b1c5122f 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -354,13 +354,32 @@ class Emailer {
354 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 354 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
355 } 355 }
356 356
357 sendMail (to: string[], subject: string, text: string) { 357 addContactFormJob (fromEmail: string, fromName: string, body: string) {
358 const text = 'Hello dear admin,\n\n' +
359 fromName + ' sent you a message' +
360 '\n\n---------------------------------------\n\n' +
361 body +
362 '\n\n---------------------------------------\n\n' +
363 'Cheers,\n' +
364 'PeerTube.'
365
366 const emailPayload: EmailPayload = {
367 from: fromEmail,
368 to: [ CONFIG.ADMIN.EMAIL ],
369 subject: '[PeerTube] Contact form submitted',
370 text
371 }
372
373 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
374 }
375
376 sendMail (to: string[], subject: string, text: string, from?: string) {
358 if (!this.enabled) { 377 if (!this.enabled) {
359 throw new Error('Cannot send mail because SMTP is not configured.') 378 throw new Error('Cannot send mail because SMTP is not configured.')
360 } 379 }
361 380
362 return this.transporter.sendMail({ 381 return this.transporter.sendMail({
363 from: CONFIG.SMTP.FROM_ADDRESS, 382 from: from || CONFIG.SMTP.FROM_ADDRESS,
364 to: to.join(','), 383 to: to.join(','),
365 subject, 384 subject,
366 text 385 text
diff --git a/server/lib/job-queue/handlers/email.ts b/server/lib/job-queue/handlers/email.ts
index 73d98ae54..220d0af32 100644
--- a/server/lib/job-queue/handlers/email.ts
+++ b/server/lib/job-queue/handlers/email.ts
@@ -6,13 +6,14 @@ export type EmailPayload = {
6 to: string[] 6 to: string[]
7 subject: string 7 subject: string
8 text: string 8 text: string
9 from?: string
9} 10}
10 11
11async function processEmail (job: Bull.Job) { 12async function processEmail (job: Bull.Job) {
12 const payload = job.data as EmailPayload 13 const payload = job.data as EmailPayload
13 logger.info('Processing email in job %d.', job.id) 14 logger.info('Processing email in job %d.', job.id)
14 15
15 return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text) 16 return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text, payload.from)
16} 17}
17 18
18// --------------------------------------------------------------------------- 19// ---------------------------------------------------------------------------
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index 3e25e6a2c..3628c0583 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -2,7 +2,13 @@ import * as express from 'express'
2import { createClient, RedisClient } from 'redis' 2import { createClient, RedisClient } from 'redis'
3import { logger } from '../helpers/logger' 3import { logger } from '../helpers/logger'
4import { generateRandomString } from '../helpers/utils' 4import { generateRandomString } from '../helpers/utils'
5import { CONFIG, USER_PASSWORD_RESET_LIFETIME, USER_EMAIL_VERIFY_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers' 5import {
6 CONFIG,
7 CONTACT_FORM_LIFETIME,
8 USER_EMAIL_VERIFY_LIFETIME,
9 USER_PASSWORD_RESET_LIFETIME,
10 VIDEO_VIEW_LIFETIME
11} from '../initializers'
6 12
7type CachedRoute = { 13type CachedRoute = {
8 body: string, 14 body: string,
@@ -76,6 +82,16 @@ class Redis {
76 return this.getValue(this.generateVerifyEmailKey(userId)) 82 return this.getValue(this.generateVerifyEmailKey(userId))
77 } 83 }
78 84
85 /************* Contact form per IP *************/
86
87 async setContactFormIp (ip: string) {
88 return this.setValue(this.generateContactFormKey(ip), '1', CONTACT_FORM_LIFETIME)
89 }
90
91 async isContactFormIpExists (ip: string) {
92 return this.exists(this.generateContactFormKey(ip))
93 }
94
79 /************* Views per IP *************/ 95 /************* Views per IP *************/
80 96
81 setIPVideoView (ip: string, videoUUID: string) { 97 setIPVideoView (ip: string, videoUUID: string) {
@@ -175,7 +191,11 @@ class Redis {
175 } 191 }
176 192
177 private generateViewKey (ip: string, videoUUID: string) { 193 private generateViewKey (ip: string, videoUUID: string) {
178 return videoUUID + '-' + ip 194 return `views-${videoUUID}-${ip}`
195 }
196
197 private generateContactFormKey (ip: string) {
198 return 'contact-form-' + ip
179 } 199 }
180 200
181 /************* Redis helpers *************/ 201 /************* Redis helpers *************/
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index f3f257d57..90108fa82 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -1,29 +1,44 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body } from 'express-validator/check' 2import { body } from 'express-validator/check'
3import { isUserNSFWPolicyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users' 3import { isUserNSFWPolicyValid, isUserVideoQuotaValid, isUserVideoQuotaDailyValid } from '../../helpers/custom-validators/users'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils' 5import { areValidationErrors } from './utils'
6 6
7const customConfigUpdateValidator = [ 7const customConfigUpdateValidator = [
8 body('instance.name').exists().withMessage('Should have a valid instance name'), 8 body('instance.name').exists().withMessage('Should have a valid instance name'),
9 body('instance.shortDescription').exists().withMessage('Should have a valid instance short description'),
9 body('instance.description').exists().withMessage('Should have a valid instance description'), 10 body('instance.description').exists().withMessage('Should have a valid instance description'),
10 body('instance.terms').exists().withMessage('Should have a valid instance terms'), 11 body('instance.terms').exists().withMessage('Should have a valid instance terms'),
11 body('instance.defaultClientRoute').exists().withMessage('Should have a valid instance default client route'), 12 body('instance.defaultClientRoute').exists().withMessage('Should have a valid instance default client route'),
12 body('instance.defaultNSFWPolicy').custom(isUserNSFWPolicyValid).withMessage('Should have a valid NSFW policy'), 13 body('instance.defaultNSFWPolicy').custom(isUserNSFWPolicyValid).withMessage('Should have a valid NSFW policy'),
13 body('instance.customizations.css').exists().withMessage('Should have a valid instance CSS customization'), 14 body('instance.customizations.css').exists().withMessage('Should have a valid instance CSS customization'),
14 body('instance.customizations.javascript').exists().withMessage('Should have a valid instance JavaScript customization'), 15 body('instance.customizations.javascript').exists().withMessage('Should have a valid instance JavaScript customization'),
15 body('cache.previews.size').isInt().withMessage('Should have a valid previews size'), 16
17 body('services.twitter.username').exists().withMessage('Should have a valid twitter username'),
18 body('services.twitter.whitelisted').isBoolean().withMessage('Should have a valid twitter whitelisted boolean'),
19
20 body('cache.previews.size').isInt().withMessage('Should have a valid previews cache size'),
21 body('cache.captions.size').isInt().withMessage('Should have a valid captions cache size'),
22
16 body('signup.enabled').isBoolean().withMessage('Should have a valid signup enabled boolean'), 23 body('signup.enabled').isBoolean().withMessage('Should have a valid signup enabled boolean'),
17 body('signup.limit').isInt().withMessage('Should have a valid signup limit'), 24 body('signup.limit').isInt().withMessage('Should have a valid signup limit'),
25 body('signup.requiresEmailVerification').isBoolean().withMessage('Should have a valid requiresEmailVerification boolean'),
26
18 body('admin.email').isEmail().withMessage('Should have a valid administrator email'), 27 body('admin.email').isEmail().withMessage('Should have a valid administrator email'),
28 body('contactForm.enabled').isBoolean().withMessage('Should have a valid contact form enabled boolean'),
29
19 body('user.videoQuota').custom(isUserVideoQuotaValid).withMessage('Should have a valid video quota'), 30 body('user.videoQuota').custom(isUserVideoQuotaValid).withMessage('Should have a valid video quota'),
31 body('user.videoQuotaDaily').custom(isUserVideoQuotaDailyValid).withMessage('Should have a valid daily video quota'),
32
20 body('transcoding.enabled').isBoolean().withMessage('Should have a valid transcoding enabled boolean'), 33 body('transcoding.enabled').isBoolean().withMessage('Should have a valid transcoding enabled boolean'),
34 body('transcoding.allowAdditionalExtensions').isBoolean().withMessage('Should have a valid additional extensions boolean'),
21 body('transcoding.threads').isInt().withMessage('Should have a valid transcoding threads number'), 35 body('transcoding.threads').isInt().withMessage('Should have a valid transcoding threads number'),
22 body('transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'), 36 body('transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'),
23 body('transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'), 37 body('transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'),
24 body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'), 38 body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
25 body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'), 39 body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
26 body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'), 40 body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
41
27 body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'), 42 body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'),
28 body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'), 43 body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'),
29 44
diff --git a/server/middlewares/validators/server.ts b/server/middlewares/validators/server.ts
index a491dfeb3..d82e19230 100644
--- a/server/middlewares/validators/server.ts
+++ b/server/middlewares/validators/server.ts
@@ -1,9 +1,13 @@
1import * as express from 'express' 1import * as express from 'express'
2import { logger } from '../../helpers/logger' 2import { logger } from '../../helpers/logger'
3import { areValidationErrors } from './utils' 3import { areValidationErrors } from './utils'
4import { isHostValid } from '../../helpers/custom-validators/servers' 4import { isHostValid, isValidContactBody } from '../../helpers/custom-validators/servers'
5import { ServerModel } from '../../models/server/server' 5import { ServerModel } from '../../models/server/server'
6import { body } from 'express-validator/check' 6import { body } from 'express-validator/check'
7import { isUserDisplayNameValid } from '../../helpers/custom-validators/users'
8import { Emailer } from '../../lib/emailer'
9import { Redis } from '../../lib/redis'
10import { CONFIG } from '../../initializers/constants'
7 11
8const serverGetValidator = [ 12const serverGetValidator = [
9 body('host').custom(isHostValid).withMessage('Should have a valid host'), 13 body('host').custom(isHostValid).withMessage('Should have a valid host'),
@@ -26,8 +30,49 @@ const serverGetValidator = [
26 } 30 }
27] 31]
28 32
33const contactAdministratorValidator = [
34 body('fromName')
35 .custom(isUserDisplayNameValid).withMessage('Should have a valid name'),
36 body('fromEmail')
37 .isEmail().withMessage('Should have a valid email'),
38 body('body')
39 .custom(isValidContactBody).withMessage('Should have a valid body'),
40
41 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
42 logger.debug('Checking contactAdministratorValidator parameters', { parameters: req.body })
43
44 if (areValidationErrors(req, res)) return
45
46 if (CONFIG.CONTACT_FORM.ENABLED === false) {
47 return res
48 .status(409)
49 .send({ error: 'Contact form is not enabled on this instance.' })
50 .end()
51 }
52
53 if (Emailer.Instance.isEnabled() === false) {
54 return res
55 .status(409)
56 .send({ error: 'Emailer is not enabled on this instance.' })
57 .end()
58 }
59
60 if (await Redis.Instance.isContactFormIpExists(req.ip)) {
61 logger.info('Refusing a contact form by %s: already sent one recently.', req.ip)
62
63 return res
64 .status(403)
65 .send({ error: 'You already sent a contact form recently.' })
66 .end()
67 }
68
69 return next()
70 }
71]
72
29// --------------------------------------------------------------------------- 73// ---------------------------------------------------------------------------
30 74
31export { 75export {
32 serverGetValidator 76 serverGetValidator,
77 contactAdministratorValidator
33} 78}
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index b7bf41b58..4038ecbf0 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -48,6 +48,9 @@ describe('Test config API validators', function () {
48 admin: { 48 admin: {
49 email: 'superadmin1@example.com' 49 email: 'superadmin1@example.com'
50 }, 50 },
51 contactForm: {
52 enabled: false
53 },
51 user: { 54 user: {
52 videoQuota: 5242881, 55 videoQuota: 5242881,
53 videoQuotaDaily: 318742 56 videoQuotaDaily: 318742
diff --git a/server/tests/api/check-params/contact-form.ts b/server/tests/api/check-params/contact-form.ts
new file mode 100644
index 000000000..2407ac0b5
--- /dev/null
+++ b/server/tests/api/check-params/contact-form.ts
@@ -0,0 +1,92 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4
5import {
6 flushTests,
7 immutableAssign,
8 killallServers,
9 reRunServer,
10 runServer,
11 ServerInfo,
12 setAccessTokensToServers
13} from '../../../../shared/utils'
14import {
15 checkBadCountPagination,
16 checkBadSortPagination,
17 checkBadStartPagination
18} from '../../../../shared/utils/requests/check-api-params'
19import { getAccount } from '../../../../shared/utils/users/accounts'
20import { sendContactForm } from '../../../../shared/utils/server/contact-form'
21import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
22
23describe('Test contact form API validators', function () {
24 let server: ServerInfo
25 const emails: object[] = []
26 const defaultBody = {
27 fromName: 'super name',
28 fromEmail: 'toto@example.com',
29 body: 'Hello, how are you?'
30 }
31
32 // ---------------------------------------------------------------
33
34 before(async function () {
35 this.timeout(60000)
36
37 await flushTests()
38 await MockSmtpServer.Instance.collectEmails(emails)
39
40 // Email is disabled
41 server = await runServer(1)
42 })
43
44 it('Should not accept a contact form if emails are disabled', async function () {
45 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 409 }))
46 })
47
48 it('Should not accept a contact form if it is disabled in the configuration', async function () {
49 killallServers([ server ])
50
51 // Contact form is disabled
52 await reRunServer(server, { smtp: { hostname: 'localhost' }, contact_form: { enabled: false } })
53 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 409 }))
54 })
55
56 it('Should not accept a contact form if from email is invalid', async function () {
57 killallServers([ server ])
58
59 // Email & contact form enabled
60 await reRunServer(server, { smtp: { hostname: 'localhost' } })
61
62 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: 'badEmail' }))
63 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: 'badEmail@' }))
64 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromEmail: undefined }))
65 })
66
67 it('Should not accept a contact form if from name is invalid', async function () {
68 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: 'name'.repeat(100) }))
69 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: '' }))
70 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, fromName: undefined }))
71 })
72
73 it('Should not accept a contact form if body is invalid', async function () {
74 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: 'body'.repeat(5000) }))
75 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: 'a' }))
76 await sendContactForm(immutableAssign(defaultBody, { url: server.url, expectedStatus: 400, body: undefined }))
77 })
78
79 it('Should accept a contact form with the correct parameters', async function () {
80 await sendContactForm(immutableAssign(defaultBody, { url: server.url }))
81 })
82
83 after(async function () {
84 MockSmtpServer.Instance.kill()
85 killallServers([ server ])
86
87 // Keep the logs if the test failed
88 if (this['ok']) {
89 await flushTests()
90 }
91 })
92})
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 7a181d1d6..77c17036a 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -1,7 +1,7 @@
1// Order of the tests we want to execute
2import './accounts' 1import './accounts'
3import './blocklist' 2import './blocklist'
4import './config' 3import './config'
4import './contact-form'
5import './follows' 5import './follows'
6import './jobs' 6import './jobs'
7import './redundancy' 7import './redundancy'
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index 4c163d47d..bebfc7398 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -33,14 +33,20 @@ function checkInitialConfig (data: CustomConfig) {
33 expect(data.instance.defaultNSFWPolicy).to.equal('display') 33 expect(data.instance.defaultNSFWPolicy).to.equal('display')
34 expect(data.instance.customizations.css).to.be.empty 34 expect(data.instance.customizations.css).to.be.empty
35 expect(data.instance.customizations.javascript).to.be.empty 35 expect(data.instance.customizations.javascript).to.be.empty
36
36 expect(data.services.twitter.username).to.equal('@Chocobozzz') 37 expect(data.services.twitter.username).to.equal('@Chocobozzz')
37 expect(data.services.twitter.whitelisted).to.be.false 38 expect(data.services.twitter.whitelisted).to.be.false
39
38 expect(data.cache.previews.size).to.equal(1) 40 expect(data.cache.previews.size).to.equal(1)
39 expect(data.cache.captions.size).to.equal(1) 41 expect(data.cache.captions.size).to.equal(1)
42
40 expect(data.signup.enabled).to.be.true 43 expect(data.signup.enabled).to.be.true
41 expect(data.signup.limit).to.equal(4) 44 expect(data.signup.limit).to.equal(4)
42 expect(data.signup.requiresEmailVerification).to.be.false 45 expect(data.signup.requiresEmailVerification).to.be.false
46
43 expect(data.admin.email).to.equal('admin1@example.com') 47 expect(data.admin.email).to.equal('admin1@example.com')
48 expect(data.contactForm.enabled).to.be.true
49
44 expect(data.user.videoQuota).to.equal(5242880) 50 expect(data.user.videoQuota).to.equal(5242880)
45 expect(data.user.videoQuotaDaily).to.equal(-1) 51 expect(data.user.videoQuotaDaily).to.equal(-1)
46 expect(data.transcoding.enabled).to.be.false 52 expect(data.transcoding.enabled).to.be.false
@@ -64,16 +70,23 @@ function checkUpdatedConfig (data: CustomConfig) {
64 expect(data.instance.defaultNSFWPolicy).to.equal('blur') 70 expect(data.instance.defaultNSFWPolicy).to.equal('blur')
65 expect(data.instance.customizations.javascript).to.equal('alert("coucou")') 71 expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
66 expect(data.instance.customizations.css).to.equal('body { background-color: red; }') 72 expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
73
67 expect(data.services.twitter.username).to.equal('@Kuja') 74 expect(data.services.twitter.username).to.equal('@Kuja')
68 expect(data.services.twitter.whitelisted).to.be.true 75 expect(data.services.twitter.whitelisted).to.be.true
76
69 expect(data.cache.previews.size).to.equal(2) 77 expect(data.cache.previews.size).to.equal(2)
70 expect(data.cache.captions.size).to.equal(3) 78 expect(data.cache.captions.size).to.equal(3)
79
71 expect(data.signup.enabled).to.be.false 80 expect(data.signup.enabled).to.be.false
72 expect(data.signup.limit).to.equal(5) 81 expect(data.signup.limit).to.equal(5)
73 expect(data.signup.requiresEmailVerification).to.be.true 82 expect(data.signup.requiresEmailVerification).to.be.true
83
74 expect(data.admin.email).to.equal('superadmin1@example.com') 84 expect(data.admin.email).to.equal('superadmin1@example.com')
85 expect(data.contactForm.enabled).to.be.false
86
75 expect(data.user.videoQuota).to.equal(5242881) 87 expect(data.user.videoQuota).to.equal(5242881)
76 expect(data.user.videoQuotaDaily).to.equal(318742) 88 expect(data.user.videoQuotaDaily).to.equal(318742)
89
77 expect(data.transcoding.enabled).to.be.true 90 expect(data.transcoding.enabled).to.be.true
78 expect(data.transcoding.threads).to.equal(1) 91 expect(data.transcoding.threads).to.equal(1)
79 expect(data.transcoding.allowAdditionalExtensions).to.be.true 92 expect(data.transcoding.allowAdditionalExtensions).to.be.true
@@ -82,6 +95,7 @@ function checkUpdatedConfig (data: CustomConfig) {
82 expect(data.transcoding.resolutions['480p']).to.be.true 95 expect(data.transcoding.resolutions['480p']).to.be.true
83 expect(data.transcoding.resolutions['720p']).to.be.false 96 expect(data.transcoding.resolutions['720p']).to.be.false
84 expect(data.transcoding.resolutions['1080p']).to.be.false 97 expect(data.transcoding.resolutions['1080p']).to.be.false
98
85 expect(data.import.videos.http.enabled).to.be.false 99 expect(data.import.videos.http.enabled).to.be.false
86 expect(data.import.videos.torrent.enabled).to.be.false 100 expect(data.import.videos.torrent.enabled).to.be.false
87} 101}
@@ -127,6 +141,8 @@ describe('Test config', function () {
127 expect(data.video.file.extensions).to.contain('.mp4') 141 expect(data.video.file.extensions).to.contain('.mp4')
128 expect(data.video.file.extensions).to.contain('.webm') 142 expect(data.video.file.extensions).to.contain('.webm')
129 expect(data.video.file.extensions).to.contain('.ogv') 143 expect(data.video.file.extensions).to.contain('.ogv')
144
145 expect(data.contactForm.enabled).to.be.true
130 }) 146 })
131 147
132 it('Should get the customized configuration', async function () { 148 it('Should get the customized configuration', async function () {
@@ -172,6 +188,9 @@ describe('Test config', function () {
172 admin: { 188 admin: {
173 email: 'superadmin1@example.com' 189 email: 'superadmin1@example.com'
174 }, 190 },
191 contactForm: {
192 enabled: false
193 },
175 user: { 194 user: {
176 videoQuota: 5242881, 195 videoQuota: 5242881,
177 videoQuotaDaily: 318742 196 videoQuotaDaily: 318742
diff --git a/server/tests/api/server/contact-form.ts b/server/tests/api/server/contact-form.ts
new file mode 100644
index 000000000..1a165331b
--- /dev/null
+++ b/server/tests/api/server/contact-form.ts
@@ -0,0 +1,84 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, wait } from '../../../../shared/utils'
6import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
7import { waitJobs } from '../../../../shared/utils/server/jobs'
8import { sendContactForm } from '../../../../shared/utils/server/contact-form'
9
10const expect = chai.expect
11
12describe('Test contact form', function () {
13 let server: ServerInfo
14 const emails: object[] = []
15
16 before(async function () {
17 this.timeout(30000)
18
19 await MockSmtpServer.Instance.collectEmails(emails)
20
21 await flushTests()
22
23 const overrideConfig = {
24 smtp: {
25 hostname: 'localhost'
26 }
27 }
28 server = await runServer(1, overrideConfig)
29 await setAccessTokensToServers([ server ])
30 })
31
32 it('Should send a contact form', async function () {
33 await sendContactForm({
34 url: server.url,
35 fromEmail: 'toto@example.com',
36 body: 'my super message',
37 fromName: 'Super toto'
38 })
39
40 await waitJobs(server)
41
42 expect(emails).to.have.lengthOf(1)
43
44 const email = emails[0]
45
46 expect(email['from'][0]['address']).equal('toto@example.com')
47 expect(email['to'][0]['address']).equal('admin1@example.com')
48 expect(email['subject']).contains('Contact form')
49 expect(email['text']).contains('my super message')
50 })
51
52 it('Should not be able to send another contact form because of the anti spam checker', async function () {
53 await sendContactForm({
54 url: server.url,
55 fromEmail: 'toto@example.com',
56 body: 'my super message',
57 fromName: 'Super toto'
58 })
59
60 await sendContactForm({
61 url: server.url,
62 fromEmail: 'toto@example.com',
63 body: 'my super message',
64 fromName: 'Super toto',
65 expectedStatus: 403
66 })
67 })
68
69 it('Should be able to send another contact form after a while', async function () {
70 await wait(1000)
71
72 await sendContactForm({
73 url: server.url,
74 fromEmail: 'toto@example.com',
75 body: 'my super message',
76 fromName: 'Super toto'
77 })
78 })
79
80 after(async function () {
81 MockSmtpServer.Instance.kill()
82 killallServers([ server ])
83 })
84})
diff --git a/server/tests/api/server/handle-down.ts b/server/tests/api/server/handle-down.ts
index 8e162b69e..cd7baadad 100644
--- a/server/tests/api/server/handle-down.ts
+++ b/server/tests/api/server/handle-down.ts
@@ -8,18 +8,17 @@ import { VideoCommentThreadTree } from '../../../../shared/models/videos/video-c
8 8
9import { 9import {
10 completeVideoCheck, 10 completeVideoCheck,
11 getVideo,
12 immutableAssign,
13 reRunServer,
14 unfollow,
15 viewVideo,
16 flushAndRunMultipleServers, 11 flushAndRunMultipleServers,
12 getVideo,
17 getVideosList, 13 getVideosList,
14 immutableAssign,
18 killallServers, 15 killallServers,
16 reRunServer,
19 ServerInfo, 17 ServerInfo,
20 setAccessTokensToServers, 18 setAccessTokensToServers,
21 uploadVideo, 19 unfollow,
22 updateVideo, 20 updateVideo,
21 uploadVideo,
23 wait 22 wait
24} from '../../../../shared/utils' 23} from '../../../../shared/utils'
25import { follow, getFollowersListPaginationAndSort } from '../../../../shared/utils/server/follows' 24import { follow, getFollowersListPaginationAndSort } from '../../../../shared/utils/server/follows'
diff --git a/server/tests/api/server/index.ts b/server/tests/api/server/index.ts
index 6afcab1f9..1f80cc6cf 100644
--- a/server/tests/api/server/index.ts
+++ b/server/tests/api/server/index.ts
@@ -1,4 +1,5 @@
1import './config' 1import './config'
2import './contact-form'
2import './email' 3import './email'
3import './follow-constraints' 4import './follow-constraints'
4import './follows' 5import './follows'
diff --git a/server/tests/helpers/core-utils.ts b/server/tests/helpers/core-utils.ts
index a6d829a9f..e604cf7e3 100644
--- a/server/tests/helpers/core-utils.ts
+++ b/server/tests/helpers/core-utils.ts
@@ -2,13 +2,16 @@
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { snakeCase, isNumber } from 'lodash'
5import { 6import {
6 parseBytes 7 parseBytes, objectConverter
7} from '../../helpers/core-utils' 8} from '../../helpers/core-utils'
9import { isNumeric } from 'validator'
8 10
9const expect = chai.expect 11const expect = chai.expect
10 12
11describe('Parse Bytes', function () { 13describe('Parse Bytes', function () {
14
12 it('Should pass when given valid value', async function () { 15 it('Should pass when given valid value', async function () {
13 // just return it 16 // just return it
14 expect(parseBytes(1024)).to.be.eq(1024) 17 expect(parseBytes(1024)).to.be.eq(1024)
@@ -45,4 +48,51 @@ describe('Parse Bytes', function () {
45 it('Should be invalid when given invalid value', async function () { 48 it('Should be invalid when given invalid value', async function () {
46 expect(parseBytes('6GB 1GB')).to.be.eq(6) 49 expect(parseBytes('6GB 1GB')).to.be.eq(6)
47 }) 50 })
51
52 it('Should convert an object', async function () {
53 function keyConverter (k: string) {
54 return snakeCase(k)
55 }
56
57 function valueConverter (v: any) {
58 if (isNumeric(v + '')) return parseInt('' + v, 10)
59
60 return v
61 }
62
63 const obj = {
64 mySuperKey: 'hello',
65 mySuper2Key: '45',
66 mySuper3Key: {
67 mySuperSubKey: '15',
68 mySuperSub2Key: 'hello',
69 mySuperSub3Key: [ '1', 'hello', 2 ],
70 mySuperSub4Key: 4
71 },
72 mySuper4Key: 45,
73 toto: {
74 super_key: '15',
75 superKey2: 'hello'
76 },
77 super_key: {
78 superKey4: 15
79 }
80 }
81
82 const res = objectConverter(obj, keyConverter, valueConverter)
83
84 expect(res.my_super_key).to.equal('hello')
85 expect(res.my_super_2_key).to.equal(45)
86 expect(res.my_super_3_key.my_super_sub_key).to.equal(15)
87 expect(res.my_super_3_key.my_super_sub_2_key).to.equal('hello')
88 expect(res.my_super_3_key.my_super_sub_3_key).to.deep.equal([ 1, 'hello', 2 ])
89 expect(res.my_super_3_key.my_super_sub_4_key).to.equal(4)
90 expect(res.toto.super_key).to.equal(15)
91 expect(res.toto.super_key_2).to.equal('hello')
92 expect(res.super_key.super_key_4).to.equal(15)
93
94 // Immutable
95 expect(res.mySuperKey).to.be.undefined
96 expect(obj['my_super_key']).to.be.undefined
97 })
48}) 98})