This file was deleted.

@@ -36,7 +36,7 @@ dependencies:
# build the container, use circleci's docker cache workaround
# only use 1 image per day to keep the cache size from getting
# too big and slowing down the build
- I="image-$(date +%j).tgz"; if [[ -e ~/docker/$I ]]; then echo "Loading $I"; gunzip -c ~/docker/$I | docker load; fi
- I="image-$(date +%j).tgz"; if [[ -e ~/docker/$I ]]; then echo "Loading $I"; pigz -d -c ~/docker/$I | docker load; fi

# create a version.json
- >
@@ -70,7 +70,7 @@ dependencies:
- docker images --no-trunc | awk '/^app/ {print $3}' | tee $CIRCLE_ARTIFACTS/docker-image-shasum256.txt

# Clean up any old images and save the new one
- I="image-$(date +%j).tgz"; mkdir -p ~/docker; rm ~/docker/*; docker save app:build | gzip -c > ~/docker/$I; ls -l ~/docker
- I="image-$(date +%j).tgz"; mkdir -p ~/docker; rm ~/docker/*; docker save app:build | pigz --fast -c > ~/docker/$I; ls -l ~/docker

test:
override:
@@ -69,8 +69,6 @@ Events recorded in the logs will include:

* request.summary (a generic event fired for each visit)
* testpilot.newuser
* testpilot.newfeedback
* testpilot.main-install
* testpilot.test-install

Data recorded by the log files will include standard system logging (in the
@@ -158,6 +158,12 @@ gulp.task('addon', function localesTask() {
.pipe(gulp.dest(DEST_PATH + 'addon'));
});

// Copy the static legal.js file to dest
gulp.task('static-script-copy', function staticScriptCopyTask() {
return gulp.src(SRC_PATH + 'scripts/**/*')
.pipe(gulp.dest(DEST_PATH + 'scripts'));
});

gulp.task('build', function buildTask(done) {
runSequence(
'clean',
@@ -167,6 +173,7 @@ gulp.task('build', function buildTask(done) {
'locales',
'addon',
'legal',
'static-script-copy',
done
);
});
@@ -42,6 +42,7 @@ module.exports.templateEnd = `
</div>
</div>
<script src="https://pontoon.mozilla.org/pontoon.js"></script>
<script src="/static/scripts/legal.js"></script>
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)

Large diffs are not rendered by default.

@@ -0,0 +1,77 @@
import base64
import hashlib
import json

from django.conf import settings
from django.core.cache import cache
from django.contrib.staticfiles.storage import staticfiles_storage
from django.contrib.staticfiles import finders

from waffle.views import wafflejs
from constance import config as constance_config

import jinja2
from jinja2.ext import Extension


DEFAULT_CHUNK_SIZE = 64 * 2 ** 10


def _hash_content(content):
"""Build a sha384 hash for subresource integrity use"""
hash = hashlib.sha384()
hash.update(content)
hash_base64 = base64.b64encode(hash.digest()).decode()
return 'sha384-{}'.format(hash_base64)


@jinja2.contextfunction
def staticintegrity(context, name):
"""Hash a local static file for subresource integrity"""
if settings.DEBUG:
# In DEBUG, static files are scattered around and need to be found.
path = finders.find(name)
else:
# Otherwise, we can just look in the static root
if hasattr(staticfiles_storage, 'hashed_files'):
name = staticfiles_storage.hashed_files.get(name, name)
path = staticfiles_storage.path(name)

key = 'staticintegrity-%s' % path
out = cache.get(key)

if out is None:
with open(path, 'rb') as content_file:
out = _hash_content(content_file.read())
cache.set(key, out)

return out


@jinja2.contextfunction
def urlintegrity(context, url):
"""Grab a pre-defined subresource hash for a URL from settings"""
url_hashes = settings.URL_INTEGRITY_HASHES
try:
url_hashes_overrides = json.loads(getattr(
constance_config, 'URL_INTEGRITY_HASHES_OVERRIDES', '{}'))
except:
url_hashes_overrides = {}

# Try the overrides first, then fall back to straight settings
return url_hashes_overrides.get(url, url_hashes.get(url, ''))


# TODO: Would be nice to replace this waffle-specific helper with something
# that could produce a hash with a sub-request to any view.
@jinja2.contextfunction
def waffleintegrity(context, request):
"""Hash the content of /wafflejs for subresource integrity"""
return _hash_content(wafflejs(request).content)


class TestPilotExtension(Extension):
def __init__(self, environment):
environment.globals['staticintegrity'] = staticintegrity
environment.globals['urlintegrity'] = urlintegrity
environment.globals['waffleintegrity'] = waffleintegrity

This file was deleted.

This file was deleted.

@@ -1,17 +1,16 @@
from unittest.mock import patch
from unittest.mock import patch, Mock

import os
import io
import json

from testfixtures import LogCapture
import jsonschema

from django.test import override_settings
from django.core.urlresolvers import reverse
from django.db import OperationalError

from constance.test import override_config

from ..utils import TestCase
from .logging import JsonLogFormatter
from .jinja import _hash_content, waffleintegrity, TestPilotExtension

import logging
logger = logging.getLogger(__name__)
@@ -90,154 +89,76 @@ def test_schema(self):
assert set(['name', 'description', 'repository']).issubset(data.keys())


class TestJsonLogFormatter(TestCase):
class _MockJinjaEnvironment(object):
def __init__(self):
self.globals = {}


MockStorageInstance = Mock()
MockStorageInstance.path = Mock(return_value='foo')
MockStorageInstance.hashed_files = {"foo": "bar"}
MockStorage = Mock(return_value=MockStorageInstance)


class TestPilotJinjaExtensionTests(TestCase):

def setUp(self):
self.handler = LogCapture()
self.logger_name = "TestingTestPilot"
self.formatter = JsonLogFormatter(logger_name=self.logger_name)

def tearDown(self):
self.handler.uninstall()

def _fetchLastLog(self):
self.assertEquals(len(self.handler.records), 1)
details = json.loads(self.formatter.format(self.handler.records[0]))
jsonschema.validate(details, JSON_LOGGING_SCHEMA)
return details

def test_basic_operation(self):
"""Ensure log formatter contains all the expected fields and values"""
message_text = "simple test"
logging.debug(message_text)
details = self._fetchLastLog()

expected_present = ["Timestamp", "Hostname"]
for key in expected_present:
self.assertTrue(key in details)

expected_meta = {
"Severity": 7,
"Type": "root",
"Pid": os.getpid(),
"Logger": self.logger_name,
"EnvVersion": self.formatter.LOGGING_FORMAT_VERSION
}
for key, value in expected_meta.items():
self.assertEquals(value, details[key])

self.assertEquals(details['Fields']['message'], message_text)

def test_custom_paramters(self):
"""Ensure log formatter can handle custom parameters"""
logger = logging.getLogger("mozsvc.test.test_logging")
logger.warn("custom test %s", "one", extra={"more": "stuff"})
details = self._fetchLastLog()

self.assertEquals(details["Type"], "mozsvc.test.test_logging")
self.assertEquals(details["Severity"], 4)

fields = details['Fields']
self.assertEquals(fields["message"], "custom test one")
self.assertEquals(fields["more"], "stuff")

def test_logging_error_tracebacks(self):
"""Ensure log formatter includes exception traceback information"""
try:
raise ValueError("\n")
except Exception:
logging.exception("there was an error")
details = self._fetchLastLog()

expected_meta = {
"Severity": 3,
}
for key, value in expected_meta.items():
self.assertEquals(value, details[key])

fields = details['Fields']
expected_fields = {
'message': 'there was an error',
'error': "ValueError('\\n',)"
}
for key, value in expected_fields.items():
self.assertEquals(value, fields[key])

self.assertTrue(fields['traceback'].startswith('Uncaught exception:'))
self.assertTrue("<class 'ValueError'>" in fields['traceback'])


# https://mana.mozilla.org/wiki/pages/viewpage.action?pageId=42895640
JSON_LOGGING_SCHEMA = json.loads("""
{
"type":"object",
"required":["Timestamp"],
"properties":{
"Timestamp":{
"type":"integer",
"minimum":0
},
"Type":{
"type":"string"
},
"Logger":{
"type":"string"
},
"Hostname":{
"type":"string",
"format":"hostname"
},
"EnvVersion":{
"type":"string",
"pattern":"^\\d+(?:\\.\\d+){0,2}$"
},
"Severity":{
"type":"integer",
"minimum":0,
"maximum":7
},
"Pid":{
"type":"integer",
"minimum":0
},
"Fields":{
"type":"object",
"minProperties":1,
"additionalProperties":{
"anyOf": [
{ "$ref": "#/definitions/field_value"},
{ "$ref": "#/definitions/field_array"},
{ "$ref": "#/definitions/field_object"}
]
}
}
},
"definitions":{
"field_value":{
"type":["string", "number", "boolean"]
},
"field_array":{
"type":"array",
"minItems": 1,
"oneOf": [
{"items": {"type":"string"}},
{"items": {"type":"number"}},
{"items": {"type":"boolean"}}
]
},
"field_object":{
"type":"object",
"required":["value"],
"properties":{
"value":{
"oneOf": [
{ "$ref": "#/definitions/field_value" },
{ "$ref": "#/definitions/field_array" }
]
},
"representation":{"type":"string"}
}
}
}
}
""".replace("\\", "\\\\")) # HACK: Fix escaping for easy copy/paste
self.environment = _MockJinjaEnvironment()
self.extension = TestPilotExtension(self.environment)

def test_hash_content(self):
content = """
Beard irony cold-pressed, venmo chicharrones PBR&B banh mi
meditation. Forage street art meh artisan, tattooed
gochujang pinterest fixie skateboard kombucha crucifix viral.
"""
self.assertEqual(
_hash_content(content.encode()),
'sha384-1hLxRtQHxIHoyvc9LOFMWEOGa2gsPIK+4e5++etpYBwkHjxWe9MQOXp/9e4zYqHm'
)

@override_settings(DEBUG=True)
@patch('django.contrib.staticfiles.finders')
@patch('builtins.open')
def test_staticintegrity_debug(self, mock_open, mock_finders):
content = 'debug file'
expected_hash = _hash_content(content.encode())
mock_finders.find = Mock(return_value='foo')
mock_open.return_value = io.BytesIO(content.encode('utf-8'))
result = self.environment.globals['staticintegrity'](None, 'foo')
self.assertEqual(result, expected_hash)

@override_settings(DEBUG=False)
@override_settings(STATICFILES_STORAGE='testpilot.base.tests.MockStorage')
@patch('builtins.open')
def test_staticintegrity_prod(self, mock_open):
content = 'prod file'
expected_hash = _hash_content(content.encode())
mock_open.return_value = io.BytesIO(content.encode('utf-8'))
result = self.environment.globals['staticintegrity'](None, 'foo')
MockStorageInstance.path.assert_called_with('bar')
self.assertEqual(result, expected_hash)

def test_urlintegrity(self):
expected_url = 'http://example.com'
expected_hash = '8675309'
with self.settings(URL_INTEGRITY_HASHES={expected_url: expected_hash}):
result = self.environment.globals['urlintegrity'](None, expected_url)
self.assertEqual(result, expected_hash)

def test_urlintegrity_override(self):
expected_url = 'http://example.com'
expected_hash = '8675309'
overrides = json.dumps({expected_url: expected_hash})
with self.settings(URL_INTEGRITY_HASHES={expected_url: 'override me'}):
with override_config(URL_INTEGRITY_HASHES_OVERRIDES=overrides):
result = self.environment.globals['urlintegrity'](None, expected_url)
self.assertEqual(result, expected_hash)

@patch('waffle.views._generate_waffle_js')
def test_waffleintegrity(self, mock_generate):
expected_content = 'foobarbaz waffle'
expected_hash = _hash_content(expected_content.encode())
mock_generate.return_value = expected_content
result = waffleintegrity(None, None)
self.assertEqual(result, expected_hash)
@@ -80,8 +80,8 @@ class UserInstallationSerializer(serializers.HyperlinkedModelSerializer):

class Meta:
model = UserInstallation
fields = ('url', 'experiment', 'client_id', 'addon_id', 'features',
'created', 'modified')
fields = ('url', 'experiment', 'client_id', 'addon_id',
'features', 'created', 'modified')

def get_url(self, obj):
request = self.context['request']
@@ -7,6 +7,10 @@

from rest_framework import fields

from testfixtures import LogCapture

from mozilla_cloud_services_logger.formatters import JsonLogFormatter

from ..utils import gravatar_url, TestCase
from ..users.models import UserProfile
from .models import (Experiment, ExperimentTourStep, UserInstallation,
@@ -23,6 +27,8 @@ class BaseTestCase(TestCase):
def setUp(self):
super(BaseTestCase, self).setUp()

self.handler = LogCapture()

self.username = 'johndoe2'
self.password = 'trustno1'
self.email = '%s@example.com' % self.username
@@ -50,6 +56,9 @@ def setUp(self):
addon_id="addon-%s@example.com" % idx
)) for idx in range(1, 4)))

def tearDown(self):
self.handler.uninstall()


class ExperimentViewTests(BaseTestCase):

@@ -215,12 +224,23 @@ def test_installations(self):
self.assertEqual(200, resp.status_code)

# Create another client installation via PUT
self.handler.records = []
client_id_2 = '123456789'
url = reverse('experiment-installation-detail',
args=(experiment.pk, client_id_2))
resp = self.client.put(url, {})
self.assertEqual(200, resp.status_code)

# Ensure that a testpilot.test-install log event was emitted
record = self.handler.records[0]
formatter = JsonLogFormatter(logger_name='testpilot.test-install')
details = json.loads(formatter.format(record))
fields = details['Fields']

self.assertEqual('testpilot.test-install', record.name)
self.assertEqual(fields['uid'], user.id)
self.assertEqual(fields['context'], experiment.title)

# Ensure that the API list result reflects the addition
data = self.jsonGet('experiment-installation-list',
experiment_pk=experiment.pk)
@@ -288,7 +308,7 @@ def test_features_list(self):
def test_me_list(self):
"""/api/me installation listing should include feature flags"""
result = self.jsonGet('me-list')
self.assertEqual(result['installed'][0]['features'], {
self.assertEqual(list(result['installed'].values())[0]['features'], {
self.feature1_title: True,
self.feature2_title: True
})
@@ -63,6 +63,10 @@ def installation_detail(request, experiment_pk, client_id):
installation, created = UserInstallation.objects.get_or_create(
user=request.user, experiment=experiment, client_id=client_id)
installation.save()
logging.getLogger('testpilot.test-install').info('', extra={
'uid': request.user.id,
'context': experiment.title
})
else:
installation = get_object_or_404(
UserInstallation,
@@ -13,7 +13,7 @@ export default Model.extend({
props: {
user: 'object',
clientUUID: 'string',
installed: {type: 'object', default: () => []},
installed: {type: 'object', default: () => {}},
hasAddon: {type: 'boolean', required: true, default: false},
addonTimeout: {type: 'number', default: 1000}
},
@@ -42,10 +42,29 @@ export default Model.extend({
this.hasAddon = Boolean(window.navigator.testpilotAddon);
if (!this.hasAddon) { return false; }

return app.waitForMessage('sync-installed', userData.installed)
let installedData;
if (!window.navigator.testpilotAddonVersion) {
// Previous add-ons didn't expose version info, so assume
// old API data
installedData = [];
for (let k in userData.installed) { // eslint-disable-line prefer-const
if (userData.installed.hasOwnProperty(k)) {
installedData.push(userData.installed[k]);
}
}
} else {
// TODO: Need a semver matching check here someday, but for
// now we can assume
// just the presence of a version means we're in the future.
installedData = userData.installed;
}

return app.waitForMessage('sync-installed', installedData)
.then(result => {
this.clientUUID = result.clientUUID;
this.installed = result.installed;
}).catch((err) => {
console.error('sync-installed failed', err); // eslint-disable-line no-console
});
});
},
@@ -9,6 +9,8 @@ export default PageView.extend({
pageTitle: 'Firefox Test Pilot - Help build Firefox',
pageTitleL10nID: 'pageTitleLandingPage',

skipHeader: true,

events: {
'click [data-hook=install]': 'installClicked',
'click [data-hook=get-started-with-account]': 'getStarted',
@@ -26,8 +28,7 @@ export default PageView.extend({
if (!this.loggedIn) {
this.renderSubview(new ExperimentListView({
loggedIn: this.loggedIn,
isFirefox: this.isFirefox,
skipHeader: true
isFirefox: this.isFirefox
}), '[data-hook="experiment-list"]');
}

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,62 @@
window.addEventListener('DOMContentLoaded', function (event) {
'use strict';

// Add an accordion to the Privacy Notice page
if (location.pathname === '/privacy') {
var ps = document.querySelectorAll('h2 ~ p');
var tablist = ps[0].parentElement;

tablist.setAttribute('role', 'tablist');
tablist.setAttribute('aria-multiselectable', 'true');

Array.prototype.map.call(ps, function (p, i) {
var expanded = false;
var label = document.createElement('span');
var tab = document.createElement('a');
var tabpanel = p.nextElementSibling;
var tabpanel_id = 'privacy-tabpanel-' + i;
var label_id = 'privacy-tab-' + i + '-label';

label.id = label_id;
label.textContent = p.textContent;
tab.href = '#';
tab.textContent = 'Learn More';
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('aria-expanded', 'false');
tab.setAttribute('aria-controls', tabpanel_id);
tab.setAttribute('aria-labelledby', label_id);
tabpanel.id = tabpanel_id;
tabpanel.tabIndex = -1;
tabpanel.setAttribute('role', 'tabpanel');
tabpanel.setAttribute('aria-labelledby', label_id);
tabpanel.setAttribute('aria-hidden', 'true');
tabpanel.hidden = true;
tabpanel.style.display = 'none';

tab.addEventListener('click', function (event) {
expanded = !expanded;
tab.textContent = expanded ? 'Show Less' : 'Learn More';
tab.setAttribute('aria-expanded', expanded);
tab.focus();
tabpanel.setAttribute('aria-hidden', !expanded);
tabpanel.hidden = !expanded;
tabpanel.style.display = expanded ? 'block' : 'none';
event.preventDefault();
});

tab.addEventListener('focus', function (event) {
tab.setAttribute('aria-selected', 'true');
});

tab.addEventListener('blur', function (event) {
tab.setAttribute('aria-selected', 'false');
});

p.innerHTML = '';
p.appendChild(label);
p.appendChild(document.createTextNode(' '));
p.appendChild(tab);
});
}
});
@@ -27,6 +27,7 @@
.title {
background: $transparent-black-05;
border-bottom: 1px solid $transparent-black-1;
color: $black;
}
}

@@ -14,6 +14,10 @@ const test = around(tape)
app.me = new Me({
user: {
id: 'gary@busey.net'
},
installed: {
'slsk@google.net': {},
'wheee@mozilla.org': {}
}
});
app.sendToGA = () => {};
@@ -144,11 +148,6 @@ test('feedback button uses the expected survey URL', t => {
'&installed=slsk%40google.net' +
'&installed=wheee%40mozilla.org';

app.me.installed = {
'slsk@google.net': {},
'wheee@mozilla.org': {}
};

myView.render();

t.ok(myView.query('[data-hook=feedback]').href === expectedURL);
@@ -3,8 +3,11 @@
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="{{ static('images/favicon.ico') }}">
<link rel="stylesheet" href="https://code.cdn.mozilla.net/fonts/fira.css">
<link rel="stylesheet" href="{{ static('styles/main.css') }}">
<link rel="stylesheet" href="https://code.cdn.mozilla.net/fonts/fira.css"
crossorigin="anonymous"
integrity="{{ urlintegrity('https://code.cdn.mozilla.net/fonts/fira.css') }}">
<link rel="stylesheet" href="{{ static('styles/main.css') }}"
integrity="{{ staticintegrity('styles/main.css') }}">

<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
@@ -16,21 +19,24 @@
{% set meta_url = request.build_absolute_uri() %}

<title>{{ meta_title }}</title>
<meta property="og:locale" content="en_US" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{{ meta_title }}" />
<meta property="twitter:title" content="{{ meta_title }}" />

<link rel="canonical" href="{{ meta_url }}">
<meta name="description" content="{{ meta_description }}" />

<meta property="og:description" content="{{ meta_description }}" />
<meta property="twitter:description" content="{{ meta_description }}" />
<link rel="canonical" href="{{ meta_url }}">
<meta property="og:image" content="{{ request.build_absolute_uri(static('images/thumbnail-facebook.png')) }}" />
<meta property="og:locale" content="en_US" />
<meta property="og:title" content="{{ meta_title }}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ meta_url }}" />
<meta property="twitter:url" content="{{ meta_url }}" />

<meta property="og:image"
content="{{ request.build_absolute_uri(static('images/thumbnail-facebook.png')) }}" />
<meta property="twitter:image"
content="{{ request.build_absolute_uri(static('images/thumbnail-twitter.png')) }}" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:description" content="{{ meta_description }}" />
<meta name="twitter:image" content="{{ request.build_absolute_uri(static('images/thumbnail-twitter.png')) }}" />
<meta name="twitter:site" content="@FxTestPilot" />
<meta name="twitter:title" content="{{ meta_title }}" />


</head>
<body>

@@ -60,9 +66,14 @@ <h1 data-l10n-id="noScriptHeading" class="title">Uh oh...</h1>

</div>

<script src="{{ url('wafflejs') }}"></script>
<script src="{{ static('app/app.js') }}"></script>
<script src="https://pontoon.mozilla.org/pontoon.js"></script>
<script src="{{ url('wafflejs') }}"
integrity="{{ waffleintegrity(request) }}"></script>
<script src="{{ static('app/app.js') }}"
integrity="{{ staticintegrity('app/app.js') }}"></script>
<script src="https://pontoon.mozilla.org/pontoon.js"
crossorigin="anonymous"
integrity="{{ urlintegrity('https://pontoon.mozilla.org/pontoon.js') }}"></script>

<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)
@@ -112,7 +112,7 @@ def path(*args):
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'waffle.middleware.WaffleMiddleware',
'testpilot.base.middleware.RequestSummaryLogger',
'mozilla_cloud_services_logger.django.middleware.RequestSummaryLogger',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
)
@@ -132,7 +132,9 @@ def path(*args):
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
],
'PAGE_SIZE': 10
'PAGE_SIZE': 10,
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
'DEFAULT_VERSION': '1.0.0'
}

SOCIALACCOUNT_PROVIDERS = {
@@ -335,6 +337,7 @@ def lazy_langs():
'newstyle_gettext': True,
'extensions': DJANGO_JINJA_DEFAULT_EXTENSIONS + [
'waffle.jinja.WaffleExtension',
'testpilot.base.jinja.TestPilotExtension',
],
'context_processors': [
'testpilot.base.context_processors.settings',
@@ -420,7 +423,7 @@ def lazy_langs():
'datefmt': '%Y-%m-%d %H:%M:%S'
},
'json': {
'()': 'testpilot.base.logging.JsonLogFormatter',
'()': 'mozilla_cloud_services_logger.formatters.JsonLogFormatter',
'logger_name': 'TestPilot'
}
},
@@ -464,6 +467,34 @@ def lazy_langs():
}
}

# Subresource integrity hashes used by urlintegrity() in templates
#
# NOTE: These need to be updated manually whenever these remote resources
# change, ideally along with at least some review of what changed. The hashes
# can be generated at https://www.srihash.org/ or with a command like:
#
# curl -s https://pontoon.mozilla.org/pontoon.js \
# | openssl dgst -sha384 -binary \
# | openssl base64 -A
#
# See also:
# https://hacks.mozilla.org/2015/09/subresource-integrity-in-firefox-43/
#
# TODO: Management command to help update hashes for all listed resources?
URL_INTEGRITY_HASHES = {
"https://pontoon.mozilla.org/pontoon.js":
"sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb",
"https://code.cdn.mozilla.net/fonts/fira.css":
"sha384-APhs/OUouhH+ZbBANL3+7a5J1sVSYyfUhGxTWiDMkTuEIO5fXWZonzNEj+CagDB7",
}

CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
CONSTANCE_CONFIG = {
'URL_INTEGRITY_HASHES_OVERRIDES': (
'{}',
'JSON map of URLs to subresource integrity hashes, overrides '
'URL_INTEGRITY_HASHES in settings.py. Useful for updates in 3rd party '
'resources between deployment windows - '
'{"https://example.com/foo.js": "sha384-yaddayadda"}'
),
}
@@ -4,3 +4,6 @@
class TestPilotUsersAppConfig(AppConfig):
name = 'testpilot.users'
verbose_name = 'Test Pilot Users'

def ready(self):
import testpilot.users.signals # noqa
@@ -0,0 +1,20 @@
"""
Customizations to the signup & login process for Test Pilot.
See also: http://django-allauth.readthedocs.org/en/latest/signals.html
"""
from django.dispatch import receiver

from allauth.account.signals import user_signed_up

import logging


@receiver(user_signed_up)
def invite_only_signup_handler(sender, **kwargs):
"""
Sent when a user signs up for an account.
"""
user = kwargs['user']
logger = logging.getLogger('testpilot.newuser')
logger.info('', extra={'uid': user.id})
@@ -11,8 +11,14 @@

from rest_framework import fields

from testfixtures import LogCapture

from allauth.account.signals import user_signed_up
from allauth.account.models import EmailAddress
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
from allauth.socialaccount.models import (SocialApp, SocialAccount,
SocialToken)

from mozilla_cloud_services_logger.formatters import JsonLogFormatter

from .providers.fxa.provider import FirefoxAccountsProvider

@@ -261,7 +267,7 @@ def test_get_logged_in(self):
'username': 'johndoe'
},
'addon': self.addonData,
'installed': []
'installed': {}
}
)

@@ -279,17 +285,19 @@ def test_get_logged_in(self):

self.assertEqual(len(result_data['installed']), 1)
self.assertDictEqual(
result_data['installed'][0],
result_data['installed'],
{
'experiment': 'http://testserver/api/experiments/%s' % experiment.pk,
'addon_id': 'addon-1@example.com',
'client_id': client_id,
'features': {},
'url':
'addon-1@example.com': {
'experiment': 'http://testserver/api/experiments/%s' % experiment.pk,
'addon_id': 'addon-1@example.com',
'client_id': client_id,
'features': {},
'url':
'http://testserver/api/experiments/%s/installations/%s' %
(experiment.pk, client_id),
'created': date_field.to_representation(installation.created),
'modified': date_field.to_representation(installation.modified),
'created': date_field.to_representation(installation.created),
'modified': date_field.to_representation(installation.modified),
}
}
)

@@ -365,3 +373,40 @@ def test_retire_request(self):
self.assertEqual(0, SocialAccount.objects.filter(user=self.user).count())
self.assertEqual(0, SocialToken.objects
.filter(account=self.social_account).count())


class UserSignupTests(TestCase):

def setUp(self):
self.handler = LogCapture()
self.formatter = JsonLogFormatter(logger_name='testpilot.newuser')

self.username = 'newuserdoe2'
self.password = 'trustno1'
self.email = '%s@example.com' % self.username

self.user = User.objects.create_user(
username=self.username,
email=self.email,
password=self.password)

UserProfile.objects.filter(user=self.user).delete()

def tearDown(self):
self.handler.uninstall()

def test_newuser_log_event(self):
"""testpilot.newuser log event should be emitted on signup"""
self.user.is_active = True
user_signed_up.send(sender=self.user.__class__,
request=None,
user=self.user)

self.assertEquals(len(self.handler.records), 1)
record = self.handler.records[0]

details = json.loads(self.formatter.format(record))
self.assertTrue('Fields' in details)

fields = details['Fields']
self.assertEqual(fields['uid'], self.user.id)
@@ -8,8 +8,8 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import api_view, permission_classes

from ..experiments.models import UserInstallation
from ..experiments.serializers import UserInstallationSerializer
from ..experiments.models import UserInstallation
from .models import UserProfile
from .serializers import UserProfileSerializer

@@ -37,9 +37,13 @@ def list(self, request):
"name": "Test Pilot",
"url": settings.ADDON_URL
},
"installed": UserInstallationSerializer(
UserInstallation.objects.filter(user=user), many=True,
context={'request': request}).data
"installed": dict(
(obj.experiment.addon_id,
UserInstallationSerializer(obj, context={
'request': request
}).data)
for obj in UserInstallation.objects.filter(user=user)
)
})