Merge ~matt-goodall/snapstore-client:per-store-config into snapstore-client:master

Proposed by Matt Goodall
Status: Merged
Approved by: Matt Goodall
Approved revision: 819d7029bab7902b0b62c558e503e54fb497ae5f
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~matt-goodall/snapstore-client:per-store-config
Merge into: snapstore-client:master
Diff against target: 1307 lines (+402/-372)
11 files modified
README (+5/-17)
snapstore (+9/-1)
snapstore_client/config.py (+44/-125)
snapstore_client/logic/login.py (+22/-11)
snapstore_client/logic/overrides.py (+36/-4)
snapstore_client/logic/tests/test_login.py (+83/-27)
snapstore_client/logic/tests/test_overrides.py (+35/-11)
snapstore_client/tests/test_config.py (+66/-63)
snapstore_client/tests/test_webservices.py (+51/-56)
snapstore_client/tests/testfixtures.py (+28/-27)
snapstore_client/webservices.py (+23/-30)
Reviewer Review Type Date Requested Status
Matt Goodall (community) Approve
Adam Collard (community) Approve
Review via email: mp+331064@code.launchpad.net

Commit message

Configure a "store" at login and use that config for future calls.

Replaces old config consisting of fixed service URLs (too fine-grained anyway) and per-store credentials. A store's config now consists of the store's URL, SSO's URL, and the user credentials (macaroons etc).

For now, there is only one store - called "default" - but the command line could be extended to login and then select a store with a different name.

Description of the change

The diff makes it look more complicated than it is I think.

To help review ...

The big difference is that there's no global/fixed state any more - config is loaded at the start of a command and explicitly passed through to things like the webservices calls. No more importing config from files relative to Python modules \o/.

As a result, config management changed quite a lot although was largely based on the old Credentials bit of the config. It's actually simpler now.

To post a comment you must log in.
Revision history for this message
Adam Collard (adam-collard) :
review: Approve
Revision history for this message
Matt Goodall (matt-goodall) :
Revision history for this message
Otto Co-Pilot (otto-copilot) wrote :
Revision history for this message
Matt Goodall (matt-goodall) :
review: Approve
Revision history for this message
Otto Co-Pilot (otto-copilot) wrote :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/README b/README
index 8c40b12..6171594 100644
--- a/README
+++ b/README
@@ -1,20 +1,8 @@
1SIAB client script.1snapstore administration client
2===================2===============================
33
4This repo contains a client to adminster a snapstore.
45
5This repo contains a few points of interest:6After authenticating with a snapstore the administrator can:
67
7 * The serve-snaps.py script will start a simple HTTP server to serve snap8 - manage revision overrides
8 files from 'snap_storage'. Please don't use this in production, it's only
9 supposed to be used for dev setups.
10
11 * The snapstore script is a CLI UI to administer the snap store. Currently the
12 only command is 'upload'. It can do two things:
13
14 - upload a snap to the store, but don't release it::
15
16 $ snapstore upload /path/to/file.snap
17
18 - upload and release a snap to a given channel:
19
20 $ snapstore upload --channel /path/to/file.snap
diff --git a/snapstore b/snapstore
index ec0978d..9c2b0b1 100755
--- a/snapstore
+++ b/snapstore
@@ -17,6 +17,9 @@ from snapstore_client.logic.overrides import (
17)17)
1818
1919
20DEFAULT_SSO_URL = 'https://login.ubuntu.com/'
21
22
20def main():23def main():
21 configure_logging()24 configure_logging()
22 args = parse_args()25 args = parse_args()
@@ -27,8 +30,13 @@ def parse_args():
27 parser = argparse.ArgumentParser()30 parser = argparse.ArgumentParser()
28 subparsers = parser.add_subparsers(help='sub-command help')31 subparsers = parser.add_subparsers(help='sub-command help')
2932
30 login_parser = subparsers.add_parser('login', help='Sign into a store.')33 login_parser = subparsers.add_parser(
34 'login', help='Sign into a store.',
35 formatter_class=argparse.ArgumentDefaultsHelpFormatter)
36 login_parser.add_argument('store_url', help='Store URL')
31 login_parser.add_argument('email', help='Ubuntu One SSO email', nargs='?')37 login_parser.add_argument('email', help='Ubuntu One SSO email', nargs='?')
38 login_parser.add_argument('--sso-url', help='Ubuntu One SSO URL',
39 default=DEFAULT_SSO_URL)
32 login_parser.set_defaults(func=login)40 login_parser.set_defaults(func=login)
3341
34 list_overrides_parser = subparsers.add_parser(42 list_overrides_parser = subparsers.add_parser(
diff --git a/snapstore_client/config.py b/snapstore_client/config.py
index 94df99b..ca7beb9 100644
--- a/snapstore_client/config.py
+++ b/snapstore_client/config.py
@@ -6,150 +6,69 @@
66
7import configparser7import configparser
8import os8import os
9from urllib.parse import urlparse
109
11from xdg import BaseDirectory10from xdg import BaseDirectory
1211
1312
14ROOT = os.path.abspath(13class Config:
15 os.path.join(
16 os.path.dirname(__file__),
17 '..'
18 )
19)
2014
2115 xdg_name = 'snapstore-client'
22DEFAULT_TEST_CONFIG = {
23 'services': {
24 'snapdevicegw': 'http://localhost:8000/',
25 'storage': 'http://localhost:8005/',
26 }
27}
28
29
30def _production_read_config():
31 root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
32 config = configparser.ConfigParser(dict(ROOT=root))
33 default_path = os.path.join(root, 'service.conf')
34 config.read(default_path)
35 return {sec: dict(config.items(sec)) for sec in config.sections()}
36
37
38class ConfigProvider(object):
39
40 """Provide a single entry point for reading config that can be altered.
41
42 This class allows production code to call 'read_config' and get a copy of
43 test configuration during test runs, but production config during normal
44 operations.
45 """
46
47 def __init__(self, default_test_config):
48 self._test_override = []
49 self._default_test_config = default_test_config
50
51 def __call__(self):
52 if self._test_override:
53 return self._test_override[-1]
54 else:
55 return _production_read_config()
56
57 def merge_config(self, config, new_config):
58 merged_config = config.copy()
59 for key in new_config.keys():
60 if key in config.keys():
61 if isinstance(config[key], dict) and \
62 isinstance(new_config[key], dict):
63 merged_config[key] = self.merge_config(
64 config[key], new_config[key])
65 else:
66 merged_config[key] = new_config[key]
67 else:
68 merged_config[key] = new_config[key]
69 return merged_config
70
71 def override_for_test(self, new_config):
72 """Override configuration for a test.
73
74 In a test, if you wanted to set a value, you can do this::
75
76 from snapdevicegw.config import read_config
77
78 def test_something(self):
79 new_config = {
80 'worker': {
81 'some_setting': 'some_value'
82 }
83 }
84 self.addCleanup(read_config.override_for_test(new_config))
85
86 The configuration provided is merged on top of DEFAULT_TEST_CONFIG
87 (see above), so there's no need to specify values that are in the
88 default config.
89 """
90 if self._test_override:
91 source = self._test_override[-1]
92 else:
93 source = self._default_test_config.copy()
94 config = self.merge_config(source, new_config)
95 self._test_override.append(config)
96 return self._test_override.pop
97
98
99read_config = ConfigProvider(DEFAULT_TEST_CONFIG)
100
101
102class Credentials:
103 """Per-user credentials storage."""
10416
105 def __init__(self):17 def __init__(self):
106 self.parser = configparser.ConfigParser()18 self.parser = configparser.ConfigParser()
107 self.load()19 self.load()
10820
109 @property21 def load(self):
110 def _section_name(self):22 path = BaseDirectory.load_first_config(
111 # The only section we care about is the host from the snapdevicegw23 self.xdg_name, 'config.ini')
112 # URL.24 if path is not None and os.path.exists(path):
113 return urlparse(read_config()['services']['snapdevicegw']).netloc25 self.parser.read(path)
26
27 def save(self):
28 path = os.path.join(
29 BaseDirectory.save_config_path(self.xdg_name),
30 'config.ini')
31 # TODO: better to write atomically
32 with open(path, 'w') as f:
33 self.parser.write(f)
11434
115 def get(self, option_name):35 def get(self, section, option):
116 try:36 try:
117 return self.parser.get(self._section_name, option_name)37 return self.parser.get(section, option)
118 except (configparser.NoSectionError,38 except (configparser.NoSectionError,
119 configparser.NoOptionError,39 configparser.NoOptionError,
120 KeyError):40 KeyError):
121 return None41 return None
12242
123 def set(self, option_name, value):43 def set(self, section, option, value):
124 section_name = self._section_name44 if not self.parser.has_section(section):
125 if not self.parser.has_section(section_name):45 self.parser.add_section(section)
126 self.parser.add_section(section_name)46 return self.parser.set(section, option, value)
127 return self.parser.set(section_name, option_name, value)
12847
129 def is_empty(self):48 def section(self, name):
130 # Only check the current section49 return Section(self, name)
131 section_name = self._section_name
132 if self.parser.has_section(section_name):
133 if self.parser.options(section_name):
134 return False
135 return True
13650
137 def load(self):51 def store_section(self, name):
138 path = BaseDirectory.load_first_config(52 return Section(self, 'store:'+name)
139 'snapstore-client', 'credentials.cfg')
140 if path is not None and os.path.exists(path):
141 self.parser.read(path)
14253
143 @staticmethod
144 def save_path():
145 return os.path.join(
146 BaseDirectory.save_config_path('snapstore-client'),
147 'credentials.cfg')
14854
149 def save(self):55class Section:
150 path = self.save_path()
151 with open(path, 'w') as f:
152 self.parser.write(f)
15356
154 def clear(self):57 def __init__(self, config, name):
155 self.parser.remove_section(self._section_name)58 self.config = config
59 self.name = name
60
61 def get(self, option):
62 return self.config.get(self.name, option)
63
64 def set(self, option, value):
65 return self.config.set(self.name, option, value)
66
67 # XXX: mostly a hack
68 # I don't think it's nice to expose save like this because it implies only
69 # the section is updated. However, the webservices code currently refreshes
70 # a store's macaroon and therefore needs to update and save config.
71 # A better approach may be to *always* save config at the end of the
72 # process so the internal implementation doesn't care about persistence.
73 def save(self):
74 self.config.save()
diff --git a/snapstore_client/logic/login.py b/snapstore_client/logic/login.py
index 47ab6b8..2963aef 100644
--- a/snapstore_client/logic/login.py
+++ b/snapstore_client/logic/login.py
@@ -16,9 +16,9 @@ from snapstore_client import (
16logger = logging.getLogger(__name__)16logger = logging.getLogger(__name__)
1717
1818
19def _extract_caveat_id(root_macaroon):19def _extract_caveat_id(sso_url, root_macaroon):
20 macaroon = Macaroon.deserialize(root_macaroon)20 macaroon = Macaroon.deserialize(root_macaroon)
21 sso_host = urlparse(config.read_config()['services']['sso']).netloc21 sso_host = urlparse(sso_url).netloc
22 for caveat in macaroon.caveats:22 for caveat in macaroon.caveats:
23 if caveat.location == sso_host:23 if caveat.location == sso_host:
24 return caveat.caveat_id24 return caveat.caveat_id
@@ -27,22 +27,26 @@ def _extract_caveat_id(root_macaroon):
2727
2828
29def login(args):29def login(args):
30 # TODO: validate these before using to avoid ugly errors.
31 gw_url = args.store_url
32 sso_url = args.sso_url
33
30 logger.info('Enter your Ubuntu One SSO credentials.')34 logger.info('Enter your Ubuntu One SSO credentials.')
31 email = args.email35 email = args.email
32 if not email:36 if not email:
33 email = input('Email: ')37 email = input('Email: ')
34 password = getpass.getpass('Password: ')38 password = getpass.getpass('Password: ')
3539
36 root = ws.issue_store_admin()40 root = ws.issue_store_admin(gw_url)
37 caveat_id = _extract_caveat_id(root)41 caveat_id = _extract_caveat_id(sso_url, root)
38 try:42 try:
39 try:43 try:
40 unbound_discharge = ws.get_sso_discharge(44 unbound_discharge = ws.get_sso_discharge(
41 email, password, caveat_id)45 sso_url, email, password, caveat_id)
42 except exceptions.StoreTwoFactorAuthenticationRequired:46 except exceptions.StoreTwoFactorAuthenticationRequired:
43 one_time_password = input('Second-factor auth: ')47 one_time_password = input('Second-factor auth: ')
44 unbound_discharge = ws.get_sso_discharge(48 unbound_discharge = ws.get_sso_discharge(
45 email, password, caveat_id,49 sso_url, email, password, caveat_id,
46 one_time_password=one_time_password)50 one_time_password=one_time_password)
47 except exceptions.StoreAuthenticationError as e:51 except exceptions.StoreAuthenticationError as e:
48 logger.error('Login failed.')52 logger.error('Login failed.')
@@ -54,10 +58,17 @@ def login(args):
54 logger.error('%s: %s', key, value)58 logger.error('%s: %s', key, value)
55 return 159 return 1
5660
57 credentials = config.Credentials()61 cfg = config.Config()
58 credentials.set('root', root)62 # For now, the store is always called "default". In the future we may want
59 credentials.set('unbound_discharge', unbound_discharge)63 # to support multiple stores by allowing the user to provide a nice name
60 credentials.set('email', email)64 # for a store at login that can be used to select the store for later
61 credentials.save()65 # operations.
66 store = cfg.store_section('default')
67 store.set('gw_url', gw_url)
68 store.set('sso_url', sso_url)
69 store.set('root', root)
70 store.set('unbound_discharge', unbound_discharge)
71 store.set('email', email)
72 cfg.save()
6273
63 return 074 return 0
diff --git a/snapstore_client/logic/overrides.py b/snapstore_client/logic/overrides.py
index f514c48..54e421e 100644
--- a/snapstore_client/logic/overrides.py
+++ b/snapstore_client/logic/overrides.py
@@ -5,6 +5,7 @@ import logging
5from requests.exceptions import HTTPError5from requests.exceptions import HTTPError
66
7from snapstore_client import (7from snapstore_client import (
8 config,
8 exceptions,9 exceptions,
9 webservices as ws,10 webservices as ws,
10)11)
@@ -19,13 +20,30 @@ logger = logging.getLogger(__name__)
1920
20def _log_credentials_error(e):21def _log_credentials_error(e):
21 logger.error('%s', e)22 logger.error('%s', e)
22 logger.error('Have you run "snapstore login"?')23 logger.error('Try to "snapstore login" again.')
24
25
26def _check_default_store(cfg):
27 """Load the default store from the config."""
28 store = cfg.store_section('default')
29 # If the gw URL is configured then everything else should be too.
30 if not store.get('gw_url'):
31 logger.error(
32 'No store configuration found. Have you run "snapstore login"?')
33 return None
34 return store
2335
2436
25def list_overrides(args):37def list_overrides(args):
38 cfg = config.Config()
39 store = _check_default_store(cfg)
40 if not store:
41 return 1
42
26 try:43 try:
27 response = ws.refresh_if_necessary(44 response = ws.refresh_if_necessary(
28 ws.get_overrides, args.snap_name, series=args.series)45 store, ws.get_overrides,
46 store, args.snap_name, series=args.series)
29 except exceptions.InvalidCredentials as e:47 except exceptions.InvalidCredentials as e:
30 _log_credentials_error(e)48 _log_credentials_error(e)
31 return 149 return 1
@@ -37,6 +55,11 @@ def list_overrides(args):
3755
3856
39def override(args):57def override(args):
58 cfg = config.Config()
59 store = _check_default_store(cfg)
60 if not store:
61 return 1
62
40 overrides = []63 overrides = []
41 for channel_map_entry in args.channel_map_entries:64 for channel_map_entry in args.channel_map_entries:
42 channel, revision = channel_map_string_to_tuple(channel_map_entry)65 channel, revision = channel_map_string_to_tuple(channel_map_entry)
@@ -47,7 +70,9 @@ def override(args):
47 'series': args.series,70 'series': args.series,
48 })71 })
49 try:72 try:
50 response = ws.refresh_if_necessary(ws.set_overrides, overrides)73 response = ws.refresh_if_necessary(
74 store, ws.set_overrides,
75 store, overrides)
51 except exceptions.InvalidCredentials as e:76 except exceptions.InvalidCredentials as e:
52 _log_credentials_error(e)77 _log_credentials_error(e)
53 return 178 return 1
@@ -59,6 +84,11 @@ def override(args):
5984
6085
61def delete_override(args):86def delete_override(args):
87 cfg = config.Config()
88 store = _check_default_store(cfg)
89 if not store:
90 return 1
91
62 overrides = []92 overrides = []
63 for channel in args.channels:93 for channel in args.channels:
64 overrides.append({94 overrides.append({
@@ -68,7 +98,9 @@ def delete_override(args):
68 'series': args.series,98 'series': args.series,
69 })99 })
70 try:100 try:
71 response = ws.refresh_if_necessary(ws.set_overrides, overrides)101 response = ws.refresh_if_necessary(
102 store, ws.set_overrides,
103 store, overrides)
72 except exceptions.InvalidCredentials as e:104 except exceptions.InvalidCredentials as e:
73 _log_credentials_error(e)105 _log_credentials_error(e)
74 return 1106 return 1
diff --git a/snapstore_client/logic/tests/test_login.py b/snapstore_client/logic/tests/test_login.py
index 20d9d74..25ef14f 100644
--- a/snapstore_client/logic/tests/test_login.py
+++ b/snapstore_client/logic/tests/test_login.py
@@ -2,9 +2,8 @@
22
3import json3import json
4from unittest import mock4from unittest import mock
5from urllib.parse import urljoin5from urllib.parse import urljoin, urlparse
66
7from acceptable._doubles import set_service_locations
8import fixtures7import fixtures
9from pymacaroons import Macaroon8from pymacaroons import Macaroon
10import responses9import responses
@@ -20,20 +19,15 @@ from snapstore_client import (
20 exceptions,19 exceptions,
21)20)
22from snapstore_client.logic.login import login21from snapstore_client.logic.login import login
23from snapstore_client.tests import (22from snapstore_client.tests import factory
24 factory,
25 testfixtures,
26)
2723
2824
29class LoginTests(TestCase):25class LoginTests(TestCase):
3026
31 def setUp(self):27 def setUp(self):
32 super().setUp()28 super().setUp()
33 self.useFixture(testfixtures.ConfigOverrideFixture(29 self.default_gw_url = 'http://store.local/'
34 {'services': {'sso': 'https://login.example.com/'}}))30 self.default_sso_url = 'https://login.staging.ubuntu.com/'
35 service_locations = config.read_config()['services']
36 set_service_locations(service_locations)
37 self.logger = self.useFixture(fixtures.FakeLogger())31 self.logger = self.useFixture(fixtures.FakeLogger())
38 self.config_path = self.useFixture(fixtures.TempDir()).path32 self.config_path = self.useFixture(fixtures.TempDir()).path
39 self.useFixture(fixtures.MonkeyPatch(33 self.useFixture(fixtures.MonkeyPatch(
@@ -60,27 +54,33 @@ class LoginTests(TestCase):
60 iter_responses = iter(full_responses)54 iter_responses = iter(full_responses)
61 return lambda request: next(iter_responses)55 return lambda request: next(iter_responses)
6256
63 def make_args(self, email=None):57 def make_args(self, store_url=None, sso_url=None, email=None):
64 return factory.Args(email=email)58 return factory.Args(
59 store_url=store_url or self.default_gw_url,
60 sso_url=sso_url or self.default_sso_url,
61 email=email,
62 )
6563
66 def add_issue_store_admin_response(self, *response_templates):64 def add_issue_store_admin_response(self, *response_templates, gw_url=None):
67 devicegw_root = config.read_config()['services']['snapdevicegw']65 gw_url = gw_url or self.default_gw_url
68 issue_store_admin_url = urljoin(66 issue_store_admin_url = urljoin(gw_url, '/v2/auth/issue-store-admin')
69 devicegw_root, '/v2/auth/issue-store-admin')
70 responses.add_callback(67 responses.add_callback(
71 'POST', issue_store_admin_url,68 'POST', issue_store_admin_url,
72 self.make_responses_callback(response_templates))69 self.make_responses_callback(response_templates))
7370
74 def add_get_sso_discharge_response(self, *response_templates):71 def add_get_sso_discharge_response(self, *response_templates,
75 sso_root = config.read_config()['services']['sso']72 sso_url=None):
76 discharge_url = urljoin(sso_root, '/api/v2/tokens/discharge')73 sso_url = sso_url or self.default_sso_url
74 discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge')
77 responses.add_callback(75 responses.add_callback(
78 'POST', discharge_url,76 'POST', discharge_url,
79 self.make_responses_callback(response_templates))77 self.make_responses_callback(response_templates))
8078
81 def make_root_macaroon(self):79 def make_root_macaroon(self, sso_url=None):
82 macaroon = Macaroon()80 macaroon = Macaroon()
83 macaroon.add_third_party_caveat('login.example.com', 'key', 'payload')81 sso_url = sso_url or self.default_sso_url
82 sso_host = urlparse(sso_url).netloc
83 macaroon.add_third_party_caveat(sso_host, 'key', 'payload')
84 return macaroon.serialize()84 return macaroon.serialize()
8585
86 @responses.activate86 @responses.activate
@@ -161,8 +161,10 @@ class LoginTests(TestCase):
161 'caveat_id': 'payload',161 'caveat_id': 'payload',
162 'otp': '123456',162 'otp': '123456',
163 }, json.loads(responses.calls[2].request.body.decode()))163 }, json.loads(responses.calls[2].request.body.decode()))
164 self.assertThat(config.Credentials().parser, ContainsDict({164 self.assertThat(config.Config().parser, ContainsDict({
165 'localhost:8000': MatchesDict({165 'store:default': MatchesDict({
166 'gw_url': Equals(self.default_gw_url),
167 'sso_url': Equals(self.default_sso_url),
166 'root': Equals(root),168 'root': Equals(root),
167 'unbound_discharge': Equals('dummy'),169 'unbound_discharge': Equals('dummy'),
168 'email': Equals('user@example.org'),170 'email': Equals('user@example.org'),
@@ -190,8 +192,10 @@ class LoginTests(TestCase):
190 'password': 'secret',192 'password': 'secret',
191 'caveat_id': 'payload',193 'caveat_id': 'payload',
192 }, json.loads(responses.calls[1].request.body.decode()))194 }, json.loads(responses.calls[1].request.body.decode()))
193 self.assertThat(config.Credentials().parser, ContainsDict({195 self.assertThat(config.Config().parser, ContainsDict({
194 'localhost:8000': MatchesDict({196 'store:default': MatchesDict({
197 'gw_url': Equals(self.default_gw_url),
198 'sso_url': Equals(self.default_sso_url),
195 'root': Equals(root),199 'root': Equals(root),
196 'unbound_discharge': Equals('dummy'),200 'unbound_discharge': Equals('dummy'),
197 'email': Equals('user@example.org'),201 'email': Equals('user@example.org'),
@@ -219,10 +223,62 @@ class LoginTests(TestCase):
219 'password': 'secret',223 'password': 'secret',
220 'caveat_id': 'payload',224 'caveat_id': 'payload',
221 }, json.loads(responses.calls[1].request.body.decode()))225 }, json.loads(responses.calls[1].request.body.decode()))
222 self.assertThat(config.Credentials().parser, ContainsDict({226 self.assertThat(config.Config().parser, ContainsDict({
223 'localhost:8000': MatchesDict({227 'store:default': MatchesDict({
228 'gw_url': Equals(self.default_gw_url),
229 'sso_url': Equals(self.default_sso_url),
224 'root': Equals(root),230 'root': Equals(root),
225 'unbound_discharge': Equals('dummy'),231 'unbound_discharge': Equals('dummy'),
226 'email': Equals('user@example.org'),232 'email': Equals('user@example.org'),
227 }),233 }),
228 }))234 }))
235
236 @responses.activate
237 def test_store_url(self):
238 gw_url = 'http://otherstore.local:1234/'
239
240 self.mock_input.return_value = 'user@example.org'
241 self.mock_getpass.return_value = 'secret'
242 root = self.make_root_macaroon()
243 self.add_issue_store_admin_response(
244 {'status': 200, 'json': {'macaroon': root}}, gw_url=gw_url)
245 self.add_get_sso_discharge_response(
246 {'status': 200, 'json': {'discharge_macaroon': 'dummy'}})
247 login(self.make_args(store_url=gw_url))
248
249 self.assertEqual(2, len(responses.calls))
250 self.assertEqual(responses.calls[0].request.url[:len(gw_url)], gw_url)
251 self.assertTrue(
252 responses.calls[1].request.url.startswith(self.default_sso_url))
253 self.assertThat(config.Config().parser, ContainsDict({
254 'store:default': ContainsDict({
255 'gw_url': Equals(gw_url),
256 'sso_url': Equals(self.default_sso_url),
257 }),
258 }))
259
260 @responses.activate
261 def test_sso_url(self):
262 sso_url = 'https://othersso.local:1234/'
263
264 self.mock_input.return_value = 'user@example.org'
265 self.mock_getpass.return_value = 'secret'
266 root = self.make_root_macaroon(sso_url=sso_url)
267 self.add_issue_store_admin_response(
268 {'status': 200, 'json': {'macaroon': root}})
269 self.add_get_sso_discharge_response(
270 {'status': 200, 'json': {'discharge_macaroon': 'dummy'}},
271 sso_url=sso_url)
272 login(self.make_args(sso_url=sso_url))
273
274 self.assertEqual(2, len(responses.calls))
275 self.assertTrue(
276 responses.calls[0].request.url.startswith(self.default_gw_url))
277 self.assertEqual(
278 responses.calls[1].request.url[:len(sso_url)], sso_url)
279 self.assertThat(config.Config().parser, ContainsDict({
280 'store:default': ContainsDict({
281 'gw_url': Equals(self.default_gw_url),
282 'sso_url': Equals(sso_url),
283 }),
284 }))
diff --git a/snapstore_client/logic/tests/test_overrides.py b/snapstore_client/logic/tests/test_overrides.py
index 80a929f..b92c966 100644
--- a/snapstore_client/logic/tests/test_overrides.py
+++ b/snapstore_client/logic/tests/test_overrides.py
@@ -3,7 +3,6 @@
3import json3import json
4from urllib.parse import urljoin4from urllib.parse import urljoin
55
6from acceptable._doubles import set_service_locations
7import fixtures6import fixtures
8import responses7import responses
9from testtools import TestCase8from testtools import TestCase
@@ -22,15 +21,19 @@ from snapstore_client.tests import (
2221
23class OverridesTests(TestCase):22class OverridesTests(TestCase):
2423
25 def setUp(self):24 def test_list_overrides_no_store_config(self):
26 super().setUp()25 self.useFixture(testfixtures.ConfigFixture(empty=True))
27 service_locations = config.read_config()['services']26 logger = self.useFixture(fixtures.FakeLogger())
28 set_service_locations(service_locations)27 rc = list_overrides(factory.Args(snap_name='some-snap', series='16'))
28 self.assertEqual(rc, 1)
29 self.assertEqual(
30 logger.output,
31 'No store configuration found. Have you run "snapstore login"?\n')
2932
30 @responses.activate33 @responses.activate
31 def test_list_overrides(self):34 def test_list_overrides(self):
35 self.useFixture(testfixtures.ConfigFixture())
32 logger = self.useFixture(fixtures.FakeLogger())36 logger = self.useFixture(fixtures.FakeLogger())
33 self.useFixture(testfixtures.CredentialsFixture())
34 snap_id = factory.generate_snap_id()37 snap_id = factory.generate_snap_id()
35 overrides = [38 overrides = [
36 factory.SnapDeviceGateway.Override(39 factory.SnapDeviceGateway.Override(
@@ -43,7 +46,7 @@ class OverridesTests(TestCase):
43 # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once46 # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once
44 # they exist.47 # they exist.
45 overrides_url = urljoin(48 overrides_url = urljoin(
46 config.read_config()['services']['snapdevicegw'],49 config.Config().store_section('default').get('gw_url'),
47 '/v2/metadata/overrides/mysnap')50 '/v2/metadata/overrides/mysnap')
48 responses.add(51 responses.add(
49 'GET', overrides_url, status=200, json={'overrides': overrides})52 'GET', overrides_url, status=200, json={'overrides': overrides})
@@ -54,10 +57,21 @@ class OverridesTests(TestCase):
54 'mysnap foo/stable i386 3 (upstream 4)\n',57 'mysnap foo/stable i386 3 (upstream 4)\n',
55 logger.output)58 logger.output)
5659
60 def test_override_no_store_config(self):
61 self.useFixture(testfixtures.ConfigFixture(empty=True))
62 logger = self.useFixture(fixtures.FakeLogger())
63 rc = override(factory.Args(
64 snap_name='some-snap', channel_map_entries=['stable=1'],
65 series='16'))
66 self.assertEqual(rc, 1)
67 self.assertEqual(
68 logger.output,
69 'No store configuration found. Have you run "snapstore login"?\n')
70
57 @responses.activate71 @responses.activate
58 def test_override(self):72 def test_override(self):
73 self.useFixture(testfixtures.ConfigFixture())
59 logger = self.useFixture(fixtures.FakeLogger())74 logger = self.useFixture(fixtures.FakeLogger())
60 self.useFixture(testfixtures.CredentialsFixture())
61 snap_id = factory.generate_snap_id()75 snap_id = factory.generate_snap_id()
62 overrides = [76 overrides = [
63 factory.SnapDeviceGateway.Override(77 factory.SnapDeviceGateway.Override(
@@ -70,7 +84,7 @@ class OverridesTests(TestCase):
70 # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once84 # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once
71 # they exist.85 # they exist.
72 overrides_url = urljoin(86 overrides_url = urljoin(
73 config.read_config()['services']['snapdevicegw'],87 config.Config().store_section('default').get('gw_url'),
74 '/v2/metadata/overrides')88 '/v2/metadata/overrides')
75 responses.add(89 responses.add(
76 'POST', overrides_url, status=200, json={'overrides': overrides})90 'POST', overrides_url, status=200, json={'overrides': overrides})
@@ -98,10 +112,20 @@ class OverridesTests(TestCase):
98 'mysnap foo/stable i386 3 (upstream 4)\n',112 'mysnap foo/stable i386 3 (upstream 4)\n',
99 logger.output)113 logger.output)
100114
115 def test_delete_override_no_store_config(self):
116 self.useFixture(testfixtures.ConfigFixture(empty=True))
117 logger = self.useFixture(fixtures.FakeLogger())
118 rc = delete_override(factory.Args(
119 snap_name='some-snap', channels=['stable'], series='16'))
120 self.assertEqual(rc, 1)
121 self.assertEqual(
122 logger.output,
123 'No store configuration found. Have you run "snapstore login"?\n')
124
101 @responses.activate125 @responses.activate
102 def test_delete_override(self):126 def test_delete_override(self):
127 self.useFixture(testfixtures.ConfigFixture())
103 logger = self.useFixture(fixtures.FakeLogger())128 logger = self.useFixture(fixtures.FakeLogger())
104 self.useFixture(testfixtures.CredentialsFixture())
105 snap_id = factory.generate_snap_id()129 snap_id = factory.generate_snap_id()
106 overrides = [130 overrides = [
107 factory.SnapDeviceGateway.Override(131 factory.SnapDeviceGateway.Override(
@@ -114,7 +138,7 @@ class OverridesTests(TestCase):
114 # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once138 # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once
115 # they exist.139 # they exist.
116 overrides_url = urljoin(140 overrides_url = urljoin(
117 config.read_config()['services']['snapdevicegw'],141 config.Config().store_section('default').get('gw_url'),
118 '/v2/metadata/overrides')142 '/v2/metadata/overrides')
119 responses.add(143 responses.add(
120 'POST', overrides_url, status=200, json={'overrides': overrides})144 'POST', overrides_url, status=200, json={'overrides': overrides})
diff --git a/snapstore_client/tests/test_config.py b/snapstore_client/tests/test_config.py
index 0940828..2ddbc5c 100644
--- a/snapstore_client/tests/test_config.py
+++ b/snapstore_client/tests/test_config.py
@@ -1,71 +1,74 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the1# Copyright 2017 Canonical Ltd. This software is licensed under the
2# GNU General Public License version 3 (see the file LICENSE).2# GNU General Public License version 3 (see the file LICENSE).
33
4import os.path
5from textwrap import dedent
6
4from testtools import TestCase7from testtools import TestCase
58
6from snapstore_client.config import ConfigProvider9from snapstore_client.config import Config
710from snapstore_client.tests.testfixtures import (
811 ConfigFixture,
9class ConfigProviderTestCase(TestCase):12 XDGConfigDirFixture,
1013)
11 def test_override_works_for_new_section_and_option(self):14
12 default = {}15
13 override = {16class ConfigTests(TestCase):
14 'new_section': {17
15 'new_option': 'new_value'18 def test_no_config(self):
16 }19 xdg_path = self.useFixture(XDGConfigDirFixture()).path
17 }20 config_ini = os.path.join(xdg_path, Config.xdg_name, 'config.ini')
18 expected = override.copy()21 self.assertFalse(os.path.exists(config_ini))
1922 Config()
20 provider = ConfigProvider(default)23
21 provider.override_for_test(override)24 def test_init_loads(self):
2225 self.useFixture(ConfigFixture())
23 self.assertEqual(expected, provider())26 cfg = Config()
2427 self.assertIsNotNone(cfg.get("store:default", "gw_url"))
25 def test_override_works_when_adding_new_option_to_existing_section(self):28
26 default = {29 def test_save(self):
27 'section': {30 xdg_path = self.useFixture(XDGConfigDirFixture()).path
28 'option': 'value'31 config_ini = os.path.join(xdg_path, 'snapstore-client', 'config.ini')
29 }32 self.assertFalse(os.path.exists(config_ini))
30 }33
31 override = {34 cfg = Config()
32 'section': {35 cfg.set("s", "k", "v")
33 'new_option': 'new_value'36 cfg.save()
34 }37
35 }38 self.assertTrue(os.path.exists(config_ini))
36 expected = {39 with open(config_ini) as f:
37 'section': {40 content = f.read()
38 'option': 'value',41 self.assertEqual(content, dedent('''\
39 'new_option': 'new_value'42 [s]
40 }43 k = v
41 }44
4245 '''))
43 provider = ConfigProvider(default)46
44 provider.override_for_test(override)47 def test_get_missing(self):
4548 self.useFixture(XDGConfigDirFixture())
46 self.assertEqual(expected, provider())49 cfg = Config()
4750 self.assertIsNone(cfg.get("s", "k"))
48 def test_override_works_when_changing_existing_option(self):51
49 default = {52 def test_get(self):
50 'section': {53 self.useFixture(XDGConfigDirFixture())
51 'option': 'value'54 cfg = Config()
52 }55 cfg.set("s", "k", "v")
53 }56 self.assertEqual(cfg.get("s", "k"), "v")
54 override = {57
55 'section': {58 def test_section(self):
56 'option': 'new_value'59 self.useFixture(XDGConfigDirFixture())
57 }60 cfg = Config()
58 }61 s = cfg.section("s")
59 expected = {62 s.set("k", "v")
60 'section': {63 self.assertEqual(s.get("k"), "v")
61 'option': 'new_value',64 self.assertEqual(cfg.get("s", "k"), "v")
62 }65
63 }66 def test_store_section(self):
6467 self.useFixture(XDGConfigDirFixture())
65 provider = ConfigProvider(default)68 cfg = Config()
66 provider.override_for_test(override)69 store = cfg.store_section("foo")
6770 store.set("k", "v")
68 self.assertEqual(expected, provider())71 self.assertEqual(cfg.get("store:foo", "k"), "v")
6972
7073
71def test_suite():74def test_suite():
diff --git a/snapstore_client/tests/test_webservices.py b/snapstore_client/tests/test_webservices.py
index fd51f7e..f6bf6bf 100644
--- a/snapstore_client/tests/test_webservices.py
+++ b/snapstore_client/tests/test_webservices.py
@@ -6,7 +6,6 @@ import sys
6import types6import types
7from urllib.parse import urljoin7from urllib.parse import urljoin
88
9from acceptable._doubles import set_service_locations
10import fixtures9import fixtures
11from requests.exceptions import HTTPError10from requests.exceptions import HTTPError
12import responses11import responses
@@ -39,39 +38,37 @@ class WebservicesTests(TestCase):
3938
40 def setUp(self):39 def setUp(self):
41 super().setUp()40 super().setUp()
42 service_locations = config.read_config()['services']41 self.config = self.useFixture(testfixtures.ConfigFixture())
43 set_service_locations(service_locations)
4442
45 @responses.activate43 @responses.activate
46 def test_issue_store_admin_success(self):44 def test_issue_store_admin_success(self):
47 devicegw_root = config.read_config()['services']['snapdevicegw']45 gw_url = 'http://store.local/'
48 issue_store_admin_url = urljoin(46 issue_store_admin_url = urljoin(gw_url, '/v2/auth/issue-store-admin')
49 devicegw_root, '/v2/auth/issue-store-admin')
50 responses.add(47 responses.add(
51 'POST', issue_store_admin_url, status=200,48 'POST', issue_store_admin_url, status=200,
52 json={'macaroon': 'dummy'})49 json={'macaroon': 'dummy'})
5350
54 self.assertEqual('dummy', webservices.issue_store_admin())51 self.assertEqual('dummy', webservices.issue_store_admin(gw_url))
5552
56 @responses.activate53 @responses.activate
57 def test_issue_store_admin_error(self):54 def test_issue_store_admin_error(self):
58 logger = self.useFixture(fixtures.FakeLogger())55 logger = self.useFixture(fixtures.FakeLogger())
59 devicegw_root = config.read_config()['services']['snapdevicegw']56 gw_url = 'http://store.local/'
60 issue_store_admin_url = urljoin(57 issue_store_admin_url = urljoin(gw_url, '/v2/auth/issue-store-admin')
61 devicegw_root, '/v2/auth/issue-store-admin')
62 responses.add(58 responses.add(
63 'POST', issue_store_admin_url, status=400,59 'POST', issue_store_admin_url, status=400,
64 json=factory.APIError.single('Something went wrong').to_dict())60 json=factory.APIError.single('Something went wrong').to_dict())
6561
66 self.assertRaises(HTTPError, webservices.issue_store_admin)62 self.assertRaises(
63 HTTPError, webservices.issue_store_admin, gw_url)
67 self.assertEqual(64 self.assertEqual(
68 'Failed to issue store_admin macaroon:\nSomething went wrong\n',65 'Failed to issue store_admin macaroon:\nSomething went wrong\n',
69 logger.output)66 logger.output)
7067
71 @responses.activate68 @responses.activate
72 def test_get_sso_discharge_success(self):69 def test_get_sso_discharge_success(self):
73 sso_root = config.read_config()['services']['sso']70 sso_url = 'http://sso.local/'
74 discharge_url = urljoin(sso_root, '/api/v2/tokens/discharge')71 discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge')
75 responses.add(72 responses.add(
76 'POST', discharge_url, status=200,73 'POST', discharge_url, status=200,
77 json={'discharge_macaroon': 'dummy'})74 json={'discharge_macaroon': 'dummy'})
@@ -79,7 +76,7 @@ class WebservicesTests(TestCase):
79 self.assertEqual(76 self.assertEqual(
80 'dummy',77 'dummy',
81 webservices.get_sso_discharge(78 webservices.get_sso_discharge(
82 'user@example.org', 'secret', 'caveat'))79 sso_url, 'user@example.org', 'secret', 'caveat'))
83 request = responses.calls[0].request80 request = responses.calls[0].request
84 self.assertEqual('application/json', request.headers['Content-Type'])81 self.assertEqual('application/json', request.headers['Content-Type'])
85 self.assertEqual({82 self.assertEqual({
@@ -90,8 +87,8 @@ class WebservicesTests(TestCase):
9087
91 @responses.activate88 @responses.activate
92 def test_get_sso_discharge_success_with_otp(self):89 def test_get_sso_discharge_success_with_otp(self):
93 sso_root = config.read_config()['services']['sso']90 sso_url = 'http://sso.local/'
94 discharge_url = urljoin(sso_root, '/api/v2/tokens/discharge')91 discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge')
95 responses.add(92 responses.add(
96 'POST', discharge_url, status=200,93 'POST', discharge_url, status=200,
97 json={'discharge_macaroon': 'dummy'})94 json={'discharge_macaroon': 'dummy'})
@@ -99,7 +96,7 @@ class WebservicesTests(TestCase):
99 self.assertEqual(96 self.assertEqual(
100 'dummy',97 'dummy',
101 webservices.get_sso_discharge(98 webservices.get_sso_discharge(
102 'user@example.org', 'secret', 'caveat',99 sso_url, 'user@example.org', 'secret', 'caveat',
103 one_time_password='123456'))100 one_time_password='123456'))
104 request = responses.calls[0].request101 request = responses.calls[0].request
105 self.assertEqual('application/json', request.headers['Content-Type'])102 self.assertEqual('application/json', request.headers['Content-Type'])
@@ -112,8 +109,8 @@ class WebservicesTests(TestCase):
112109
113 @responses.activate110 @responses.activate
114 def test_get_sso_discharge_twofactor_required(self):111 def test_get_sso_discharge_twofactor_required(self):
115 sso_root = config.read_config()['services']['sso']112 sso_url = 'http://sso.local/'
116 discharge_url = urljoin(sso_root, '/api/v2/tokens/discharge')113 discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge')
117 responses.add(114 responses.add(
118 'POST', discharge_url, status=401,115 'POST', discharge_url, status=401,
119 json={'error_list': [{'code': 'twofactor-required'}]})116 json={'error_list': [{'code': 'twofactor-required'}]})
@@ -121,32 +118,32 @@ class WebservicesTests(TestCase):
121 self.assertRaises(118 self.assertRaises(
122 exceptions.StoreTwoFactorAuthenticationRequired,119 exceptions.StoreTwoFactorAuthenticationRequired,
123 webservices.get_sso_discharge,120 webservices.get_sso_discharge,
124 'user@example.org', 'secret', 'caveat')121 sso_url, 'user@example.org', 'secret', 'caveat')
125122
126 @responses.activate123 @responses.activate
127 def test_get_sso_discharge_structured_error(self):124 def test_get_sso_discharge_structured_error(self):
128 sso_root = config.read_config()['services']['sso']125 sso_url = 'http://sso.local/'
129 discharge_url = urljoin(sso_root, '/api/v2/tokens/discharge')126 discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge')
130 responses.add(127 responses.add(
131 'POST', discharge_url, status=400,128 'POST', discharge_url, status=400,
132 json=factory.APIError.single('Something went wrong').to_dict())129 json=factory.APIError.single('Something went wrong').to_dict())
133130
134 e = self.assertRaises(131 e = self.assertRaises(
135 exceptions.StoreAuthenticationError, webservices.get_sso_discharge,132 exceptions.StoreAuthenticationError, webservices.get_sso_discharge,
136 'user@example.org', 'secret', 'caveat')133 sso_url, 'user@example.org', 'secret', 'caveat')
137 self.assertEqual('Something went wrong', e.message)134 self.assertEqual('Something went wrong', e.message)
138135
139 @responses.activate136 @responses.activate
140 def test_get_sso_discharge_unstructured_error(self):137 def test_get_sso_discharge_unstructured_error(self):
141 logger = self.useFixture(fixtures.FakeLogger())138 logger = self.useFixture(fixtures.FakeLogger())
142 sso_root = config.read_config()['services']['sso']139 sso_url = 'http://sso.local/'
143 discharge_url = urljoin(sso_root, '/api/v2/tokens/discharge')140 discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge')
144 responses.add(141 responses.add(
145 'POST', discharge_url, status=503, body='Try again later.')142 'POST', discharge_url, status=503, body='Try again later.')
146143
147 self.assertRaises(144 self.assertRaises(
148 HTTPError, webservices.get_sso_discharge,145 HTTPError, webservices.get_sso_discharge,
149 'user@example.org', 'secret', 'caveat')146 sso_url, 'user@example.org', 'secret', 'caveat')
150 self.assertEqual(147 self.assertEqual(
151 'Failed to get SSO discharge:\n'148 'Failed to get SSO discharge:\n'
152 '====================\n'149 '====================\n'
@@ -157,59 +154,58 @@ class WebservicesTests(TestCase):
157 @responses.activate154 @responses.activate
158 def test_get_overrides_success(self):155 def test_get_overrides_success(self):
159 logger = self.useFixture(fixtures.FakeLogger())156 logger = self.useFixture(fixtures.FakeLogger())
160 credentials = self.useFixture(testfixtures.CredentialsFixture())
161 overrides = [factory.SnapDeviceGateway.Override()]157 overrides = [factory.SnapDeviceGateway.Override()]
162 # XXX cjwatson 2017-06-26: Use acceptable-generated double once it158 # XXX cjwatson 2017-06-26: Use acceptable-generated double once it
163 # exists.159 # exists.
160 store = config.Config().store_section('default')
164 overrides_url = urljoin(161 overrides_url = urljoin(
165 config.read_config()['services']['snapdevicegw'],162 store.get('gw_url'), '/v2/metadata/overrides/mysnap')
166 '/v2/metadata/overrides/mysnap')
167 responses.add('GET', overrides_url, status=200, json=overrides)163 responses.add('GET', overrides_url, status=200, json=overrides)
168164
169 self.assertEqual(overrides, webservices.get_overrides('mysnap'))165 self.assertEqual(overrides, webservices.get_overrides(
166 store, 'mysnap'))
170 request = responses.calls[0].request167 request = responses.calls[0].request
171 self.assertThat(168 self.assertThat(
172 request.headers['Authorization'],169 request.headers['Authorization'],
173 matchers.MacaroonHeaderVerifies(credentials.key))170 matchers.MacaroonHeaderVerifies(self.config.key))
174 self.assertNotIn('Failed to get overrides:', logger.output)171 self.assertNotIn('Failed to get overrides:', logger.output)
175172
176 @responses.activate173 @responses.activate
177 def test_get_overrides_error(self):174 def test_get_overrides_error(self):
178 logger = self.useFixture(fixtures.FakeLogger())175 logger = self.useFixture(fixtures.FakeLogger())
179 self.useFixture(testfixtures.CredentialsFixture())176 store = config.Config().store_section('default')
180 overrides_url = urljoin(177 overrides_url = urljoin(
181 config.read_config()['services']['snapdevicegw'],178 store.get('gw_url'), '/v2/metadata/overrides/mysnap')
182 '/v2/metadata/overrides/mysnap')
183 responses.add(179 responses.add(
184 'GET', overrides_url, status=400,180 'GET', overrides_url, status=400,
185 json=factory.APIError.single('Something went wrong').to_dict())181 json=factory.APIError.single('Something went wrong').to_dict())
186182
187 self.assertRaises(HTTPError, webservices.get_overrides, 'mysnap')183 self.assertRaises(
184 HTTPError, webservices.get_overrides, store, 'mysnap')
188 self.assertEqual(185 self.assertEqual(
189 'Failed to get overrides:\nSomething went wrong\n', logger.output)186 'Failed to get overrides:\nSomething went wrong\n', logger.output)
190187
191 @responses.activate188 @responses.activate
192 def test_set_overrides_success(self):189 def test_set_overrides_success(self):
193 logger = self.useFixture(fixtures.FakeLogger())190 logger = self.useFixture(fixtures.FakeLogger())
194 credentials = self.useFixture(testfixtures.CredentialsFixture())
195 override = factory.SnapDeviceGateway.Override()191 override = factory.SnapDeviceGateway.Override()
196 # XXX cjwatson 2017-06-26: Use acceptable-generated double once it192 # XXX cjwatson 2017-06-26: Use acceptable-generated double once it
197 # exists.193 # exists.
198 overrides_url = urljoin(194 store = config.Config().store_section('default')
199 config.read_config()['services']['snapdevicegw'],195 overrides_url = urljoin(store.get('gw_url'), '/v2/metadata/overrides')
200 '/v2/metadata/overrides')
201 responses.add('POST', overrides_url, status=200, json=[override])196 responses.add('POST', overrides_url, status=200, json=[override])
202197
203 self.assertEqual([override], webservices.set_overrides([{198 self.assertEqual([override], webservices.set_overrides(
204 'snap_name': override['snap_name'],199 store, [{
205 'revision': override['revision'],200 'snap_name': override['snap_name'],
206 'channel': override['channel'],201 'revision': override['revision'],
207 'series': override['series'],202 'channel': override['channel'],
208 }]))203 'series': override['series'],
204 }]))
209 request = responses.calls[0].request205 request = responses.calls[0].request
210 self.assertThat(206 self.assertThat(
211 request.headers['Authorization'],207 request.headers['Authorization'],
212 matchers.MacaroonHeaderVerifies(credentials.key))208 matchers.MacaroonHeaderVerifies(self.config.key))
213 self.assertEqual([{209 self.assertEqual([{
214 'snap_name': override['snap_name'],210 'snap_name': override['snap_name'],
215 'revision': override['revision'],211 'revision': override['revision'],
@@ -221,22 +217,21 @@ class WebservicesTests(TestCase):
221 @responses.activate217 @responses.activate
222 def test_set_overrides_error(self):218 def test_set_overrides_error(self):
223 logger = self.useFixture(fixtures.FakeLogger())219 logger = self.useFixture(fixtures.FakeLogger())
224 self.useFixture(testfixtures.CredentialsFixture())
225 override = factory.SnapDeviceGateway.Override()220 override = factory.SnapDeviceGateway.Override()
226 # XXX cjwatson 2017-06-26: Use acceptable-generated double once it221 # XXX cjwatson 2017-06-26: Use acceptable-generated double once it
227 # exists.222 # exists.
228 overrides_url = urljoin(223 store = config.Config().store_section('default')
229 config.read_config()['services']['snapdevicegw'],224 overrides_url = urljoin(store.get('gw_url'), '/v2/metadata/overrides')
230 '/v2/metadata/overrides')
231 responses.add(225 responses.add(
232 'POST', overrides_url, status=400,226 'POST', overrides_url, status=400,
233 json=factory.APIError.single('Something went wrong').to_dict())227 json=factory.APIError.single('Something went wrong').to_dict())
234228
235 self.assertRaises(HTTPError, webservices.set_overrides, {229 self.assertRaises(HTTPError, lambda: webservices.set_overrides(
236 'snap_name': override['snap_name'],230 store, {
237 'revision': override['revision'],231 'snap_name': override['snap_name'],
238 'channel': override['channel'],232 'revision': override['revision'],
239 'series': override['series'],233 'channel': override['channel'],
240 })234 'series': override['series'],
235 }))
241 self.assertEqual(236 self.assertEqual(
242 'Failed to set override:\nSomething went wrong\n', logger.output)237 'Failed to set override:\nSomething went wrong\n', logger.output)
diff --git a/snapstore_client/tests/testfixtures.py b/snapstore_client/tests/testfixtures.py
index 5070d5a..78bf1dd 100644
--- a/snapstore_client/tests/testfixtures.py
+++ b/snapstore_client/tests/testfixtures.py
@@ -2,7 +2,6 @@
22
3import os.path3import os.path
4from textwrap import dedent4from textwrap import dedent
5from urllib.parse import urlparse
65
7from fixtures import (6from fixtures import (
8 Fixture,7 Fixture,
@@ -11,23 +10,27 @@ from fixtures import (
11)10)
12from pymacaroons import Macaroon11from pymacaroons import Macaroon
1312
14from snapstore_client import config
1513
14class XDGConfigDirFixture(Fixture):
15 """Set up xdg to read/write config from temp dir."""
1616
17class ConfigOverrideFixture(Fixture):17 path = None
18
19 def __init__(self, new_config):
20 super().__init__()
21 self._new_config = new_config
2218
23 def _setUp(self):19 def _setUp(self):
24 self.addCleanup(config.read_config.override_for_test(self._new_config))20 self.path = self.useFixture(TempDir()).path
21 self.useFixture(MonkeyPatch(
22 'xdg.BaseDirectory.xdg_config_home', self.path))
23 self.useFixture(MonkeyPatch(
24 'xdg.BaseDirectory.xdg_config_dirs', [self.path]))
2525
2626
27class CredentialsFixture(Fixture):27class ConfigFixture(Fixture):
2828
29 def __init__(self):29 def __init__(self, empty=False):
30 super().__init__()30 super().__init__()
31 self.empty = empty
32 self.gw_url = 'http://store.local/'
33 self.sso_url = 'http://sso.local/'
31 self.key = 'random-key'34 self.key = 'random-key'
32 self.root = Macaroon(key=self.key)35 self.root = Macaroon(key=self.key)
33 self.root.add_third_party_caveat(36 self.root.add_third_party_caveat(
@@ -36,22 +39,20 @@ class CredentialsFixture(Fixture):
36 location='login.example.com', identifier='payload', key='sso-key')39 location='login.example.com', identifier='payload', key='sso-key')
3740
38 def _setUp(self):41 def _setUp(self):
39 config_path = self.useFixture(TempDir()).path42 xdg_config_path = self.useFixture(XDGConfigDirFixture()).path
40 credentials_path = os.path.join(43 app_config_path = os.path.join(xdg_config_path, 'snapstore-client')
41 config_path, 'snapstore-client', 'credentials.cfg')44 os.makedirs(app_config_path)
42 os.makedirs(os.path.dirname(credentials_path))45 if self.empty:
43 snapdevicegw_netloc = urlparse(46 return
44 config.read_config()['services']['snapdevicegw']).netloc47 with open(os.path.join(app_config_path, 'config.ini'), 'w') as f:
45 with open(credentials_path, 'w') as credentials_file:48 f.write(dedent("""
46 credentials = dedent("""\49 [store:default]
47 [{snapdevicegw}]50 gw_url = {gw_url}
51 sso_url = {sso_url}
48 root = {root}52 root = {root}
49 unbound_discharge = {unbound_discharge}53 unbound_discharge = {unbound_discharge}
50 """).format(54 """).format(
51 snapdevicegw=snapdevicegw_netloc,55 gw_url=self.gw_url, sso_url=self.sso_url,
52 root=self.root.serialize(),56 root=self.root.serialize(),
53 unbound_discharge=self.unbound_discharge.serialize(),57 unbound_discharge=self.unbound_discharge.serialize(),
54 )58 ))
55 print(credentials, file=credentials_file)
56 self.useFixture(MonkeyPatch(
57 'xdg.BaseDirectory.xdg_config_dirs', [config_path]))
diff --git a/snapstore_client/webservices.py b/snapstore_client/webservices.py
index fc8c460..fb1927a 100644
--- a/snapstore_client/webservices.py
+++ b/snapstore_client/webservices.py
@@ -8,20 +8,16 @@ import urllib.parse
8from pymacaroons import Macaroon8from pymacaroons import Macaroon
9import requests9import requests
1010
11from snapstore_client import (11from snapstore_client import exceptions
12 config,
13 exceptions,
14)
1512
1613
17logger = logging.getLogger(__name__)14logger = logging.getLogger(__name__)
1815
1916
20def issue_store_admin():17def issue_store_admin(gw_url):
21 """Ask the store to issue a store_admin macaroon."""18 """Ask the store to issue a store_admin macaroon."""
22 devicegw_root = config.read_config()['services']['snapdevicegw']
23 issue_store_admin_url = urllib.parse.urljoin(19 issue_store_admin_url = urllib.parse.urljoin(
24 devicegw_root, '/v2/auth/issue-store-admin')20 gw_url, '/v2/auth/issue-store-admin')
25 resp = requests.post(issue_store_admin_url)21 resp = requests.post(issue_store_admin_url)
26 if resp.status_code != 200:22 if resp.status_code != 200:
27 _print_error_message('issue store_admin macaroon', resp)23 _print_error_message('issue store_admin macaroon', resp)
@@ -29,9 +25,10 @@ def issue_store_admin():
29 return resp.json()['macaroon']25 return resp.json()['macaroon']
3026
3127
32def get_sso_discharge(email, password, caveat_id, one_time_password=None):28def get_sso_discharge(sso_url, email, password, caveat_id,
33 sso_root = config.read_config()['services']['sso']29 one_time_password=None):
34 discharge_url = urllib.parse.urljoin(sso_root, '/api/v2/tokens/discharge')30 discharge_url = urllib.parse.urljoin(
31 sso_url, '/api/v2/tokens/discharge')
35 data = {'email': email, 'password': password, 'caveat_id': caveat_id}32 data = {'email': email, 'password': password, 'caveat_id': caveat_id}
36 if one_time_password is not None:33 if one_time_password is not None:
37 data['otp'] = one_time_password34 data['otp'] = one_time_password
@@ -56,9 +53,9 @@ def get_sso_discharge(email, password, caveat_id, one_time_password=None):
56 return resp.json()['discharge_macaroon']53 return resp.json()['discharge_macaroon']
5754
5855
59def refresh_sso_discharge(unbound_discharge_raw):56def refresh_sso_discharge(store, unbound_discharge_raw):
60 sso_root = config.read_config()['services']['sso']57 refresh_url = urllib.parse.urljoin(
61 refresh_url = urllib.parse.urljoin(sso_root, '/api/v2/tokens/refresh')58 store.get('sso_url'), '/api/v2/tokens/refresh')
62 data = {'discharge_macaroon': unbound_discharge_raw}59 data = {'discharge_macaroon': unbound_discharge_raw}
63 resp = requests.post(60 resp = requests.post(
64 refresh_url, headers={'Accept': 'application/json'}, json=data)61 refresh_url, headers={'Accept': 'application/json'}, json=data)
@@ -78,12 +75,11 @@ def _deserialize_macaroon(name, value):
78 'failed to deserialize {} macaroon'.format(name))75 'failed to deserialize {} macaroon'.format(name))
7976
8077
81def _get_macaroon_auth():78def _get_macaroon_auth(store):
82 """Return an Authorization header containing store macaroons."""79 """Return an Authorization header containing store macaroons."""
83 credentials = config.Credentials()80 root_raw = store.get('root')
84 root_raw = credentials.get('root')
85 root = _deserialize_macaroon('root', root_raw)81 root = _deserialize_macaroon('root', root_raw)
86 unbound_discharge_raw = credentials.get('unbound_discharge')82 unbound_discharge_raw = store.get('unbound_discharge')
87 unbound_discharge = _deserialize_macaroon(83 unbound_discharge = _deserialize_macaroon(
88 'unbound discharge', unbound_discharge_raw)84 'unbound discharge', unbound_discharge_raw)
89 bound_discharge = root.prepare_for_request(unbound_discharge)85 bound_discharge = root.prepare_for_request(unbound_discharge)
@@ -99,27 +95,25 @@ def _raise_needs_refresh(response):
99 raise exceptions.StoreMacaroonNeedsRefresh()95 raise exceptions.StoreMacaroonNeedsRefresh()
10096
10197
102def refresh_if_necessary(func, *args, **kwargs):98def refresh_if_necessary(store, func, *args, **kwargs):
103 """Make a request, refreshing macaroons if necessary."""99 """Make a request, refreshing macaroons if necessary."""
104 try:100 try:
105 return func(*args, **kwargs)101 return func(*args, **kwargs)
106 except exceptions.StoreMacaroonNeedsRefresh:102 except exceptions.StoreMacaroonNeedsRefresh:
107 credentials = config.Credentials()
108 unbound_discharge = refresh_sso_discharge(103 unbound_discharge = refresh_sso_discharge(
109 credentials.get('unbound_discharge'))104 store.get('sso_url'), store.get('unbound_discharge'))
110 credentials.set('unbound_discharge', unbound_discharge)105 store.set('unbound_discharge', unbound_discharge)
111 credentials.save()106 store.save()
112 return func(*args, **kwargs)107 return func(*args, **kwargs)
113108
114109
115def get_overrides(snap_name, series='16'):110def get_overrides(store, snap_name, series='16'):
116 """Get all overrides for a snap."""111 """Get all overrides for a snap."""
117 devicegw_root = config.read_config()['services']['snapdevicegw']
118 overrides_url = urllib.parse.urljoin(112 overrides_url = urllib.parse.urljoin(
119 devicegw_root,113 store.get('gw_url'),
120 '/v2/metadata/overrides/{}'.format(urllib.parse.quote_plus(snap_name)))114 '/v2/metadata/overrides/{}'.format(urllib.parse.quote_plus(snap_name)))
121 headers = {115 headers = {
122 'Authorization': _get_macaroon_auth(),116 'Authorization': _get_macaroon_auth(store),
123 'X-Ubuntu-Series': series,117 'X-Ubuntu-Series': series,
124 }118 }
125 resp = requests.get(overrides_url, headers=headers)119 resp = requests.get(overrides_url, headers=headers)
@@ -130,12 +124,11 @@ def get_overrides(snap_name, series='16'):
130 return resp.json()124 return resp.json()
131125
132126
133def set_overrides(overrides):127def set_overrides(store, overrides):
134 """Add or remove channel map overrides for a snap."""128 """Add or remove channel map overrides for a snap."""
135 devicegw_root = config.read_config()['services']['snapdevicegw']
136 overrides_url = urllib.parse.urljoin(129 overrides_url = urllib.parse.urljoin(
137 devicegw_root, '/v2/metadata/overrides')130 store.get('gw_url'), '/v2/metadata/overrides')
138 headers = {'Authorization': _get_macaroon_auth()}131 headers = {'Authorization': _get_macaroon_auth(store)}
139 resp = requests.post(overrides_url, headers=headers, json=overrides)132 resp = requests.post(overrides_url, headers=headers, json=overrides)
140 _raise_needs_refresh(resp)133 _raise_needs_refresh(resp)
141 if resp.status_code != 200:134 if resp.status_code != 200:

Subscribers

People subscribed via source and target branches

to all changes: