├── .gitignore ├── .jshintrc ├── .travis.yml ├── Gruntfile.js ├── LICENSE.txt ├── README.md ├── bower.json ├── build └── .gitkeep ├── dist ├── fxpay.debug.js ├── fxpay.min.js └── fxpay.min.js.map ├── example ├── README.rst ├── hosted-paid-app │ ├── README.rst │ ├── manifest.webapp │ ├── media │ │ ├── css │ │ │ └── index.css │ │ ├── index.html │ │ └── js │ │ │ └── index.js │ ├── package.json │ └── server.js ├── hosted │ ├── README.rst │ ├── manifest.webapp │ ├── package.json │ └── server.js ├── packaged-web │ ├── README.rst │ └── manifest.webapp ├── packaged │ ├── README.rst │ └── manifest.webapp └── shared │ ├── css │ └── index.css │ ├── img │ ├── cheese_128.png │ ├── cheese_32.png │ ├── cheese_48.png │ ├── cheese_64.png │ ├── kiwi_128.png │ ├── kiwi_32.png │ ├── kiwi_48.png │ └── kiwi_64.png │ ├── index.html │ └── js │ ├── index.js │ └── lib │ └── jquery-1.9.1.min.js ├── jsdoc.conf.json ├── karma.conf.js ├── lib └── fxpay │ ├── adapter.js │ ├── api.js │ ├── errors.js │ ├── fxpay.js │ ├── jwt.js │ ├── pay.js │ ├── products.js │ ├── receipts.js │ ├── settings.js │ └── utils.js ├── package.json ├── stackato.yml ├── tests ├── helper.js ├── test-api.js ├── test-errors.js ├── test-fxpay.js ├── test-get-products.js ├── test-jwt.js ├── test-main.js ├── test-product-receipt-validation.js ├── test-products.js ├── test-purchase.js ├── test-receipts-all.js ├── test-receipts-verify-app-data.js ├── test-receipts-verify-data.js ├── test-receipts-verify-inapp-data.js ├── test-settings.js ├── test-utils.js ├── test-validate-app-receipt.js ├── test-web-purchase.js └── test-webrt-purchase.js └── umd ├── end.frag └── start.frag /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw[po] 2 | *~ 3 | .DS_Store 4 | build/* 5 | bower_components 6 | 7 | node_modules 8 | 9 | # For gh-pages 10 | .ghpages 11 | 12 | .grunt/* 13 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "boss": true, 3 | "browser": true, 4 | "camelcase": false, 5 | "eqeqeq": true, 6 | "eqnull": true, 7 | "es5": false, 8 | "esnext": true, 9 | "expr": true, 10 | "forin": false, 11 | "globals": { 12 | "define": false 13 | }, 14 | "indent": 2, 15 | "laxbreak": true, 16 | "laxcomma": true, 17 | "maxerr": 100, 18 | "maxlen": 80, 19 | "node": true, 20 | "noarg": true, 21 | "passfail": false, 22 | "predef": [ 23 | "assert", 24 | "beforeEach", 25 | "afterEach", 26 | "describe", 27 | "exports", 28 | "fxpay", 29 | "helper", 30 | "it", 31 | "process", 32 | "Promise", 33 | "requirejs", 34 | "sinon" 35 | ], 36 | "shadow": false, 37 | "strict": false, 38 | "supernew": false, 39 | "undef": true, 40 | "unused": true, 41 | "white": false 42 | } 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 0.10 5 | before_script: 6 | - npm rebuild 7 | - "export DISPLAY=:99.0" 8 | - "sh -e /etc/init.d/xvfb start" 9 | script: npm test 10 | notifications: 11 | irc: 12 | channels: 13 | - "irc.mozilla.org#payments" 14 | on_success: change 15 | on_failure: always 16 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = function(grunt) { 4 | var testOption = grunt.option('tests'); 5 | 6 | var nodeModulesPath = __dirname + '/node_modules'; 7 | var almondPath = nodeModulesPath + '/almond/almond.js'; 8 | var almondInclude = path.relative( 9 | __dirname + '/lib/fxpay', almondPath).replace(/\.js$/, ''); 10 | 11 | grunt.initConfig({ 12 | pkg: grunt.file.readJSON('package.json'), 13 | 14 | jshint: { 15 | options: { jshintrc: __dirname + '/.jshintrc' }, 16 | files: [ 17 | 'Gruntfile.js', 18 | 'lib/*.js', 19 | 'lib/*/*.js', 20 | 'tests/*.js', 21 | ], 22 | }, 23 | 24 | karma: { 25 | options: { 26 | files: [ 27 | 'tests/test-main.js', 28 | {pattern: 'lib/fxpay/*.js', included: false}, 29 | {pattern: 'tests/helper.js', included: false}, 30 | {pattern: testOption || 'tests/test*.js', included: false}, 31 | {pattern: 'lib/bower_components/es6-promise/promise.js', 32 | included: false} 33 | ], 34 | logLevel: grunt.option('log-level') || 'ERROR', 35 | }, 36 | dev: { 37 | configFile: 'karma.conf.js', 38 | autoWatch: true 39 | }, 40 | run: { 41 | configFile: 'karma.conf.js', 42 | singleRun: true 43 | }, 44 | }, 45 | 46 | usebanner: { 47 | chaff: { 48 | options: { 49 | position: 'top', 50 | banner: ")]}'", 51 | linebreak: true 52 | }, 53 | files: { 54 | src: ['build/lib/fxpay.min.js.map'] 55 | } 56 | } 57 | }, 58 | 59 | bower: { 60 | default: { 61 | options: { 62 | targetDir: './lib/bower_components', 63 | layout: 'byType', 64 | bowerOptions: { 65 | // Do not install project devDependencies 66 | production: true, 67 | } 68 | } 69 | } 70 | }, 71 | 72 | bump: { 73 | options: { 74 | // The pattern 'version': '..' will be updated in all these files. 75 | files: ['bower.json', 'lib/fxpay/settings.js', 'package.json'], 76 | commit: false, 77 | createTag: false, 78 | push: false, 79 | } 80 | }, 81 | 82 | clean: { 83 | build: [ 84 | 'build/**/*', 85 | '!build/.gitkeep' 86 | ], 87 | dist: [ 88 | 'dist/*', 89 | '!dist/.gitkeep' 90 | ], 91 | }, 92 | 93 | copy: { 94 | lib: { 95 | cwd: 'build/lib/', 96 | src: ['*.js', '*.map'], 97 | dest: 'dist/', 98 | filter: 'isFile', 99 | expand: true, 100 | }, 101 | 'example-packaged': { 102 | cwd: 'example/packaged/', 103 | src: '*', 104 | dest: 'build/app/', 105 | expand: true, 106 | }, 107 | 'example-packaged-web': { 108 | cwd: 'example/packaged-web/', 109 | src: '*', 110 | dest: 'build/app-web/', 111 | expand: true, 112 | }, 113 | 'example-shared-to-pkg': { 114 | cwd: 'example/shared/', 115 | src: '**/*', 116 | dest: 'build/app/', 117 | expand: true, 118 | }, 119 | 'example-shared-to-web-pkg': { 120 | cwd: 'example/shared/', 121 | src: '**/*', 122 | dest: 'build/app-web/', 123 | expand: true, 124 | }, 125 | 'lib-to-package': { 126 | cwd: 'build/lib/', 127 | src: '*', 128 | dest: 'build/app/', 129 | expand: true, 130 | }, 131 | 'lib-to-web-package': { 132 | cwd: 'build/lib/', 133 | src: '*', 134 | dest: 'build/app-web/', 135 | expand: true, 136 | }, 137 | }, 138 | 139 | zip: { 140 | app: { 141 | cwd: 'build/app/', 142 | src: 'build/app/**', 143 | dest: 'build/app.zip', 144 | compression: 'DEFLATE', 145 | }, 146 | appWeb: { 147 | cwd: 'build/app-web/', 148 | src: 'build/app-web/**', 149 | dest: 'build/app-web.zip', 150 | compression: 'DEFLATE', 151 | }, 152 | }, 153 | 154 | jsdoc : { 155 | docs: { 156 | src: ['lib/fxpay/*.js'], 157 | options: { 158 | destination: 'build/docs', 159 | template: 'node_modules/jsdoc-simple-template', 160 | readme: 'README.md', 161 | configure: 'jsdoc.conf.json', 162 | }, 163 | } 164 | }, 165 | 166 | 'gh-pages': { 167 | options: { 168 | base: 'build/docs', 169 | message: 'Updating docs', 170 | repo: 'git@github.com:mozilla/fxpay.git' 171 | }, 172 | src: ['**'] 173 | }, 174 | 175 | // Builds an unminned optimized combined 176 | // library file. 177 | requirejs: { 178 | debug: { 179 | options: { 180 | include: [almondInclude], 181 | findNestedDependencies: true, 182 | name: 'fxpay', 183 | optimize: 'none', 184 | out: 'build/lib/fxpay.debug.js', 185 | baseUrl: 'lib/fxpay', 186 | normalizeDirDefines: 'all', 187 | skipModuleInsertion: true, 188 | paths: { 189 | promise: '../bower_components/es6-promise/promise', 190 | }, 191 | wrap: { 192 | start: grunt.file.read('umd/start.frag'), 193 | end: grunt.file.read('umd/end.frag') 194 | }, 195 | } 196 | } 197 | }, 198 | 199 | // Takes the requirejs optimized debug file 200 | // and compresses it and creates the sourcemap. 201 | uglify: { 202 | options: { 203 | sourceMap: true 204 | }, 205 | minned: { 206 | files: { 207 | 'build/lib/fxpay.min.js': 'build/lib/fxpay.debug.js', 208 | } 209 | } 210 | }, 211 | 212 | fileExists: { 213 | almond: { 214 | src: [almondPath], 215 | errorMessage: 'Please run npm install first', 216 | } 217 | } 218 | 219 | }); 220 | 221 | grunt.loadNpmTasks('grunt-bower-task'); 222 | grunt.loadNpmTasks('grunt-banner'); 223 | grunt.loadNpmTasks('grunt-bump'); 224 | grunt.loadNpmTasks('grunt-contrib-clean'); 225 | grunt.loadNpmTasks('grunt-contrib-copy'); 226 | grunt.loadNpmTasks('grunt-contrib-jshint'); 227 | grunt.loadNpmTasks('grunt-contrib-requirejs'); 228 | grunt.loadNpmTasks('grunt-contrib-uglify'); 229 | grunt.loadNpmTasks('grunt-gh-pages'); 230 | grunt.loadNpmTasks('grunt-jsdoc'); 231 | grunt.loadNpmTasks('grunt-karma'); 232 | grunt.loadNpmTasks('grunt-zip'); 233 | 234 | grunt.registerMultiTask('fileExists', 'Check files are present', function (){ 235 | var files = grunt.file.expand({ 236 | nonull: true 237 | }, this.data.src); 238 | var len = files.length; 239 | grunt.log.writeln( 240 | 'Checking existence of %d file%s', len, (len === 1 ? '' : 's')); 241 | var filesExist = files.every(function (file) { 242 | grunt.verbose.writeln('Checking file: %s', file); 243 | var fileExists = grunt.file.exists(file); 244 | if (!fileExists) { 245 | grunt.log.error("%s doesn't exist", file); 246 | } 247 | return fileExists; 248 | }); 249 | if (filesExist) { 250 | grunt.log.ok(); 251 | } else { 252 | var errorMessage = this.data.errorMessage; 253 | if (errorMessage) { 254 | grunt.log.error(errorMessage); 255 | } 256 | } 257 | return filesExist; 258 | }); 259 | 260 | grunt.registerTask('package', [ 261 | 'clean:build', 262 | 'compress', 263 | 'copy:example-packaged', 264 | 'copy:example-packaged-web', 265 | 'copy:example-shared-to-pkg', 266 | 'copy:example-shared-to-web-pkg', 267 | 'copy:lib-to-package', 268 | 'copy:lib-to-web-package', 269 | 'zip', 270 | ]); 271 | 272 | // The `compress` step builds a debug version first and then uses that as 273 | // the source for the minified version. 274 | grunt.registerTask('compress', [ 275 | 'fileExists:almond', 276 | 'bower', 277 | 'requirejs', 278 | 'uglify:minned', 279 | 'usebanner:chaff' 280 | ]); 281 | 282 | grunt.registerTask('publish-docs', ['docs', 'gh-pages']); 283 | grunt.registerTask('test', ['jshint', 'karma:run']); 284 | grunt.registerTask('release', ['clean', 'compress', 'copy:lib']); 285 | grunt.registerTask('docs', ['clean:build', 'jsdoc']); 286 | grunt.registerTask('default', 'test'); 287 | }; 288 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Mozilla Corporation 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the Mozilla Corporation nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fxpay 2 | 3 | JavaScript library to support [Firefox Marketplace][mkt] payments in 4 | a web application. 5 | 6 | 7 | [![Build Status](https://travis-ci.org/mozilla/fxpay.svg?branch=master)](https://travis-ci.org/mozilla/fxpay) 8 | ![Bower Version](https://badge.fury.io/bo/fxpay.svg) 9 | [![devDependency Status](https://david-dm.org/mozilla/fxpay/dev-status.svg)](https://david-dm.org/mozilla/fxpay#info=devDependencies) 10 | 11 | 12 | ## Usage 13 | 14 | This is a complete [guide to fxpay usage on MDN][mdn-docs] 15 | 16 | ## Examples 17 | 18 | You can find working code in the [example][example] directory of this repository 19 | 20 | ## FxPay Developers 21 | 22 | To hack on this library you need [NodeJS][node] and [npm][npm] installed. 23 | After cloning the source, cd to the root and install all dependencies: 24 | 25 | npm install 26 | 27 | To execute scripts, you should add the local `.bin` directory to 28 | your `$PATH`: 29 | 30 | PATH="./node_modules/.bin:${PATH}" 31 | export PATH 32 | 33 | This is pretty standard for any Node project so you you might already have it. 34 | 35 | To test that you have your path set up, type `which grunt` and make 36 | sure you see a path to the executable. 37 | 38 | Before going any further, you'll need to install the bower components 39 | used for development. Run this: 40 | 41 | grunt bower 42 | 43 | ### Compression 44 | 45 | To build yourself a compressed version of `fxpay.js`, run this: 46 | 47 | grunt compress 48 | 49 | The compressed source file will appear in the `build` directory 50 | as `fxpay.min.js`. You'll also get a [source map][sourcemaps] file in 51 | the same directory as `fxpay.min.js.map`. 52 | 53 | **IMPORTANT**: To use this library in a web page you have to 54 | compress it first because the source code spans multiple files. 55 | The usage instructions above explain how to install public releases from 56 | Bower which is of course easier. 57 | 58 | 59 | ### Running Tests 60 | 61 | From a source checkout, run all tests and lint checks like this: 62 | 63 | grunt test 64 | 65 | To run the JavaScript unit tests continuously while you are developing, type: 66 | 67 | grunt karma:dev 68 | 69 | This opens a web browser and will report test results to your [console][console]. 70 | As you edit a code file, it will re-run the tests. 71 | **NOTE**: this can be buggy sometimes. 72 | 73 | To fire off a single test run with a browser and see the results, type: 74 | 75 | grunt karma:run 76 | 77 | Here's how to run a specific test file: 78 | 79 | grunt karma:run --tests tests/test-get-products.js 80 | 81 | You can also use grep patterns to match files: 82 | 83 | grunt karma:run --tests 'tests/test-get-*' 84 | 85 | If you want to run a specific test function, you can use 86 | a grep pattern to match the name in the `describe()` or `it()` 87 | definition. For example, run all tests under 88 | `describe('fxpay.purchase()')` like this: 89 | 90 | grunt karma:run --grep='fxpay.purchase()' 91 | 92 | or run a test defined as `it('should open a payment window on the web')` 93 | like this: 94 | 95 | grunt karma:run --grep='should open a payment window on the web' 96 | 97 | If you should need to change the karma log-level (default is ERROR) 98 | you can do so as follows: 99 | 100 | grunt test --log-level=DEBUG 101 | 102 | 103 | ### Check For Lint 104 | 105 | To check for syntax errors (lint), run: 106 | 107 | grunt jshint 108 | 109 | ### Create A Release 110 | 111 | You have to do a couple things to create a release: 112 | 113 | * Run `grunt release`. This compresses the files and copies them to the dist dir. 114 | 115 | * Commit and push your changes to master. 116 | 117 | * Publish the pending [github release][releases] (or create one) which will tag master 118 | at the version string. 119 | 120 | * Make sure all release notes in the draft are up to date. 121 | * If no release exists yet, create one and title it as the pending 122 | version number. For example: `0.0.1`. 123 | * Alternatively, you could manually tag the release with git by running 124 | `git tag 0.0.1 && git push upstream 0.0.1`. 125 | 126 | * Bump the version for the next release. Library version numbers are 127 | managed in multiple files. 128 | To increment the version number and update all files at once, 129 | run `grunt bump`. 130 | 131 | * Commit and push your changes. 132 | 133 | 134 | ### Build the docs 135 | 136 | To build the JSDoc API docs locally run `grunt docs`. The built docs can be found 137 | in `build/docs`. 138 | 139 | For anyone with the commit bit that wants to publish the docs to the gh-pages branch 140 | of this repo run: `grunt publish-docs`. 141 | 142 | The current API docs are available here: [FxPay API Docs](https://mozilla.github.io/fxpay/) *(Note: they are currently under development).* 143 | 144 | 145 | ## Changelog 146 | 147 | See the [release page][releases] 148 | 149 | 150 | [mkt]: https://marketplace.firefox.com 151 | [node]: http://nodejs.org/ 152 | [npm]: https://www.npmjs.org/ 153 | [console]: https://developer.mozilla.org/en-US/docs/Web/API/console 154 | [mdn-docs]: https://developer.mozilla.org/en-US/Marketplace/Monetization/In-app_payments_section/fxPay_iap 155 | [example]: https://github.com/mozilla/fxpay/tree/master/example/ 156 | [sourcemaps]: http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/ 157 | [releases]: https://github.com/mozilla/fxpay/releases 158 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fxpay", 3 | "description": "Adds Firefox Marketplace payments to a web application", 4 | "version": "0.0.18", 5 | "homepage": "https://github.com/mozilla/fxpay", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/mozilla/fxpay.git" 9 | }, 10 | "dependencies": { 11 | "es6-promise": "~2.0.1" 12 | }, 13 | "main": "dist/fxpay.min.js", 14 | "ignore": [ 15 | "**/.*", 16 | "Gruntfile.js", 17 | "bower.json", 18 | "build", 19 | "example", 20 | "karma.conf.js", 21 | "lib", 22 | "node_modules", 23 | "package.json", 24 | "stackato.yml", 25 | "tests", 26 | "jsdoc.conf.json" 27 | ], 28 | "license": "MPL-2.0" 29 | } 30 | -------------------------------------------------------------------------------- /build/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/fxpay/be655d21af6544c60c6b7ae55ad28b38b1737ddd/build/.gitkeep -------------------------------------------------------------------------------- /example/README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Example Apps 3 | ============ 4 | 5 | These are some examples of `Firefox OS`_ web apps that work with the 6 | fxpay library. Each app has its own README for how to install and 7 | play around with them. 8 | 9 | `hosted app with in-app purchases `_ 10 | A hosted web app that can sell and restore in-app products. 11 | 12 | `hosted paid app `_ 13 | A hosted web app that validates its receipt to ensure the user paid for it. 14 | 15 | `packaged web app with in-app purchases `_ 16 | A packaged app (``type: web``) that can sell and restore in-app products. 17 | 18 | `privileged packaged app with in-app purchases `_ 19 | A packaged app (``type: privileged``) that can sell and restore in-app products. 20 | 21 | Using A Custom Webpay 22 | --------------------- 23 | 24 | If you want to test payments against your local `Webpay`_ server 25 | using these example apps then you'll also need to 26 | `build a custom profile`_ with payments 27 | preferences. To use the `Firefox OS Simulator`_ with your custom profile, 28 | go into about:addons, click on Preferences for the 29 | Firefox OS Simulator addon, and set the Gaia path to your custom built 30 | profile. 31 | 32 | .. _`Firefox OS`: https://developer.mozilla.org/en-US/Firefox_OS 33 | .. _`Firefox OS Simulator`: https://developer.mozilla.org/en-US/docs/Tools/Firefox_OS_Simulator 34 | .. _Webpay: https://github.com/mozilla/webpay 35 | .. _`build a custom profile`: http://marketplace.readthedocs.org/en/latest/topics/payments.html#build-a-custom-b2g-profile 36 | -------------------------------------------------------------------------------- /example/hosted-paid-app/README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Hosted Paid App 3 | =============== 4 | 5 | This is an example of a `hosted app`_ that uses the fxpay library to 6 | validate its receipt to ensure that users paid for the app. 7 | 8 | Installation 9 | ------------ 10 | 11 | To run the example you'll need to first compress a 12 | minified fxpay library, install some node modules, and start a web server. 13 | Run these commands from the root of the repository:: 14 | 15 | grunt compress 16 | cd example/hosted-paid-app 17 | npm install 18 | npm start 19 | 20 | You can now open http://localhost:3001 to see the example app but by 21 | launching the URL directly, no receipt will be installed. 22 | 23 | To see a more realistic example, try installing the app with the 24 | Firefox Marketplace `receipt tester`_. 25 | 26 | If you install the app as a desktop web app, 27 | the easiest way to debug it is to launch it from the 28 | shell after installation like this:: 29 | 30 | /Applications/FxPayHostedPaidApp.app/Contents/MacOS/webapprt -debug 6000 31 | 32 | Then fire up the `WebIDE`_ and hook it up as a `remote runtime`_. 33 | You'll need to accept a prompt and then you'll be able to use 34 | the debugging tools. 35 | 36 | .. _`hosted app`: https://developer.mozilla.org/en-US/Marketplace/Options/Hosted_apps 37 | .. _`receipt tester`: https://marketplace.firefox.com/developers/test/receipts/ 38 | .. _`remote runtime`: https://developer.mozilla.org/en-US/docs/Tools/Remote_Debugging/Debugging_Firefox_Desktop 39 | .. _`WebIDE`: https://developer.mozilla.org/en-US/docs/Tools/WebIDE 40 | -------------------------------------------------------------------------------- /example/hosted-paid-app/manifest.webapp: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.1", 3 | "name": "FxPayHostedPaidApp", 4 | "description": "Example of a hosted paid app that does receipt validation", 5 | "developer": { 6 | "name": "Kumar McMillan" 7 | }, 8 | "icons": { 9 | "128": "/shared/img/kiwi_128.png", 10 | "64": "/shared/img/kiwi_64.png", 11 | "48": "/shared/img/kiwi_48.png", 12 | "32": "/shared/img/kiwi_32.png" 13 | }, 14 | "launch_path": "/index.html", 15 | "installs_allowed_from": [ 16 | "https://marketplace.firefox.com", 17 | "https://marketplace-dev.allizom.org", 18 | "https://marketplace.allizom.org", 19 | "https://payments-alt.allizom.org" 20 | ], 21 | "type": "web" 22 | } 23 | -------------------------------------------------------------------------------- /example/hosted-paid-app/media/css/index.css: -------------------------------------------------------------------------------- 1 | #results { 2 | margin-top: 3em; 3 | } 4 | 5 | #results span { 6 | font-weight: bold; 7 | } 8 | -------------------------------------------------------------------------------- /example/hosted-paid-app/media/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | fxpay paid app demo 10 | 11 | 12 |
13 |
14 |

Paid App Example

15 |
16 |
17 |
18 | Receipt validation result: working on it... 19 |
20 |
21 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /example/hosted-paid-app/media/js/index.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | "use strict"; 3 | 4 | function showResult(result, error) { 5 | $('#error').text(error ? error.toString(): ""); 6 | $('#results span').text(result); 7 | } 8 | 9 | function logProductInfo(productInfo) { 10 | console.log('productInfo:', productInfo); 11 | if (productInfo && productInfo.receiptInfo) { 12 | console.log('receipt status:', productInfo.receiptInfo.status); 13 | if (productInfo.receiptInfo.reason) { 14 | console.log('receipt status reason:', productInfo.receiptInfo.reason); 15 | } 16 | } 17 | } 18 | 19 | fxpay.configure({ 20 | allowTestReceipts: true, 21 | receiptCheckSites: [ 22 | // Allow the production service. 23 | 'https://receiptcheck.marketplace.firefox.com', 24 | 'https://marketplace.firefox.com', 25 | 26 | // The following would not be needed in a live app. These our some test 27 | // services for development of the fxpay library only. 28 | 29 | // Allow our test servers. 30 | 'https://receiptcheck-dev.allizom.org', 31 | 'https://receiptcheck-marketplace-dev.allizom.org', 32 | 'https://receiptcheck-payments-alt.allizom.org', 33 | 'https://marketplace-dev.allizom.org', 34 | 'https://marketplace.allizom.org', 35 | 'https://payments-alt.allizom.org', 36 | 37 | // Allow some common local servers.. 38 | 'http://mp.dev', 39 | 'http://fireplace.loc', 40 | ], 41 | }); 42 | 43 | fxpay.validateAppReceipt().then(function(productInfo) { 44 | logProductInfo(productInfo); 45 | console.log('receipt is valid; app was purchased'); 46 | console.log('product URL:', productInfo.productUrl); 47 | showResult('VALID'); 48 | }).catch(function(reason) { 49 | logProductInfo(reason.productInfo); 50 | showResult('INVALID', reason.error || reason); 51 | }); 52 | 53 | console.log('initialized hosted paid app'); 54 | 55 | })(); 56 | -------------------------------------------------------------------------------- /example/hosted-paid-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fxpay-hosted-paid-app", 3 | "version": "0.0.1", 4 | "engines": {"node": ">=0.10"}, 5 | "private": true, 6 | "dependencies": { 7 | "express": "4.10.3", 8 | "morgan": "1.5.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /example/hosted-paid-app/server.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | 4 | var express = require('express'); 5 | var morgan = require('morgan'); 6 | 7 | var app = express(); 8 | var router = express.Router(); 9 | var projectDir = path.normalize(__dirname + '/../..'); 10 | var hostedDir = projectDir + '/example/hosted-paid-app'; 11 | var media = hostedDir + '/media/'; 12 | var sharedMedia = projectDir + '/example/shared'; 13 | var fxPayRelPath = 'build/lib/fxpay.debug.js'; 14 | 15 | if (!fs.existsSync(projectDir + '/' + fxPayRelPath)) { 16 | throw new Error(fxPayRelPath + ' does not exist. ' + 17 | 'You need to run `grunt compress` first'); 18 | } 19 | 20 | router.use(morgan('dev')); // logging 21 | 22 | router.get('/fxpay.debug.js:suffix?', function (req, res) { 23 | res.sendFile(fxPayRelPath + (req.params.suffix || ''), 24 | {root: projectDir}); 25 | }); 26 | 27 | router.get('/lib/fxpay/:sourceFile', function (req, res) { 28 | // Load uncompressed files when debugging with a source map. 29 | res.sendFile('lib/fxpay/' + req.params.sourceFile, 30 | {root: projectDir}); 31 | }); 32 | 33 | router.get('/manifest.webapp', function (req, res) { 34 | res.sendFile('manifest.webapp', {root: hostedDir}); 35 | }); 36 | 37 | console.log('Serving /shared from:', sharedMedia); 38 | router.use('/shared/', express.static(sharedMedia)); 39 | 40 | console.log('Serving root media from:', media); 41 | router.use('/', express.static(media)); 42 | 43 | app.use('/', router); 44 | 45 | var port = process.env.PORT || 3001; 46 | app.listen(port); 47 | console.log('Listening on port ' + port); 48 | -------------------------------------------------------------------------------- /example/hosted/README.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Hosted App With In-App Purchases 3 | ================================ 4 | 5 | This is an example of a `hosted app`_ that uses the fxpay library to 6 | sell and restore in-app products. 7 | 8 | Installation 9 | ------------ 10 | 11 | To run the example you'll need to first compress a 12 | minified fxpay library, install some node modules, and start a web server. 13 | Run these commands from the root of the repository:: 14 | 15 | grunt compress 16 | cd example/hosted 17 | npm install 18 | npm start 19 | 20 | You can now open http://localhost:3000 to see the example app. 21 | All features should be working in any standard web browser. 22 | 23 | If you install the app as a desktop web app, 24 | the easiest way to debug it is to launch it from the 25 | shell after installation like this:: 26 | 27 | /Applications/FxPayHosted.app/Contents/MacOS/webapprt -debug 6000 28 | 29 | Then fire up the `WebIDE`_ and hook it up as a `remote runtime`_. 30 | You'll need to accept a prompt and then you'll be able to use 31 | the debugging tools. 32 | 33 | On Mozilla's PAAS 34 | ----------------- 35 | 36 | The example app is hosted on Mozilla's 37 | `PAAS `_ for convenience. 38 | You can access it at http://fxpay-hosted.paas.allizom.org/ and you can 39 | submit it as an app to your local Firefox Marketplace by uploading the 40 | manifest at http://fxpay-hosted.paas.allizom.org/manifest.webapp . 41 | 42 | To push changes to the app, run this from the fxpay repository root:: 43 | 44 | grunt compress 45 | stackato push -n 46 | 47 | Accessing Your Local API 48 | ------------------------ 49 | 50 | By default, the example app offers ``http://mp.dev`` as the local 51 | Firefox Marketplace API option. 52 | If you'd like to specify a different local URL, you can do so by 53 | passing it as a query string parameter. For example, to set your 54 | local API URL to ``http://fireplace.local``, load the example app 55 | from this URL: 56 | 57 | http://localhost:3000/?local_api=http%3A%2F%2Ffireplace.local 58 | 59 | 60 | .. _`remote runtime`: https://developer.mozilla.org/en-US/docs/Tools/Remote_Debugging/Debugging_Firefox_Desktop 61 | .. _`hosted app`: https://developer.mozilla.org/en-US/Marketplace/Options/Hosted_apps 62 | .. _`WebIDE`: https://developer.mozilla.org/en-US/docs/Tools/WebIDE 63 | -------------------------------------------------------------------------------- /example/hosted/manifest.webapp: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.1", 3 | "name": "FxPayHosted", 4 | "description": "Example of a hosted app that can do server-less in-app payments", 5 | "developer": { 6 | "name": "Kumar McMillan" 7 | }, 8 | "icons": { 9 | "128": "/img/kiwi_128.png", 10 | "64": "/img/kiwi_64.png", 11 | "48": "/img/kiwi_48.png", 12 | "32": "/img/kiwi_32.png" 13 | }, 14 | "launch_path": "/index.html", 15 | "installs_allowed_from": ["*"], 16 | "type": "web" 17 | } 18 | -------------------------------------------------------------------------------- /example/hosted/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fxpay-example", 3 | "version": "0.0.1", 4 | "engines": {"node": ">=0.10"}, 5 | "private": true, 6 | "dependencies": { 7 | "express": "4.10.3", 8 | "morgan": "1.5.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /example/hosted/server.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | 4 | var express = require('express'); 5 | var morgan = require('morgan'); 6 | 7 | var app = express(); 8 | var router = express.Router(); 9 | var projectDir = path.normalize(__dirname + '/../..'); 10 | var hostedDir = projectDir + '/example/hosted'; 11 | var media = projectDir + '/example/shared'; 12 | var fxPayRelPath = 'build/lib/fxpay.debug.js'; 13 | 14 | if (!fs.existsSync(projectDir + '/' + fxPayRelPath)) { 15 | throw new Error(fxPayRelPath + ' does not exist. ' + 16 | 'You need to run `grunt compress` first'); 17 | } 18 | 19 | router.use(morgan('dev')); // logging 20 | 21 | router.get('/fxpay.debug.js:suffix?', function (req, res) { 22 | res.sendFile(fxPayRelPath + (req.params.suffix || ''), 23 | {root: projectDir}); 24 | }); 25 | 26 | router.get('/lib/fxpay/:sourceFile', function (req, res) { 27 | // Load uncompressed files when debugging with a source map. 28 | res.sendFile('lib/fxpay/' + req.params.sourceFile, 29 | {root: projectDir}); 30 | }); 31 | 32 | router.get('/manifest.webapp', function (req, res) { 33 | res.sendFile('manifest.webapp', {root: hostedDir}); 34 | }); 35 | 36 | console.log('Serving media from:', media); 37 | router.use('/', express.static(media)); 38 | 39 | app.use('/', router); 40 | 41 | var port = process.env.PORT || 3000; 42 | app.listen(port); 43 | console.log('Listening on port ' + port); 44 | -------------------------------------------------------------------------------- /example/packaged-web/README.rst: -------------------------------------------------------------------------------- 1 | ====================================== 2 | Packaged Web App With In-App Purchases 3 | ====================================== 4 | 5 | This is an example of a `packaged app`_ of `type:web` (meaning it is not 6 | privileged) that uses the fxpay library to sell and restore in-app products. 7 | 8 | By default packaged apps get a random origin but fxpay needs a 9 | reliable origin to look up in-app products. This shows how you can still 10 | use a web package having no origin to do in-app payments. 11 | 12 | Installation 13 | ~~~~~~~~~~~~ 14 | 15 | First, build this example into a packaged app. Run this from the root 16 | of the fxpay repository 17 | after you've installed the developer tools listed in the main README:: 18 | 19 | grunt package 20 | 21 | This will create ``build/app-web`` and ``build/app-web.zip``. 22 | You can install the app from that directory using the `WebIDE`_. 23 | 24 | .. _`packaged app`: https://developer.mozilla.org/en-US/Marketplace/Options/Packaged_apps 25 | .. _`WebIDE`: https://developer.mozilla.org/en-US/docs/Tools/WebIDE 26 | .. _`Firefox Marketplace`: https://marketplace.firefox.com/ 27 | -------------------------------------------------------------------------------- /example/packaged-web/manifest.webapp: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.1", 3 | "name": "FxPay Web Package", 4 | "description": "Example of a packaged app of type:web that can do server-less in-app payments", 5 | "developer": { 6 | "name": "Kumar McMillan" 7 | }, 8 | "icons": { 9 | "128": "/img/kiwi_128.png", 10 | "64": "/img/kiwi_64.png", 11 | "48": "/img/kiwi_48.png", 12 | "32": "/img/kiwi_32.png" 13 | }, 14 | "launch_path": "/index.html", 15 | "installs_allowed_from": ["*"], 16 | "type": "web" 17 | } 18 | -------------------------------------------------------------------------------- /example/packaged/README.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Packaged App With In-App Purchases 3 | ================================== 4 | 5 | This is an example of a `packaged app`_ that uses the fxpay library to 6 | sell and restore in-app products. 7 | 8 | By default packaged apps get a random origin but fxpay needs a 9 | reliable origin to look up in-app products. Because of this, the packaged 10 | app needs to be `privileged`_ and must define an origin. 11 | 12 | Installation 13 | ~~~~~~~~~~~~ 14 | 15 | To install a `privileged`_ app it must be signed by something like 16 | `Firefox Marketplace`_. However, you can use the 17 | `WebIDE`_ to install it as well. First, build this example into a 18 | packaged app. Run this from the root of the fxpay repository 19 | after you've installed the developer tools listed in the main README:: 20 | 21 | grunt package 22 | 23 | This will create ``build/app`` and ``build/app.zip``. 24 | You can install the app from that directory using the `WebIDE`_. 25 | 26 | .. _`packaged app`: https://developer.mozilla.org/en-US/Marketplace/Options/Packaged_apps 27 | .. _`privileged`: https://developer.mozilla.org/en-US/Marketplace/Options/Packaged_apps#Privileged_app 28 | .. _`WebIDE`: https://developer.mozilla.org/en-US/docs/Tools/WebIDE 29 | .. _`Firefox Marketplace`: https://marketplace.firefox.com/ 30 | -------------------------------------------------------------------------------- /example/packaged/manifest.webapp: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.1", 3 | "name": "FxPay", 4 | "origin": "app://fxpay.allizom.org", 5 | "description": "Example of a packaged app that can do server-less in-app payments", 6 | "developer": { 7 | "name": "Kumar McMillan" 8 | }, 9 | "icons": { 10 | "128": "/img/kiwi_128.png", 11 | "64": "/img/kiwi_64.png", 12 | "48": "/img/kiwi_48.png", 13 | "32": "/img/kiwi_32.png" 14 | }, 15 | "launch_path": "/index.html", 16 | "installs_allowed_from": ["*"], 17 | "type": "privileged" 18 | } 19 | -------------------------------------------------------------------------------- /example/shared/css/index.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | 27 | body { 28 | text-align: center; 29 | font-size: 80%; 30 | color: #121007; 31 | font-family: Arial, Helvetica; 32 | } 33 | 34 | h2 { 35 | font-size: 2em; 36 | margin-bottom: 1em; 37 | } 38 | 39 | h3 { 40 | font-size: 1.5em; 41 | margin-bottom: 0.5em; 42 | } 43 | 44 | a, button { 45 | color: #121007; 46 | } 47 | 48 | button { 49 | text-decoration: none; 50 | } 51 | 52 | ul li { 53 | list-style: none; 54 | } 55 | 56 | button { 57 | font-size: 1.5em; 58 | padding: 1em; 59 | text-align: center; 60 | text-shadow: 0pt 1px 1px rgba(255, 255, 255, 0.75); 61 | background-color: rgb(245, 245, 245); 62 | background-image: -moz-linear-gradient(center top , rgb(255, 255, 255), rgb(230, 230, 230)); 63 | box-shadow: 0pt 1px 0pt rgba(255, 255, 255, 0.2) inset, 0pt 1px 2px rgba(0, 0, 0, 0.05); 64 | border-width: 1px; 65 | border-style: solid; 66 | border-color: rgb(204, 204, 204) rgb(204, 204, 204) rgb(179, 179, 179); 67 | border-radius: 6px 6px 6px 6px; 68 | cursor: pointer; 69 | } 70 | 71 | select { 72 | font-size: 1.3em; 73 | width: 100%; 74 | display: inline-block; 75 | text-overflow: ellipsis; 76 | margin-bottom: 0.5em; 77 | } 78 | 79 | #app { 80 | margin: 1em 17em; 81 | padding: 2em; 82 | border-width: 1px; 83 | border-style: solid; 84 | border-color: rgb(204, 204, 204) rgb(204, 204, 204) rgb(179, 179, 179); 85 | border-radius: 6px 6px 6px 6px; 86 | } 87 | 88 | .clear { 89 | clear: both; 90 | } 91 | 92 | .product img { 93 | float: left; 94 | margin-right: 1em; 95 | } 96 | .product button { 97 | float: right; 98 | } 99 | 100 | .product { 101 | text-align: left; 102 | padding: 1em; 103 | } 104 | 105 | ul { 106 | margin: 0 auto; 107 | width: 25em; 108 | border: 1px solid #C0C0C0; 109 | border-radius: 6px; 110 | } 111 | 112 | ul li { 113 | border-bottom: 1px solid #C0C0C0; 114 | } 115 | 116 | ul li:nth-last-child(1) { 117 | border-bottom: none; 118 | } 119 | 120 | .placeholder { 121 | text-align: center; 122 | } 123 | 124 | #your-products { 125 | margin-top: 3em; 126 | } 127 | 128 | #install-banner { 129 | display: none; 130 | padding: 1em; 131 | background: #777; 132 | border-bottom: 2px solid #000; 133 | } 134 | 135 | #install-banner p { 136 | font-size: 1.2em; 137 | } 138 | 139 | #install-banner button { 140 | margin: 1em; 141 | padding: 0.3em; 142 | } 143 | 144 | #error { 145 | text-align: center; 146 | font-size: 1.5em; 147 | color: red; 148 | } 149 | 150 | #delete-purchases { 151 | display: none; 152 | margin-top: 1em; 153 | } 154 | 155 | /* portrait tablet */ 156 | @media (max-width: 768px) { 157 | h2 { 158 | margin-bottom: 0.5em; 159 | } 160 | h3 { 161 | margin-bottom: 0.1em; 162 | } 163 | #app { 164 | margin: 0; 165 | border: 0; 166 | padding: 0.5em; 167 | } 168 | ul { 169 | width: 100%; 170 | } 171 | #your-products { 172 | margin-top: 1em; 173 | } 174 | footer { 175 | margin-top: 1em; 176 | padding-top: 1em; 177 | border-width: 1px 0 0 0; 178 | border-style: solid; 179 | border-color: rgb(204, 204, 204) rgb(204, 204, 204) rgb(179, 179, 179); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /example/shared/img/cheese_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/fxpay/be655d21af6544c60c6b7ae55ad28b38b1737ddd/example/shared/img/cheese_128.png -------------------------------------------------------------------------------- /example/shared/img/cheese_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/fxpay/be655d21af6544c60c6b7ae55ad28b38b1737ddd/example/shared/img/cheese_32.png -------------------------------------------------------------------------------- /example/shared/img/cheese_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/fxpay/be655d21af6544c60c6b7ae55ad28b38b1737ddd/example/shared/img/cheese_48.png -------------------------------------------------------------------------------- /example/shared/img/cheese_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/fxpay/be655d21af6544c60c6b7ae55ad28b38b1737ddd/example/shared/img/cheese_64.png -------------------------------------------------------------------------------- /example/shared/img/kiwi_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/fxpay/be655d21af6544c60c6b7ae55ad28b38b1737ddd/example/shared/img/kiwi_128.png -------------------------------------------------------------------------------- /example/shared/img/kiwi_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/fxpay/be655d21af6544c60c6b7ae55ad28b38b1737ddd/example/shared/img/kiwi_32.png -------------------------------------------------------------------------------- /example/shared/img/kiwi_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/fxpay/be655d21af6544c60c6b7ae55ad28b38b1737ddd/example/shared/img/kiwi_48.png -------------------------------------------------------------------------------- /example/shared/img/kiwi_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/fxpay/be655d21af6544c60c6b7ae55ad28b38b1737ddd/example/shared/img/kiwi_64.png -------------------------------------------------------------------------------- /example/shared/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | fxpay demo 9 | 10 | 11 |
12 | 13 |
14 |
15 |
16 | 23 |
24 |
25 |
26 |

Products

27 | 31 |
    32 |
    33 |
    34 |

    Your Products

    35 |
      36 |
    • You haven't bought any yet
    • 37 |
    38 | 39 |
    40 |
    41 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /example/shared/js/index.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | var apiUrlBase; 3 | 4 | var apiUrls = { 5 | prod: 'https://marketplace.firefox.com', 6 | dev: 'https://marketplace-dev.allizom.org', 7 | stage: 'https://marketplace.allizom.org', 8 | alt: 'https://payments-alt.allizom.org', 9 | local: queryParam('local_api') || 'http://mp.dev', 10 | }; 11 | 12 | console.log('local API configured as:', apiUrls.local); 13 | 14 | 15 | // Helper functions: 16 | // 17 | function initApi(env) { 18 | var productsUl = $('#products ul'); 19 | if (!env) { 20 | env = $('#api-server option:selected').val(); 21 | } 22 | apiUrlBase = apiUrls[env]; 23 | if (!apiUrlBase) { 24 | throw new Error('unknown API env: ' + env); 25 | } 26 | console.log('setting API to', apiUrlBase); 27 | 28 | fxpay.configure({ 29 | fakeProducts: $('#simulate-checkbox').is(':checked'), 30 | apiUrlBase: apiUrlBase, 31 | adapter: null, // force re-creation of the adapter. 32 | }); 33 | 34 | // Reset some state. 35 | clearError(); 36 | clearPurchases(); 37 | productsUl.empty(); 38 | 39 | console.log('getting products from', apiUrlBase); 40 | 41 | fxpay.getProducts().then(function(products) { 42 | 43 | products.forEach(function(product) { 44 | console.info('got product:', product); 45 | addProduct(productsUl, product); 46 | 47 | if (product.hasReceipt()) { 48 | product.validateReceipt().then(function(restoredProduct) { 49 | console.log('product', restoredProduct.productId, restoredProduct, 50 | 'restored from receipt'); 51 | productBought(restoredProduct); 52 | }).catch(function(error) { 53 | console.error('error restoring product', 54 | error.productInfo.productId, 'message:', 55 | error.toString()); 56 | showError(error); 57 | }); 58 | } 59 | 60 | }); 61 | 62 | }).catch(function(err) { 63 | console.error('error getting products:', err); 64 | showError(err); 65 | }); 66 | } 67 | 68 | function addProduct(parent, prodData, opt) { 69 | opt = opt || {showBuy: true}; 70 | var li = $('
  • ', {class: 'product'}); 71 | if (prodData.smallImageUrl) { 72 | li.append($('', {src: prodData.smallImageUrl, 73 | height: 64, width: 64})); 74 | } 75 | if (opt.showBuy) { 76 | li.append($('').data({product: prodData})); 77 | } 78 | li.append($('

    ' + encodeHtmlEntities(prodData.name) + '

    ')); 79 | // TODO bug 1042953: 80 | //li.append($('

    ' + encodeHtmlEntities(prodData.description) + '

    ')); 81 | li.append($('
    ', {class: 'clear'})); 82 | parent.append(li); 83 | } 84 | 85 | function encodeHtmlEntities(str) { 86 | return str.replace(/[\u00A0-\u9999<>\&]/gim, function(i) { 87 | return '&#' + i.charCodeAt(0) + ';'; 88 | }); 89 | } 90 | 91 | function productBought(productInfo) { 92 | $('#your-products ul li.placeholder').hide(); 93 | addProduct($('#your-products ul'), productInfo, {showBuy: false}); 94 | $('#delete-purchases').show(); 95 | } 96 | 97 | function clearPurchases() { 98 | $('#your-products ul li:not(.placeholder)').remove(); 99 | $('#your-products ul li.placeholder').show(); 100 | } 101 | 102 | function clearError() { 103 | $('#error').text(''); 104 | } 105 | 106 | function showError(error) { 107 | console.error(error.toString()); 108 | $('#error').text(error.toString()); 109 | } 110 | 111 | function queryParam(name) { 112 | // Returns a query string parameter value by `name` or null. 113 | var urlParts = window.location.href.split('?'); 114 | var query; 115 | var value = null; 116 | 117 | if (urlParts.length > 1) { 118 | query = urlParts[1].split('&'); 119 | 120 | query.forEach(function(nameVal) { 121 | var parts = nameVal.split('='); 122 | if (parts[0] === name) { 123 | value = decodeURIComponent(parts[1]); 124 | } 125 | }); 126 | } 127 | 128 | return value; 129 | } 130 | 131 | 132 | // DOM handling: 133 | // 134 | $('ul').on('click', '.product button', function(evt) { 135 | evt.preventDefault(); 136 | clearError(); 137 | var prod = $(this).data('product'); 138 | console.log('purchasing', prod.name, prod.productId); 139 | 140 | fxpay.purchase(prod.productId).then(function(product) { 141 | console.log('product:', product.productId, product, 'purchased'); 142 | productBought(product); 143 | }).catch(function (err) { 144 | console.error('error purchasing product', 145 | (err.productInfo && err.productInfo.productId), 146 | 'message:', err.toString()); 147 | showError(err); 148 | }); 149 | 150 | // TODO: update the UI here with a spinner or something. 151 | }); 152 | 153 | $('#delete-purchases').click(function(evt) { 154 | clearPurchases(); 155 | console.log('clearing all receipts'); 156 | var appSelf = navigator.mozApps && navigator.mozApps.getSelf(); 157 | if (appSelf) { 158 | console.log('removing receipts from mozApps'); 159 | var num = 0; 160 | appSelf.receipts.forEach(function(receipt) { 161 | var req = appSelf.removeReceipt(receipt); 162 | num++; 163 | req.onsuccess = function() { 164 | console.log('receipt successfully removed'); 165 | }; 166 | req.onerror = function() { 167 | console.error('could not remove receipt:', this.error.name); 168 | }; 169 | }); 170 | console.log('number of receipts removed:', num); 171 | } else { 172 | console.log('removing receipts from local storage'); 173 | // I guess this is kind of brutal but it's just a demo app :) 174 | window.localStorage.clear(); 175 | } 176 | $('#delete-purchases').hide(); 177 | }); 178 | 179 | $('button.install').click(function(evt) { 180 | var a = document.createElement('a'); 181 | a.href = '/manifest.webapp'; 182 | var fullManifest = ( 183 | a.protocol + '//' + a.hostname + (a.port ? ':' + a.port: '') + 184 | a.pathname); 185 | 186 | var req = window.navigator.mozApps.install(fullManifest); 187 | 188 | req.onsuccess = function() { 189 | var app = this.result; 190 | app.launch(); 191 | }; 192 | 193 | req.onerror = function() { 194 | console.error('Error installing app:', this.error.name); 195 | }; 196 | 197 | }); 198 | 199 | $('#api-server').change(function(evt) { 200 | initApi(); 201 | }); 202 | 203 | $('#simulate-checkbox').change(function(evt) { 204 | initApi(); 205 | }); 206 | 207 | 208 | // Startup 209 | // 210 | console.log('example app startup'); 211 | 212 | fxpay.configure({ 213 | receiptCheckSites: [ 214 | // Allow the production service. 215 | 'https://receiptcheck.marketplace.firefox.com', 216 | 'https://marketplace.firefox.com', 217 | 218 | // The following would not be needed in a live app. These our some test 219 | // services for development of the fxpay library only. 220 | 221 | // Allow our test servers. 222 | 'https://receiptcheck-dev.allizom.org', 223 | 'https://receiptcheck-marketplace-dev.allizom.org', 224 | 'https://receiptcheck-payments-alt.allizom.org', 225 | 'https://marketplace-dev.allizom.org', 226 | 'https://marketplace.allizom.org', 227 | 'https://payments-alt.allizom.org', 228 | 229 | // Allow some common local servers.. 230 | 'http://mp.dev', 231 | 'http://fireplace.loc', 232 | ], 233 | extraProviderUrls: { 234 | // Map some development sites. 235 | 'mozilla-dev/payments/pay/v1': 236 | 'https://marketplace-dev.allizom.org/mozpay/?req={jwt}', 237 | 'mozilla-stage/payments/pay/v1': 238 | 'https://marketplace.allizom.org/mozpay/?req={jwt}', 239 | 'mozilla-alt/payments/pay/v1': 240 | 'https://payments-alt.allizom.org/mozpay/?req={jwt}', 241 | 'mozilla-local/payments/pay/v1': 242 | 'http://fireplace.loc/mozpay/?req={jwt}', 243 | }, 244 | // Initially, start by allowing fake products so that test 245 | // receipts can validate. The checkbox in initApi() will 246 | // toggle this setting. 247 | fakeProducts: true 248 | }); 249 | 250 | 251 | initApi(); 252 | 253 | if (navigator.mozApps && !navigator.mozApps.getSelf()) { 254 | // We're running on Firefox web so provide an option 255 | // to install as an app. 256 | $('#install-banner').show(); 257 | } 258 | 259 | }); 260 | -------------------------------------------------------------------------------- /jsdoc.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["plugins/markdown"] 3 | } 4 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Wed Mar 05 2014 16:06:01 GMT-0600 (CST) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path, that will be used to resolve files and exclude 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | frameworks: [ 13 | 'mocha', 14 | 'requirejs', 15 | 'sinon', 16 | 'chai' 17 | ], 18 | 19 | 20 | // list of files / patterns to load in the browser. 21 | // This is defined in Gruntfile.js 22 | files: [], 23 | 24 | 25 | // list of files to exclude 26 | exclude: [], 27 | 28 | 29 | // test results reporter to use 30 | // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' 31 | reporters: ['mocha'], 32 | 33 | 34 | // web server port 35 | port: 9876, 36 | 37 | 38 | // enable / disable colors in the output (reporters and logs) 39 | colors: true, 40 | 41 | 42 | // level of logging 43 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 44 | logLevel: config.LOG_INFO, 45 | 46 | 47 | // enable / disable watching file and executing tests whenever any file changes 48 | autoWatch: true, 49 | 50 | 51 | // Start these browsers, currently available: 52 | // - Chrome 53 | // - ChromeCanary 54 | // - Firefox 55 | // - Opera (has to be installed with `npm install karma-opera-launcher`) 56 | // - Safari (only Mac; has to be installed with `npm install karma-safari-launcher`) 57 | // - PhantomJS 58 | // - IE (only Windows; has to be installed with `npm install karma-ie-launcher`) 59 | browsers: ['Firefox'], 60 | 61 | 62 | // If browser does not capture in given timeout [ms], kill it 63 | captureTimeout: 60000, 64 | 65 | 66 | // Continuous Integration mode 67 | // if true, it capture browsers, run tests and exit 68 | singleRun: false 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /lib/fxpay/adapter.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'exports', 3 | 'api', 4 | 'errors', 5 | 'products', 6 | 'receipts', 7 | 'utils' 8 | ], function(exports, api, errors, products, receipts, utils) { 9 | 10 | 'use strict'; 11 | 12 | function FxInappAdapter() { 13 | // 14 | // Adapter for Firefox Marketplace in-app products. 15 | // 16 | // This implements the backend details about how a 17 | // purchase JWT is generated and it has some hooks for 18 | // initialization and finishing up purchases. 19 | // 20 | // This is the default adapter and serves as a guide 21 | // for what public methods you need to implement if you 22 | // were to create your own. 23 | // 24 | // You should avoid setting any adapter properties here 25 | // that might rely on settings. Instead, use the configure() 26 | // hook. 27 | // 28 | } 29 | 30 | FxInappAdapter.prototype.toString = function() { 31 | return ''; 32 | }; 33 | 34 | FxInappAdapter.prototype.configure = function(settings) { 35 | // 36 | // Adds a configuration hook for when settings change. 37 | // 38 | // This is called when settings are first intialized 39 | // and also whenever settings are reconfigured. 40 | // 41 | this.settings = settings; 42 | this.api = new api.API(settings.apiUrlBase); 43 | settings.log.info('configuring Firefox Marketplace In-App adapter'); 44 | }; 45 | 46 | FxInappAdapter.prototype.startTransaction = function(opt, callback) { 47 | // 48 | // Start a transaction. 49 | // 50 | // The `opt` object contains the following parameters: 51 | // 52 | // - productId: the ID of the product purchased. 53 | // 54 | // When finished, execute callback(error, transactionData). 55 | // 56 | // - error: an error if one occurred or null if not 57 | // - transactionData: an object that describes the transaction. 58 | // This can be specific to your adapter but must include 59 | // the `productJWT` parameter which is a JSON Web Token 60 | // that can be passed to navigator.mozPay(). 61 | // 62 | opt = utils.defaults(opt, { 63 | productId: null 64 | }); 65 | var settings = this.settings; 66 | this.api.post(settings.prepareJwtApiUrl, {inapp: opt.productId}, 67 | function(err, productData) { 68 | if (err) { 69 | return callback(err); 70 | } 71 | settings.log.debug('requested JWT for ', opt.productId, 'from API; got:', 72 | productData); 73 | return callback(null, {productJWT: productData.webpayJWT, 74 | productId: opt.productId, 75 | productData: productData}); 76 | }); 77 | }; 78 | 79 | FxInappAdapter.prototype.transactionStatus = function(transData, callback) { 80 | // 81 | // Get the status of a transaction. 82 | // 83 | // The `transData` object received is the same one returned by 84 | // startTransaction(). 85 | // 86 | // When finished, execute callback(error, isCompleted, productInfo). 87 | // 88 | // - error: an error if one occurred or null if not. 89 | // - isCompleted: true or false if the transaction has been 90 | // completed successfully. 91 | // - productInfo: an object that describes the product purchased. 92 | // If there was an error or the transaction was not completed, 93 | // this can be null. 94 | // A productInfo object should have the propeties described at: 95 | // 96 | // https://developer.mozilla.org/en-US/Marketplace/Monetization 97 | // /In-app_payments_section/fxPay_iap#Product_Info_Object 98 | // 99 | var self = this; 100 | var url = self.api.url(transData.productData.contribStatusURL, 101 | {versioned: false}); 102 | self.api.get(url, function(err, data) { 103 | if (err) { 104 | return callback(err); 105 | } 106 | 107 | if (data.status === 'complete') { 108 | self._finishTransaction(data, transData.productId, 109 | function(err, productInfo) { 110 | if (err) { 111 | return callback(err); 112 | } 113 | callback(null, true, productInfo); 114 | }); 115 | } else if (data.status === 'incomplete') { 116 | return callback(null, false); 117 | } else { 118 | return callback(errors.ConfigurationError( 119 | 'transaction status ' + data.status + ' from ' + 120 | url + ' was unexpected')); 121 | } 122 | }); 123 | }; 124 | 125 | FxInappAdapter.prototype._finishTransaction = function(data, productId, 126 | callback) { 127 | // 128 | // Private helper method to finish transactionStatus(). 129 | // 130 | var settings = this.settings; 131 | settings.log.info('received completed transaction:', data); 132 | 133 | receipts.add(data.receipt, function(err) { 134 | if (err) { 135 | return callback(err); 136 | } 137 | products.getById(productId, function(err, fullProductInfo) { 138 | if (err) { 139 | return callback(err, fullProductInfo); 140 | } 141 | callback(null, fullProductInfo); 142 | }, { 143 | // If this is a purchase for fake products, only fetch stub products. 144 | fetchStubs: settings.fakeProducts, 145 | }); 146 | }); 147 | }; 148 | 149 | exports.FxInappAdapter = FxInappAdapter; 150 | 151 | }); 152 | -------------------------------------------------------------------------------- /lib/fxpay/api.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'exports', 3 | 'errors', 4 | 'settings', 5 | 'utils' 6 | ], function(exports, errors, settings, utils) { 7 | 8 | 'use strict'; 9 | 10 | function API(baseUrl, opt) { 11 | opt = opt || {}; 12 | this.baseUrl = baseUrl; 13 | this.log = settings.log; 14 | this.timeoutMs = settings.apiTimeoutMs || 10000; 15 | this.versionPrefix = settings.apiVersionPrefix || undefined; 16 | } 17 | 18 | API.prototype.url = function(path, opt) { 19 | opt = opt || {}; 20 | opt.versioned = (typeof opt.versioned !== 'undefined' 21 | ? opt.versioned: true); 22 | var url = this.baseUrl; 23 | if (opt.versioned) { 24 | url += (this.versionPrefix || ''); 25 | } 26 | url += path; 27 | return url; 28 | }; 29 | 30 | API.prototype.request = function(method, path, data, cb, opt) { 31 | opt = opt || {}; 32 | var defaultCType = (data ? 'application/x-www-form-urlencoded': null); 33 | opt.contentType = opt.contentType || defaultCType; 34 | var defaultHeaders = { 35 | 'Accept': 'application/json' 36 | }; 37 | if (opt.contentType) { 38 | defaultHeaders['Content-Type'] = opt.contentType; 39 | } 40 | 41 | opt.headers = opt.headers || {}; 42 | for (var h in defaultHeaders) { 43 | if (!(h in opt.headers)) { 44 | opt.headers[h] = defaultHeaders[h]; 45 | } 46 | } 47 | opt.headers['x-fxpay-version'] = settings.libVersion; 48 | 49 | var log = this.log; 50 | var api = this; 51 | var url; 52 | if (!cb) { 53 | cb = function(err, data) { 54 | if (err) { 55 | throw err; 56 | } 57 | log.info('default callback received data:', data); 58 | }; 59 | } 60 | if (/^http(s)?:\/\/.*/.test(path)) { 61 | // Requesting an absolute URL so no need to prefix it. 62 | url = path; 63 | } else { 64 | url = this.url(path); 65 | } 66 | var xhr = new XMLHttpRequest(); 67 | // This doesn't seem to be supported by sinon yet. 68 | //xhr.responseType = "json"; 69 | 70 | var events = { 71 | abort: function() { 72 | cb(errors.ApiRequestAborted('XHR request aborted for path: ' + path)); 73 | }, 74 | error: function(evt) { 75 | log.debug('XHR error event:', evt); 76 | cb(errors.ApiRequestError('received XHR error for path: ' + path)); 77 | }, 78 | load: function() { 79 | var data; 80 | if (this.status.toString().slice(0, 1) !== '2') { 81 | // TODO: handle status === 0 ? 82 | // TODO: handle redirects? 83 | var err = errors.BadApiResponse( 84 | 'Unexpected status: ' + this.status + ' for URL: ' + url); 85 | log.debug(err.toString(), 'response:', this.responseText); 86 | return cb(err); 87 | } 88 | 89 | log.debug('XHR load: GOT response:', this.responseText); 90 | try { 91 | // TODO: be smarter about content-types here. 92 | data = JSON.parse(this.responseText); 93 | } catch (parseErr) { 94 | var err = errors.BadJsonResponse( 95 | 'Unparsable JSON for URL: ' + url + '; exception: ' + parseErr); 96 | log.debug(err.toString(), 'response:', this.responseText); 97 | return cb(err); 98 | } 99 | 100 | cb(null, data); 101 | }, 102 | timeout: function() { 103 | cb(errors.ApiRequestTimeout( 104 | 'XHR request to ' + url + ' timed out after ' + 105 | api.timeoutMs + 'ms')); 106 | } 107 | }; 108 | 109 | for (var k in events) { 110 | xhr.addEventListener(k, events[k], false); 111 | } 112 | 113 | log.debug('opening', method, 'to', url); 114 | xhr.open(method, url, true); 115 | 116 | // Has to be after xhr.open to avoid 117 | // invalidStateError in IE. 118 | xhr.timeout = api.timeoutMs; 119 | 120 | for (var hdr in opt.headers) { 121 | xhr.setRequestHeader(hdr, opt.headers[hdr]); 122 | } 123 | if (opt.contentType === 'application/x-www-form-urlencoded' && data) { 124 | data = utils.serialize(data); 125 | } 126 | xhr.send(data); 127 | }; 128 | 129 | API.prototype.get = function(path, cb, opt) { 130 | this.request('GET', path, null, cb, opt); 131 | }; 132 | 133 | API.prototype.del = function(path, cb, opt) { 134 | this.request('DELETE', path, null, cb, opt); 135 | }; 136 | 137 | API.prototype.post = function(path, data, cb, opt) { 138 | this.request('POST', path, data, cb, opt); 139 | }; 140 | 141 | API.prototype.put = function(path, data, cb, opt) { 142 | this.request('PUT', path, data, cb, opt); 143 | }; 144 | 145 | exports.API = API; 146 | 147 | }); 148 | -------------------------------------------------------------------------------- /lib/fxpay/errors.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'exports', 3 | 'settings' 4 | ], function(exports, settings) { 5 | 6 | 'use strict'; 7 | 8 | exports.createError = createError; 9 | 10 | // All error classes will implicitly inherit from this. 11 | exports.FxPayError = createError('FxPayError'); 12 | 13 | exportErrors([ 14 | ['ApiError'], 15 | ['ConfigurationError'], 16 | ['FailedWindowMessage'], 17 | ['IncorrectUsage'], 18 | ['InvalidApp'], 19 | ['InvalidJwt'], 20 | ['NotImplementedError'], 21 | ['PaymentFailed'], 22 | ['PayWindowClosedByUser', {code: 'DIALOG_CLOSED_BY_USER'}], 23 | ['PlatformError'], 24 | ['UnknownMessageOrigin'], 25 | ]); 26 | 27 | 28 | exportErrors([ 29 | ['InvalidAppOrigin'], 30 | ], { 31 | inherits: exports.InvalidApp, 32 | }); 33 | 34 | 35 | exportErrors([ 36 | ['AppReceiptMissing'], 37 | ['InvalidReceipt'], 38 | ['PurchaseTimeout'], 39 | ['TestReceiptNotAllowed'], 40 | ], { 41 | inherits: exports.PaymentFailed, 42 | }); 43 | 44 | 45 | exportErrors([ 46 | ['AddReceiptError'], 47 | ['PayPlatformError'], 48 | ['PayPlatformUnavailable'], 49 | ], { 50 | inherits: exports.PlatformError, 51 | }); 52 | 53 | 54 | exportErrors([ 55 | ['ApiRequestAborted'], 56 | ['ApiRequestError'], 57 | ['ApiRequestTimeout'], 58 | ['BadApiResponse'], 59 | ['BadJsonResponse'], 60 | ], { 61 | inherits: exports.ApiError, 62 | }); 63 | 64 | 65 | function createError(name, classOpt) { 66 | classOpt = classOpt || {}; 67 | var errorParent = classOpt.inherits || exports.FxPayError || Error; 68 | 69 | function CreatedFxPayError(message, opt) { 70 | opt = opt || {}; 71 | 72 | if (!(this instanceof CreatedFxPayError)) { 73 | // Let callers create instances without `new`. 74 | var obj = Object.create(CreatedFxPayError.prototype); 75 | return CreatedFxPayError.apply(obj, arguments); 76 | } 77 | this.message = message; 78 | this.stack = (new Error()).stack; 79 | if (opt.code) { 80 | this.code = opt.code; 81 | } 82 | 83 | // Some code will attach a productInfo object 84 | // on to the exception before throwing. 85 | // This object contains information such as 86 | // the exact reason why a receipt was invalid. 87 | this.productInfo = opt.productInfo || {}; 88 | var logger = settings.log || console; 89 | logger.error(this.toString()); 90 | 91 | return this; 92 | } 93 | 94 | CreatedFxPayError.prototype = Object.create(errorParent.prototype); 95 | CreatedFxPayError.prototype.name = name; 96 | 97 | if (classOpt.code) { 98 | CreatedFxPayError.prototype.code = classOpt.code; 99 | } 100 | 101 | CreatedFxPayError.prototype.toString = function() { 102 | var str = Error.prototype.toString.call(this); 103 | if (this.code) { 104 | str += ' {code: ' + this.code + '}'; 105 | } 106 | return str; 107 | }; 108 | 109 | return CreatedFxPayError; 110 | } 111 | 112 | 113 | function exportErrors(errList, defaultOpt) { 114 | errList.forEach(function(errArgs) { 115 | var cls = errArgs[0]; 116 | var opt = defaultOpt || {}; 117 | if (errArgs[1]) { 118 | Object.keys(errArgs[1]).forEach(function(optKey) { 119 | opt[optKey] = errArgs[1][optKey]; 120 | }); 121 | } 122 | // Export the created error class. 123 | exports[cls] = createError.call(this, cls, opt); 124 | }); 125 | } 126 | 127 | }); 128 | -------------------------------------------------------------------------------- /lib/fxpay/fxpay.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'exports', 3 | 'errors', 4 | 'promise', 5 | 'pay', 6 | 'receipts', 7 | 'products', 8 | 'settings', 9 | 'utils' 10 | ], function(exports, errors, promise, pay, 11 | receipts, products, settings, utils) { 12 | 13 | 'use strict'; 14 | 15 | // 16 | // publicly exported functions: 17 | // 18 | 19 | var Promise = promise.Promise; 20 | 21 | exports.errors = errors; 22 | exports.receipts = receipts; 23 | exports.settings = settings; 24 | 25 | exports.configure = function() { 26 | return settings.configure.apply(settings, arguments); 27 | }; 28 | 29 | 30 | exports.init = function _init(opt) { 31 | settings.initialize(opt); 32 | utils.logDeprecation( 33 | 'fxpay.init() is no longer supported; use ' + 34 | 'fxpay.getProducts()...product.validateReceipt() instead', '0.0.15'); 35 | 36 | exports.getProducts() 37 | .then(function(products) { 38 | products.forEach(function(product) { 39 | 40 | if (product.hasReceipt()) { 41 | product.validateReceipt().then(function(productInfo) { 42 | settings.onrestore(null, productInfo); 43 | }).catch(function(error) { 44 | settings.onrestore(error, error.productInfo); 45 | }); 46 | } 47 | 48 | }); 49 | }) 50 | .then(settings.oninit) 51 | .catch(settings.onerror); 52 | 53 | }; 54 | 55 | 56 | exports.validateAppReceipt = function validateAppReceipt() { 57 | settings.initialize(); 58 | return new Promise(function(resolve, reject) { 59 | utils.getAppSelf(function(error, appSelf) { 60 | if (error) { 61 | return reject(error); 62 | } 63 | if (!appSelf) { 64 | return reject(errors.PayPlatformUnavailable( 65 | 'mozApps.getSelf() required for receipts')); 66 | } 67 | var allAppReceipts = []; 68 | 69 | receipts.all(function(error, allReceipts) { 70 | if (error) { 71 | return reject(error); 72 | } 73 | 74 | allReceipts.forEach(function(receipt) { 75 | var storedata = receipts.checkStoreData(receipt); 76 | if (!storedata) { 77 | settings.log.info( 78 | 'ignoring receipt with missing or unparsable storedata'); 79 | return; 80 | } 81 | if (storedata.inapp_id) { 82 | settings.log.info('ignoring in-app receipt with storedata', 83 | storedata); 84 | return; 85 | } 86 | allAppReceipts.push(receipt); 87 | }); 88 | 89 | settings.log.info('app receipts found:', allAppReceipts.length); 90 | 91 | var appReceipt; 92 | 93 | if (allAppReceipts.length === 0) { 94 | return reject(errors.AppReceiptMissing( 95 | 'no receipt found in getSelf()')); 96 | } else if (allAppReceipts.length === 1) { 97 | appReceipt = allAppReceipts[0]; 98 | settings.log.info('Installed receipt:', appReceipt); 99 | return receipts.validateAppReceipt(appReceipt, 100 | function(error, productInfo) { 101 | settings.log.info('got verification result for', productInfo); 102 | if (error) { 103 | error.productInfo = productInfo; 104 | reject(error); 105 | } else { 106 | resolve(productInfo); 107 | } 108 | }); 109 | } else { 110 | // TODO: support multiple app stores? bug 1134739. 111 | // This is an unlikely case where multiple app receipts are 112 | // installed. 113 | return reject(errors.NotImplementedError( 114 | 'multiple app receipts were found which is not yet supported')); 115 | } 116 | }); 117 | }); 118 | }); 119 | }; 120 | 121 | 122 | exports.purchase = function _purchase(productId) { 123 | settings.initialize(); 124 | var callback; 125 | var opt; 126 | 127 | if (typeof arguments[1] === 'function') { 128 | // Old style: fxpay.purchase(productId, callback, opt) 129 | callback = arguments[1]; 130 | opt = arguments[2]; 131 | } else { 132 | // New style: fxpay.purchase(productId, opt); 133 | opt = arguments[1]; 134 | } 135 | 136 | opt = utils.defaults(opt, { 137 | maxTries: undefined, 138 | managePaymentWindow: undefined, 139 | paymentWindow: undefined, 140 | pollIntervalMs: undefined, 141 | }); 142 | 143 | settings.initialize(); 144 | 145 | var promise = new Promise(function(resolve, reject) { 146 | if (typeof opt.managePaymentWindow === 'undefined') { 147 | // By default, do not manage the payment window when a custom 148 | // window is defined. This means the client must close its own window. 149 | opt.managePaymentWindow = !opt.paymentWindow; 150 | } 151 | 152 | var partialProdInfo = new products.Product({productId: productId}); 153 | settings.log.debug('starting purchase for product', productId); 154 | 155 | if (!settings.mozPay) { 156 | if (!opt.paymentWindow) { 157 | // Open a blank payment window on the same event loop tick 158 | // as the click handler. This avoids popup blockers. 159 | opt.paymentWindow = utils.openWindow(); 160 | } else { 161 | settings.log.info('web flow will use client provided payment window'); 162 | utils.reCenterWindow(opt.paymentWindow, 163 | settings.winWidth, settings.winHeight); 164 | } 165 | } 166 | 167 | function closePayWindow() { 168 | if (opt.paymentWindow && !opt.paymentWindow.closed) { 169 | if (opt.managePaymentWindow) { 170 | opt.paymentWindow.close(); 171 | } else { 172 | settings.log.info('payment window should be closed but client ' + 173 | 'is managing it'); 174 | } 175 | } 176 | } 177 | 178 | settings.adapter.startTransaction({productId: productId}, 179 | function(err, transData) { 180 | if (err) { 181 | closePayWindow(); 182 | err.productInfo = partialProdInfo; 183 | return reject(err); 184 | } 185 | pay.processPayment(transData.productJWT, function(err) { 186 | if (err) { 187 | closePayWindow(); 188 | err.productInfo = partialProdInfo; 189 | return reject(err); 190 | } 191 | 192 | // The payment flow has completed and the window has closed. 193 | // Wait for payment verification. 194 | 195 | waitForTransaction( 196 | transData, 197 | function(err, fullProductInfo) { 198 | if (err) { 199 | err.productInfo = partialProdInfo; 200 | reject(err); 201 | } else { 202 | resolve(fullProductInfo); 203 | } 204 | }, { 205 | maxTries: opt.maxTries, 206 | pollIntervalMs: opt.pollIntervalMs 207 | } 208 | ); 209 | }, { 210 | managePaymentWindow: opt.managePaymentWindow, 211 | paymentWindow: opt.paymentWindow, 212 | }); 213 | }); 214 | }); 215 | 216 | if (callback) { 217 | utils.logDeprecation( 218 | 'purchase(id, callback) is no longer supported; use the returned ' + 219 | 'promise instead', '0.0.15'); 220 | promise.then(function(productInfo) { 221 | callback(null, productInfo); 222 | }).catch(function(error) { 223 | callback(error, error.productInfo || new products.Product()); 224 | }); 225 | } 226 | 227 | return promise; 228 | }; 229 | 230 | 231 | exports.getProduct = function getProduct() { 232 | settings.initialize(); 233 | return products.get.apply(products, arguments); 234 | }; 235 | 236 | 237 | exports.getProducts = function getProducts() { 238 | settings.initialize(); 239 | return products.all.apply(products, arguments); 240 | }; 241 | 242 | 243 | // 244 | // private functions: 245 | // 246 | 247 | 248 | // NOTE: if you change this function signature, change the setTimeout below. 249 | function waitForTransaction(transData, cb, opt) { 250 | opt = opt || {}; 251 | opt.maxTries = opt.maxTries || 10; 252 | opt.pollIntervalMs = opt.pollIntervalMs || 1000; 253 | opt._tries = opt._tries || 1; 254 | 255 | var log = settings.log; 256 | log.debug('Getting transaction state for', transData, 257 | 'tries=', opt._tries); 258 | 259 | if (opt._tries > opt.maxTries) { 260 | log.error('Giving up on transaction for', transData, 261 | 'after', opt._tries, 'tries'); 262 | return cb(errors.PurchaseTimeout( 263 | 'timeout while waiting for completed transaction')); 264 | } 265 | 266 | settings.adapter.transactionStatus( 267 | transData, function(err, isComplete, productInfo) { 268 | if (err) { 269 | return cb(err); 270 | } 271 | if (isComplete) { 272 | return cb(null, productInfo); 273 | } else { 274 | log.debug('Re-trying incomplete transaction in', 275 | opt.pollIntervalMs, 'ms'); 276 | window.setTimeout(function() { 277 | waitForTransaction(transData, cb, { 278 | maxTries: opt.maxTries, 279 | pollIntervalMs: opt.pollIntervalMs, 280 | _tries: opt._tries + 1 281 | }); 282 | }, opt.pollIntervalMs); 283 | } 284 | }); 285 | } 286 | 287 | }); 288 | -------------------------------------------------------------------------------- /lib/fxpay/jwt.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'exports', 3 | 'errors', 4 | 'settings' 5 | ], function(exports, errors, settings) { 6 | 7 | 'use strict'; 8 | 9 | // This is a very minimal JWT utility. It does not validate signatures. 10 | 11 | exports.decode = function jwt_decode(jwt, callback) { 12 | var parts = jwt.split('.'); 13 | 14 | if (parts.length !== 3) { 15 | settings.log.debug('JWT: not enough segments:', jwt); 16 | return callback(errors.InvalidJwt('JWT does not have 3 segments')); 17 | } 18 | 19 | var jwtData = parts[1]; 20 | // Normalize URL safe base64 into regular base64. 21 | jwtData = jwtData.replace("-", "+", "g").replace("_", "/", "g"); 22 | var jwtString; 23 | try { 24 | jwtString = atob(jwtData); 25 | } catch (error) { 26 | return callback(errors.InvalidJwt( 27 | 'atob() error: ' + error.toString() + 28 | ' when decoding JWT ' + jwtData)); 29 | } 30 | var data; 31 | 32 | try { 33 | data = JSON.parse(jwtString); 34 | } catch (error) { 35 | return callback(errors.InvalidJwt( 36 | 'JSON.parse() error: ' + error.toString() + 37 | ' when parsing ' + jwtString)); 38 | } 39 | callback(null, data); 40 | }; 41 | 42 | 43 | exports.getPayUrl = function jwt_getPayUrl(encodedJwt, callback) { 44 | exports.decode(encodedJwt, function(err, jwtData) { 45 | if (err) { 46 | return callback(err); 47 | } 48 | 49 | var payUrl = settings.payProviderUrls[jwtData.typ]; 50 | if (!payUrl) { 51 | return callback(errors.InvalidJwt( 52 | 'JWT type ' + jwtData.typ + 53 | ' does not map to any known payment providers')); 54 | } 55 | if (payUrl.indexOf('{jwt}') === -1) { 56 | return callback(errors.ConfigurationError( 57 | 'JWT type ' + jwtData.typ + 58 | ' pay URL is formatted incorrectly: ' + payUrl)); 59 | } 60 | 61 | payUrl = payUrl.replace('{jwt}', encodedJwt); 62 | settings.log.info('JWT', jwtData.typ, 'resulted in pay URL:', payUrl); 63 | callback(null, payUrl); 64 | }); 65 | }; 66 | 67 | }); 68 | -------------------------------------------------------------------------------- /lib/fxpay/pay.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'exports', 3 | 'errors', 4 | 'jwt', 5 | 'settings', 6 | 'utils' 7 | ], function(exports, errors, jwt, settings, utils) { 8 | 9 | 'use strict'; 10 | 11 | var timer; 12 | 13 | exports.processPayment = function pay_processPayment(jwt, callback, opt) { 14 | opt = utils.defaults(opt, { 15 | managePaymentWindow: true, 16 | paymentWindow: undefined, 17 | }); 18 | 19 | if (settings.mozPay) { 20 | settings.log.info('processing payment with mozPay using jwt', jwt); 21 | 22 | var payReq = settings.mozPay([jwt]); 23 | 24 | payReq.onerror = function mozPay_onerror() { 25 | settings.log.error('mozPay: received onerror():', this.error.name); 26 | if (settings.onBrokenWebRT && 27 | this.error.name === 'USER_CANCELLED') { 28 | // This is a workaround for bug 1133963. 29 | settings.log.warn( 30 | 'webRT: pretending the cancel message is actually a success!'); 31 | callback(); 32 | } else { 33 | callback(errors.PayPlatformError( 34 | 'mozPay error: ' + this.error.name, 35 | {code: this.error.name})); 36 | } 37 | }; 38 | 39 | payReq.onsuccess = function mozPay_onsuccess() { 40 | settings.log.debug('mozPay: received onsuccess()'); 41 | callback(); 42 | }; 43 | 44 | } else { 45 | if (!opt.paymentWindow) { 46 | return callback(errors.IncorrectUsage( 47 | 'Cannot start a web payment without a ' + 48 | 'reference to the payment window')); 49 | } 50 | settings.log.info('processing payment with web flow'); 51 | return processWebPayment(opt.paymentWindow, opt.managePaymentWindow, 52 | jwt, callback); 53 | } 54 | }; 55 | 56 | 57 | exports.acceptPayMessage = function pay_acceptPayMessage(event, 58 | allowedOrigin, 59 | paymentWindow, 60 | callback) { 61 | settings.log.debug('received', event.data, 'from', event.origin); 62 | 63 | if (event.origin !== allowedOrigin) { 64 | return callback(errors.UnknownMessageOrigin( 65 | 'ignoring message from foreign window at ' + event.origin)); 66 | } 67 | var eventData = event.data || {}; 68 | 69 | if (eventData.status === 'unloaded') { 70 | // Look for the window having been closed. 71 | if (timer) { 72 | window.clearTimeout(timer); 73 | } 74 | // This delay is introduced so that the closed property 75 | // of the window has time to be updated. 76 | timer = window.setTimeout(function(){ 77 | if (!paymentWindow || paymentWindow.closed === true) { 78 | return callback( 79 | errors.PayWindowClosedByUser('Window closed by user')); 80 | } 81 | }, 300); 82 | } else if (eventData.status === 'ok') { 83 | settings.log.info('received pay success message from window at', 84 | event.origin); 85 | return callback(); 86 | } else if (eventData.status === 'failed') { 87 | return callback(errors.FailedWindowMessage( 88 | 'received pay fail message with status=' + eventData.status + 89 | ', code=' + eventData.errorCode + ' from window at ' + 90 | event.origin, {code: eventData.errorCode})); 91 | } else { 92 | return callback(errors.FailedWindowMessage( 93 | 'received pay message with unknown status ' + 94 | eventData.status + ' from window at ' + 95 | event.origin)); 96 | } 97 | }; 98 | 99 | 100 | function processWebPayment(paymentWindow, managePaymentWindow, payJwt, 101 | callback) { 102 | jwt.getPayUrl(payJwt, function(err, payUrl) { 103 | if (err) { 104 | return callback(err); 105 | } 106 | // Now that we've extracted a payment URL from the JWT, 107 | // load it into the freshly created popup window. 108 | paymentWindow.location = payUrl; 109 | 110 | // This interval covers closure of the popup 111 | // whilst on external domains that won't postMessage 112 | // onunload. 113 | var popupInterval = setInterval(function() { 114 | if (!paymentWindow || paymentWindow.closed) { 115 | clearInterval(popupInterval); 116 | return callback(errors.PayWindowClosedByUser( 117 | 'polling detected a closed window')); 118 | } 119 | }, 500); 120 | 121 | function receivePaymentMessage(event) { 122 | 123 | function messageCallback(err) { 124 | if (err instanceof errors.UnknownMessageOrigin) { 125 | // These could come from anywhere so ignore them. 126 | return; 127 | } 128 | 129 | // We know if we're getting messages from our UI 130 | // at this point so we can do away with the 131 | // interval watching for the popup closing 132 | // whilst on 3rd party domains. 133 | if (popupInterval) { 134 | clearInterval(popupInterval); 135 | } 136 | 137 | settings.window.removeEventListener('message', 138 | receivePaymentMessage); 139 | if (managePaymentWindow) { 140 | paymentWindow.close(); 141 | } else { 142 | settings.log.info('payment window should be closed but client ' + 143 | 'is managing it'); 144 | } 145 | if (err) { 146 | return callback(err); 147 | } 148 | callback(); 149 | } 150 | 151 | exports.acceptPayMessage(event, utils.getUrlOrigin(payUrl), 152 | paymentWindow, messageCallback); 153 | } 154 | 155 | settings.window.addEventListener('message', receivePaymentMessage); 156 | 157 | }); 158 | } 159 | }); 160 | -------------------------------------------------------------------------------- /lib/fxpay/products.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'exports', 3 | 'api', 4 | 'errors', 5 | 'promise', 6 | 'receipts', 7 | 'settings', 8 | 'utils' 9 | ], function(exports, apiModule, errors, promise, receipts, settings, utils) { 10 | 11 | 'use strict'; 12 | 13 | var Promise = promise.Promise; 14 | 15 | exports.get = function(productId, opt) { 16 | return buildProductReceiptMap() 17 | .then(function() { 18 | return new Promise(function(resolve, reject) { 19 | exports.getById(productId, function(error, product) { 20 | if (error) { 21 | settings.log.error( 22 | 'no existing product with productId=' + productId + 23 | '; error: ' + error); 24 | return reject(error); 25 | } 26 | 27 | resolve(product); 28 | }, opt); 29 | }); 30 | }); 31 | }; 32 | 33 | 34 | exports.all = function(callback) { 35 | settings.initialize(); 36 | var promise = buildProductReceiptMap() 37 | .then(function() { 38 | return new Promise(function(resolve, reject) { 39 | var allProducts = []; 40 | var origin; 41 | var api = new apiModule.API(settings.apiUrlBase); 42 | var url; 43 | 44 | if (settings.fakeProducts) { 45 | settings.log.warn('about to fetch fake products'); 46 | url = '/payments/stub-in-app-products/'; 47 | } else { 48 | origin = encodeURIComponent(utils.getSelfOrigin()); 49 | settings.log.info('about to fetch real products for app', 50 | origin); 51 | url = '/payments/' + origin + '/in-app/?active=1'; 52 | } 53 | 54 | api.get(url, function(err, result) { 55 | if (err) { 56 | return reject(err); 57 | } 58 | if (!result || !result.objects) { 59 | settings.log.debug('unexpected API response', result); 60 | return reject(errors.BadApiResponse( 61 | 'received empty API response')); 62 | } 63 | settings.log.info('total products fetched:', result.objects.length); 64 | for (var i=0; i < result.objects.length; i++) { 65 | var ob = result.objects[i]; 66 | var productInfo = createProductFromApi(ob); 67 | allProducts.push(productInfo); 68 | } 69 | resolve(allProducts); 70 | }); 71 | }); 72 | }); 73 | 74 | if (callback) { 75 | utils.logDeprecation( 76 | 'getProducts(callback) is no longer supported; use the returned ' + 77 | 'promise instead', '0.0.15'); 78 | promise.then(function(products) { 79 | callback(null, products); 80 | }).catch(function(error) { 81 | callback(error, []); 82 | }); 83 | } 84 | 85 | return promise; 86 | }; 87 | 88 | 89 | exports.getById = function(productId, onFetch, opt) { 90 | opt = opt || {}; 91 | if (typeof opt.fetchStubs === 'undefined') { 92 | opt.fetchStubs = false; 93 | } 94 | if (!opt.api) { 95 | opt.api = new apiModule.API(settings.apiUrlBase); 96 | } 97 | var origin = encodeURIComponent(utils.getSelfOrigin()); 98 | var url; 99 | 100 | if (opt.fetchStubs) { 101 | url = '/payments/stub-in-app-products/' + productId.toString() + '/'; 102 | } else { 103 | url = '/payments/' + origin + '/in-app/' + productId.toString() + '/'; 104 | } 105 | settings.log.info( 106 | 'fetching product info at URL', url, 'fetching stubs?', opt.fetchStubs); 107 | 108 | opt.api.get(url, function(err, productData) { 109 | if (err) { 110 | settings.log.error('Error fetching product info', err.toString()); 111 | return onFetch(err, {productId: productId}); 112 | } 113 | onFetch(null, createProductFromApi(productData)); 114 | }); 115 | }; 116 | 117 | 118 | function Product(params) { 119 | params = params || {}; 120 | this.pricePointId = params.pricePointId; 121 | this.productId = params.productId; 122 | this.name = params.name; 123 | this.smallImageUrl = params.smallImageUrl; 124 | this.receiptInfo = params.receiptInfo || {}; 125 | } 126 | 127 | exports.Product = Product; 128 | 129 | Product.prototype.getReceiptMap = function() { 130 | if (!settings.productReceiptMap) { 131 | // Sadly, building a receipt map must be done asynchronously so 132 | // we need to rely on a higher level function to set it up. 133 | throw errors.IncorrectUsage( 134 | 'cannot proceed with this method; receipt map is empty'); 135 | } 136 | return settings.productReceiptMap; 137 | }; 138 | 139 | Product.prototype.hasReceipt = function() { 140 | return typeof this.getReceiptMap()[this.productId] !== 'undefined'; 141 | }; 142 | 143 | Product.prototype.validateReceipt = function() { 144 | var receiptMap = this.getReceiptMap(); 145 | var product = this; 146 | 147 | return new Promise(function(resolve, reject) { 148 | 149 | var receipt = receiptMap[product.productId]; 150 | if (!receipt) { 151 | return reject(errors.InvalidReceipt( 152 | 'could not find installed receipt for productId=' + 153 | product.productId)); 154 | } 155 | 156 | receipts.validateInAppProductReceipt(receipt, product, 157 | function(error, product) { 158 | if (error) { 159 | settings.log.error('receipt validation error: ' + error); 160 | error.productInfo = product; 161 | return reject(error); 162 | } else { 163 | return resolve(product); 164 | } 165 | }); 166 | 167 | }); 168 | }; 169 | 170 | 171 | // 172 | // private functions: 173 | // 174 | 175 | 176 | function buildProductReceiptMap() { 177 | return new Promise(function(resolve, reject) { 178 | if (settings.productReceiptMap) { 179 | return resolve(settings.productReceiptMap); 180 | } 181 | 182 | settings.log.debug('building a product->receipt map'); 183 | 184 | receipts.all(function(error, allReceipts) { 185 | if (error) { 186 | return reject(error); 187 | } 188 | 189 | settings.productReceiptMap = {}; 190 | 191 | allReceipts.forEach(function(receipt) { 192 | var storedata = receipts.checkStoreData(receipt); 193 | if (!storedata) { 194 | settings.log.debug( 195 | 'ignoring receipt with missing or unparsable storedata'); 196 | return; 197 | } 198 | if (!storedata.inapp_id) { 199 | return settings.log.debug('ignoring receipt without inapp_id'); 200 | } 201 | settings.log.debug('found receipt with inapp_id=', 202 | storedata.inapp_id); 203 | settings.productReceiptMap[storedata.inapp_id] = receipt; 204 | }); 205 | 206 | resolve(settings.productReceiptMap); 207 | }); 208 | }); 209 | } 210 | 211 | function createProductFromApi(ob) { 212 | return new Product({ 213 | pricePointId: ob.price_id, 214 | productId: ob.guid, 215 | name: ob.name, 216 | smallImageUrl: ob.logo_url, 217 | }); 218 | } 219 | 220 | }); 221 | -------------------------------------------------------------------------------- /lib/fxpay/settings.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'exports', 3 | 'adapter', 4 | 'errors' 5 | ], function(exports, adapter, errors) { 6 | 7 | 'use strict'; 8 | var pkgInfo = {"version": "0.0.18"}; // this is updated by `grunt bump` 9 | 10 | var defaultSettings = { 11 | 12 | // Public settings. 13 | // 14 | // Reject test receipts which are generated by the Marketplace 15 | // and do not indicate real purchases. 16 | allowTestReceipts: false, 17 | apiUrlBase: 'https://marketplace.firefox.com', 18 | apiVersionPrefix: '/api/v1', 19 | // When truthy, this will override the API object's default. 20 | apiTimeoutMs: null, 21 | // When defined, this optional map will override or 22 | // append values to payProviderUrls. 23 | extraProviderUrls: null, 24 | // When true, work with fake products and test receipts. 25 | // This implies allowTestReceipts=true. 26 | fakeProducts: false, 27 | // This object is used for all logging. 28 | log: window.console || { 29 | // Shim in a minimal set of the console API. 30 | debug: function() {}, 31 | error: function() {}, 32 | info: function() {}, 33 | log: function() {}, 34 | warn: function() {}, 35 | }, 36 | // Only these receipt check services are allowed. 37 | receiptCheckSites: [ 38 | 'https://receiptcheck.marketplace.firefox.com', 39 | 'https://marketplace.firefox.com' 40 | ], 41 | 42 | // Private settings. 43 | // 44 | adapter: null, 45 | // This will be the App object returned from mozApps.getSelf(). 46 | // On platforms that do not implement mozApps it will be false. 47 | appSelf: null, 48 | // True if configuration has been run at least once. 49 | alreadyConfigured: false, 50 | // Map of product IDs to installed receipts. 51 | // These receipts may or may not be valid. 52 | productReceiptMap: null, 53 | // Map of JWT types to payment provider URLs. 54 | payProviderUrls: { 55 | 'mozilla/payments/pay/v1': 56 | 'https://marketplace.firefox.com/mozpay/?req={jwt}' 57 | }, 58 | // Reference window so tests can swap it out with a stub. 59 | window: window, 60 | // Width for payment window as a popup. 61 | winWidth: 276, 62 | // Height for payment window as a popup. 63 | winHeight: 384, 64 | // Relative API URL that accepts a product ID and returns a JWT. 65 | prepareJwtApiUrl: '/webpay/inapp/prepare/', 66 | onerror: function(err) { 67 | throw err; 68 | }, 69 | oninit: function() { 70 | exports.log.info('fxpay version:', exports.libVersion); 71 | exports.log.info('initialization ran successfully'); 72 | }, 73 | onrestore: function(error, info) { 74 | if (error) { 75 | exports.log.error('error while restoring product:', info.productId, 76 | 'message:', error); 77 | } else { 78 | exports.log.info('product', info.productId, 79 | 'was restored from receipt'); 80 | } 81 | }, 82 | localStorage: window.localStorage || null, 83 | localStorageKey: 'fxpayReceipts', 84 | // When true, we're running on a broken webRT. See bug 1133963. 85 | onBrokenWebRT: (navigator.mozPay && 86 | navigator.userAgent.indexOf('Mobile') === -1), 87 | mozPay: navigator.mozPay || null, 88 | mozApps: navigator.mozApps || null, 89 | libVersion: pkgInfo.version, 90 | }; 91 | 92 | exports.configure = function settings_configure(newSettings, opt) { 93 | // 94 | // Configure new settings values. 95 | // 96 | opt = opt || {}; 97 | 98 | // On first run, we always need to reset. 99 | if (!exports.alreadyConfigured) { 100 | opt.reset = true; 101 | } 102 | 103 | // Reset existing configuration. 104 | if (opt.reset) { 105 | for (var def in defaultSettings) { 106 | exports[def] = defaultSettings[def]; 107 | } 108 | } 109 | 110 | // Merge new values into existing configuration. 111 | for (var param in newSettings) { 112 | if (typeof exports[param] === 'undefined') { 113 | return exports.onerror(errors.IncorrectUsage( 114 | 'configure() received an unknown setting: ' + param)); 115 | } 116 | exports[param] = newSettings[param]; 117 | } 118 | 119 | // Set some implied values from other parameters. 120 | if (exports.extraProviderUrls) { 121 | exports.log.info('adding extra pay provider URLs', 122 | exports.extraProviderUrls); 123 | for (var paySpec in exports.extraProviderUrls) { 124 | exports.payProviderUrls[paySpec] = exports.extraProviderUrls[paySpec]; 125 | } 126 | } 127 | if (exports.fakeProducts) { 128 | exports.allowTestReceipts = true; 129 | } 130 | 131 | // Construct our in-app payments adapter. 132 | var DefaultAdapter = adapter.FxInappAdapter; 133 | if (!exports.adapter) { 134 | exports.log.info('creating default adapter'); 135 | exports.adapter = new DefaultAdapter(); 136 | } 137 | 138 | // Configure the new adapter or re-configure an existing adapter. 139 | exports.adapter.configure(exports); 140 | exports.log.info('using adapter:', exports.adapter.toString()); 141 | 142 | exports.log.info('(re)configuration completed; fxpay version:', 143 | exports.libVersion); 144 | exports.alreadyConfigured = true; 145 | 146 | return exports; 147 | }; 148 | 149 | 150 | exports.initialize = function(newSettings) { 151 | // 152 | // A hook to ensure that settings have been initialized. 153 | // Any public fxpay method that a user may call should call 154 | // this at the top. It can be called repeatedly without harm. 155 | // 156 | // When a newSettings object is defined, all settings will be 157 | // reconfigured with those values. 158 | // 159 | if (typeof newSettings === 'object' && newSettings) { 160 | exports.configure(newSettings); 161 | } else if (!exports.alreadyConfigured) { 162 | exports.configure(); 163 | } 164 | }; 165 | 166 | }); 167 | -------------------------------------------------------------------------------- /lib/fxpay/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utils module. 3 | * @module utils 4 | */ 5 | 6 | define([ 7 | 'exports', 8 | 'errors', 9 | 'settings' 10 | ], function(exports, errors, settings) { 11 | 12 | 'use strict'; 13 | 14 | /** 15 | * Populates an object with defaults if the key is not yet defined. 16 | * Similar to _.defaults except this takes only a single defaults object. 17 | * @param {object} object - the object to populate defaults on 18 | * @param {object} defaults - the defaults to use 19 | * @returns {object} 20 | */ 21 | exports.defaults = function(object, defaults) { 22 | object = object || {}; 23 | Object.keys(defaults).forEach(function(key) { 24 | if (typeof object[key] === 'undefined') { 25 | object[key] = defaults[key]; 26 | } 27 | }); 28 | return object; 29 | }; 30 | 31 | /** 32 | * Gets the app origin 33 | * @returns {string} 34 | */ 35 | exports.getSelfOrigin = function(settingsObj) { 36 | settingsObj = settingsObj || settings; 37 | 38 | if (settingsObj.appSelf) { 39 | // Check the manifest-declared origin (not the auto-generated one). 40 | var hasDeclaredOrigin = !!settingsObj.appSelf.manifest.origin; 41 | 42 | if (settingsObj.appSelf.manifest.type === 'web' || !hasDeclaredOrigin) { 43 | // This package does not have a reliable origin so 44 | // here we look for its marketplace hosted package URL 45 | // to derive a marketplace-specific origin. 46 | var pat = new RegExp( 47 | '^https?://(?:marketplace|mp\\.dev).*/app/([^/]+)/manifest\\.webapp$' 48 | ); 49 | var match = pat.exec(settingsObj.appSelf.manifestURL); 50 | if (!match) { 51 | throw new errors.InvalidAppOrigin( 52 | 'Cannot derive marketplace GUID from "' + 53 | settingsObj.appSelf.manifestURL + 54 | '". The package must be installed from the Firefox Marketplace ' + 55 | 'or define an origin. For local testing, use fake products.' 56 | ); 57 | } 58 | // Create an origin out of the marketplace GUID. 59 | var marketplaceOrigin = 'marketplace:' + match[1]; 60 | settingsObj.log.info('derived marketplace origin as', marketplaceOrigin, 61 | 'from URL', settingsObj.appSelf.manifestURL); 62 | return marketplaceOrigin; 63 | } 64 | 65 | // Trust the declared origin from a privileged/certified app. 66 | return settingsObj.appSelf.origin; 67 | 68 | } else { 69 | // Get the origin from a non-app website. 70 | var win = settingsObj.window; 71 | if (win.location.origin) { 72 | return win.location.origin; 73 | } else { 74 | return win.location.protocol + '//' + win.location.hostname; 75 | } 76 | } 77 | }; 78 | 79 | /** 80 | * Gets the the origin of the URL provided. 81 | * @param {string} url - the URL to introspect the origin from 82 | * @returns {string} 83 | */ 84 | exports.getUrlOrigin = function(url) { 85 | var a = document.createElement('a'); 86 | a.href = url; 87 | return a.origin || (a.protocol + '//' + a.host); 88 | }; 89 | 90 | /** 91 | * Gets the center coordinates for a passed width and height. 92 | * Uses centering calcs that work on multiple monitors (bug 1122683). 93 | * @param {number} w - width 94 | * @param {number} h - height 95 | * @returns {list} 96 | */ 97 | exports.getCenteredCoordinates = function(w, h) { 98 | var x = window.screenX + 99 | Math.max(0, Math.floor((window.innerWidth - w) / 2)); 100 | var y = window.screenY + 101 | Math.max(0, Math.floor((window.innerHeight - h) / 2)); 102 | return [x, y]; 103 | }; 104 | 105 | /** 106 | * Re-center an existing window. 107 | * @param {object} winRef - A reference to an existing window 108 | * @param {number} [w] - width 109 | * @param {number} [h] - height 110 | * @returns {undefined} 111 | */ 112 | exports.reCenterWindow = function(winRef, w, h) { 113 | w = w || settings.winWidth; 114 | h = h || settings.winHeight; 115 | var xy = exports.getCenteredCoordinates(w, h); 116 | try { 117 | // Allow for the chrome as resizeTo args are the external 118 | // window dimensions not the internal ones. 119 | w = w + (winRef.outerWidth - winRef.innerWidth); 120 | h = h + (winRef.outerHeight - winRef.innerHeight); 121 | settings.log.log('width: ', w, 'height:', h); 122 | winRef.resizeTo(w, h); 123 | winRef.moveTo(xy[0], xy[1]); 124 | } catch(e) { 125 | settings.log.log("We don't have permission to resize this window"); 126 | } 127 | }; 128 | 129 | /** 130 | * Open a window 131 | * @param {object} [options] - the settings object 132 | * @param {string} [options.url] - the window url 133 | * @param {string} [options.title] - the window title 134 | * @param {number} [options.w] - the window width 135 | * @param {number} [options.h] - the window height 136 | * @returns {object} windowRef - a window reference. 137 | */ 138 | exports.openWindow = function(options) { 139 | var defaults = { 140 | url: '', 141 | title: 'FxPay', 142 | w: settings.winWidth, 143 | h: settings.winHeight, 144 | }; 145 | 146 | options = exports.defaults(options, defaults); 147 | var xy = exports.getCenteredCoordinates(options.w, options.h); 148 | 149 | var winOptString = 'toolbar=no,location=yes,directories=no,' + 150 | 'menubar=no,scrollbars=yes,resizable=no,copyhistory=no,' + 151 | 'width=' + options.w + ',height=' + options.h + 152 | ',top=' + xy[1] + ',left=' + xy[0]; 153 | 154 | var windowRef = settings.window.open(options.url, options.title, 155 | winOptString); 156 | if (!windowRef) { 157 | settings.log.error('window.open() failed. URL:', options.url); 158 | } 159 | return windowRef; 160 | }; 161 | 162 | /** 163 | * Get the App object returned from [`mozApps.getSelf()`](http://goo.gl/x4BDqs) 164 | * @param {module:utils~getAppSelfCallback} callback - the callback function. 165 | * @returns {undefined} 166 | */ 167 | exports.getAppSelf = function getAppSelf(callback) { 168 | function storeAppSelf(appSelf) { 169 | if (appSelf === null) { 170 | throw new Error('cannot store a null appSelf'); 171 | } 172 | settings.appSelf = appSelf; 173 | return appSelf; 174 | } 175 | 176 | if (settings.appSelf !== null) { 177 | // This means getAppSelf() has already run successfully so let's 178 | // return the value immediately. 179 | return callback(null, settings.appSelf); 180 | } 181 | 182 | if (!settings.mozApps) { 183 | settings.log.info( 184 | 'web platform does not define mozApps, cannot get appSelf'); 185 | return callback(null, storeAppSelf(false)); 186 | } 187 | var appRequest = settings.mozApps.getSelf(); 188 | 189 | appRequest.onsuccess = function() { 190 | var appSelf = this.result; 191 | // In the case where we're in a Firefox that supports mozApps but 192 | // we're not running as an app, this could be falsey. 193 | settings.log.info('got appSelf from mozApps.getSelf():', appSelf); 194 | callback(null, storeAppSelf(appSelf || false)); 195 | }; 196 | 197 | appRequest.onerror = function() { 198 | var errCode = this.error.name; 199 | settings.log.error('mozApps.getSelf() returned an error', errCode); 200 | // We're not caching an appSelf result here. 201 | // This allows nested functions to report errors better. 202 | callback(errors.InvalidApp('invalid application: ' + errCode, 203 | {code: errCode}), settings.appSelf); 204 | }; 205 | }; 206 | 207 | /** 208 | * The callback called by {@link module:utils.getAppSelf } 209 | * @callback module:utils~getAppSelfCallback 210 | * @param {object} error - an error object. Will be null if no error. 211 | * @param {object} appSelf - the [appSelf object](http://goo.gl/HilsmA) 212 | */ 213 | 214 | 215 | /** 216 | * Log a deprecation message with some extra info. 217 | * @param {string} msg - log message 218 | * @param {string} versionDeprecated - the version when deprecated 219 | * @returns {undefined} 220 | */ 221 | exports.logDeprecation = function(msg, versionDeprecated) { 222 | settings.log.warn( 223 | msg + '. This was deprecated in ' + versionDeprecated + '. ' + 224 | 'More info: https://github.com/mozilla/fxpay/releases/tag/' + 225 | versionDeprecated); 226 | }; 227 | 228 | /** 229 | * Take an object of key value pairs and serialize it into a url-encoded 230 | * query string. 231 | * @example 232 | * // returns foo=bar&baz=zup 233 | * utils.serialize({"foo": "bar", "baz": "zup"}); 234 | * @param {object} obj - object to serialize 235 | * @returns {string} 236 | */ 237 | exports.serialize = function serialize(obj) { 238 | var str = []; 239 | for (var p in obj){ 240 | if (obj.hasOwnProperty(p)) { 241 | str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p])); 242 | } 243 | } 244 | return str.join("&"); 245 | }; 246 | 247 | }); 248 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fxpay", 3 | "description": "Adds Firefox Marketplace payments to a web application", 4 | "version": "0.0.18", 5 | "homepage": "https://github.com/mozilla/fxpay", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/mozilla/fxpay.git" 9 | }, 10 | "dependencies": {}, 11 | "devDependencies": { 12 | "almond": "^0.3.1", 13 | "async": "0.9.0", 14 | "chai": "1.9.1", 15 | "grunt": "^0.4.5", 16 | "grunt-banner": "^0.3.1", 17 | "grunt-bower-task": "0.4.0", 18 | "grunt-bump": "0.0.15", 19 | "grunt-cli": "0.1.13", 20 | "grunt-contrib-clean": "^0.6.0", 21 | "grunt-contrib-copy": "^0.8.0", 22 | "grunt-contrib-jshint": "0.6.5", 23 | "grunt-contrib-requirejs": "^0.4.4", 24 | "grunt-contrib-uglify": "0.6.0", 25 | "grunt-gh-pages": "^0.10.0", 26 | "grunt-jsdoc": "^0.6.3", 27 | "grunt-karma": "0.10.1", 28 | "grunt-zip": "^0.16.2", 29 | "jsdoc-simple-template": "0.0.1", 30 | "karma": "0.12.31", 31 | "karma-chai": "0.1.0", 32 | "karma-chrome-launcher": "0.1.4", 33 | "karma-coffee-preprocessor": "0.2.1", 34 | "karma-firefox-launcher": "0.1.3", 35 | "karma-html2js-preprocessor": "0.1.0", 36 | "karma-mocha": "0.1.10", 37 | "karma-mocha-reporter": "1.0.2", 38 | "karma-requirejs": "^0.2.2", 39 | "karma-script-launcher": "0.1.0", 40 | "karma-sinon": "1.0.4", 41 | "mocha": "2.2.1", 42 | "sinon": "1.14.1" 43 | }, 44 | "scripts": { 45 | "test": "node -e \"require('grunt').cli()\" null bower test" 46 | }, 47 | "author": "Kumar McMillan", 48 | "contributors": [], 49 | "license": "MPL-2.0" 50 | } 51 | -------------------------------------------------------------------------------- /stackato.yml: -------------------------------------------------------------------------------- 1 | name: fxpay-hosted 2 | instances: 1 3 | framework: 4 | type: node 5 | runtime: node010 6 | document-root: example/hosted 7 | hooks: 8 | pre-staging: 9 | - cd example/hosted && npm install 10 | ignores: [ 11 | '.git', 12 | 'node_modules', 13 | 'package.json', 14 | 'example/hosted/node_modules' 15 | ] 16 | mem: 128 17 | -------------------------------------------------------------------------------- /tests/helper.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'exports', 3 | 'settings', 4 | 'utils' 5 | ], function(exports, settings, utils) { 6 | 7 | exports.server = null; 8 | exports.someAppOrigin = 'app://my-app'; 9 | // Product info as returned from a GET request to the API. 10 | exports.apiProduct = {guid: 'server-guid', name: "Name from API", 11 | logo_url: "img.jpg"}; 12 | 13 | exports.setUp = function setUp() { 14 | exports.server = sinon.fakeServer.create(); 15 | settings.configure({ 16 | apiUrlBase: 'http://tests-should-never-hit-this.com', 17 | mozApps: exports.mozAppsStub, 18 | productReceiptMap: null, 19 | }, { 20 | reset: true 21 | }); 22 | window.localStorage.clear(); 23 | exports.appSelf.init(); 24 | }; 25 | 26 | 27 | exports.tearDown = function tearDown() { 28 | exports.server.restore(); 29 | }; 30 | 31 | 32 | exports.makeReceipt = function makeReceipt(data, opt) { 33 | // Generate a pseudo web application receipt. 34 | // https://wiki.mozilla.org/Apps/WebApplicationReceipt 35 | data = data || {}; 36 | opt = opt || {}; 37 | 38 | // Fill in some defaults: 39 | data.typ = data.typ || 'purchase-receipt'; 40 | data.iss = data.iss || 'https://payments-alt.allizom.org'; 41 | data.verify = (data.verify || 42 | 'https://fake-receipt-check-server.net/verify/'); 43 | data.product = data.product || { 44 | url: opt.productUrl || exports.someAppOrigin, 45 | storedata: opt.storedata || 'contrib=297&id=500419&inapp_id=1' 46 | }; 47 | data.user = data.user || { 48 | type: 'directed-identifier', 49 | value: 'anonymous-user' 50 | }; 51 | 52 | // Make up some fake timestamps. 53 | data.iat = data.iat || 1402935236; 54 | data.exp = data.exp || 1418660036; 55 | data.nbf = data.nbf || 1402935236; 56 | 57 | // Make a URL safe base 64 encoded receipt: 58 | var receipt = (btoa(JSON.stringify(data)).replace(/\+/g, '-') 59 | .replace(/\//g, '_').replace(/\=+$/, '')); 60 | 61 | // jwtKey and jwtSig are stubbed out here because they 62 | // are not used by the library. 63 | return 'jwtKey.' + (opt.receipt || receipt) + '.jwtSig'; 64 | }; 65 | 66 | 67 | exports.resolvePurchase = function(opt) { 68 | opt = utils.defaults(opt, { 69 | receipt: '~', 70 | mozPay: null, 71 | productData: null, 72 | payCompleter: null, 73 | fetchProductsPattern: new RegExp('.*/payments/.*/in-app/.*'), 74 | enableTransPolling: false, 75 | mozPayResolver: function(domRequest) { 76 | domRequest.onsuccess(); 77 | }, 78 | addReceiptResolver: function(domRequest) { 79 | domRequest.onsuccess(); 80 | }, 81 | }); 82 | 83 | if (!opt.transData) { 84 | opt.transData = exports.transactionData({receipt: opt.receipt}); 85 | } 86 | 87 | // Respond to fetching the JWT. 88 | exports.server.respondWith( 89 | 'POST', 90 | settings.apiUrlBase + settings.apiVersionPrefix 91 | + '/webpay/inapp/prepare/', 92 | exports.productData(opt.productData)); 93 | 94 | exports.server.respond(); 95 | 96 | if (opt.mozPay) { 97 | console.log('Simulate a payment completion with mozPay'); 98 | // Resolve DOMRequest for navigator.mozPay(). 99 | opt.mozPayResolver(opt.mozPay.returnValues[0]); 100 | } else if (opt.payCompleter) { 101 | console.log('Simulate a payment completion with custom function'); 102 | opt.payCompleter(); 103 | } 104 | 105 | // Respond to checking the transaction state. 106 | exports.server.autoRespond = !!opt.enableTransPolling; 107 | exports.server.respondWith('GET', settings.apiUrlBase + '/transaction/XYZ', 108 | opt.transData); 109 | exports.server.respond(); 110 | 111 | // Resolve DOMRequest for mozApps.getSelf().addReceipt(). 112 | opt.addReceiptResolver(exports.receiptAdd); 113 | 114 | // Respond to fetching the product object after successful transaction. 115 | exports.server.respondWith('GET', opt.fetchProductsPattern, 116 | [200, {"Content-Type": "application/json"}, 117 | JSON.stringify(exports.apiProduct)]); 118 | exports.server.respond(); 119 | }; 120 | 121 | 122 | exports.productData = function(overrides, status) { 123 | // Create a JSON exports.server response to a request for product data. 124 | overrides = overrides || {}; 125 | var data = { 126 | webpayJWT: '', 127 | contribStatusURL: '/transaction/XYZ', 128 | }; 129 | for (var k in data) { 130 | if (overrides[k]) { 131 | data[k] = overrides[k]; 132 | } 133 | } 134 | return [status || 200, {"Content-Type": "application/json"}, 135 | JSON.stringify(data)]; 136 | }; 137 | 138 | 139 | exports.transactionData = function(overrides, status) { 140 | // Create a JSON exports.server response to a request for transaction data. 141 | overrides = overrides || {}; 142 | var data = { 143 | status: 'complete', 144 | // Pretend this is a real Marketplace receipt. 145 | receipt: '~' 146 | }; 147 | for (var k in data) { 148 | if (overrides[k]) { 149 | data[k] = overrides[k]; 150 | } 151 | } 152 | return [status || 200, {"Content-Type": "application/json"}, 153 | JSON.stringify(data)]; 154 | }; 155 | 156 | 157 | exports.receiptAdd = { 158 | error: null, 159 | _receipt: null, 160 | onsuccess: function() {}, 161 | onerror: function() {}, 162 | reset: function() { 163 | this._receipt = null; 164 | this.error = null; 165 | this.onsuccess = function() {}; 166 | this.onerror = function() {}; 167 | } 168 | }; 169 | 170 | 171 | exports.appSelf = { 172 | init: function() { 173 | this.error = null; 174 | this.origin = exports.someAppOrigin; 175 | this.manifest = { 176 | installs_allowed_from: ['*'], 177 | origin: this.origin, 178 | type: 'privileged', 179 | permissions: { 180 | systemXHR: {description: "Required to access payment API"} 181 | }, 182 | }; 183 | this.receipts = []; 184 | // This is the result of getSelf(). Setting it to this makes 185 | // stubbing easier. 186 | this.result = this; 187 | 188 | this.addReceipt = function(receipt) { 189 | exports.receiptAdd._receipt = receipt; 190 | return exports.receiptAdd; 191 | }; 192 | this.onsuccess = function() {}; 193 | this.onerror = function() {}; 194 | }, 195 | }; 196 | 197 | 198 | // https://developer.mozilla.org/en-US/docs/Web/API/Apps.getSelf 199 | exports.mozAppsStub = { 200 | getSelf: function() { 201 | return exports.appSelf; 202 | } 203 | }; 204 | 205 | 206 | exports.ReceiptValidator = function ReceiptValidator(opt) { 207 | opt = utils.defaults(opt, { 208 | response: {status: 'ok'}, 209 | onValidationResponse: undefined, 210 | onRequest: function() {}, 211 | verifyUrl: 'https://fake-receipt-check-server.net/verify/', 212 | }); 213 | 214 | if (!opt.onValidationResponse) { 215 | opt.onValidationResponse = function(request) { 216 | opt.onRequest(request.requestBody); 217 | request.respond(200, {"Content-Type": "application/json"}, 218 | JSON.stringify(opt.response)); 219 | }; 220 | } 221 | 222 | exports.server.respondWith('POST', opt.verifyUrl, opt.onValidationResponse); 223 | }; 224 | 225 | exports.ReceiptValidator.prototype.finish = function() { 226 | // Send the receipt validation response: 227 | exports.server.respond(); 228 | }; 229 | 230 | exports.mozPayStub = function mozPayStub() { 231 | // https://developer.mozilla.org/en-US/docs/Web/API/Navigator.mozPay 232 | return { 233 | onsuccess: function() {}, 234 | onerror: function() {}, 235 | }; 236 | }; 237 | 238 | }); 239 | -------------------------------------------------------------------------------- /tests/test-api.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'api', 3 | 'fxpay', 4 | 'helper', 5 | 'errors', 6 | 'settings' 7 | ], function(apiModule, fxpay, helper, errors, settings) { 8 | 9 | describe('fxpay.API()', function () { 10 | var api; 11 | var baseUrl = 'https://not-a-real-api'; 12 | var versionPrefix = '/api/v1'; 13 | 14 | beforeEach(function() { 15 | helper.setUp(); 16 | fxpay.configure({apiVersionPrefix: versionPrefix}); 17 | api = new apiModule.API(baseUrl); 18 | }); 19 | 20 | afterEach(function() { 21 | helper.tearDown(); 22 | }); 23 | 24 | it('should handle POSTs', function (done) { 25 | helper.server.respondWith( 26 | 'POST', /.*\/post/, 27 | function(request) { 28 | assert.equal(request.requestHeaders.Accept, 'application/json'); 29 | assert.equal(request.requestHeaders['Content-Type'], 30 | 'application/x-www-form-urlencoded;charset=utf-8'); 31 | assert.equal(request.requestBody, 'foo=bar&baz=zop'); 32 | request.respond(200, {"Content-Type": "application/json"}, 33 | '{"data": "received"}'); 34 | }); 35 | 36 | api.post('/post', {foo: 'bar', 'baz': 'zop'}, function(err, data) { 37 | assert.equal(data.data, 'received'); 38 | done(err); 39 | }); 40 | 41 | helper.server.respond(); 42 | }); 43 | 44 | it('should handle GETs', function (done) { 45 | helper.server.respondWith( 46 | 'GET', /.*\/get/, 47 | function(request) { 48 | assert.equal(request.requestHeaders.Accept, 'application/json'); 49 | assert.equal(request.requestHeaders['Content-Type'], undefined); 50 | request.respond(200, {"Content-Type": "application/json"}, 51 | '{"data": "received"}'); 52 | }); 53 | 54 | api.get('/get', function(err, data) { 55 | assert.equal(data.data, 'received'); 56 | done(err); 57 | }); 58 | 59 | helper.server.respond(); 60 | }); 61 | 62 | it('should handle PUTs', function (done) { 63 | helper.server.respondWith( 64 | 'PUT', /.*\/put/, 65 | function(request) { 66 | assert.equal(request.requestHeaders.Accept, 'application/json'); 67 | assert.equal(request.requestHeaders['Content-Type'], 68 | 'application/x-www-form-urlencoded;charset=utf-8'); 69 | request.respond(200, {"Content-Type": "application/json"}, 70 | '{"data": "received"}'); 71 | }); 72 | 73 | api.put('/put', {foo: 'bar'}, function(err, data) { 74 | assert.equal(data.data, 'received'); 75 | done(err); 76 | }); 77 | 78 | helper.server.respond(); 79 | }); 80 | 81 | it('should handle DELETEs', function (done) { 82 | helper.server.respondWith( 83 | 'DELETE', /.*\/delete/, 84 | [200, {"Content-Type": "application/json"}, 85 | '{"data": "received"}']); 86 | 87 | api.del('/delete', function(err, data) { 88 | assert.equal(data.data, 'received'); 89 | done(err); 90 | }); 91 | 92 | helper.server.respond(); 93 | }); 94 | 95 | it('should send the library version with each request', function (done) { 96 | helper.server.respondWith( 97 | 'GET', /.*/, 98 | function(request) { 99 | assert.ok(settings.libVersion); // make sure it's defined. 100 | assert.equal(request.requestHeaders['x-fxpay-version'], 101 | settings.libVersion); 102 | request.respond(200, {"Content-Type": "application/json"}, '{}'); 103 | }); 104 | 105 | api.get('/get', function(err) { 106 | done(err); 107 | }); 108 | 109 | helper.server.respond(); 110 | }); 111 | 112 | it('should allow custom content-type POSTs', function (done) { 113 | helper.server.respondWith( 114 | 'POST', /.*\/post/, 115 | function(request) { 116 | assert.equal(request.requestHeaders['Content-Type'], 117 | 'text/plain;charset=utf-8'); 118 | assert.equal(request.requestBody, 'custom-data'); 119 | request.respond(200, {"Content-Type": "application/json"}, 120 | '{"data": "received"}'); 121 | }); 122 | 123 | api.post('/post', 'custom-data', function(err) { 124 | done(err); 125 | }, {contentType: 'text/plain'}); 126 | 127 | helper.server.respond(); 128 | }); 129 | 130 | it('should send custom headers', function (done) { 131 | helper.server.respondWith( 132 | 'GET', /.*\/get/, 133 | function(request) { 134 | assert.equal(request.requestHeaders.Foobar, 'bazba'); 135 | assert.equal(request.requestHeaders.Zoopa, 'wonza'); 136 | request.respond(200, {"Content-Type": "application/json"}, 137 | '{"data": "received"}'); 138 | }); 139 | 140 | api.get('/get', function(err) { 141 | done(err); 142 | }, {headers: {Foobar: 'bazba', Zoopa: 'wonza'}}); 143 | 144 | helper.server.respond(); 145 | }); 146 | 147 | it('should report XHR abort', function (done) { 148 | helper.server.respondWith(function(xhr) { 149 | // We use a custom event because xhr.abort() triggers load first 150 | // https://github.com/cjohansen/Sinon.JS/issues/432 151 | dispatchXhrEvent(xhr, 'abort'); 152 | }); 153 | 154 | api.post('/some/path', null, function(err) { 155 | assert.instanceOf(err, errors.ApiRequestAborted); 156 | done(); 157 | }); 158 | 159 | helper.server.respond(); 160 | }); 161 | 162 | it('should report XHR errors', function (done) { 163 | helper.server.respondWith(function(xhr) { 164 | dispatchXhrEvent(xhr, 'error'); 165 | }); 166 | 167 | api.post('/some/path', null, function(err) { 168 | assert.instanceOf(err, errors.ApiRequestError); 169 | done(); 170 | }); 171 | 172 | helper.server.respond(); 173 | }); 174 | 175 | it('should report non-200 responses', function (done) { 176 | helper.server.respondWith( 177 | 'POST', /.*\/some\/path/, 178 | [500, {"Content-Type": "application/json"}, 179 | JSON.stringify({webpayJWT: '', 180 | contribStatusURL: '/somewhere'})]); 181 | 182 | api.post('/some/path', null, function(err) { 183 | assert.instanceOf(err, errors.BadApiResponse); 184 | done(); 185 | }); 186 | 187 | helper.server.respond(); 188 | }); 189 | 190 | it('should report unparsable JSON', function (done) { 191 | helper.server.respondWith( 192 | 'POST', /.*\/some\/path/, 193 | /* jshint -W044 */ 194 | [200, {"Content-Type": "application/json"}, 195 | "{this\is not; valid JSON'"]); 196 | /* jshint +W044 */ 197 | 198 | api.post('/some/path', null, function(err) { 199 | assert.instanceOf(err, errors.BadJsonResponse); 200 | done(); 201 | }); 202 | 203 | helper.server.respond(); 204 | }); 205 | 206 | it('should parse and return JSON', function (done) { 207 | helper.server.respondWith( 208 | 'POST', /.*\/some\/path/, 209 | [200, {"Content-Type": "application/json"}, 210 | '{"is_json": true}']); 211 | 212 | api.post('/some/path', null, function(err, data) { 213 | assert.equal(data.is_json, true); 214 | done(err); 215 | }); 216 | 217 | helper.server.respond(); 218 | }); 219 | 220 | it('should request a full URL based on a path', function (done) { 221 | helper.server.respondWith( 222 | 'POST', new RegExp(baseUrl + versionPrefix + '/path/check'), 223 | [200, {"Content-Type": "application/json"}, 224 | '{"foo":"bar"}']); 225 | 226 | api.post('/path/check', null, function(err) { 227 | // If this is not a 404 then we're good. 228 | done(err); 229 | }); 230 | 231 | helper.server.respond(); 232 | }); 233 | 234 | it('should request an absolute https URL when specified', function (done) { 235 | var absUrl = 'https://secure-site.com/some/page'; 236 | 237 | helper.server.respondWith('POST', absUrl, 238 | [200, {"Content-Type": "application/json"}, 239 | '{"foo":"bar"}']); 240 | 241 | api.post(absUrl, null, function(err) { 242 | // If this is not a 404 then we're good. 243 | done(err); 244 | }); 245 | 246 | helper.server.respond(); 247 | }); 248 | 249 | it('should request an absolute http URL when specified', function (done) { 250 | var absUrl = 'http://insecure-site.com/some/page'; 251 | 252 | helper.server.respondWith('POST', absUrl, 253 | [200, {"Content-Type": "application/json"}, 254 | '{"foo":"bar"}']); 255 | 256 | api.post(absUrl, null, function(err) { 257 | // If this is not a 404 then we're good. 258 | done(err); 259 | }); 260 | 261 | helper.server.respond(); 262 | }); 263 | 264 | it('should timeout', function (done) { 265 | helper.server.respondWith(function(xhr) { 266 | // We simulate a timeout event here because Sinon 267 | // doesn't seem to support the XHR.timeout property. 268 | // https://github.com/cjohansen/Sinon.JS/issues/431 269 | dispatchXhrEvent(xhr, 'timeout'); 270 | }); 271 | 272 | api.post('/timeout', null, function(err) { 273 | assert.instanceOf(err, errors.ApiRequestTimeout); 274 | done(); 275 | }); 276 | 277 | helper.server.respond(); 278 | }); 279 | 280 | it('should allow you to get unversioned URLs', function (done) { 281 | assert.equal(api.url('/not/versioned', {versioned: false}), 282 | baseUrl + '/not/versioned'); 283 | done(); 284 | }); 285 | 286 | it('should allow you to get versioned URLs', function (done) { 287 | assert.equal(api.url('/this/is/versioned'), 288 | baseUrl + versionPrefix + '/this/is/versioned'); 289 | done(); 290 | }); 291 | 292 | 293 | function dispatchXhrEvent(xhr, eventName, bubbles, cancelable) { 294 | xhr.dispatchEvent(new sinon.Event(eventName, bubbles, cancelable, xhr)); 295 | // Prevent future listening, like, in future tests. 296 | // Maybe this is fixed now? 297 | // See https://github.com/cjohansen/Sinon.JS/issues/430 298 | xhr.eventListeners = {}; 299 | } 300 | 301 | }); 302 | 303 | }); 304 | -------------------------------------------------------------------------------- /tests/test-errors.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors' 3 | ], function(errors) { 4 | 5 | describe('fxpay.errors.createError()', function() { 6 | 7 | var FxPayError = errors.FxPayError; 8 | var createError = errors.createError; 9 | 10 | var CustomError = createError('MyError'); 11 | var customError = CustomError(); 12 | 13 | it('should create instances of Error', function() { 14 | assert.instanceOf(customError, Error); 15 | }); 16 | 17 | it('should create instances of FxPayError', function() { 18 | assert.instanceOf(customError, FxPayError); 19 | }); 20 | 21 | it('should set a custom name', function() { 22 | assert.equal(customError.name, 'MyError'); 23 | }); 24 | 25 | it('should set an empty productInfo object', function() { 26 | assert.typeOf(customError.productInfo, 'object'); 27 | }); 28 | 29 | it('should accept a productInfo object', function() { 30 | var info = {someProperty: 'foo'}; 31 | assert.equal(CustomError('message', {productInfo: info}).productInfo, 32 | info); 33 | }); 34 | 35 | it('should stringify the class name', function() { 36 | assert.equal(CustomError().toString(), 'MyError'); 37 | }); 38 | 39 | it('should stringify with message', function() { 40 | assert.equal(CustomError('message').toString(), 41 | 'MyError: message'); 42 | }); 43 | 44 | it('should stringify with message and code', function() { 45 | assert.equal(CustomError('message', {code: 'A_CODE'}).toString(), 46 | 'MyError: message {code: A_CODE}'); 47 | }); 48 | 49 | it('should set a message', function() { 50 | assert.equal(CustomError('some message').message, 51 | 'some message'); 52 | }); 53 | 54 | it('should set a custom code', function() { 55 | assert.equal(CustomError('message', {code: 'CUSTOM_CODE'}).code, 56 | 'CUSTOM_CODE'); 57 | }); 58 | 59 | it('should set a default code', function() { 60 | var ErrorWithCode = createError('ErrorWithCode', {code: 'MY_CODE'}); 61 | assert.equal(ErrorWithCode().code, 'MY_CODE'); 62 | }); 63 | 64 | it('should define a stack', function() { 65 | assert.ok(customError.stack); 66 | }); 67 | 68 | it('should allow custom parents', function() { 69 | var SubError = createError('NewError', {inherits: CustomError}); 70 | var subError = SubError(); 71 | assert.instanceOf(subError, CustomError); 72 | assert.instanceOf(subError, FxPayError); 73 | assert.instanceOf(subError, Error); 74 | }); 75 | 76 | it('should create PaymentFailed errors', function() { 77 | assert.instanceOf(errors.AppReceiptMissing(), errors.PaymentFailed); 78 | assert.instanceOf(errors.InvalidReceipt(), errors.PaymentFailed); 79 | assert.instanceOf(errors.PurchaseTimeout(), errors.PaymentFailed); 80 | assert.instanceOf(errors.TestReceiptNotAllowed(), errors.PaymentFailed); 81 | }); 82 | 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /tests/test-fxpay.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'receipts', 6 | 'settings', 7 | ], function(errorsModule, fxpay, helper, receipts, settingsModule) { 8 | 9 | describe('fxpay', function() { 10 | 11 | beforeEach(function() { 12 | helper.setUp(); 13 | }); 14 | 15 | afterEach(function() { 16 | helper.tearDown(); 17 | }); 18 | 19 | it('should expose fxpay.errors', function() { 20 | assert.equal(fxpay.errors.FxPayError, errorsModule.FxPayError); 21 | }); 22 | 23 | it('should expose fxpay.settings', function() { 24 | assert.equal(fxpay.settings.fakeProducts, settingsModule.fakeProducts); 25 | }); 26 | 27 | it('should expose fxpay.receipts', function() { 28 | assert.equal(fxpay.receipts.validateInAppProductReceipt, 29 | receipts.validateInAppProductReceipt); 30 | }); 31 | 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/test-get-products.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'settings', 6 | 'utils' 7 | ], function(errors, fxpay, helper, settings, utils) { 8 | 9 | describe('fxpay.getProducts()', function() { 10 | 11 | beforeEach(function() { 12 | helper.setUp(); 13 | fxpay.configure({ 14 | appSelf: helper.appSelf, 15 | }); 16 | }); 17 | 18 | afterEach(function() { 19 | helper.tearDown(); 20 | }); 21 | 22 | it('resolves promise with product info', function(done) { 23 | 24 | var serverObjects = setUpApiResponses(); 25 | 26 | fxpay.getProducts() 27 | .then(function(products) { 28 | assert.equal(products[0].name, serverObjects[0].name); 29 | assert.equal(products[0].productId, serverObjects[0].guid); 30 | assert.equal(products[0].smallImageUrl, serverObjects[0].logo_url); 31 | assert.equal(products[0].pricePointId, serverObjects[0].price_id); 32 | assert.equal(products[1].name, serverObjects[1].name); 33 | assert.equal(products[1].productId, serverObjects[1].guid); 34 | assert.equal(products[1].smallImageUrl, serverObjects[1].logo_url); 35 | assert.equal(products[1].pricePointId, serverObjects[1].price_id); 36 | assert.equal(products.length, 2); 37 | done(); 38 | }).catch(done); 39 | 40 | }); 41 | 42 | it('still supports old callback interface', function(done) { 43 | 44 | var serverObjects = setUpApiResponses(); 45 | 46 | fxpay.getProducts(function(error, products) { 47 | assert.equal(products[0].name, serverObjects[0].name); 48 | done(error); 49 | }); 50 | 51 | }); 52 | 53 | it('can retrieve fake products', function(done) { 54 | 55 | fxpay.configure({fakeProducts: true}); 56 | 57 | var serverObjects = setUpApiResponses({ 58 | serverObjects: [ 59 | {"guid": "guid1", "app": "fxpay", "price_id": 1, 60 | "name": "Clown Shoes", "logo_url": "http://site/image1.png"}, 61 | {"guid": "guid2", "app": "fxpay", "price_id": 2, 62 | "name": "Belt and Suspenders", "logo_url": "http://site/image2.png"} 63 | ], 64 | url: settings.apiUrlBase + settings.apiVersionPrefix + 65 | '/payments/stub-in-app-products/', 66 | }); 67 | 68 | fxpay.getProducts().then(function(products) { 69 | assert.equal(products[0].name, serverObjects[0].name); 70 | assert.equal(products[1].name, serverObjects[1].name); 71 | assert.equal(products.length, 2); 72 | done(); 73 | }).catch(done); 74 | 75 | }); 76 | 77 | it('calls back with API errors', function(done) { 78 | 79 | helper.server.respondImmediately = true; 80 | helper.server.respondWith('GET', /.*/, [404, {}, '']); 81 | 82 | fxpay.getProducts().then(function() { 83 | done(Error('unexpected success')); 84 | }).catch(function(err) { 85 | assert.instanceOf(err, errors.BadApiResponse); 86 | done(); 87 | }).catch(done); 88 | 89 | }); 90 | 91 | it('still supports callback interface for errors', function(done) { 92 | 93 | helper.server.respondImmediately = true; 94 | helper.server.respondWith('GET', /.*/, [404, {}, '']); 95 | 96 | fxpay.getProducts(function(error, products) { 97 | assert.instanceOf(error, errors.BadApiResponse); 98 | assert.equal(products.length, 0); 99 | done(); 100 | }); 101 | 102 | }); 103 | 104 | it('requires a JSON response from the server', function(done) { 105 | 106 | setUpApiResponses({serverObjects: null}); 107 | 108 | fxpay.getProducts().then(function() { 109 | done(Error('unexpected success')); 110 | }).catch(function(err) { 111 | assert.instanceOf(err, errors.BadApiResponse); 112 | done(); 113 | }).catch(done); 114 | }); 115 | 116 | 117 | function setUpApiResponses(opt) { 118 | opt = utils.defaults(opt, { 119 | serverObjects: [ 120 | {"guid": "guid3", "app": "fxpay", "price_id": 237, 121 | "name": "Virtual Kiwi", "logo_url": "http://site/image1.png"}, 122 | {"guid": "guid4", "app": "fxpay", "price_id": 238, 123 | "name": "Majestic Cheese", "logo_url": "http://site/image2.png"} 124 | ], 125 | url: settings.apiUrlBase + settings.apiVersionPrefix + 126 | '/payments/' + encodeURIComponent(helper.someAppOrigin) + 127 | '/in-app/?active=1', 128 | }); 129 | 130 | // When working with promises, we need to define responses up front 131 | // and respond as each request comes in. 132 | helper.server.respondImmediately = true; 133 | 134 | helper.server.respondWith( 135 | 'GET', this.url, 136 | [200, {"Content-Type": "application/json"}, 137 | JSON.stringify({ 138 | meta: { 139 | next: null, 140 | previous: null, 141 | total_count: opt.serverObjects ? opt.serverObjects.length: 0, 142 | offset: 0, 143 | limit: 25, 144 | }, 145 | objects: opt.serverObjects, 146 | })]); 147 | 148 | return opt.serverObjects; 149 | } 150 | 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /tests/test-jwt.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'jwt' 5 | ], function(errors, fxpay, jwt) { 6 | describe('jwt.decode()', function() { 7 | 8 | it('should decode JWTs', function(done) { 9 | var fakeJwt = { 10 | aud: 'payments-alt.allizom.org', 11 | request: {simulate: {result: 'postback'}, 12 | pricePoint: '10'} 13 | }; 14 | var encJwt = '.' + btoa(JSON.stringify(fakeJwt)) + '.'; 15 | 16 | jwt.decode(encJwt, function(err, data) { 17 | // Do a quick sanity check that this was decoded. 18 | 19 | assert.deepPropertyVal(data, 'aud', fakeJwt.aud); 20 | assert.deepPropertyVal(data, 'request.simulate.result', 21 | fakeJwt.request.simulate.result); 22 | assert.deepPropertyVal(data, 'request.pricePoint', 23 | fakeJwt.request.pricePoint); 24 | done(err); 25 | }); 26 | }); 27 | 28 | it('should decode URL safe JWTs', function(done) { 29 | // This JWT has `-` and `_` chars which need to be converted. 30 | var encJwt = 31 | "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJhdWQiOiAibW9j" + 32 | "a3BheXByb3ZpZGVyLnBocGZvZ2FwcC5jb20iLCAiaXNzIjogIkVudGVyI" + 33 | "HlvdSBhcHAga2V5IGhlcmUhIiwgInJlcXVlc3QiOiB7Im5hbWUiOiAiUG" + 34 | "llY2Ugb2YgQ2FrZSIsICJwcmljZSI6ICIxMC41MCIsICJwcmljZVRpZXI" + 35 | "iOiAxLCAicHJvZHVjdGRhdGEiOiAidHJhbnNhY3Rpb25faWQ9ODYiLCAi" + 36 | "Y3VycmVuY3lDb2RlIjogIlVTRCIsICJkZXNjcmlwdGlvbiI6ICJWaXJ0d" + 37 | "WFsIGNob2NvbGF0ZSBjYWtlIHRvIGZpbGwgeW91ciB2aXJ0dWFsIHR1bW" + 38 | "15In0sICJleHAiOiAxMzUyMjMyNzkyLCAiaWF0IjogMTM1MjIyOTE5Miw" + 39 | "gInR5cCI6ICJtb2NrL3BheW1lbnRzL2luYXBwL3YxIn0.QZxc62USCy4U" + 40 | "IyKIC1TKelVhNklvk-Ou1l_daKntaFI"; 41 | 42 | jwt.decode(encJwt, function(err, data) { 43 | assert.deepPropertyVal(data, 'request.name', 'Piece of Cake'); 44 | assert.deepPropertyVal( 45 | data, 'request.description', 46 | 'Virtual chocolate cake to fill your virtual tummy'); 47 | done(err); 48 | }); 49 | }); 50 | 51 | it('should error on missing segments', function(done) { 52 | jwt.decode('one.two', function(err) { 53 | assert.instanceOf(err, errors.InvalidJwt); 54 | done(); 55 | }); 56 | }); 57 | 58 | it('should error on invalid binary data', function(done) { 59 | jwt.decode('.this{}IS not*base64 encoded.', 60 | function(err) { 61 | assert.instanceOf(err, errors.InvalidJwt); 62 | done(); 63 | } 64 | ); 65 | }); 66 | 67 | it('should error on non JSON data within JWT', function(done) { 68 | var encJwt = '.' + btoa('(not / valid JSON}') + '.'; 69 | jwt.decode(encJwt, function(err) { 70 | assert.instanceOf(err, errors.InvalidJwt); 71 | done(); 72 | }); 73 | }); 74 | 75 | }); 76 | 77 | 78 | describe('jwt.getPayUrl()', function() { 79 | 80 | it('should pass through parse errors', function(done) { 81 | var encJwt = '.' + btoa('(not / valid JSON}') + '.'; 82 | jwt.getPayUrl(encJwt, function(err) { 83 | assert.instanceOf(err, errors.InvalidJwt); 84 | done(); 85 | }); 86 | }); 87 | 88 | it('should error on unknown JWT types', function(done) { 89 | var payRequest = {typ: 'unknown-type'}; 90 | var encJwt = '.' + btoa(JSON.stringify(payRequest)) + '.'; 91 | jwt.getPayUrl(encJwt, function(err) { 92 | assert.instanceOf(err, errors.InvalidJwt); 93 | done(); 94 | }); 95 | }); 96 | 97 | it('should error on invalid URL templates', function(done) { 98 | fxpay.configure({ 99 | payProviderUrls: { 100 | someType: 'https://pay/start?req={nope}' // missing {req} 101 | } 102 | }); 103 | var payRequest = {typ: 'someType'}; 104 | var encJwt = '.' + btoa(JSON.stringify(payRequest)) + '.'; 105 | jwt.getPayUrl(encJwt, function(err) { 106 | assert.instanceOf(err, errors.ConfigurationError); 107 | done(); 108 | }); 109 | }); 110 | 111 | it('should return a JWT formatted URL', function(done) { 112 | fxpay.configure({ 113 | payProviderUrls: { 114 | someType: 'https://pay/start?req={jwt}' 115 | } 116 | }); 117 | 118 | var payRequest = {typ: 'someType'}; 119 | var encJwt = '.' + btoa(JSON.stringify(payRequest)) + '.'; 120 | 121 | jwt.getPayUrl(encJwt, function(err, payUrl) { 122 | assert.equal(payUrl, 'https://pay/start?req=' + encJwt); 123 | done(err); 124 | }); 125 | }); 126 | 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /tests/test-main.js: -------------------------------------------------------------------------------- 1 | var tests = []; 2 | for (var file in window.__karma__.files) { 3 | if (window.__karma__.files.hasOwnProperty(file)) { 4 | if (/test-.*?\.js$/.test(file) && !/test-main\.js/.test(file)) { 5 | tests.push(file); 6 | } 7 | } 8 | } 9 | 10 | requirejs.config({ 11 | // Karma serves files from '/base' 12 | baseUrl: '/base/lib/fxpay/', 13 | 14 | paths: { 15 | 'helper': '../../tests/helper', 16 | 'promise': '/base/lib/bower_components/es6-promise/promise', 17 | }, 18 | 19 | // ask Require.js to load these files (all our tests) 20 | deps: tests, 21 | 22 | // start test run, once Require.js is done 23 | callback: window.__karma__.start 24 | }); 25 | -------------------------------------------------------------------------------- /tests/test-product-receipt-validation.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'settings', 6 | 'utils' 7 | ], function(errors, fxpay, helper, settings, utils) { 8 | 9 | describe('product.validateReceipt()', function() { 10 | var defaultProductUrl = 'http://boar4485.testmanifest.com'; 11 | var receipt = makeReceipt(); 12 | 13 | beforeEach(function() { 14 | helper.setUp(); 15 | helper.appSelf.origin = defaultProductUrl; 16 | fxpay.configure({ 17 | appSelf: helper.appSelf, 18 | receiptCheckSites: [ 19 | 'https://fake-receipt-check-server.net', 20 | ] 21 | }); 22 | }); 23 | 24 | afterEach(function() { 25 | helper.tearDown(); 26 | window.localStorage.clear(); 27 | }); 28 | 29 | it('succeeds for valid receipts', function(done) { 30 | helper.appSelf.receipts = [receipt]; 31 | 32 | setUpReceiptCheck({ 33 | onValidationRequest: function(requestBody) { 34 | assert.equal(requestBody, receipt); 35 | }, 36 | }); 37 | 38 | fxpay.getProduct(helper.apiProduct.guid).then(function(product) { 39 | assert.equal(product.productId, helper.apiProduct.guid); 40 | assert.equal(product.name, helper.apiProduct.name); 41 | assert.equal(product.smallImageUrl, helper.apiProduct.logo_url); 42 | assert.typeOf(product.receiptInfo, 'object'); 43 | return product.validateReceipt(); 44 | }).then(function(product) { 45 | assert.equal(product.receiptInfo.status, 'ok'); 46 | assert.equal(product.receiptInfo.receipt, receipt); 47 | done(); 48 | }).catch(done); 49 | 50 | }); 51 | 52 | it('finds receipts in local storage', function(done) { 53 | window.localStorage.setItem(settings.localStorageKey, 54 | JSON.stringify([receipt])); 55 | 56 | setUpReceiptCheck(); 57 | 58 | fxpay.getProduct(helper.apiProduct.guid).then(function(product) { 59 | return product.validateReceipt(); 60 | }).then(function(product) { 61 | assert.equal(product.productId, helper.apiProduct.guid); 62 | done(); 63 | }).catch(done); 64 | 65 | }); 66 | 67 | it('fails on validation errors', function(done) { 68 | helper.appSelf.receipts = [receipt]; 69 | var receiptResponse = {status: "invalid", reason: "ERROR_DECODING"}; 70 | 71 | setUpReceiptCheck({ 72 | validatorResponse: receiptResponse, 73 | }); 74 | 75 | fxpay.getProduct(helper.apiProduct.guid).then(function(product) { 76 | return product.validateReceipt(); 77 | }).catch(function(error) { 78 | assert.instanceOf(error, errors.InvalidReceipt); 79 | assert.equal(error.productInfo.productId, helper.apiProduct.guid); 80 | assert.equal(error.productInfo.receiptInfo.status, 81 | receiptResponse.status); 82 | assert.equal(error.productInfo.receiptInfo.reason, 83 | receiptResponse.reason); 84 | done(); 85 | }).catch(done); 86 | 87 | }); 88 | 89 | it('fails on validation service errors', function(done) { 90 | helper.appSelf.receipts = [makeReceipt()]; 91 | 92 | setUpReceiptCheck({ 93 | onValidationResponse: function(request) { 94 | request.respond(500, {}, 'Internal Error'); 95 | }, 96 | }); 97 | 98 | fxpay.getProduct(helper.apiProduct.guid).then(function(product) { 99 | return product.validateReceipt(); 100 | }).catch(function(error) { 101 | assert.instanceOf(error, errors.BadApiResponse); 102 | assert.typeOf(error.productInfo, 'object'); 103 | assert.equal(error.productInfo.productId, helper.apiProduct.guid); 104 | done(); 105 | }).catch(done); 106 | 107 | }); 108 | 109 | it('fails when receipt is missing', function(done) { 110 | helper.appSelf.receipts = []; 111 | setUpReceiptCheck(); 112 | 113 | fxpay.getProduct(helper.apiProduct.guid).then(function(product) { 114 | return product.validateReceipt(); 115 | }).then(function() { 116 | done(Error('unexpected success')); 117 | }).catch(function(error) { 118 | assert.instanceOf(error, errors.InvalidReceipt); 119 | assert.include(error.message, 'could not find installed receipt'); 120 | done(); 121 | }).catch(done); 122 | 123 | }); 124 | 125 | it('ignores malformed receipts', function(done) { 126 | helper.appSelf.receipts = ['']; 127 | 128 | setUpReceiptCheck(); 129 | 130 | fxpay.getProduct(helper.apiProduct.guid).then(function(product) { 131 | return product.validateReceipt(); 132 | }).catch(function(error) { 133 | assert.instanceOf(error, errors.InvalidReceipt); 134 | assert.typeOf(error.productInfo, 'object'); 135 | done(); 136 | }).catch(done); 137 | 138 | }); 139 | 140 | it('validates test receipts', function(done) { 141 | 142 | var testReceipt = makeReceipt({}, { 143 | typ: 'test-receipt', 144 | iss: 'https://payments-alt.allizom.org', 145 | verify: 'https://fake-receipt-check-server.net/' + 146 | 'developers/test-receipt/', 147 | }); 148 | helper.appSelf.receipts = [testReceipt]; 149 | 150 | fxpay.configure({fakeProducts: true}); 151 | 152 | setUpReceiptCheck({ 153 | fetchProductUrl: new RegExp( 154 | 'http(s)?://[^/]+/api/v1/payments/stub-in-app-products/' + 155 | helper.apiProduct.guid + '/'), 156 | verifyUrl: new RegExp( 157 | 'https://fake-receipt-check-server\\.net/developers/test-receipt/'), 158 | onValidationRequest: function(requestBody) { 159 | assert.equal(requestBody, testReceipt); 160 | }, 161 | }); 162 | 163 | fxpay.getProduct(helper.apiProduct.guid, { 164 | fetchStubs: true, 165 | }).then(function(product) { 166 | return product.validateReceipt(); 167 | }).then(function(product) { 168 | assert.equal(product.productId, helper.apiProduct.guid); 169 | assert.equal(product.name, helper.apiProduct.name); 170 | assert.equal(product.smallImageUrl, helper.apiProduct.logo_url); 171 | done(); 172 | }).catch(done); 173 | 174 | }); 175 | 176 | it('can still succeed from init', function(done) { 177 | helper.appSelf.receipts = [receipt]; 178 | 179 | setUpReceiptCheck({ 180 | fetchAllProducts: true, 181 | onValidationRequest: function(requestBody) { 182 | assert.equal(requestBody, receipt); 183 | }, 184 | }); 185 | 186 | fxpay.init({ 187 | onerror: function(err) { 188 | done(err); 189 | }, 190 | oninit: function() {}, 191 | onrestore: function(err, info) { 192 | if (!err) { 193 | assert.equal(info.productId, helper.apiProduct.guid); 194 | assert.equal(info.name, helper.apiProduct.name); 195 | assert.equal(info.smallImageUrl, helper.apiProduct.logo_url); 196 | assert.equal(info.receiptInfo.status, 'ok'); 197 | assert.equal(info.receiptInfo.receipt, receipt); 198 | } 199 | done(err); 200 | } 201 | }); 202 | }); 203 | 204 | it('can still fail from init', function(done) { 205 | helper.appSelf.receipts = [receipt]; 206 | 207 | setUpReceiptCheck({ 208 | fetchAllProducts: true, 209 | onValidationResponse: function(request) { 210 | request.respond(500, {}, 'Internal Error'); 211 | }, 212 | }); 213 | 214 | fxpay.init({ 215 | onerror: done, 216 | oninit: function() {}, 217 | onrestore: function(err, info) { 218 | assert.instanceOf(err, errors.BadApiResponse); 219 | assert.typeOf(info, 'object'); 220 | done(); 221 | } 222 | }); 223 | 224 | }); 225 | 226 | 227 | function setUpReceiptCheck(opt) { 228 | opt = utils.defaults(opt, { 229 | fetchProductUrl: new RegExp( 230 | 'http(s)?://[^/]+/api/v1/payments/' + 231 | encodeURIComponent(helper.appSelf.origin) + '/in-app/' + 232 | helper.apiProduct.guid + '/'), 233 | fetchAllProducts: false, 234 | validatorResponse: undefined, 235 | onValidationRequest: function() {}, 236 | onValidationResponse: undefined, 237 | verifyUrl: undefined, 238 | }); 239 | 240 | // When working with promises, we need to define responses up front 241 | // and respond as each request comes in. 242 | helper.server.respondImmediately = true; 243 | 244 | if (opt.fetchAllProducts) { 245 | // When testing init, we need to respond to fxpay.getProducts() 246 | helper.server.respondWith( 247 | 'GET', settings.apiUrlBase + settings.apiVersionPrefix + 248 | '/payments/' + encodeURIComponent(defaultProductUrl) + 249 | '/in-app/?active=1', 250 | [200, {"Content-Type": "application/json"}, 251 | JSON.stringify({ 252 | meta: { 253 | next: null, 254 | previous: null, 255 | total_count: 1, 256 | offset: 0, 257 | limit: 25, 258 | }, 259 | objects: [helper.apiProduct] 260 | })]); 261 | } 262 | 263 | // Respond to fetching the product info related to the receipt. 264 | helper.server.respondWith('GET', opt.fetchProductUrl, 265 | function(request) { 266 | request.respond(200, {"Content-Type": "application/json"}, 267 | JSON.stringify(helper.apiProduct)); 268 | }); 269 | 270 | var validator = new helper.ReceiptValidator({ 271 | verifyUrl: opt.verifyUrl, 272 | onRequest: opt.onValidationRequest, 273 | response: opt.validatorResponse, 274 | onValidationResponse: opt.onValidationResponse, 275 | }); 276 | validator.finish(); 277 | 278 | } 279 | 280 | 281 | function makeReceipt(overrides, receiptData) { 282 | overrides = utils.defaults(overrides, { 283 | productUrl: defaultProductUrl, 284 | storedata: 'contrib=297&id=500419&inapp_id=' + helper.apiProduct.guid, 285 | }); 286 | return helper.makeReceipt(receiptData, overrides); 287 | } 288 | 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /tests/test-products.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'products' 6 | ], function(errors, fxpay, helper, products) { 7 | 8 | describe('products.Product', function() { 9 | var Product = products.Product; 10 | var product = new Product({productId: 'some-id'}); 11 | 12 | beforeEach(function() { 13 | helper.setUp(); 14 | fxpay.configure({ 15 | appSelf: helper.appSelf, 16 | }); 17 | }); 18 | 19 | afterEach(function() { 20 | helper.tearDown(); 21 | }); 22 | 23 | describe('hasReceipt()', function() { 24 | 25 | it('returns false when no receipts', function() { 26 | fxpay.configure({productReceiptMap: {}}); 27 | assert.equal(product.hasReceipt(), false); 28 | }); 29 | 30 | it('returns true when receipt exists', function() { 31 | var productReceiptMap = {}; 32 | productReceiptMap[product.productId] = helper.makeReceipt(); 33 | fxpay.configure({productReceiptMap: productReceiptMap}); 34 | 35 | assert.equal(product.hasReceipt(), true); 36 | }); 37 | 38 | it('requires re-population of receipt map', function() { 39 | fxpay.configure({productReceiptMap: null}); 40 | assert.throws(function() { 41 | product.hasReceipt(); 42 | }, errors.IncorrectUsage); 43 | }); 44 | 45 | }); 46 | 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/test-purchase.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'settings' 6 | ], function(errors, fxpay, helper, settings) { 7 | 8 | describe('fxpay.purchase() on B2G', function () { 9 | var mozPay; 10 | 11 | beforeEach(function() { 12 | helper.setUp(); 13 | mozPay = sinon.spy(helper.mozPayStub); 14 | fxpay.configure({ 15 | appSelf: helper.appSelf, 16 | mozPay: mozPay, 17 | }); 18 | }); 19 | 20 | afterEach(function() { 21 | helper.tearDown(); 22 | mozPay.reset(); 23 | helper.receiptAdd.reset(); 24 | }); 25 | 26 | it('should send a JWT to mozPay', function (done) { 27 | var webpayJWT = ''; 28 | var cfg = { 29 | apiUrlBase: 'https://not-the-real-marketplace', 30 | apiVersionPrefix: '/api/v1', 31 | adapter: null, 32 | }; 33 | fxpay.configure(cfg); 34 | 35 | fxpay.purchase(helper.apiProduct.guid).then(function(productInfo) { 36 | assert.ok(mozPay.called); 37 | assert.ok(mozPay.calledWith([webpayJWT]), mozPay.firstCall); 38 | assert.equal(productInfo.productId, helper.apiProduct.guid); 39 | done(); 40 | }).catch(done); 41 | 42 | helper.resolvePurchase({productData: {webpayJWT: webpayJWT}, 43 | mozPay: mozPay}); 44 | }); 45 | 46 | it('should support the old callback interface', function (done) { 47 | 48 | fxpay.purchase(helper.apiProduct.guid, function(error, productInfo) { 49 | if (!error) { 50 | assert.ok(mozPay.called); 51 | assert.equal(productInfo.productId, helper.apiProduct.guid); 52 | } 53 | done(error); 54 | }); 55 | 56 | helper.resolvePurchase({mozPay: mozPay}); 57 | }); 58 | 59 | it('should timeout polling the transaction', function (done) { 60 | var productId = 'some-guid'; 61 | 62 | fxpay.purchase(productId, { 63 | maxTries: 2, 64 | pollIntervalMs: 1 65 | }).then(function() { 66 | done(Error('unexpected success')); 67 | }).catch(function(err) { 68 | assert.instanceOf(err, errors.PurchaseTimeout); 69 | assert.equal(err.productInfo.productId, productId); 70 | done(); 71 | }).catch(done); 72 | 73 | helper.resolvePurchase({ 74 | mozPay: mozPay, 75 | transData: helper.transactionData({status: 'incomplete'}), 76 | enableTransPolling: true, 77 | }); 78 | }); 79 | 80 | it('should call back with mozPay error', function (done) { 81 | var productId = 'some-guid'; 82 | 83 | fxpay.purchase(productId).then(function() { 84 | done(Error('unexpected success')); 85 | }).catch(function(err) { 86 | assert.instanceOf(err, errors.PayPlatformError); 87 | assert.equal(err.code, 'DIALOG_CLOSED_BY_USER'); 88 | assert.equal(err.productInfo.productId, productId); 89 | done(); 90 | }).catch(done); 91 | 92 | helper.resolvePurchase({ 93 | mozPay: mozPay, 94 | mozPayResolver: function(domRequest) { 95 | domRequest.error = {name: 'DIALOG_CLOSED_BY_USER'}; 96 | domRequest.onerror(); 97 | }, 98 | }); 99 | }); 100 | 101 | it('should support old callback interface for errors', function (done) { 102 | 103 | fxpay.purchase(helper.apiProduct.guid, function(err, productInfo) { 104 | assert.instanceOf(err, errors.PayPlatformError); 105 | assert.equal(productInfo.productId, helper.apiProduct.guid); 106 | done(); 107 | }); 108 | 109 | helper.resolvePurchase({ 110 | mozPay: mozPay, 111 | mozPayResolver: function(domRequest) { 112 | domRequest.error = {name: 'DIALOG_CLOSED_BY_USER'}; 113 | domRequest.onerror(); 114 | }, 115 | }); 116 | }); 117 | 118 | it('should report invalid transaction state', function (done) { 119 | 120 | fxpay.purchase(helper.apiProduct.guid).then(function() { 121 | done(Error('unexpected success')); 122 | }).catch(function(err) { 123 | assert.instanceOf(err, errors.ConfigurationError); 124 | done(); 125 | }).catch(done); 126 | 127 | helper.resolvePurchase({ 128 | mozPay: mozPay, 129 | transData: helper.transactionData( 130 | {status: 'THIS_IS_NOT_A_VALID_STATE'}), 131 | }); 132 | }); 133 | 134 | it('should add receipt to device with addReceipt', function (done) { 135 | var receipt = ''; 136 | 137 | fxpay.purchase(helper.apiProduct.guid).then(function(productInfo) { 138 | assert.equal(helper.receiptAdd._receipt, receipt); 139 | assert.equal(productInfo.productId, helper.apiProduct.guid); 140 | done(); 141 | }).catch(done); 142 | 143 | helper.resolvePurchase({receipt: receipt, mozPay: mozPay}); 144 | }); 145 | 146 | it('should call back with complete product info', function (done) { 147 | 148 | fxpay.purchase(helper.apiProduct.guid).then(function(info) { 149 | assert.equal(info.productId, helper.apiProduct.guid); 150 | assert.equal(info.name, helper.apiProduct.name); 151 | assert.equal(info.smallImageUrl, helper.apiProduct.logo_url); 152 | done(); 153 | }).catch(done); 154 | 155 | helper.resolvePurchase({mozPay: mozPay}); 156 | }); 157 | 158 | it('should fetch stub products when using fake products', function (done) { 159 | fxpay.configure({fakeProducts: true}); 160 | 161 | fxpay.purchase(helper.apiProduct.guid).then(function(info) { 162 | assert.equal(info.productId, helper.apiProduct.guid); 163 | assert.equal(info.name, helper.apiProduct.name); 164 | assert.equal(info.smallImageUrl, helper.apiProduct.logo_url); 165 | done(); 166 | }).catch(done); 167 | 168 | helper.resolvePurchase({ 169 | fetchProductsPattern: new RegExp('.*\/stub-in-app-products\/.*'), 170 | mozPay: mozPay 171 | }); 172 | }); 173 | 174 | it('should add receipt to device with localStorage', function (done) { 175 | var receipt = ''; 176 | 177 | setUpLocStorAddReceipt(); 178 | 179 | // Without addReceipt(), receipt should go in localStorage. 180 | 181 | fxpay.purchase(helper.apiProduct.guid).then(function(productInfo) { 182 | assert.equal( 183 | JSON.parse( 184 | window.localStorage.getItem(settings.localStorageKey))[0], 185 | receipt); 186 | assert.equal(productInfo.productId, helper.apiProduct.guid); 187 | done(); 188 | }).catch(done); 189 | 190 | helper.resolvePurchase({receipt: receipt, mozPay: mozPay}); 191 | }); 192 | 193 | it('should error when no storage mechanisms exist', function(done) { 194 | var receipt = ''; 195 | delete helper.appSelf.addReceipt; // older FxOSs do not have this. 196 | 197 | fxpay.configure({ 198 | localStorage: null, // no fallback. 199 | }); 200 | 201 | fxpay.purchase(helper.apiProduct.guid).then(function() { 202 | done(Error('unexpected success')); 203 | }).catch(function(error) { 204 | assert.instanceOf(error, errors.PayPlatformUnavailable); 205 | done(); 206 | }).catch(done); 207 | 208 | helper.resolvePurchase({receipt: receipt, mozPay: mozPay}); 209 | }); 210 | 211 | it('should not add dupes to localStorage', function (done) { 212 | var receipt = ''; 213 | 214 | setUpLocStorAddReceipt(); 215 | 216 | // Set up an already stored receipt. 217 | window.localStorage.setItem(settings.localStorageKey, 218 | JSON.stringify([receipt])); 219 | 220 | fxpay.purchase(helper.apiProduct.guid).then(function(productInfo) { 221 | var addedReceipts = JSON.parse( 222 | window.localStorage.getItem(settings.localStorageKey)); 223 | 224 | // Make sure a new receipt wasn't added. 225 | assert.equal(addedReceipts.length, 1); 226 | 227 | assert.equal(productInfo.productId, helper.apiProduct.guid); 228 | done(); 229 | }).catch(done); 230 | 231 | helper.resolvePurchase({receipt: receipt, mozPay: mozPay}); 232 | }); 233 | 234 | it('should pass through receipt errors', function (done) { 235 | 236 | fxpay.purchase(helper.apiProduct.guid).then(function() { 237 | done(Error('unexpected success')); 238 | }).catch(function(err) { 239 | assert.instanceOf(err, errors.AddReceiptError); 240 | assert.equal(err.code, 'ADD_RECEIPT_ERROR'); 241 | assert.equal(err.productInfo.productId, helper.apiProduct.guid); 242 | done(); 243 | }).catch(done); 244 | 245 | helper.resolvePurchase({ 246 | mozPay: mozPay, 247 | addReceiptResolver: function(domRequest) { 248 | domRequest.error = {name: 'ADD_RECEIPT_ERROR'}; 249 | domRequest.onerror(); 250 | }, 251 | }); 252 | }); 253 | 254 | 255 | function setUpLocStorAddReceipt() { 256 | // Set up a purchase where mozApps does not support addReceipt(). 257 | delete helper.appSelf.addReceipt; 258 | 259 | helper.appSelf.onsuccess(); 260 | } 261 | 262 | }); 263 | }); 264 | -------------------------------------------------------------------------------- /tests/test-receipts-all.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'receipts', 6 | 'settings' 7 | ], function(errors, fxpay, helper, receipts, settings) { 8 | 9 | describe('receipts.all()', function() { 10 | 11 | beforeEach(function() { 12 | helper.setUp(); 13 | }); 14 | 15 | afterEach(function() { 16 | helper.tearDown(); 17 | }); 18 | 19 | it('exposes mozApps receipts', function(done) { 20 | var receipt = ''; 21 | fxpay.configure({ 22 | appSelf: { 23 | receipts: [receipt] 24 | } 25 | }); 26 | receipts.all(function(error, fetchedReceipts) { 27 | if (!error) { 28 | assert.equal(fetchedReceipts[0], receipt); 29 | assert.equal(fetchedReceipts.length, 1); 30 | } 31 | done(error); 32 | }); 33 | }); 34 | 35 | it('ignores missing receipts', function(done) { 36 | fxpay.configure({appSelf: {}}); // no receipts property 37 | receipts.all(function(error, fetchedReceipts) { 38 | if (!error) { 39 | assert.equal(fetchedReceipts.length, 0); 40 | } 41 | done(error); 42 | }); 43 | }); 44 | 45 | it('gets mozApps receipts and localStorage ones', function(done) { 46 | var receipt1 = ''; 47 | var receipt2 = ''; 48 | 49 | fxpay.configure({ 50 | appSelf: { 51 | receipts: [receipt1] 52 | } 53 | }); 54 | window.localStorage.setItem(settings.localStorageKey, 55 | JSON.stringify([receipt2])); 56 | 57 | receipts.all(function(error, fetchedReceipts) { 58 | if (!error) { 59 | assert.equal(fetchedReceipts[0], receipt1); 60 | assert.equal(fetchedReceipts[1], receipt2); 61 | assert.equal(fetchedReceipts.length, 2); 62 | } 63 | done(error); 64 | }); 65 | }); 66 | 67 | it('filters out dupe receipts', function(done) { 68 | var receipt1 = ''; 69 | 70 | fxpay.configure({ 71 | appSelf: { 72 | receipts: [receipt1] 73 | } 74 | }); 75 | window.localStorage.setItem(settings.localStorageKey, 76 | JSON.stringify([receipt1])); 77 | 78 | receipts.all(function(error, fetchedReceipts) { 79 | if (!error) { 80 | assert.equal(fetchedReceipts[0], receipt1); 81 | assert.equal(fetchedReceipts.length, 1); 82 | } 83 | done(error); 84 | }); 85 | }); 86 | 87 | it('handles appSelf errors', function(done) { 88 | helper.appSelf.error = {name: 'INVALID_MANIFEST'}; 89 | fxpay.configure({ 90 | appSelf: null // clear appSelf cache. 91 | }); 92 | receipts.all(function(error) { 93 | assert.instanceOf(error, errors.InvalidApp, 94 | 'should be instanceOf errors.InvalidApp'); 95 | assert.equal(error.code, 'INVALID_MANIFEST'); 96 | done(); 97 | }); 98 | 99 | helper.appSelf.onerror(); 100 | }); 101 | 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /tests/test-receipts-verify-app-data.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'receipts' 6 | ], function(errors, fxpay, helper, receipts) { 7 | 8 | describe('receipts.verifyAppData()', function() { 9 | var receiptCheckSite = 'https://niceverifier.org'; 10 | 11 | beforeEach(function() { 12 | helper.setUp(); 13 | fxpay.configure({ 14 | appSelf: helper.appSelf, 15 | receiptCheckSites: [receiptCheckSite] 16 | }); 17 | }); 18 | 19 | afterEach(function() { 20 | helper.tearDown(); 21 | }); 22 | 23 | it('fails on missing storedata', function(done) { 24 | receipts.verifyAppData( 25 | makeReceipt({storedata: 'foo=baz&barz=zonk'}), 26 | function(err) { 27 | assert.instanceOf(err, errors.InvalidReceipt); 28 | done(); 29 | }); 30 | }); 31 | 32 | it('passes through receipt data', function(done) { 33 | var productId = '123'; 34 | var productUrl = 'app://some-packaged-origin'; 35 | var storedata = 'id=' + productId; 36 | helper.appSelf.origin = productUrl; 37 | 38 | receipts.verifyAppData( 39 | makeReceipt({storedata: storedata, 40 | productUrl: productUrl}), 41 | function(err, data, info) { 42 | if (!err) { 43 | assert.equal(info.productId, productId); 44 | assert.equal(info.productUrl, productUrl); 45 | assert.equal(data.product.storedata, storedata); 46 | } 47 | done(err); 48 | }); 49 | }); 50 | 51 | 52 | function makeReceipt(overrides, receiptData) { 53 | receiptData = receiptData || {}; 54 | receiptData.verify = receiptCheckSite + '/verify/'; 55 | 56 | return helper.makeReceipt(receiptData, overrides); 57 | } 58 | 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/test-receipts-verify-data.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'products', 6 | 'receipts' 7 | ], function(errors, fxpay, helper, products, receipts) { 8 | 9 | describe('receipts.verifyData()', function() { 10 | var someProduct = new products.Product({productId: 'some-uuid'}); 11 | var receiptCheckSite = 'https://niceverifier.org'; 12 | 13 | beforeEach(function() { 14 | helper.setUp(); 15 | fxpay.configure({ 16 | appSelf: helper.appSelf, 17 | receiptCheckSites: [receiptCheckSite] 18 | }); 19 | }); 20 | 21 | afterEach(function() { 22 | helper.tearDown(); 23 | }); 24 | 25 | it('fails on non-strings', function(done) { 26 | receipts.verifyData({not: 'a receipt'}, someProduct, function(err) { 27 | assert.instanceOf(err, errors.InvalidReceipt); 28 | done(); 29 | }); 30 | }); 31 | 32 | it('fails on too many key segments', function(done) { 33 | receipts.verifyData('one~too~many', someProduct, function(err) { 34 | assert.instanceOf(err, errors.InvalidReceipt); 35 | done(); 36 | }); 37 | }); 38 | 39 | it('fails on not enough JWT segments', function(done) { 40 | receipts.verifyData('one.two', someProduct, function(err) { 41 | assert.instanceOf(err, errors.InvalidReceipt); 42 | done(); 43 | }); 44 | }); 45 | 46 | it('fails on invalid base64 encoding', function(done) { 47 | receipts.verifyData(receipt({receipt: 'not%valid&&base64'}), 48 | someProduct, 49 | function(err) { 50 | assert.instanceOf(err, errors.InvalidReceipt); 51 | done(); 52 | }); 53 | }); 54 | 55 | it('fails on invalid JSON', function(done) { 56 | receipts.verifyData('jwtAlgo.' + btoa('^not valid JSON') + '.jwtSig', 57 | someProduct, 58 | function(err) { 59 | assert.instanceOf(err, errors.InvalidReceipt); 60 | done(); 61 | }); 62 | }); 63 | 64 | it('fails on missing product URL', function(done) { 65 | receipts.verifyData(receipt(null, { 66 | product: { 67 | storedata: 'storedata' 68 | } 69 | }), someProduct, function(err) { 70 | assert.instanceOf(err, errors.InvalidReceipt); 71 | done(); 72 | }); 73 | }); 74 | 75 | it('fails on missing storedata', function(done) { 76 | receipts.verifyData( 77 | 'jwtAlgo.' + btoa(JSON.stringify({product: {}})) + '.jwtSig', 78 | someProduct, 79 | function(err) { 80 | assert.instanceOf(err, errors.InvalidReceipt); 81 | done(); 82 | }); 83 | }); 84 | 85 | it('fails on non-string storedata', function(done) { 86 | receipts.verifyData(receipt({storedata: {}}), 87 | someProduct, 88 | function(err) { 89 | assert.instanceOf(err, errors.InvalidReceipt); 90 | done(); 91 | }); 92 | }); 93 | 94 | it('fails on foreign product URL for packaged app', function(done) { 95 | var data = receipt({productUrl: 'wrong-app'}); 96 | receipts.verifyData(data, someProduct, function(err) { 97 | assert.instanceOf(err, errors.InvalidReceipt); 98 | done(); 99 | }); 100 | }); 101 | 102 | it('fails on foreign product URL for hosted app', function(done) { 103 | var webOrigin = 'http://some-site.com'; 104 | 105 | fxpay.configure({ 106 | window: {location: {origin: webOrigin}}, 107 | appSelf: null, 108 | }); 109 | 110 | var data = receipt({productUrl: 'http://wrong-site.com'}); 111 | receipts.verifyData(data, someProduct, function(err) { 112 | assert.instanceOf(err, errors.InvalidReceipt); 113 | done(); 114 | }); 115 | }); 116 | 117 | it('knows how to validate hosted app product URLs', function(done) { 118 | var webOrigin = 'http://some-site.com'; 119 | 120 | fxpay.configure({ 121 | window: {location: {origin: webOrigin}}, 122 | appSelf: null, 123 | }); 124 | 125 | var data = receipt({productUrl: webOrigin}); 126 | receipts.verifyData(data, someProduct, function(err) { 127 | done(err); 128 | }); 129 | }); 130 | 131 | it('handles non-prefixed app origins', function(done) { 132 | helper.appSelf.origin = 'app://the-origin'; 133 | // TODO: remove this when fixed in Marketplace. bug 1034264. 134 | var data = receipt({productUrl: 'the-origin'}); 135 | 136 | receipts.verifyData(data, someProduct, function(err) { 137 | done(err); 138 | }); 139 | }); 140 | 141 | it('handles properly prefixed app origins', function(done) { 142 | helper.appSelf.origin = 'app://the-app'; 143 | var data = receipt({productUrl: helper.appSelf.origin}); 144 | 145 | receipts.verifyData(data, someProduct, function(err) { 146 | done(err); 147 | }); 148 | }); 149 | 150 | it('handles HTTP hosted app origins', function(done) { 151 | helper.appSelf.origin = 'http://hosted-app'; 152 | var data = receipt({productUrl: helper.appSelf.origin}); 153 | 154 | receipts.verifyData(data, someProduct, function(err) { 155 | done(err); 156 | }); 157 | }); 158 | 159 | it('handles HTTPS hosted app origins', function(done) { 160 | helper.appSelf.origin = 'https://hosted-app'; 161 | var data = receipt({productUrl: helper.appSelf.origin}); 162 | 163 | receipts.verifyData(data, someProduct, function(err) { 164 | done(err); 165 | }); 166 | }); 167 | 168 | it('allows wrong product URLs for test receipts', function(done) { 169 | // Only allow test receipts when fakeProducts is true. 170 | fxpay.configure({fakeProducts: true}); 171 | receipts.verifyData(receipt({productUrl: 'wrong-app'}, 172 | {typ: 'test-receipt'}), 173 | someProduct, 174 | function(err) { 175 | done(err); 176 | }); 177 | }); 178 | 179 | it('fails on disallowed receipt check URLs', function(done) { 180 | receipts.verifyData(receipt(null, 181 | {verify: 'http://mykracksite.ru'}), 182 | someProduct, 183 | function(err) { 184 | assert.instanceOf(err, errors.InvalidReceipt); 185 | done(); 186 | }); 187 | }); 188 | 189 | it('disallows test receipts when not testing', function(done) { 190 | receipts.verifyData(receipt(null, {typ: 'test-receipt'}), 191 | someProduct, 192 | function(err, info) { 193 | assert.instanceOf(err, errors.TestReceiptNotAllowed); 194 | assert.typeOf(info, 'object'); 195 | done(); 196 | }); 197 | }); 198 | 199 | 200 | function receipt(overrides, receiptData) { 201 | overrides = overrides || {}; 202 | receiptData = receiptData || {}; 203 | 204 | receiptData.verify = (receiptData.verify || 205 | receiptCheckSite + '/verify/'); 206 | overrides.productUrl = overrides.productUrl || helper.someAppOrigin; 207 | 208 | return helper.makeReceipt(receiptData, overrides); 209 | } 210 | 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /tests/test-receipts-verify-inapp-data.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'products', 6 | 'receipts' 7 | ], function(errors, fxpay, helper, products, receipts) { 8 | 9 | describe('receipts.verifyInAppProductData()', function() { 10 | var someProduct = new products.Product({productId: 'some-uuid'}); 11 | var receiptCheckSite = 'https://niceverifier.org'; 12 | 13 | beforeEach(function() { 14 | helper.setUp(); 15 | fxpay.configure({ 16 | appSelf: helper.appSelf, 17 | receiptCheckSites: [receiptCheckSite] 18 | }); 19 | }); 20 | 21 | afterEach(function() { 22 | helper.tearDown(); 23 | }); 24 | 25 | it('fails on missing product', function(done) { 26 | receipts.verifyInAppProductData({}, someProduct, function(err) { 27 | assert.instanceOf(err, errors.InvalidReceipt); 28 | done(); 29 | }); 30 | }); 31 | 32 | it('fails on corrupted storedata', function(done) { 33 | receipts.verifyInAppProductData( 34 | makeReceipt({storedata: 'not%a!valid(string'}), 35 | someProduct, 36 | function(err) { 37 | assert.instanceOf(err, errors.InvalidReceipt); 38 | assert.equal(err.productInfo.productId, someProduct.productId); 39 | done(); 40 | }); 41 | }); 42 | 43 | it('handles malformed storedata', function(done) { 44 | receipts.verifyInAppProductData(makeReceipt({storedata: '&&&'}), 45 | someProduct, 46 | function(err) { 47 | assert.instanceOf(err, errors.InvalidReceipt); 48 | assert.equal(err.productInfo.productId, someProduct.productId); 49 | done(); 50 | }); 51 | }); 52 | 53 | it('fails on missing storedata', function(done) { 54 | receipts.verifyInAppProductData( 55 | makeReceipt({storedata: 'foo=baz&barz=zonk'}), 56 | someProduct, 57 | function(err) { 58 | assert.instanceOf(err, errors.InvalidReceipt); 59 | assert.equal(err.productInfo.productId, someProduct.productId); 60 | done(); 61 | }); 62 | }); 63 | 64 | it('passes through receipt data', function(done) { 65 | var productId = 'receipt-product-guid'; 66 | var productUrl = 'app://some-packaged-origin'; 67 | var storedata = 'inapp_id=' + productId; 68 | helper.appSelf.origin = productUrl; 69 | 70 | receipts.verifyInAppProductData( 71 | makeReceipt({storedata: storedata, 72 | productUrl: productUrl}), 73 | someProduct, 74 | function(err, data, info) { 75 | if (!err) { 76 | assert.equal(info.productId, productId); 77 | assert.equal(info.productUrl, productUrl); 78 | assert.equal(data.product.storedata, storedata); 79 | } 80 | done(err); 81 | }); 82 | }); 83 | 84 | 85 | function makeReceipt(overrides, receiptData) { 86 | receiptData = receiptData || {}; 87 | receiptData.verify = receiptCheckSite + '/verify/'; 88 | 89 | return helper.makeReceipt(receiptData, overrides); 90 | } 91 | 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /tests/test-settings.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'settings' 4 | ], function(errors, settings) { 5 | 6 | describe('fxpay.settings', function() { 7 | var adapterConfigure; 8 | var adapter; 9 | 10 | var Adapter = function() {}; 11 | Adapter.prototype.configure = function() {}; 12 | 13 | beforeEach(function() { 14 | settings.configure({}, {reset: true}); 15 | adapter = new Adapter(); 16 | adapterConfigure = sinon.spy(adapter, 'configure'); 17 | }); 18 | 19 | afterEach(function() { 20 | settings.configure({}, {reset: true}); 21 | }); 22 | 23 | it('should allow you to set an adapter', function() { 24 | settings.configure({adapter: adapter}); 25 | assert.equal(adapterConfigure.called, true); 26 | }); 27 | 28 | it('should reconfigure the adapter', function() { 29 | settings.configure({adapter: adapter}); 30 | // The adapter should always be reconfigured. 31 | settings.configure(); 32 | assert.equal(adapterConfigure.callCount, 2); 33 | }); 34 | 35 | it('should let you merge in new parameters', function() { 36 | assert.equal(settings.allowTestReceipts, false); 37 | settings.configure({allowTestReceipts: true}); 38 | assert.equal(settings.allowTestReceipts, true); 39 | // Configuring something else should preserve old values. 40 | settings.configure({apiUrlBase: 'https://mysite.net'}); 41 | assert.equal(settings.allowTestReceipts, true); 42 | }); 43 | 44 | it('should allow test receipts for fake products', function() { 45 | settings.configure({fakeProducts: true}); 46 | assert.equal(settings.allowTestReceipts, true); 47 | }); 48 | 49 | it('should merge in new payment providers', function() { 50 | var prodDefault = settings.payProviderUrls['mozilla/payments/pay/v1']; 51 | var newProviders = { 52 | 'mysite/pay/v1': 'https://mysite.net/pay/?req={jwt}', 53 | }; 54 | settings.configure({extraProviderUrls: newProviders}); 55 | assert.equal(settings.payProviderUrls['mozilla/payments/pay/v1'], 56 | prodDefault); 57 | assert.equal(settings.payProviderUrls['mysite/pay/v1'], 58 | newProviders['mysite/pay/v1']); 59 | }); 60 | 61 | it('should let you initialize settings with values', function() { 62 | settings.initialize({allowTestReceipts: true}); 63 | assert.equal(settings.allowTestReceipts, true); 64 | }); 65 | 66 | it('should only initialize settings once', function() { 67 | settings.alreadyConfigured = false; 68 | settings.initialize(); 69 | 70 | var defaultConfigure = sinon.spy(settings.adapter, 'configure'); 71 | 72 | settings.initialize(); 73 | settings.initialize(); 74 | 75 | // Make sure repeated calls do not re-configure. 76 | assert.equal(defaultConfigure.callCount, 0); 77 | }); 78 | 79 | it('does not re-initialize settings with null parameters', function() { 80 | settings.alreadyConfigured = false; 81 | settings.initialize(); 82 | 83 | var defaultConfigure = sinon.spy(settings.adapter, 'configure'); 84 | 85 | settings.initialize(null); 86 | 87 | // Make sure repeated calls do not re-configure. 88 | assert.equal(defaultConfigure.callCount, 0); 89 | }); 90 | 91 | it('should only allow you to re-initialize settings', function() { 92 | var defaultConfigure = sinon.spy(settings.adapter, 'configure'); 93 | 94 | settings.initialize({allowTestReceipts: true}); 95 | settings.initialize({allowTestReceipts: true}); 96 | 97 | // Since we passed in new settings, each call should reconfigure. 98 | assert.equal(defaultConfigure.callCount, 2); 99 | }); 100 | 101 | it('initializes settings with defaults on first run', function() { 102 | var nonDefaultValue = 'not-a-default-value'; 103 | settings.allowTestReceipts = nonDefaultValue; 104 | 105 | settings.alreadyConfigured = false; 106 | settings.initialize(); 107 | 108 | // This ensures that the first run resets all settings. 109 | assert.ok(settings.allowTestReceipts !== nonDefaultValue); 110 | }); 111 | 112 | it('initializes settings with defaults even with overrides', function() { 113 | var nonDefaultValue = 'not-a-default-value'; 114 | settings.allowTestReceipts = nonDefaultValue; 115 | 116 | settings.alreadyConfigured = false; 117 | settings.initialize({log: console}); 118 | 119 | // This ensures that the first run resets all settings even 120 | // if the first initialize call overrode some values. 121 | assert.ok(settings.allowTestReceipts !== nonDefaultValue); 122 | }); 123 | 124 | it('should error with unknown options', function () { 125 | assert.throws(function() { 126 | settings.configure({notAvalidOption: false}); 127 | }, errors.IncorrectUsage); 128 | }); 129 | 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /tests/test-utils.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'utils', 6 | ], function(errors, fxpay, helper, utils) { 7 | 8 | describe('utils.defaults()', function() { 9 | 10 | it('should handle merging defaults into object', function() { 11 | var obj = { 12 | bar: false, 13 | foo: 'something', 14 | }; 15 | var defaults = { 16 | bar: true, 17 | newKey: 'new-thing' 18 | }; 19 | var result = utils.defaults(obj, defaults); 20 | assert.deepEqual(result, { 21 | bar: false, 22 | foo: 'something', 23 | newKey: 'new-thing', 24 | }); 25 | }); 26 | 27 | it('should handle merging defaults into empty object', function() { 28 | var obj = {}; 29 | var defaults = { 30 | bar: true, 31 | newKey: 'new-thing' 32 | }; 33 | var result = utils.defaults(obj, defaults); 34 | assert.deepEqual(result, { 35 | bar: true, 36 | newKey: 'new-thing', 37 | }); 38 | }); 39 | 40 | it('should not override existing props', function() { 41 | var obj = { 42 | bar: true, 43 | newKey: 'new-thing' 44 | }; 45 | var defaults = { 46 | bar: false, 47 | newKey: 'other-thing' 48 | }; 49 | var result = utils.defaults(obj, defaults); 50 | assert.deepEqual(result, { 51 | bar: true, 52 | newKey: 'new-thing', 53 | }); 54 | }); 55 | 56 | it('should not override null', function() { 57 | var obj = { 58 | bar: null, 59 | newKey: 'new-thing' 60 | }; 61 | var defaults = { 62 | bar: false, 63 | newKey: 'other-thing' 64 | }; 65 | var result = utils.defaults(obj, defaults); 66 | assert.deepEqual(result, { 67 | bar: null, 68 | newKey: 'new-thing', 69 | }); 70 | }); 71 | 72 | it('should override an undefined property', function() { 73 | var obj = { 74 | bar: undefined, 75 | }; 76 | var defaults = { 77 | bar: false, 78 | }; 79 | var result = utils.defaults(obj, defaults); 80 | assert.deepEqual(result, { 81 | bar: false, 82 | }); 83 | }); 84 | 85 | it('should handle the object being undefined', function() { 86 | var defaults = { 87 | bar: 'result', 88 | }; 89 | var result = utils.defaults(undefined, defaults); 90 | assert.deepEqual(result, { 91 | bar: 'result', 92 | }); 93 | }); 94 | }); 95 | 96 | 97 | describe('utils.openWindow()', function() { 98 | 99 | beforeEach(function(){ 100 | this.openWindowSpy = sinon.spy(); 101 | fxpay.configure({window: {open: this.openWindowSpy}}); 102 | }); 103 | 104 | it('should be called with props', function() { 105 | utils.openWindow({ 106 | url: 'http://blah.com', 107 | title: 'whatever', 108 | w: 200, 109 | h: 400 110 | }); 111 | assert(this.openWindowSpy.calledWithMatch('http://blah.com', 'whatever')); 112 | assert.include(this.openWindowSpy.args[0][2], 'width=200'); 113 | assert.include(this.openWindowSpy.args[0][2], 'height=400'); 114 | }); 115 | 116 | it('should be called with defaults', function() { 117 | utils.openWindow(); 118 | assert(this.openWindowSpy.calledWithMatch('', 'FxPay')); 119 | assert.include(this.openWindowSpy.args[0][2], 'width=276'); 120 | assert.include(this.openWindowSpy.args[0][2], 'height=384'); 121 | }); 122 | 123 | it('should be passed a features string with no whitespace', function() { 124 | utils.openWindow(); 125 | assert.notInclude(this.openWindowSpy.args[0][2], ' '); 126 | }); 127 | }); 128 | 129 | 130 | describe('utils.getSelfOrigin', function() { 131 | 132 | it('should return the app origin', function() { 133 | assert.equal( 134 | utils.getSelfOrigin({ 135 | appSelf: { 136 | origin: 'app://origin', 137 | manifest: { 138 | origin: 'app://origin', 139 | type: 'privileged', 140 | }, 141 | }, 142 | }), 143 | 'app://origin'); 144 | }); 145 | 146 | it('should return the marketplace GUID origin', function() { 147 | assert.equal( 148 | utils.getSelfOrigin({ 149 | log: {info: function() {}}, 150 | appSelf: { 151 | origin: 'app://unusable-origin', 152 | manifest: { 153 | type: 'web', 154 | }, 155 | manifestURL: ('https://marketplace-dev.allizom.org' + 156 | '/app/some-guid/manifest.webapp'), 157 | }, 158 | }), 159 | 'marketplace:some-guid'); 160 | }); 161 | 162 | it('should fall back to marketplace when no declared origin', function() { 163 | assert.equal( 164 | utils.getSelfOrigin({ 165 | log: {info: function() {}}, 166 | // Set up a privileged app that has not declared an origin. 167 | appSelf: { 168 | origin: 'app://unusable-origin', 169 | manifest: { 170 | type: 'privileged', 171 | origin: null, 172 | }, 173 | manifestURL: ('https://marketplace-dev.allizom.org' + 174 | '/app/some-guid/manifest.webapp'), 175 | }, 176 | }), 177 | 'marketplace:some-guid'); 178 | }); 179 | 180 | it('should error on non-marketplace packages', function() { 181 | assert.throws(function() { 182 | utils.getSelfOrigin({ 183 | appSelf: { 184 | // This would be a randomly generated origin by the platform. 185 | origin: 'app://unusable-origin', 186 | manifest: { 187 | type: 'web', 188 | origin: null, 189 | }, 190 | manifestURL: 'http://some-random-site/f/manifest.webapp', 191 | }, 192 | }); 193 | }, errors.InvalidAppOrigin); 194 | }); 195 | 196 | it('should fall back to location origin', function() { 197 | var stubLocation = {origin: 'http://foo.com:3000'}; 198 | assert.equal( 199 | utils.getSelfOrigin({window: {location: stubLocation}}), 200 | 'http://foo.com:3000'); 201 | }); 202 | 203 | it('should fall back to a derived origin', function() { 204 | var stubLocation = {protocol: 'http:', 205 | hostname: 'foo.com:3000'}; 206 | assert.equal( 207 | utils.getSelfOrigin({window: {location: stubLocation}}), 208 | 'http://foo.com:3000'); 209 | }); 210 | 211 | }); 212 | 213 | 214 | describe('utils.getUrlOrigin()', function() { 215 | 216 | it('returns location from URL', function() { 217 | assert.equal(utils.getUrlOrigin('http://foo.com/somewhere.html'), 218 | 'http://foo.com'); 219 | }); 220 | 221 | it('returns location with port', function() { 222 | assert.equal(utils.getUrlOrigin('http://foo.com:3000/somewhere.html'), 223 | 'http://foo.com:3000'); 224 | }); 225 | 226 | }); 227 | 228 | describe('utils.serialize()', function() { 229 | 230 | it('should serialize object', function() { 231 | assert.equal(utils.serialize({foo: 'bar', baz: 'zup'}), 232 | 'foo=bar&baz=zup'); 233 | }); 234 | 235 | it('should urlencode keys + values', function() { 236 | assert.equal( 237 | utils.serialize({'album name': 'Back in Black', 'artist': 'AC/DC'}), 238 | 'album%20name=Back%20in%20Black&artist=AC%2FDC'); 239 | }); 240 | 241 | }); 242 | 243 | describe('utils.getAppSelf()', function() { 244 | 245 | beforeEach(function() { 246 | helper.setUp(); 247 | }); 248 | 249 | afterEach(function() { 250 | helper.tearDown(); 251 | }); 252 | 253 | it('returns mozApps self', function(done) { 254 | utils.getAppSelf(function(error, appSelf) { 255 | assert.equal(appSelf, helper.appSelf); 256 | done(error); 257 | }); 258 | 259 | helper.appSelf.onsuccess(); 260 | }); 261 | 262 | it('caches and returns mozApps self', function(done) { 263 | utils.getAppSelf(function() { 264 | // Now get the cached version: 265 | utils.getAppSelf(function(error, appSelf) { 266 | assert.equal(appSelf, helper.appSelf); 267 | done(error); 268 | }); 269 | }); 270 | 271 | helper.appSelf.onsuccess(); 272 | }); 273 | 274 | it('returns mozApps errors', function(done) { 275 | utils.getAppSelf(function(error, appSelf) { 276 | assert.instanceOf(error, errors.InvalidApp); 277 | assert.equal(error.code, 'SOME_ERROR'); 278 | assert.strictEqual(appSelf, null); 279 | done(); 280 | }); 281 | 282 | helper.appSelf.error = {name: 'SOME_ERROR'}; 283 | helper.appSelf.onerror(); 284 | }); 285 | 286 | it('returns false when mozApps is falsey', function(done) { 287 | // This is what happens when we're running on a non-apps platform. 288 | fxpay.configure({mozApps: null}); 289 | utils.getAppSelf(function(error, appSelf) { 290 | assert.strictEqual(appSelf, false); 291 | done(error); 292 | }); 293 | }); 294 | 295 | it('returns pre-fetched appSelf', function(done) { 296 | fxpay.configure({appSelf: 'some-cached-value'}); 297 | utils.getAppSelf(function(error, appSelf) { 298 | assert.equal(appSelf, 'some-cached-value'); 299 | done(error); 300 | }); 301 | }); 302 | 303 | it('returns any non-null appSelf', function(done) { 304 | fxpay.configure({appSelf: false}); 305 | utils.getAppSelf(function(error, appSelf) { 306 | assert.strictEqual(appSelf, false); 307 | done(error); 308 | }); 309 | }); 310 | }); 311 | 312 | }); 313 | -------------------------------------------------------------------------------- /tests/test-validate-app-receipt.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'utils' 6 | ], function(errors, fxpay, helper, utils) { 7 | 8 | describe('fxpay.validateAppReceipt()', function() { 9 | var defaultProductUrl = 'http://boar4485.testmanifest.com'; 10 | 11 | beforeEach(function() { 12 | var receipt = makeReceipt(); 13 | helper.setUp(); 14 | helper.appSelf.origin = defaultProductUrl; 15 | helper.appSelf.receipts = [receipt]; 16 | fxpay.configure({ 17 | appSelf: helper.appSelf, 18 | receiptCheckSites: [ 19 | 'https://fake-receipt-check-server.net', 20 | 'https://payments-alt.allizom.org' 21 | ] 22 | }); 23 | }); 24 | 25 | afterEach(function() { 26 | helper.tearDown(); 27 | }); 28 | 29 | it('calls back for successful server validation', function(done) { 30 | var appId = '1234'; 31 | var productUrl = helper.appSelf.origin; 32 | var receipt = makeReceipt({ 33 | storedata: 'id=' + appId, 34 | productUrl: productUrl, 35 | }); 36 | helper.appSelf.receipts = [receipt]; 37 | 38 | var validator = new helper.ReceiptValidator({ 39 | onRequest: function(requestBody) { 40 | assert.equal(requestBody, receipt); 41 | }, 42 | }); 43 | 44 | fxpay.validateAppReceipt().then(function(productInfo) { 45 | assert.equal(productInfo.receiptInfo.status, 'ok'); 46 | assert.equal(productInfo.receiptInfo.receipt, receipt); 47 | assert.equal(productInfo.productId, appId); 48 | assert.equal(productInfo.productUrl, productUrl); 49 | done(); 50 | }).catch(done); 51 | 52 | validator.finish(); 53 | }); 54 | 55 | it('calls back for server validation errors', function(done) { 56 | var badResponse = {status: 'invalid', reason: 'ERROR_DECODING'}; 57 | 58 | var validator = new helper.ReceiptValidator({ 59 | response: badResponse, 60 | }); 61 | 62 | fxpay.validateAppReceipt().then(function() { 63 | done(Error('unexpected success')); 64 | }).catch(function(reason) { 65 | assert.equal(reason.productInfo.receiptInfo.status, badResponse.status); 66 | assert.equal(reason.productInfo.receiptInfo.reason, badResponse.reason); 67 | assert.instanceOf(reason, errors.InvalidReceipt); 68 | done(); 69 | }).catch(done); 70 | 71 | validator.finish(); 72 | }); 73 | 74 | it('fails when origin does not match product URL', function(done) { 75 | helper.appSelf.origin = 'http://some-other-origin.net'; 76 | 77 | var validator = new helper.ReceiptValidator(); 78 | 79 | fxpay.validateAppReceipt().then(function() { 80 | done(Error('unexpected success')); 81 | }).catch(function(reason) { 82 | assert.instanceOf(reason, errors.InvalidReceipt); 83 | assert.typeOf(reason.productInfo, 'object'); 84 | done(); 85 | }).catch(done); 86 | 87 | validator.finish(); 88 | }); 89 | 90 | it('fails when receipt was issued by a disallowed store', function(done) { 91 | // Disallow all other stores: 92 | helper.appSelf.manifest.installs_allowed_from = [ 93 | 'https://my-benevolent-app-store.net', 94 | ]; 95 | 96 | var validator = new helper.ReceiptValidator(); 97 | 98 | fxpay.validateAppReceipt().then(function() { 99 | done(Error('unexpected success')); 100 | }).catch(function(reason) { 101 | assert.instanceOf(reason, errors.InvalidReceipt); 102 | assert.typeOf(reason.productInfo, 'object'); 103 | done(); 104 | }).catch(done); 105 | 106 | validator.finish(); 107 | }); 108 | 109 | it('fails when no stores are allowed', function(done) { 110 | // This is an unlikely case but we should honor it I suppose. 111 | helper.appSelf.manifest.installs_allowed_from = []; 112 | 113 | var validator = new helper.ReceiptValidator(); 114 | 115 | fxpay.validateAppReceipt().then(function() { 116 | done(Error('unexpected success')); 117 | }).catch(function(reason) { 118 | assert.instanceOf(reason, errors.InvalidReceipt); 119 | assert.typeOf(reason.productInfo, 'object'); 120 | done(); 121 | }).catch(done); 122 | 123 | validator.finish(); 124 | }); 125 | 126 | it('allows any receipt with splat', function(done) { 127 | helper.appSelf.manifest.installs_allowed_from = ['*']; 128 | 129 | var validator = new helper.ReceiptValidator(); 130 | 131 | fxpay.validateAppReceipt().then(function() { 132 | done(); 133 | }).catch(done); 134 | 135 | validator.finish(); 136 | }); 137 | 138 | it('converts empty installs_allowed_from to splat', function(done) { 139 | // Make this imply installs_allowed_from = ['*']. 140 | delete helper.appSelf.manifest.installs_allowed_from; 141 | 142 | var validator = new helper.ReceiptValidator(); 143 | 144 | fxpay.validateAppReceipt().then(function() { 145 | done(); 146 | }).catch(done); 147 | 148 | validator.finish(); 149 | }); 150 | 151 | it('fails when test receipts are not allowed', function(done) { 152 | var testReceipt = makeReceipt(null, { 153 | typ: 'test-receipt', 154 | }); 155 | helper.appSelf.receipts = [testReceipt]; 156 | 157 | fxpay.validateAppReceipt().then(function() { 158 | done(Error('unexpected success')); 159 | }).catch(function(reason) { 160 | assert.instanceOf(reason, errors.TestReceiptNotAllowed); 161 | assert.typeOf(reason.productInfo, 'object'); 162 | done(); 163 | }).catch(done); 164 | 165 | }); 166 | 167 | it('accepts test receipts', function(done) { 168 | fxpay.configure({allowTestReceipts: true}); 169 | 170 | var testReceipt = makeReceipt(null, { 171 | typ: 'test-receipt', 172 | iss: 'https://payments-alt.allizom.org', 173 | verify: 'https://payments-alt.allizom.org/developers/test-receipt/', 174 | }); 175 | helper.appSelf.receipts = [testReceipt]; 176 | 177 | var validator = new helper.ReceiptValidator({ 178 | verifyUrl: new RegExp( 179 | 'https://payments-alt\\.allizom\\.org/developers/test-receipt/'), 180 | }); 181 | 182 | fxpay.validateAppReceipt().then(function(productInfo) { 183 | assert.equal(productInfo.receiptInfo.status, 'ok'); 184 | done(); 185 | }).catch(done); 186 | 187 | validator.finish(); 188 | }); 189 | 190 | it('fails when no receipt is present', function(done) { 191 | helper.appSelf.receipts = []; 192 | 193 | var validator = new helper.ReceiptValidator(); 194 | 195 | fxpay.validateAppReceipt().then(function() { 196 | done(Error('unexpected success')); 197 | }).catch(function(reason) { 198 | assert.instanceOf(reason, errors.AppReceiptMissing); 199 | assert.typeOf(reason.productInfo, 'object'); 200 | done(); 201 | }).catch(done); 202 | 203 | validator.finish(); 204 | }); 205 | 206 | it('fails when mozApps is null', function(done) { 207 | fxpay.configure({mozApps: null, appSelf: null}); 208 | 209 | fxpay.validateAppReceipt().then(function() { 210 | done(Error('unexpected success')); 211 | }).catch(function(reason) { 212 | assert.instanceOf(reason, errors.PayPlatformUnavailable); 213 | assert.typeOf(reason.productInfo, 'object'); 214 | done(); 215 | }).catch(done); 216 | 217 | }); 218 | 219 | it('fails when appSelf is null', function(done) { 220 | fxpay.configure({appSelf: null}); 221 | helper.appSelf.result = null; 222 | 223 | fxpay.validateAppReceipt().then(function() { 224 | done(Error('unexpected success')); 225 | }).catch(function(reason) { 226 | assert.instanceOf(reason, errors.PayPlatformUnavailable); 227 | assert.typeOf(reason.productInfo, 'object'); 228 | done(); 229 | }).catch(done); 230 | 231 | helper.appSelf.onsuccess(); 232 | }); 233 | 234 | it('fails when multiple receipts are installed', function(done) { 235 | helper.appSelf.receipts = [makeReceipt(), makeReceipt()]; 236 | 237 | fxpay.validateAppReceipt().then(function() { 238 | done(Error('unexpected success')); 239 | }).catch(function(reason) { 240 | assert.instanceOf(reason, errors.NotImplementedError); 241 | assert.typeOf(reason.productInfo, 'object'); 242 | done(); 243 | }).catch(done); 244 | }); 245 | 246 | it('fails when receipt is malformed', function(done) { 247 | helper.appSelf.receipts = ['^%%%$$$$garbage']; 248 | 249 | var validator = new helper.ReceiptValidator(); 250 | 251 | fxpay.validateAppReceipt().then(function() { 252 | done(Error('unexpected success')); 253 | }).catch(function(reason) { 254 | assert.instanceOf(reason, errors.AppReceiptMissing); 255 | assert.typeOf(reason.productInfo, 'object'); 256 | done(); 257 | }).catch(done); 258 | 259 | validator.finish(); 260 | }); 261 | 262 | it('ignores in-app receipts', function(done) { 263 | var appId = '1234'; 264 | var appReceipt = makeReceipt({storedata: 'id=' + appId}); 265 | var inAppProductReceipt = makeReceipt({storedata: 'id=555&inapp_id=234'}); 266 | helper.appSelf.receipts = [ 267 | appReceipt, 268 | inAppProductReceipt, 269 | ]; 270 | 271 | var validator = new helper.ReceiptValidator(); 272 | 273 | fxpay.validateAppReceipt().then(function(productInfo) { 274 | assert.equal(productInfo.productId, appId); 275 | done(); 276 | }).catch(done); 277 | 278 | validator.finish(); 279 | }); 280 | 281 | 282 | function makeReceipt(overrides, receiptData) { 283 | overrides = utils.defaults(overrides, { 284 | productUrl: defaultProductUrl, 285 | storedata: 'id=1234', 286 | }); 287 | return helper.makeReceipt(receiptData, overrides); 288 | } 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /tests/test-web-purchase.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | 'pay', 6 | 'settings', 7 | 'utils', 8 | ], function(errors, fxpay, helper, pay, settings, utils) { 9 | 10 | describe('fxpay.purchase() on the web', function() { 11 | var payReq = {typ: 'mozilla/payments/pay/v1'}; 12 | var fakeJwt = '.' + btoa(JSON.stringify(payReq)) + '.'; 13 | var productId = 'some-uuid'; 14 | 15 | var providerUrlTemplate; 16 | var fakePayWindow; 17 | var windowSpy; 18 | var customPayWindow; 19 | var customWindowSpy; 20 | var handlers; 21 | var clock; 22 | 23 | beforeEach(function() { 24 | helper.setUp(); 25 | handlers = {}; 26 | fakePayWindow = { 27 | closed: false, 28 | close: function() {}, 29 | }; 30 | windowSpy = { 31 | close: sinon.spy(fakePayWindow, 'close'), 32 | }; 33 | customPayWindow = { 34 | close: function() {}, 35 | resizeTo: function() {}, 36 | moveTo: function() {}, 37 | }; 38 | customWindowSpy = { 39 | close: sinon.spy(customPayWindow, 'close'), 40 | resizeTo: sinon.spy(customPayWindow, 'resizeTo'), 41 | moveTo: sinon.spy(customPayWindow, 'moveTo'), 42 | }; 43 | providerUrlTemplate = settings.payProviderUrls[payReq.typ]; 44 | 45 | 46 | fxpay.configure({ 47 | appSelf: null, 48 | mozApps: null, 49 | mozPay: null, 50 | apiUrlBase: 'https://not-the-real-marketplace', 51 | apiVersionPrefix: '/api/v1', 52 | adapter: null, 53 | window: { 54 | location: '', 55 | open: function() { 56 | return fakePayWindow; 57 | }, 58 | addEventListener: function(type, handler) { 59 | handlers[type] = handler; 60 | }, 61 | removeEventListener: function() {}, 62 | }, 63 | }); 64 | 65 | clock = sinon.useFakeTimers(); 66 | // Must be after fakeTimers are setup. 67 | sinon.spy(window, 'clearInterval'); 68 | }); 69 | 70 | afterEach(function() { 71 | helper.tearDown(); 72 | helper.receiptAdd.reset(); 73 | // Must be before clock.restare(). 74 | window.clearInterval.restore(); 75 | clock.restore(); 76 | }); 77 | 78 | it('should open a payment window and resolve', function (done) { 79 | 80 | fxpay.purchase(productId).then(function() { 81 | assert.equal( 82 | fakePayWindow.location, 83 | providerUrlTemplate.replace('{jwt}', fakeJwt)); 84 | assert(windowSpy.close.called); 85 | done(); 86 | }).catch(done); 87 | 88 | helper.resolvePurchase({ 89 | productData: {webpayJWT: fakeJwt}, 90 | payCompleter: function() { 91 | simulatePostMessage({status: 'ok'}); 92 | } 93 | }); 94 | }); 95 | 96 | it('should reject with payment errors', function (done) { 97 | 98 | fxpay.purchase(productId).then(function() { 99 | done(Error('unexpected success')); 100 | }).catch(function(err) { 101 | assert.instanceOf(err, errors.FailedWindowMessage); 102 | assert.equal(err.code, 'DIALOG_CLOSED_BY_USER'); 103 | assert(windowSpy.close.called); 104 | done(); 105 | }).catch(done); 106 | 107 | helper.resolvePurchase({ 108 | productData: {webpayJWT: fakeJwt}, 109 | payCompleter: function() { 110 | simulatePostMessage({status: 'failed', 111 | errorCode: 'DIALOG_CLOSED_BY_USER'}); 112 | } 113 | }); 114 | }); 115 | 116 | it('should allow client to specify a custom window', function (done) { 117 | 118 | fxpay.purchase(productId, { 119 | paymentWindow: customPayWindow, 120 | managePaymentWindow: true, 121 | }).then(function() { 122 | assert.equal( 123 | customPayWindow.location, 124 | providerUrlTemplate.replace('{jwt}', fakeJwt)); 125 | assert(customWindowSpy.resizeTo.called); 126 | assert(customWindowSpy.moveTo.called); 127 | assert(customWindowSpy.close.called); 128 | done(); 129 | }).catch(done); 130 | 131 | helper.resolvePurchase({ 132 | productData: {webpayJWT: fakeJwt}, 133 | payCompleter: function() { 134 | simulatePostMessage({status: 'ok'}); 135 | } 136 | }); 137 | }); 138 | 139 | it('should not manage custom pay windows by default', function (done) { 140 | 141 | fxpay.purchase(productId, { 142 | paymentWindow: customPayWindow, 143 | }).then(function() { 144 | assert.equal( 145 | customPayWindow.location, 146 | providerUrlTemplate.replace('{jwt}', fakeJwt)); 147 | assert(!customWindowSpy.close.called); 148 | done(); 149 | }).catch(done); 150 | 151 | helper.resolvePurchase({ 152 | productData: {webpayJWT: fakeJwt}, 153 | payCompleter: function() { 154 | simulatePostMessage({status: 'ok'}); 155 | } 156 | }); 157 | }); 158 | 159 | it('should close payment window on adapter errors', function (done) { 160 | settings.adapter.startTransaction = function(opt, callback) { 161 | callback('SOME_EARLY_ERROR'); 162 | }; 163 | 164 | fxpay.purchase(productId).then(function() { 165 | done(Error('unexpected success')); 166 | }).catch(function(err) { 167 | assert.equal(err, 'SOME_EARLY_ERROR'); 168 | assert(windowSpy.close.called); 169 | done(); 170 | }).catch(done); 171 | 172 | }); 173 | 174 | it('should not close managed window on adapter errors', function (done) { 175 | settings.adapter.startTransaction = function(opt, callback) { 176 | callback('SOME_EARLY_ERROR'); 177 | }; 178 | 179 | fxpay.purchase(productId, { 180 | paymentWindow: customPayWindow, 181 | }).then(function() { 182 | done(Error('unexpected success')); 183 | }).catch(function(err) { 184 | assert.equal(err, 'SOME_EARLY_ERROR'); 185 | assert(!customWindowSpy.close.called); 186 | done(); 187 | }).catch(done); 188 | 189 | }); 190 | 191 | it('should close payment window on pay module errors', function (done) { 192 | 193 | fxpay.purchase(productId).then(function() { 194 | done(Error('unexpected success')); 195 | }).catch(function(err) { 196 | assert.instanceOf(err, errors.InvalidJwt); 197 | assert(windowSpy.close.called); 198 | done(); 199 | }).catch(done); 200 | 201 | // Force an unexpected JWT type error. 202 | var req = {typ: 'unknown/provider/id'}; 203 | var badJwt = '.' + btoa(JSON.stringify(req)) + '.'; 204 | 205 | helper.resolvePurchase({ 206 | productData: {webpayJWT: badJwt}, 207 | payCompleter: function() {}, 208 | }); 209 | }); 210 | 211 | it('should respond to user closed window', function (done) { 212 | 213 | fxpay.purchase(productId).then(function() { 214 | done(Error('unexpected success')); 215 | }).catch(function(err) { 216 | assert(window.clearInterval.called, 'clearInterval should be called'); 217 | assert.instanceOf(err, errors.PayWindowClosedByUser); 218 | assert.equal(err.code, 'DIALOG_CLOSED_BY_USER'); 219 | done(); 220 | }).catch(done); 221 | 222 | // Respond to fetching the JWT. 223 | helper.server.respondWith('POST', /.*\/webpay\/inapp\/prepare/, 224 | helper.productData({webpayJWT: fakeJwt})); 225 | helper.server.respond(); 226 | 227 | fakePayWindow.closed = true; 228 | clock.tick(600); 229 | 230 | }); 231 | 232 | 233 | function simulatePostMessage(data) { 234 | handlers.message({data: data, 235 | origin: utils.getUrlOrigin(providerUrlTemplate)}); 236 | } 237 | 238 | }); 239 | 240 | 241 | describe('pay.processPayment()', function() { 242 | 243 | it('should reject calls without a paymentWindow', function(done) { 244 | fxpay.configure({mozPay: false}); 245 | pay.processPayment('', function(error) { 246 | assert.instanceOf(error, errors.IncorrectUsage); 247 | done(); 248 | }); 249 | }); 250 | 251 | }); 252 | 253 | 254 | describe('pay.acceptPayMessage()', function() { 255 | var defaultOrigin = 'http://marketplace.firefox.com'; 256 | var fakeWindow; 257 | var clock; 258 | 259 | beforeEach(function() { 260 | fakeWindow = {}; 261 | clock = sinon.useFakeTimers(); 262 | }); 263 | 264 | afterEach(function() { 265 | clock.restore(); 266 | }); 267 | 268 | it('calls back on success', function(done) { 269 | pay.acceptPayMessage( 270 | makeEvent(), defaultOrigin, 271 | fakeWindow, function(err) { 272 | done(err); 273 | } 274 | ); 275 | }); 276 | 277 | it('calls back with error code on failure', function(done) { 278 | pay.acceptPayMessage( 279 | makeEvent({status: 'failed', errorCode: 'EXTERNAL_CODE'}), 280 | defaultOrigin, fakeWindow, function(err) { 281 | assert.instanceOf(err, errors.FailedWindowMessage); 282 | assert.equal(err.code, 'EXTERNAL_CODE'); 283 | done(); 284 | } 285 | ); 286 | }); 287 | 288 | it('rejects unknown statuses', function(done) { 289 | pay.acceptPayMessage( 290 | makeEvent({status: 'cheezborger'}), 291 | defaultOrigin, fakeWindow, function(err) { 292 | assert.instanceOf(err, errors.FailedWindowMessage); 293 | done(); 294 | } 295 | ); 296 | }); 297 | 298 | it('rejects undefined data', function(done) { 299 | pay.acceptPayMessage( 300 | makeEvent({data: null}), defaultOrigin, 301 | fakeWindow, function(err) { 302 | assert.instanceOf(err, errors.FailedWindowMessage); 303 | done(); 304 | } 305 | ); 306 | }); 307 | 308 | it('rejects foreign messages', function(done) { 309 | pay.acceptPayMessage( 310 | makeEvent({origin: 'http://bar.com'}), 'http://foo.com', fakeWindow, 311 | function(err) { 312 | assert.instanceOf(err, errors.UnknownMessageOrigin); 313 | done(); 314 | } 315 | ); 316 | }); 317 | 318 | it('had window closed by user via an unload event', function(done) { 319 | fakeWindow.closed = true; 320 | pay.acceptPayMessage( 321 | makeEvent({status: 'unloaded'}), 322 | defaultOrigin, fakeWindow, function(err) { 323 | assert.instanceOf(err, errors.PayWindowClosedByUser); 324 | assert.equal(err.code, 'DIALOG_CLOSED_BY_USER'); 325 | done(); 326 | } 327 | ); 328 | clock.tick(300); 329 | }); 330 | 331 | function makeEvent(param) { 332 | param = utils.defaults(param, { 333 | status: 'ok', 334 | data: undefined, 335 | errorCode: undefined, 336 | origin: defaultOrigin, 337 | }); 338 | if (typeof param.data === 'undefined') { 339 | param.data = {status: param.status, errorCode: param.errorCode}; 340 | } 341 | return {origin: param.origin, data: param.data}; 342 | } 343 | 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /tests/test-webrt-purchase.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'errors', 3 | 'fxpay', 4 | 'helper', 5 | ], function(errors, fxpay, helper) { 6 | 7 | describe('fxpay.purchase() on broken webRT', function () { 8 | var productId = 'some-uuid'; 9 | var mozPay; 10 | 11 | beforeEach(function() { 12 | helper.setUp(); 13 | mozPay = sinon.spy(helper.mozPayStub); 14 | fxpay.configure({ 15 | appSelf: helper.appSelf, 16 | onBrokenWebRT: true, 17 | mozPay: mozPay, 18 | }); 19 | }); 20 | 21 | afterEach(function() { 22 | helper.tearDown(); 23 | mozPay.reset(); 24 | helper.receiptAdd.reset(); 25 | }); 26 | 27 | it('should pretend a cancel is a success', function(done) { 28 | 29 | fxpay.purchase(productId).then(function() { 30 | done(); 31 | }).catch(done); 32 | 33 | helper.resolvePurchase({ 34 | mozPay: mozPay, 35 | mozPayResolver: function(domRequest) { 36 | domRequest.error = {name: 'USER_CANCELLED'}; 37 | domRequest.onerror(); 38 | }, 39 | }); 40 | 41 | }); 42 | 43 | it('should pass through non-cancel errors', function(done) { 44 | 45 | fxpay.purchase(productId).then(function() { 46 | done(Error('unexpected success')); 47 | }).catch(function(err) { 48 | assert.instanceOf(err, errors.PayPlatformError); 49 | assert.equal(err.code, 'SOME_RANDOM_ERROR'); 50 | done(); 51 | }).catch(done); 52 | 53 | helper.resolvePurchase({ 54 | mozPay: mozPay, 55 | mozPayResolver: function(domRequest) { 56 | domRequest.error = {name: 'SOME_RANDOM_ERROR'}; 57 | domRequest.onerror(); 58 | }, 59 | }); 60 | 61 | }); 62 | 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /umd/end.frag: -------------------------------------------------------------------------------- 1 | 2 | return require('fxpay'); 3 | })); 4 | 5 | -------------------------------------------------------------------------------- /umd/start.frag: -------------------------------------------------------------------------------- 1 | (function (root, factory) { 2 | 'use strict'; 3 | 4 | if (typeof define === 'function') { 5 | define(factory); 6 | } else if (typeof exports === 'object') { 7 | module.exports = factory(); 8 | } else { 9 | root.fxpay = factory(); 10 | } 11 | }(this, function () { 12 | --------------------------------------------------------------------------------