Quellcode durchsuchen

Merge branch 'master' into cmg-share-modal

Chrystal Griffiths vor 8 Jahren
Ursprung
Commit
6334066d2d
21 geänderte Dateien mit 291 neuen und 63 gelöschten Zeilen
  1. 9 0
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  2. 8 8
      services/web/app/coffee/Features/Errors/Errors.coffee
  3. 28 0
      services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee
  4. 5 2
      services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee
  5. 7 0
      services/web/app/coffee/Features/Project/ProjectController.coffee
  6. 8 2
      services/web/app/coffee/infrastructure/ProxyManager.coffee
  7. 1 0
      services/web/app/coffee/models/User.coffee
  8. 15 0
      services/web/app/views/project/list/notifications.pug
  9. 1 0
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  10. 7 3
      services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee
  11. 4 0
      services/web/public/stylesheets/app/cms-page.less
  12. 28 0
      services/web/public/stylesheets/app/editor/publish-modal.less
  13. 0 42
      services/web/public/stylesheets/app/portals.less
  14. 1 0
      services/web/public/stylesheets/app/wiki.less
  15. 43 0
      services/web/public/stylesheets/components/tabs.less
  16. 1 0
      services/web/public/stylesheets/ol-style.less
  17. 3 0
      services/web/test/acceptance/coffee/helpers/User.coffee
  18. 1 1
      services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee
  19. 40 0
      services/web/test/unit/coffee/Notifications/NotificationsBuilderTests.coffee
  20. 16 0
      services/web/test/unit/coffee/Project/ProjectControllerTests.coffee
  21. 65 5
      services/web/test/unit/coffee/infrastructure/ProxyManagerTests.coffee

+ 9 - 0
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -11,6 +11,7 @@ UserHandler = require("../User/UserHandler")
 UserSessionsManager = require("../User/UserSessionsManager")
 Analytics = require "../Analytics/AnalyticsManager"
 passport = require 'passport'
+NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 
 module.exports = AuthenticationController =
 
@@ -112,6 +113,7 @@ module.exports = AuthenticationController =
 		UserHandler.setupLoginData(user, ()->)
 		LoginRateLimiter.recordSuccessfulLogin(user.email)
 		AuthenticationController._recordSuccessfulLogin(user._id)
+		AuthenticationController.ipMatchCheck(req, user)
 		Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip})
 		Analytics.identifyUser(user._id, req.sessionID)
 		logger.log email: user.email, user_id: user._id.toString(), "successful log in"
@@ -119,6 +121,13 @@ module.exports = AuthenticationController =
 		# capture the request ip for use when creating the session
 		user._login_req_ip = req.ip
 
+	ipMatchCheck: (req, user) ->
+		if req.ip != user.lastLoginIp
+			NotificationsBuilder.ipMatcherAffiliation(user._id, req.ip).create()
+		UserUpdater.updateUser user._id.toString(), {
+			$set: { "lastLoginIp": req.ip }
+		}
+
 	setInSessionUser: (req, props) ->
 		for key, value of props
 			if req?.session?.passport?.user?

+ 8 - 8
services/web/app/coffee/Features/Errors/Errors.coffee

@@ -31,56 +31,56 @@ UnsupportedFileTypeError = (message) ->
 	error.name = "UnsupportedFileTypeError"
 	error.__proto__ = UnsupportedFileTypeError.prototype
 	return error
-UnsupportedFileTypeError.prototype.__proto___ = Error.prototype
+UnsupportedFileTypeError.prototype.__proto__ = Error.prototype
 
 UnsupportedBrandError = (message) ->
 	error = new Error(message)
 	error.name = "UnsupportedBrandError"
 	error.__proto__ = UnsupportedBrandError.prototype
 	return error
-UnsupportedBrandError.prototype.__proto___ = Error.prototype
+UnsupportedBrandError.prototype.__proto__ = Error.prototype
 
 UnsupportedExportRecordsError = (message) ->
 	error = new Error(message)
 	error.name = "UnsupportedExportRecordsError"
 	error.__proto__ = UnsupportedExportRecordsError.prototype
 	return error
-UnsupportedExportRecordsError.prototype.__proto___ = Error.prototype
+UnsupportedExportRecordsError.prototype.__proto__ = Error.prototype
 
 V1HistoryNotSyncedError = (message) ->
 	error = new Error(message)
 	error.name = "V1HistoryNotSyncedError"
 	error.__proto__ = V1HistoryNotSyncedError.prototype
 	return error
-V1HistoryNotSyncedError.prototype.__proto___ = Error.prototype
+V1HistoryNotSyncedError.prototype.__proto__ = Error.prototype
 
 ProjectHistoryDisabledError = (message) ->
 	error = new Error(message)
 	error.name = "ProjectHistoryDisabledError"
 	error.__proto__ = ProjectHistoryDisabledError.prototype
 	return error
-ProjectHistoryDisabledError.prototype.__proto___ = Error.prototype
+ProjectHistoryDisabledError.prototype.__proto__ = Error.prototype
 
 V1ConnectionError = (message) ->
 	error = new Error(message)
 	error.name = "V1ConnectionError"
 	error.__proto__ = V1ConnectionError.prototype
 	return error
-V1ConnectionError.prototype.__proto___ = Error.prototype
+V1ConnectionError.prototype.__proto__ = Error.prototype
 
 UnconfirmedEmailError = (message) ->
 	error = new Error(message)
 	error.name = "UnconfirmedEmailError"
 	error.__proto__ = UnconfirmedEmailError.prototype
 	return error
-UnconfirmedEmailError.prototype.__proto___ = Error.prototype
+UnconfirmedEmailError.prototype.__proto__ = Error.prototype
 
 EmailExistsError = (message) ->
 	error = new Error(message)
 	error.name = "EmailExistsError"
 	error.__proto__ = EmailExistsError.prototype
 	return error
-EmailExistsError.prototype.__proto___ = Error.prototype
+EmailExistsError.prototype.__proto__ = Error.prototype
 
 module.exports = Errors =
 	NotFoundError: NotFoundError

+ 28 - 0
services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee

@@ -1,5 +1,7 @@
 logger = require("logger-sharelatex")
 NotificationsHandler = require("./NotificationsHandler")
+request = require "request"
+settings = require "settings-sharelatex"
 
 module.exports =
 
@@ -29,3 +31,29 @@ module.exports =
 			NotificationsHandler.createNotification user._id, @key, "notification_project_invite", messageOpts, invite.expires, callback
 		read:  (callback=()->) ->
 			NotificationsHandler.markAsReadByKeyOnly @key, callback
+
+	ipMatcherAffiliation: (userId, ip) ->
+		key: "ip-matched-affiliation-#{ip}"
+		create: (callback=()->) ->
+			return null unless settings?.apis?.v1?.url # service is not configured
+			_key = @key
+			request {
+				method: 'GET'
+				url: "#{settings.apis.v1.url}/api/v2/users/#{userId}/ip_matcher"
+				auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass }
+				body: { ip: ip }
+				json: true
+				timeout: 20 * 1000
+			}, (error, response, body) ->
+				return error if error?
+				return null unless response.statusCode == 200
+
+				messageOpts =
+					university_id: body.id
+					university_name: body.name
+					content: body.enrolment_ad_html
+				logger.log user_id:userId, key:_key, "creating notification key for user"
+				NotificationsHandler.createNotification userId, _key, "notification_ip_matched_affiliation", messageOpts, null, false, callback
+
+		read: (callback = ->)->
+			NotificationsHandler.markAsReadWithKey userId, @key, callback

+ 5 - 2
services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee

@@ -29,12 +29,15 @@ module.exports =
 					unreadNotifications = []
 				callback(null, unreadNotifications)
 
-	createNotification: (user_id, key, templateKey, messageOpts, expiryDateTime, callback)->
+	createNotification: (user_id, key, templateKey, messageOpts, expiryDateTime, forceCreate, callback)->
+		if !callback
+			callback = forceCreate
+			forceCreate = true
 		payload = {
 				key:key
 				messageOpts:messageOpts
 				templateKey:templateKey
-				forceCreate: true
+				forceCreate:forceCreate
 		}
 		if expiryDateTime?
 			payload.expires = expiryDateTime

+ 7 - 0
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -26,6 +26,8 @@ TokenAccessHandler = require '../TokenAccess/TokenAccessHandler'
 CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler'
 Modules = require '../../infrastructure/Modules'
 ProjectEntityHandler = require './ProjectEntityHandler'
+UserGetter = require("../User/UserGetter")
+NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 crypto = require 'crypto'
 { V1ConnectionError } = require '../Errors/Errors'
 Features = require('../../infrastructure/Features')
@@ -209,6 +211,11 @@ module.exports = ProjectController =
 				user = results.user
 				warnings = ProjectController._buildWarningsList results.v1Projects
 
+				# in v2 add notifications for matching university IPs
+				if Settings.overleaf?
+					UserGetter.getUser user_id, { 'lastLoginIp': 1 }, (error, user) ->
+						if req.ip != user.lastLoginIp
+							NotificationsBuilder.ipMatcherAffiliation(user._id, req.ip).create()
 
 				ProjectController._injectProjectOwners projects, (error, projects) ->
 					return next(error) if error?

+ 8 - 2
services/web/app/coffee/infrastructure/ProxyManager.coffee

@@ -7,14 +7,20 @@ module.exports = ProxyManager =
 	apply: (publicApiRouter) ->
 		for proxyUrl, target of settings.proxyUrls
 			do (target) ->
-				publicApiRouter.get proxyUrl, ProxyManager.createProxy(target)
+				method = target.options?.method || 'get'
+				publicApiRouter[method] proxyUrl, ProxyManager.createProxy(target)
 
 	createProxy:  (target) ->
 		(req, res, next) ->
 			targetUrl = makeTargetUrl(target, req)
 			logger.log targetUrl: targetUrl, reqUrl: req.url, "proxying url"
 
-			upstream = request(targetUrl)
+			options =
+				url: targetUrl
+			options.headers = { Cookie: req.headers.cookie } if req.headers?.cookie
+			Object.assign(options, target.options) if target?.options?
+			options.form = req.body if options.method in ['post', 'put']
+			upstream = request(options)
 			upstream.on "error", (error) ->
 				logger.error err: error, "error in ProxyManager"
 

+ 1 - 0
services/web/app/coffee/models/User.coffee

@@ -22,6 +22,7 @@ UserSchema = new Schema
 	confirmed         : {type : Boolean, default : false}
 	signUpDate        : {type : Date, default: () -> new Date() }
 	lastLoggedIn      : {type : Date}
+	lastLoginIp       : {type : String, default : ''}
 	loginCount        : {type : Number, default: 0}
 	holdingAccount    : {type : Boolean, default: false}
 	ace               : {

+ 15 - 0
services/web/app/views/project/list/notifications.pug

@@ -60,6 +60,21 @@ span(ng-controller="NotificationsController").userNotifications
 									button(ng-click="dismiss(notification)").close.pull-right
 										span(aria-hidden="true") ×
 										span.sr-only #{translate("close")}
+						.alert.alert-info(ng-switch-when="notification_ip_matched_affiliation")
+							div.notification_inner
+								.notification_body
+									| It looks like you're at 
+									strong {{ notification.messageOpts.university_name }}! <br/>
+									|	Did you know that {{notification.messageOpts.university_name}} is providing
+									strong  free Overleaf Professional accounts 
+									|	 to everyone at {{notification.messageOpts.university_name}}? <br/>
+									|	Add an institutional email address to claim your account.
+									a.pull-right.btn.btn-sm.btn-info(href="/user/settings")
+										| Add Affiliation
+								span().notification_close
+									button(ng-click="dismiss(notification)").close.pull-right
+										span(aria-hidden="true") &times;
+										span.sr-only #{translate("close")}
 						.alert.alert-info(ng-switch-default)
 							div.notification_inner
 								span(ng-bind-html="notification.html").notification_body

+ 1 - 0
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -516,6 +516,7 @@ define [
 
 				scope.$on '$destroy', () ->
 					if scope.sharejsDoc?
+						scope.$broadcast('changeEditor')
 						tearDownSpellCheck()
 						tearDownCursorPosition()
 						detachFromAce(scope.sharejsDoc)

+ 7 - 3
services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee

@@ -3,12 +3,12 @@ define [], () ->
 		constructor: (@$scope, @adapter, @localStorage) ->
 			@$scope.$on 'editorInit', @jumpToPositionInNewDoc
 
-			@$scope.$on 'beforeChangeDocument', () =>
-				@storeCursorPosition()
-				@storeFirstVisibleLine()
+			@$scope.$on 'beforeChangeDocument', @storePositionAndLine
 
 			@$scope.$on 'afterChangeDocument', @jumpToPositionInNewDoc
 
+			@$scope.$on 'changeEditor', @storePositionAndLine
+
 			@$scope.$on "#{@$scope.name}:gotoLine", (e, line, column) =>
 				if line?
 					setTimeout () =>
@@ -24,6 +24,10 @@ define [], () ->
 			@$scope.$on "#{@$scope.name}:clearSelection", (e) =>
 				@adapter.clearSelection()
 
+		storePositionAndLine: () =>
+			@storeCursorPosition()
+			@storeFirstVisibleLine()
+
 		jumpToPositionInNewDoc: () =>
 			@doc_id = @$scope.sharejsDoc?.doc_id
 			setTimeout () =>

+ 4 - 0
services/web/public/stylesheets/app/cms-page.less

@@ -4,6 +4,10 @@
   including About and Blog
 */
 .cms-page {
+  img {
+    height: auto;
+    max-width: 100%;
+  }
   .btn-description {
     margin-right: @margin-sm;
   }

+ 28 - 0
services/web/public/stylesheets/app/editor/publish-modal.less

@@ -1,4 +1,32 @@
 .modal-body-publish {
+    .form-control-box {
+      margin-bottom: 1.5ex;
+      margin-left: 1.0em;
+      label {
+        display: inline-block;
+        width: 10em;
+        vertical-align: baseline;
+      }
+      .form-control {
+        display: inline-block;
+        width: 60%;
+      }
+      input[type="checkbox"] {
+        margin-right: 0.5em;
+      }
+      textarea {
+        vertical-align: baseline;
+      }
+      select {
+        padding-top: 1ex;
+        padding-bottom: 1ex;
+        padding-left: 1em;
+        padding-right: 1em;
+      }
+      option {
+        margin-left: -4px;
+      }
+    }
 		#search-input-container {
 				overflow: hidden;
 				margin: 5px 0 10px;

+ 0 - 42
services/web/public/stylesheets/app/portals.less

@@ -100,51 +100,9 @@
   }
   // End Print
 
-  /*
-    Begin Tabs
-  */
   .nav-tabs {
-    // Overrides for nav.less
     background-color: @ol-blue-gray-0;
-    border: 0!important;
-    margin-bottom: @margin-md;
-    margin-top: -@line-height-computed; //- adjusted for portal-name
-    padding: @padding-lg 0 @padding-md;
-    text-align: center;
-
-    a {
-      color: @link-color;
-      &:hover {
-        background-color: transparent!important;
-        border: 0!important;
-        color: @link-hover-color!important;
-      }
-    }
-
-    li {
-      display: inline-block;
-      float: none;
-      a {
-        border: 0;
-      }
-    }
-
-    li.active > a {
-      background-color: transparent!important;
-      border: 0;
-      border-bottom: 1px solid @accent-color-secondary!important;
-      color: @accent-color-secondary;
-      &:hover {
-        color: @accent-color-secondary!important;
-      }
-    }
-  }
-
-  .tab-content:extend(.container) {
-    background-color: transparent!important;
-    border: none!important;
   }
-  // End Tabs
 
   @media (max-width: @screen-size-sm-max) {
     .content-pull {

+ 1 - 0
services/web/public/stylesheets/app/wiki.less

@@ -15,6 +15,7 @@
 	}
 
 	img {
+		height: auto;
 		max-width: 100%;
 	}
 	

+ 43 - 0
services/web/public/stylesheets/components/tabs.less

@@ -0,0 +1,43 @@
+.ol-tabs {
+  // Overrides for nav.less
+  .nav-tabs {
+    border: 0!important;
+    margin-bottom: @margin-md;
+    margin-top: -@line-height-computed; //- adjusted for portal-name
+    padding: @padding-lg 0 @padding-md;
+    text-align: center;
+  }
+
+  a {
+    color: @link-color;
+    &:hover {
+      background-color: transparent!important;
+      border: 0!important;
+      color: @link-hover-color!important;
+    }
+  }
+
+  li {
+    display: inline-block;
+    float: none;
+    a {
+      border: 0;
+    }
+  }
+
+  li.active > a {
+    background-color: transparent!important;
+    border: 0!important;
+    border-bottom: 1px solid @accent-color-secondary!important;
+    color: @accent-color-secondary!important;
+    &:hover {
+      color: @accent-color-secondary!important;
+    }
+  }
+  .tab-content:extend(.container) {
+    background-color: transparent!important;
+    border: none!important;
+  }
+}
+
+  

+ 1 - 0
services/web/public/stylesheets/ol-style.less

@@ -10,6 +10,7 @@
 @import "components/icons.less";
 @import "components/navs-ol.less";
 @import "components/pagination.less";
+@import "components/tabs.less";
 
 // Pages
 @import "app/about.less";

+ 3 - 0
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -80,6 +80,9 @@ class User
 			update["features.#{key}"] = value
 		UserModel.update { _id: @id }, update, callback
 
+	setOverleafId: (overleaf_id, callback = (error) ->) ->
+		UserModel.update { _id: @id }, { 'overleaf.id': overleaf_id }, callback
+
 	logout: (callback = (error) ->) ->
 		@getCsrfToken (error) =>
 			return callback(error) if error?

+ 1 - 1
services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee

@@ -15,7 +15,7 @@ describe "AuthenticationController", ->
 		tk.freeze(Date.now())
 		@AuthenticationController = SandboxedModule.require modulePath, requires:
 			"./AuthenticationManager": @AuthenticationManager = {}
-			"../User/UserUpdater" : @UserUpdater = {}
+			"../User/UserUpdater" : @UserUpdater = {updateUser:sinon.stub()}
 			"metrics-sharelatex": @Metrics = { inc: sinon.stub() }
 			"../Security/LoginRateLimiter": @LoginRateLimiter = { processLoginRequest:sinon.stub(), recordSuccessfulLogin:sinon.stub() }
 			"../User/UserHandler": @UserHandler = {setupLoginData:sinon.stub()}

+ 40 - 0
services/web/test/unit/coffee/Notifications/NotificationsBuilderTests.coffee

@@ -0,0 +1,40 @@
+SandboxedModule = require('sandboxed-module')
+assert = require('chai').assert
+require('chai').should()
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Notifications/NotificationsBuilder.js'
+
+describe 'NotificationsBuilder', ->
+	user_id = "123nd3ijdks"
+
+	beforeEach ->
+		@handler =
+			createNotification: sinon.stub().callsArgWith(6)
+
+		@settings =	 apis: { v1: { url: 'v1.url', user: '', pass: '' } }
+		@body = {id: 1, name: 'stanford', enrolment_ad_html: 'v1 ad content'}
+		response = {statusCode: 200}
+		@request = sinon.stub().returns(@stubResponse).callsArgWith(1, null, response, @body)
+		@controller = SandboxedModule.require modulePath, requires:
+			"./NotificationsHandler":@handler
+			"settings-sharelatex":@settings
+			'request': @request
+			"logger-sharelatex":
+				log:->
+				err:->
+
+	it 'should call v1 and create affiliation notifications', (done)->
+		ip = '192.168.0.1'
+		@controller.ipMatcherAffiliation(user_id, ip).create (callback)=>
+			@request.calledOnce.should.equal true
+			expectedOpts =
+				university_id: @body.id
+				university_name: @body.name
+				content: @body.enrolment_ad_html
+			@handler.createNotification.calledWith(
+				user_id,
+				 "ip-matched-affiliation-#{ip}",
+				 "notification_ip_matched_affiliation",
+				 expectedOpts
+			).should.equal true
+			done()

+ 16 - 0
services/web/test/unit/coffee/Project/ProjectControllerTests.coffee

@@ -69,6 +69,10 @@ describe "ProjectController", ->
 		@CollaboratorsHandler =
 			userIsTokenMember: sinon.stub().callsArgWith(2, null, false)
 		@ProjectEntityHandler = {}
+		@NotificationBuilder =
+			ipMatcherAffiliation: sinon.stub().returns({create: sinon.stub()})
+		@UserGetter =
+			getUser: sinon.stub().callsArgWith 2, null, {lastLoginIp: '192.170.18.2'}
 		@Modules =
 			hooks:
 				fire: sinon.stub()
@@ -105,11 +109,16 @@ describe "ProjectController", ->
 			"./ProjectEntityHandler": @ProjectEntityHandler
 			"../Errors/Errors": Errors
 			"../../infrastructure/Features": @Features
+			"../Notifications/NotificationsBuilder":@NotificationBuilder
+			"../User/UserGetter": @UserGetter
 
 		@projectName = "£12321jkj9ujkljds"
 		@req =
 			params:
 				Project_id: @project_id
+			headers: {}
+			connection:
+				remoteAddress: "192.170.18.1"
 			session:
 				user: @user
 			body:
@@ -301,6 +310,13 @@ describe "ProjectController", ->
 				done()
 			@ProjectController.projectListPage @req, @res
 
+		it "should create trigger ip matcher notifications", (done)->
+			@settings.overleaf = true
+			@res.render = (pageName, opts)=>
+				@NotificationBuilder.ipMatcherAffiliation.called.should.equal true
+				done()
+			@ProjectController.projectListPage @req, @res
+
 		it "should send the projects", (done)->
 			@res.render = (pageName, opts)=>
 				opts.projects.length.should.equal (@projects.length + @collabertions.length + @readOnly.length + @tokenReadAndWrite.length + @tokenReadOnly.length)

+ 65 - 5
services/web/test/unit/coffee/infrastructure/ProxyManagerTests.coffee

@@ -35,11 +35,26 @@ describe "ProxyManager", ->
 			assertCalledWith(@router.get, '/foo/bar')
 			assertCalledWith(@router.get, '/foo/:id')
 
+		it 'applies methods other than get', ->
+			@router =
+				post: sinon.stub()
+				put: sinon.stub()
+			@settings.proxyUrls =
+				'/foo/bar': {options: {method: 'post'}}
+				'/foo/:id': {options: {method: 'put'}}
+			@proxyManager.apply(@router)
+			sinon.assert.calledOnce(@router.post)
+			sinon.assert.calledOnce(@router.put)
+			assertCalledWith(@router.post, '/foo/bar')
+			assertCalledWith(@router.put, '/foo/:id')
+
 	describe 'createProxy', ->
 		beforeEach ->
 			@req.url = @proxyPath
 			@req.route.path = @proxyPath
 			@req.query = {}
+			@req.params = {}
+			@req.headers = {}
 			@settings.proxyUrls = {}
 
 		afterEach ->
@@ -57,7 +72,7 @@ describe "ProxyManager", ->
 			targetUrl = 'https://user:pass@foo.bar:123/pa/th.ext?query#hash'
 			@settings.proxyUrls[@proxyPath] = targetUrl
 			@proxyManager.createProxy(targetUrl)(@req)
-			assertCalledWith(@request, targetUrl)
+			assertCalledWith(@request, {url: targetUrl})
 
 		it 'overwrite query', ->
 			targetUrl = 'foo.bar/baz?query'
@@ -65,19 +80,19 @@ describe "ProxyManager", ->
 			@settings.proxyUrls[@proxyPath] = targetUrl
 			@proxyManager.createProxy(targetUrl)(@req)
 			newTargetUrl = 'foo.bar/baz?requestQuery=important'
-			assertCalledWith(@request, newTargetUrl)
+			assertCalledWith(@request, {url: newTargetUrl})
 
 		it 'handles target objects', ->
 			target = { baseUrl: 'api.v1', path: '/pa/th'}
 			@settings.proxyUrls[@proxyPath] = target
 			@proxyManager.createProxy(target)(@req, @res, @next)
-			assertCalledWith(@request, 'api.v1/pa/th')
+			assertCalledWith(@request, {url: 'api.v1/pa/th'})
 
 		it 'handles missing baseUrl', ->
 			target = { path: '/pa/th'}
 			@settings.proxyUrls[@proxyPath] = target
 			@proxyManager.createProxy(target)(@req, @res, @next)
-			assertCalledWith(@request, 'undefined/pa/th')
+			assertCalledWith(@request, {url: 'undefined/pa/th'})
 
 		it 'handles dynamic path', ->
 			target = baseUrl: 'api.v1', path: (params) -> "/resource/#{params.id}"
@@ -86,4 +101,49 @@ describe "ProxyManager", ->
 			@req.route.path = '/res/:id'
 			@req.params = id: 123
 			@proxyManager.createProxy(target)(@req, @res, @next)
-			assertCalledWith(@request, 'api.v1/resource/123')
+			assertCalledWith(@request, {url: 'api.v1/resource/123'})
+
+		it 'set arbitrary options on request', ->
+			target = baseUrl: 'api.v1', path: '/foo', options: foo: 'bar'
+			@req.url = '/foo'
+			@req.route.path = '/foo'
+			@proxyManager.createProxy(target)(@req, @res, @next)
+			assertCalledWith(@request,
+				foo: 'bar'
+				url: 'api.v1/foo'
+			)
+
+		it 'passes cookies', ->
+			target = baseUrl: 'api.v1', path: '/foo'
+			@req.url = '/foo'
+			@req.route.path = '/foo'
+			@req.headers = cookie: 'cookie'
+			@proxyManager.createProxy(target)(@req, @res, @next)
+			assertCalledWith(@request,
+				headers: Cookie: 'cookie'
+				url: 'api.v1/foo'
+			)
+
+		it 'passes body for post', ->
+			target = baseUrl: 'api.v1', path: '/foo', options: method: 'post'
+			@req.url = '/foo'
+			@req.route.path = '/foo'
+			@req.body = foo: 'bar'
+			@proxyManager.createProxy(target)(@req, @res, @next)
+			assertCalledWith(@request,
+				form: foo: 'bar'
+				method: 'post'
+				url: 'api.v1/foo'
+			)
+
+		it 'passes body for put', ->
+			target = baseUrl: 'api.v1', path: '/foo', options: method: 'put'
+			@req.url = '/foo'
+			@req.route.path = '/foo'
+			@req.body = foo: 'bar'
+			@proxyManager.createProxy(target)(@req, @res, @next)
+			assertCalledWith(@request,
+				form: foo: 'bar'
+				method: 'put'
+				url: 'api.v1/foo'
+			)