Merge ~twom/snapstore-client:add-upload-authentication into snapstore-client:master

Proposed by Tom Wardill
Status: Merged
Approved by: Tom Wardill
Approved revision: 00476d8bc59f05203b169e8793855c1731a67ad1
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~twom/snapstore-client:add-upload-authentication
Merge into: snapstore-client:master
Diff against target: 1255 lines (+947/-45)
12 files modified
README (+23/-0)
snapstore (+36/-0)
snapstore_client/logic/login.py (+7/-0)
snapstore_client/logic/overrides.py (+27/-32)
snapstore_client/logic/push.py (+306/-0)
snapstore_client/logic/tests/test-snap-assert.assert (+101/-0)
snapstore_client/logic/tests/test-snap-map.json (+73/-0)
snapstore_client/logic/tests/test_login.py (+3/-1)
snapstore_client/logic/tests/test_overrides.py (+152/-8)
snapstore_client/logic/tests/test_push.py (+169/-0)
snapstore_client/utils.py (+26/-0)
snapstore_client/webservices.py (+24/-4)
Reviewer Review Type Date Requested Status
Tony Simpson (community) Approve
Review via email: mp+364917@code.launchpad.net

Commit message

Add control and upload for an offline proxy

Description of the change

Offline proxies can't issue or validate macaroons, so use HTTP Basic Auth.
Also add uploading of snaps via downloaded .snap, .assert and channel map metadata. This code mostly ported from the cli of the snap-store-proxy.

Relies on the upload-snap-to-cache branch of snapstore-snap

To post a comment you must log in.
Revision history for this message
Adam Collard (adam-collard) :
12c75da... by Tom Wardill

Use env password as default

dd76e1e... by Tom Wardill

Lint error

Revision history for this message
Tom Wardill (twom) :
0d85442... by Tom Wardill

Add basic README instructions

935fb22... by Tom Wardill

Rename upload to push

00476d8... by Tom Wardill

Extract a tar file before upload

Revision history for this message
Tony Simpson (tonysimpson) wrote :

Looks good to me.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/README b/README
index 6171594..ab1584e 100644
--- a/README
+++ b/README
@@ -6,3 +6,26 @@ This repo contains a client to adminster a snapstore.
6After authenticating with a snapstore the administrator can:6After authenticating with a snapstore the administrator can:
77
8 - manage revision overrides8 - manage revision overrides
9
10Building
11--------
12
13You may wish to perform these installations inside a LXD container.
14
15Install the dependencies::
16
17 cat dependencies | xargs sudo apt install -y
18
19Create the environment
20----------------------
21
22Use the Makefile::
23
24 make bootstrap
25
26Running for development
27-----------------------
28
29The virtualenv environment is setup in /tmp::
30
31 /tmp/snapstore-client.tmp/env/bin/python snapstore
diff --git a/snapstore b/snapstore
index a21a8a1..cd67b71 100755
--- a/snapstore
+++ b/snapstore
@@ -7,6 +7,7 @@
77
8import argparse8import argparse
9import logging9import logging
10import os
10import sys11import sys
1112
12from snapstore_client.cli import configure_logging13from snapstore_client.cli import configure_logging
@@ -16,6 +17,7 @@ from snapstore_client.logic.overrides import (
16 list_overrides,17 list_overrides,
17 override,18 override,
18)19)
20from snapstore_client.logic.push import push_snap
1921
2022
21DEFAULT_SSO_URL = 'https://login.ubuntu.com/'23DEFAULT_SSO_URL = 'https://login.ubuntu.com/'
@@ -44,6 +46,8 @@ def parse_args():
44 login_parser.add_argument('email', help='Ubuntu One SSO email', nargs='?')46 login_parser.add_argument('email', help='Ubuntu One SSO email', nargs='?')
45 login_parser.add_argument('--sso-url', help='Ubuntu One SSO URL',47 login_parser.add_argument('--sso-url', help='Ubuntu One SSO URL',
46 default=DEFAULT_SSO_URL)48 default=DEFAULT_SSO_URL)
49 login_parser.add_argument('--offline', help="Use offline mode interaction",
50 action='store_true')
47 login_parser.set_defaults(func=login)51 login_parser.set_defaults(func=login)
4852
49 list_overrides_parser = subparsers.add_parser(53 list_overrides_parser = subparsers.add_parser(
@@ -55,6 +59,11 @@ def parse_args():
55 list_overrides_parser.add_argument(59 list_overrides_parser.add_argument(
56 'snap_name',60 'snap_name',
57 help='The name of the snap whose channel map should be listed.')61 help='The name of the snap whose channel map should be listed.')
62 list_overrides_parser.add_argument(
63 '--password',
64 help='Password for interacting with an offline proxy',
65 default=os.environ.get('SNAP_PROXY_PASSWORD')
66 )
58 list_overrides_parser.set_defaults(func=list_overrides)67 list_overrides_parser.set_defaults(func=list_overrides)
5968
60 override_parser = subparsers.add_parser(69 override_parser = subparsers.add_parser(
@@ -69,6 +78,11 @@ def parse_args():
69 override_parser.add_argument(78 override_parser.add_argument(
70 'channel_map_entries', nargs='+', metavar='channel_map_entry',79 'channel_map_entries', nargs='+', metavar='channel_map_entry',
71 help='A channel map override, in the form <channel>=<revision>.')80 help='A channel map override, in the form <channel>=<revision>.')
81 override_parser.add_argument(
82 '--password',
83 help='Password for interacting with an offline proxy',
84 default=os.environ.get('SNAP_PROXY_PASSWORD')
85 )
72 override_parser.set_defaults(func=override)86 override_parser.set_defaults(func=override)
7387
74 delete_override_parser = subparsers.add_parser(88 delete_override_parser = subparsers.add_parser(
@@ -83,8 +97,30 @@ def parse_args():
83 delete_override_parser.add_argument(97 delete_override_parser.add_argument(
84 'channels', nargs='+', metavar='channel',98 'channels', nargs='+', metavar='channel',
85 help='A channel whose overrides should be deleted.')99 help='A channel whose overrides should be deleted.')
100 delete_override_parser.add_argument(
101 '--password',
102 help='Password for interacting with an offline proxy',
103 default=os.environ.get('SNAP_PROXY_PASSWORD')
104 )
86 delete_override_parser.set_defaults(func=delete_override)105 delete_override_parser.set_defaults(func=delete_override)
87106
107 push_snap_parser = subparsers.add_parser(
108 'push-snap', help='push a snap to an offline proxy')
109 push_snap_parser.add_argument(
110 'snap_tar_file',
111 help='The .tar.gz file of a bundled downloaded snap')
112 push_snap_parser.add_argument(
113 '--push-channel-map',
114 action='store_true',
115 help="Force push of the channel map,"
116 " removing any existing overrides")
117 push_snap_parser.add_argument(
118 '--password',
119 help='Password for interacting with an offline proxy',
120 default=os.environ.get('SNAP_PROXY_PASSWORD')
121 )
122 push_snap_parser.set_defaults(func=push_snap)
123
88 if len(sys.argv) == 1:124 if len(sys.argv) == 1:
89 # Display help if no arguments are provided.125 # Display help if no arguments are provided.
90 parser.print_help()126 parser.print_help()
diff --git a/snapstore_client/logic/login.py b/snapstore_client/logic/login.py
index 7d3a313..9550988 100644
--- a/snapstore_client/logic/login.py
+++ b/snapstore_client/logic/login.py
@@ -31,6 +31,13 @@ def login(args):
31 gw_url = args.store_url31 gw_url = args.store_url
32 sso_url = args.sso_url32 sso_url = args.sso_url
3333
34 if args.offline:
35 cfg = config.Config()
36 store = cfg.store_section('default')
37 store.set('gw_url', gw_url)
38 cfg.save()
39 return
40
34 logger.info('Enter your Ubuntu One SSO credentials.')41 logger.info('Enter your Ubuntu One SSO credentials.')
35 email = args.email42 email = args.email
36 if not email:43 if not email:
diff --git a/snapstore_client/logic/overrides.py b/snapstore_client/logic/overrides.py
index 667bd0a..d3c8356 100644
--- a/snapstore_client/logic/overrides.py
+++ b/snapstore_client/logic/overrides.py
@@ -13,34 +13,16 @@ from snapstore_client.presentation_helpers import (
13 channel_map_string_to_tuple,13 channel_map_string_to_tuple,
14 override_to_string,14 override_to_string,
15)15)
16from snapstore_client.utils import (
17 _check_default_store,
18 _log_authorized_error,
19 _log_credentials_error,
20)
1621
1722
18logger = logging.getLogger(__name__)23logger = logging.getLogger(__name__)
1924
2025
21def _log_credentials_error(e):
22 logger.error('%s', e)
23 logger.error('Try to "snap-store-proxy-client login" again.')
24
25
26def _log_authorized_error():
27 logger.error(("Perhaps you have not been registered as an "
28 "admin with the proxy."))
29 logger.error("Try 'snap-proxy add-admin' on the proxy host.")
30
31
32def _check_default_store(cfg):
33 """Load the default store from the config."""
34 store = cfg.store_section('default')
35 # If the gw URL is configured then everything else should be too.
36 if not store.get('gw_url'):
37 logger.error(
38 'No store configuration found. '
39 'Have you run "snap-store-proxy-client login"?')
40 return None
41 return store
42
43
44def list_overrides(args):26def list_overrides(args):
45 cfg = config.Config()27 cfg = config.Config()
46 store = _check_default_store(cfg)28 store = _check_default_store(cfg)
@@ -48,9 +30,14 @@ def list_overrides(args):
48 return 130 return 1
4931
50 try:32 try:
51 response = ws.refresh_if_necessary(33 if args.password:
52 store, ws.get_overrides,34 response = ws.get_overrides(
53 store, args.snap_name, series=args.series)35 store, args.snap_name,
36 series=args.series, password=args.password)
37 else:
38 response = ws.refresh_if_necessary(
39 store, ws.get_overrides,
40 store, args.snap_name, series=args.series)
54 except exceptions.InvalidCredentials as e:41 except exceptions.InvalidCredentials as e:
55 _log_credentials_error(e)42 _log_credentials_error(e)
56 return 143 return 1
@@ -77,9 +64,13 @@ def override(args):
77 'series': args.series,64 'series': args.series,
78 })65 })
79 try:66 try:
80 response = ws.refresh_if_necessary(67 if args.password:
81 store, ws.set_overrides,68 response = ws.set_overrides(
82 store, overrides)69 store, overrides, password=args.password)
70 else:
71 response = ws.refresh_if_necessary(
72 store, ws.set_overrides,
73 store, overrides)
83 except exceptions.InvalidCredentials as e:74 except exceptions.InvalidCredentials as e:
84 _log_credentials_error(e)75 _log_credentials_error(e)
85 return 176 return 1
@@ -105,9 +96,13 @@ def delete_override(args):
105 'series': args.series,96 'series': args.series,
106 })97 })
107 try:98 try:
108 response = ws.refresh_if_necessary(99 if args.password:
109 store, ws.set_overrides,100 response = ws.set_overrides(
110 store, overrides)101 store, overrides, password=args.password)
102 else:
103 response = ws.refresh_if_necessary(
104 store, ws.set_overrides,
105 store, overrides)
111 except exceptions.InvalidCredentials as e:106 except exceptions.InvalidCredentials as e:
112 _log_credentials_error(e)107 _log_credentials_error(e)
113 return 1108 return 1
diff --git a/snapstore_client/logic/push.py b/snapstore_client/logic/push.py
114new file mode 100644109new file mode 100644
index 0000000..abd06e8
--- /dev/null
+++ b/snapstore_client/logic/push.py
@@ -0,0 +1,306 @@
1import json
2import logging
3from pathlib import Path
4import tarfile
5import tempfile
6from urllib.parse import urlsplit, urlunsplit
7
8import requests
9from requests.exceptions import HTTPError
10
11from snapstore_client import (
12 config,
13 exceptions,
14)
15from snapstore_client.utils import (
16 _check_default_store,
17 _log_authorized_error,
18 _log_credentials_error,
19)
20
21logger = logging.getLogger(__name__)
22
23
24# XXX twom 2019-03-19 hardcoded for now, awaiting RBAC integration
25USERNAME = 'admin'
26
27
28class pushException(Exception):
29 pass
30
31
32class ChannelMapExistsException(Exception):
33 pass
34
35
36def _push_ident(store, password, downloaded_map):
37 """push the snap details to snapident"""
38
39 snap_id = downloaded_map['snap-id']
40 snap_details = downloaded_map['snap']
41 push_details = {
42 'snap_id': snap_id,
43 'snap_name': snap_details['name'],
44 'private': False, # If you're pushing to your own proxy
45 'stores': ['ubuntu'], # ditto
46 'blob': snap_details,
47 'publisher_id': snap_details['publisher']['id'],
48 'publisher_name': snap_details['publisher']['display-name'],
49 'publisher_title': snap_details['publisher']['username'],
50 'publisher_validation': snap_details['publisher']['validation'],
51 'license': snap_details['license'],
52 'prices': snap_details['prices'],
53 'summary': snap_details['summary'],
54 'title': snap_details['title'],
55 }
56
57 url = store.get('gw_url')
58
59 response = requests.post(
60 url + '/snaps/update',
61 json={'snaps': [push_details]},
62 auth=requests.auth.HTTPBasicAuth(USERNAME, password)
63 )
64 if response.status_code != 200:
65 raise pushException(
66 "Failure in pushing to snapident: {}".format(
67 response.content))
68
69
70def _push_revs(store, password, downloaded_map):
71 """push the revision information to snaprevs"""
72 store_url = store.get('gw_url')
73
74 snap_id = downloaded_map['snap-id']
75 for instance in downloaded_map['channel-map']:
76 # rewrite the download_url
77 download_url = instance['download']['url']
78
79 url = urlsplit(download_url)
80 fqdn = urlsplit(store_url)
81 # Rewrite scheme and location
82 download_url = urlunsplit(url._replace(scheme=fqdn[0], netloc=fqdn[1]))
83
84 # create the revision
85 rev = {
86 'snap_id': snap_id,
87 'revision': instance['revision'],
88 'version': instance['version'],
89 'confinement': instance['confinement'],
90 'architectures': instance['architectures'],
91 'created_at': instance['created-at'],
92 # This is required, but isn't available to retrieve from /info
93 'created_by': '',
94 'binary_path': download_url,
95 'binary_filesize': instance['download']['size'],
96 'binary_sha3_384': instance['download']['sha3-384'],
97 'snap_yaml': instance.get('snap-yaml', ''),
98 'epoch': instance['epoch'],
99 'type': instance['type'],
100 'base': instance['base'],
101 'common_ids': instance['common-ids'],
102 }
103 revs_response = requests.post(
104 store_url + '/revisions/create',
105 json=[rev],
106 auth=requests.auth.HTTPBasicAuth(USERNAME, password)
107 )
108 # 409 / Conflict - already exists
109 if revs_response.status_code not in [201, 409]:
110 raise pushException(
111 "Failure to push revisions to snaprevs: {}".format(
112 revs_response.content))
113
114
115def _push_channelmap(store, password, downloaded_map, force_push=False):
116 """push the channel map to snaprevs"""
117 store_url = store.get('gw_url')
118
119 snap_id = downloaded_map['snap-id']
120
121 # Check if we have an existing channel map
122 existing = requests.post(
123 store_url + '/channelmaps/filter',
124 json={'filters': [{'series': '16', 'snap_id': snap_id}]},
125 auth=requests.auth.HTTPBasicAuth(USERNAME, password))
126 if existing.status_code != 200:
127 raise pushException("Error retrieving current channelmap")
128 if existing.json().get('channelmaps') and not force_push:
129 raise ChannelMapExistsException("Channel map already exists.")
130
131 for instance in downloaded_map['channel-map']:
132 if instance['channel']['track'] == 'latest':
133 track = None
134 else:
135 track = instance['channel']['track']
136 chan_map = {
137 # This is wrong, it should be the current developer id
138 # but the pusher might not be a developer
139 # XXX (twom): can we get this from upstream?
140 'developer_id': downloaded_map['snap']['publisher']['id'],
141 'release_requests': [
142 {
143 'snap_id': snap_id,
144 'channel': [
145 track,
146 instance['channel']['risk'],
147 None, # We can't deal in 'branch', it's not on the API
148 ],
149 'architecture': instance['channel']['architecture'],
150 'series': '16',
151 'revision': instance['revision']
152 }
153 ]
154 }
155 map_response = requests.post(
156 store_url + '/channelmaps/update',
157 json=chan_map,
158 auth=requests.auth.HTTPBasicAuth(USERNAME, password)
159 )
160 if map_response.status_code != 200:
161 raise pushException("Error pushing channel map")
162
163
164def _load_assertion_file(assert_path):
165 with open(str(assert_path)) as assert_file:
166 raw_contents = assert_file.read().splitlines()
167 return raw_contents
168
169
170def _split_assertions(raw_contents):
171 # split the file into separate asserts
172 available_asserts = []
173 current_assert = []
174 for line in raw_contents:
175 # if we've encountered a new assert, save the old one, start a new one
176 if line.startswith('type: ') and current_assert:
177 available_asserts.append(current_assert)
178 current_assert = []
179 current_assert.append(line)
180 # account for the last one in the file
181 available_asserts.append(current_assert)
182
183 # Because we've split the file, we've ended up with the newlines
184 # from between the concatenated asserts.
185 # Remove it from the ones that have it so they're clean to push
186 for assertion in available_asserts:
187 assertion[:] = assertion[:-1] if assertion[-1] == '' else assertion
188 return available_asserts
189
190
191def _add_assertion_to_service(store, password, list_assertions):
192 store_url = store.get('gw_url')
193
194 # push the split asserts
195 for assertion in list_assertions:
196 # get the type
197 assert_type = assertion[0].split('type: ')[1]
198 # ignore the account-key of canonical, it's already a trusted assert
199 # and will error on push
200 canonical_key = 'account-id: canonical'
201 if (assert_type == 'account-key' and canonical_key in assertion):
202 continue
203 if (assert_type == 'account' and canonical_key in assertion):
204 continue
205 response = requests.post(
206 store_url + '/v1/assertions',
207 '\n'.join(assertion),
208 headers={'Content-Type': 'application/x.ubuntu.assertion'},
209 auth=requests.auth.HTTPBasicAuth(USERNAME, password))
210 if response.status_code != 201:
211 raise pushException(
212 "Failed to push: {} - {}".format(
213 response.status_code, response.content))
214
215
216def _push_assertions(store, password, assert_path):
217 """Split a .assert and push individual assertions to snapassert"""
218
219 # load the file
220 raw_contents = _load_assertion_file(assert_path)
221 # split the assert file into single assertions for push
222 current_asserts = _split_assertions(raw_contents)
223 # push the assertions to the internal snap-assertion-service
224 try:
225 _add_assertion_to_service(store, password, current_asserts)
226 except pushException:
227 raise pushException("Assertion file: {}".format(assert_path))
228
229
230def _push_file_to_nginx_cache(store, password, snap_path, snap_id, revision):
231 """Add the file to the nginx cache via the snapproxy service"""
232 store_url = store.get('gw_url')
233 target_filename = '{}_{}.snap'.format(snap_id, revision)
234 files = {target_filename: open(snap_path, 'rb')}
235 response = requests.post(
236 store_url + '/files/upload',
237 files=files,
238 auth=requests.auth.HTTPBasicAuth(USERNAME, password))
239 if response.status_code != 200:
240 print(response.status_code)
241 raise pushException("Failed to push file to proxy cache")
242
243
244def _push(store, tar_file, password, force_channel_map=False):
245 """
246 push a given .snap with matching .assert and .json
247 to an offline proxy
248 """
249 extract_dir = Path(tempfile.mkdtemp())
250 with tarfile.open(str(tar_file), 'r:gz') as tar:
251 tar.extractall(str(extract_dir))
252
253 json_file = extract_dir / 'channel-map.json'
254 json_path = Path(json_file)
255 if not json_path.exists():
256 logger.error("Snap information file not found at {}".format(json_path))
257 return
258
259 with json_path.open() as fh:
260 downloaded_map = json.load(fh)
261
262 _push_ident(store, password, downloaded_map)
263 _push_revs(store, password, downloaded_map)
264 try:
265 _push_channelmap(
266 store, password, downloaded_map, force_channel_map)
267 except ChannelMapExistsException as e:
268 logger.info(str(e))
269 logger.info(
270 "Not updating the channel map, either manage revisions using "
271 "`snap-proxy override` or try again with `--push-channel-map`")
272
273 for instance in downloaded_map['channel-map']:
274 snap_name = downloaded_map['snap']['name']
275 snap_file_name = '{}_{}.snap'.format(
276 snap_name, instance['revision'])
277 assert_file_name = '{}_{}.assert'.format(
278 snap_name, instance['revision'])
279 snap_path = str(json_path.parent / snap_file_name)
280 assert_path = str(json_path.parent / assert_file_name)
281 _push_file_to_nginx_cache(
282 store, password, snap_path,
283 downloaded_map['snap-id'], instance['revision'])
284 _push_assertions(store, password, assert_path)
285
286
287def push_snap(args):
288 cfg = config.Config()
289 store = _check_default_store(cfg)
290 if not store:
291 return 1
292
293 try:
294 if args.password:
295 _push(store, args.snap_tar_file,
296 args.password, args.push_channel_map)
297 else:
298 logger.error(
299 "This command only works with a supplied password"
300 " and a proxy in offline mode")
301 except exceptions.InvalidCredentials as e:
302 _log_credentials_error(e)
303 return 1
304 except HTTPError:
305 _log_authorized_error()
306 return 1
diff --git a/snapstore_client/logic/tests/test-snap-assert.assert b/snapstore_client/logic/tests/test-snap-assert.assert
0new file mode 100644307new file mode 100644
index 0000000..b6b2ebd
--- /dev/null
+++ b/snapstore_client/logic/tests/test-snap-assert.assert
@@ -0,0 +1,101 @@
1type: account-key
2authority-id: canonical
3revision: 2
4public-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul
5account-id: canonical
6name: store
7since: 2016-04-01T00:00:00.0Z
8body-length: 717
9sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk
10
11AcbBTQRWhcGAARAA0KKYYQWuHOrsFVi4p4l7ZzSvX7kLgJFFeFgOkzdWKBTHEnsMKjl5mefFe9ji
12qe8NlmJdfY7BenP7XeBtwKp700H/t9lLrZbpTNAPHXYxEWFJp5bPqIcJYBZ+29oLVLN1Tc5X482R
13vCiDqL8+pPYqBrK2fNlyPlNNSum9wI70rDDL4r6FVvr+osTnGejibdV8JphWX+lrSQDnRSdM8KJi
14UM43vTgLGTi9W54oRhsA2OFexRfRksTrnqGoonCjqX5wO3OFSaMDzMsO2MJ/hPfLgDqw53qjzuKL
15Iec9OL3k5basvu2cj5u9tKwVFDsCKK2GbKUsWWpx2KTpOifmhmiAbzkTHbH9KaoMS7p0kJwhTQGA
16o9aJ9VMTWHJc/NCBx7eu451u6d46sBPCXS/OMUh2766fQmoRtO1OwCTxsRKG2kkjbMn54UdFULl9
17VfzvyghMNRKIezsEkmM8wueTqGUGZWa6CEZqZKwhe/PROxOPYzqtDH18XZknbU1n5lNb7vNfem9F
182ai+3+JyFnW9UhfvpVF7gzAgdyCqNli4C6BIN43uwoS8HkykocZS/+Gv52aUQ/NZ8BKOHLw+7ant
19Q0o8W9ltSLZbEMxFIPSN0stiZlkXAp6DLyvh1Y4wXSynDjUondTpej2fSvSlCz/W5v5V7qA4nIcG
20vUvV7RjVzv17ut0AEQEAAQ==
21
22AcLDXAQAAQoABgUCV83k9QAKCRDUpVvql9g3IBT8IACKZ7XpiBZ3W4lqbPssY6On81WmxQLtvsMV
23WTp6zZpl/wWOSt2vMNUk9pvcmrNq1jG9CuhDfWFLGXEjcrrmVkN3YuCOajMSPFCGrxsIBLSRt/bP
24nrKykdLAAzMfG8rP1d82bjFFiIieE+urQ0Kcv09Jtdvavq3JT1Tek5mFyyfhHNlQEKOzWqmRWiLg
253c3VOZUs1ZD8TSlnuq/x+5T0X0YtOyGjSlVxk7UybbyMNd6MZfNaMpIG4x+mxD3KHFtBAC7O6kLe
26eX3i6j5nCY5UABfA3DZEAkWP4zlmdBEOvZ9t293NaDdOpzsUHRkoi0Zez/9BHQ/kwx/uNc2WqrYm
27inCmu16JGNeXqsyinnLl7Ghn2RwhvDMlLxF6RTx8xdx1yk6p3PBTwhZMUvuZGjUtN/AG8BmVJQ19
28rsGSRkkSywvnhVJRB2sudnrMBmNS2goJbzSbmJnOlBrd2WsV0T9SgNMWZBiov3LvU4o2SmAb6b+k
29rYwh8H5QHcuuYJuxDjFhPswIp6Wes5T6hUicf3SWtObcDS4HSkVS4ImBjjX9YgCuFy7QdnooOWEY
30aPvkRw3XCVeYq0K6w9GRsk1YFErD4XmXXZjDYY650MX9v42Sz5MmphHV8jdIY5ssbadwFSe2rCQI
316UX08zy7RsIb19hTndE6ncvSNDChUR9eEnCm73eYaWTWTnq1cxdVP/s52r8uss++OYOkPWqh5nOu
32haRn7INjH/yZX4qXjNXlTjo0PnHH0q08vNKDwLhxS+D9du+70FeacXFyLIbcWllSbJ7DmbumGpFo
33yYbtj3FDDPzachFQdIG3lSt+cSUGeyfSs6wVtc3cIPka/2Urx7RprfmoWSI6+a5NcLdj0u2z8O96
34HxeIgxDpg/3gT8ZIuFKePMcLDM19Fh/p0ysCsX+84B9chNWtsMSmIaE57V+959MVtsLu7SLb9gi7
35skrju0pQCwsu2wHMLTNd1f3PTHmrr49hxetTus07HSQUApMtAGKzQilF5zqFjbyaTd4xgQbd+PKW
36CjFyzQTDOcUhXpuUGt/IzlqiFfsCsmbj2K4KdSNYMlqIgZ3Azu8KvZLIhsyN7v5vNIZSPfEbjdeu
37ClU9r0VRiJmtYBUjcSghD9LWn+yRLwOxhfQVjm0cBwIt5R/yPF/qC76yIVuWUtM5Y2/zJR1J8OFq
38qWchvlImHtvDzS9FQeLyzJAOjvZ2CnWp2gILgUz0WQdOk1Dq8ax7KS9BQ42zxw9EZAEPw3PEFqRy
39IQsRTONp+iVS8YxSmoYZjDlCgRMWUmawez/Fv5b9Fb/XkO5Eq4e+KfrpUujXItaipb+tV8h5v3tr
40oG3Ie3WOHrVjCLXIdYslpL1O4nadqR6Xv58pHj6k
41
42type: account
43authority-id: canonical
44account-id: 1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX
45display-name: Tom Wardill
46timestamp: 2018-02-06T09:22:49.118291Z
47username: twom
48validation: unproven
49sign-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul
50
51AcLBUgQAAQoABgUCWnlz6QAA40sQALjt4pGWbG9icFARLbNSnx0ldc9cEytJ5ctLVvG0x3P7hP3u
526VntHcurt5mkQmW9nTJ8CY3R5uAPVkVda1gFabFlIqZPl4fAv0KqH9vziqSsc1rn05nyLcPMEu2P
53WmWsOh/F7O3IqJrvdX1Xpuv0BsDjZi9merN20BHcY8LDVPd5qUDyN/BISbNzogzHC3MdilYAP75p
54YoYYhH7dHruLgPREOKEhAjIoFguvPtML1xAUFz5XDpvm0sHDURuDYN/cwpFOLsOkPCDokyYC/Udb
55Bh9foT4o1FYvBQOi2DjFBs0UFgAk/2ud8Rv9N10O2MnsQ0ZGerAsGAVIARkz5zjSHZPFokL5PJQ/
56AzJBVRR2NPtRvBEnF/bG0xR5JR/5AZK2K7mIKrQt22CFL4M4iL7uQI1Q1i7PBpysYT7vZwyyFkEP
57M2NXmvpZ34i+5JnyEtdn2H+JVhvQS1RpqdwDwfpqn+lYkJBvDN8B9aDtyp+yMdDTLoLt74JZKP4V
58YeptgPiaMUHOQJx6gdp/Fa9FJAQiNUFjXe9/lfF1yebu0CVXdO6CaSRGBXrQfk2E/WpZNsmZOTq+
59Rc0dGtlMeulEo8fY9RcJlsKe7hjlzX1Yk7JFkGqb60Fzqy0AzoJwDXjTnQPo0AKyNz+ryqly3Sur
60c58OOgdNDw9KbjYoVXvjFLzAyA/k
61
62type: snap-declaration
63authority-id: canonical
64series: 16
65snap-id: p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM
66publisher-id: 1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX
67snap-name: gif-cli
68timestamp: 2018-10-02T15:43:27.762767Z
69sign-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul
70
71AcLBUgQAAQoABgUCW7OSHwAAV/cQAB9wlV5pNd/+20BSCv6tsBAUugrHbFRVjIMHsYoj7Can0mSB
729vRSbDnFB91VWk+6gBpR+xXtHJithiyCl3fitf2tNuRKm/W6M4fhdctQXX8pcPSsonfSq1+1apgT
73vy7BZaChVEMROY3y5/NZNj4x/+1t38cA4dqxOHIXbhc1B/SifKpFQWq8hu+GMSFFVSxWCJp7rLor
74sqfBFUY1cFeCGsW/iawAPTXk3z7tZhkhoi1FQeu9KrQGMCY0aaD1uZhPLfCwP4mDmsuW3lxJ5QIt
75fhQdO46jh6Ea9nahjAMWrIYPL6G1Yk5aFH6cQ7LfcQq/JHEcDvFUXbtM5jQJU4gPcjnSkFlRCSNA
76aig1trd/RjqQyVMiIBDGRvOPZZij7AdnQ+P88CH1k5oWppNTgk4VM0UsSCdFFXVw7sQlPUWjRaYP
77PHfy1xkMZBw11jLDytOVwLKjPIZs8dCTHKkPfAD4oTP0+W/OvOBerUoHGeaxlZ8Cx17wFcfllEcO
78fiYmnYOAd0JRIXeOXH9/LTbSnb9dMPaaL9jIz0YhmNJOPuzUPpfGeoEQMlt61OfTtEuvjDAq+Nh/
79mhCDl0RnvH+8rjLv0ksYLETsFCkKlOYYMugoj0ruBjl3ss6GU/36RFX9quKEdDyyyus71C1V6ohF
80VzUiaij6jfTkWC4rMPyl69L3Z7fw
81
82type: snap-revision
83authority-id: canonical
84snap-sha3-384: MrP3xzTBH8SQLC1oOfMNIigeJ_0GUE1yqugKZzx2dKdqO2fHbzYa2SfJtxS5JWMe
85developer-id: 1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX
86snap-id: p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM
87snap-revision: 3
88snap-size: 9678848
89timestamp: 2018-10-14T20:48:18.733551Z
90sign-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul
91
92AcLBUgQAAQoABgUCW8OrkgAAm/oQAAnCsfsbDBJt59mPG9A1BRHYZHV7EqYis75QxZPzMLlkpoUG
93Xqc8g0aHgEt+STwrrAqUli9G5Yg3Tk8rWb7CV7MlB3c/pBgnW8XLWm3zp1S4ZonDPpKYmgeBcmui
94hH9lXPlHHnL9dGbObhPk7jbAFHOmqgC9S5lD6Uuj7RTn8Hv+hT7YbvZ8LpjcRfLfUJAIhfCCcxHm
95euwRMyGP5NFwOLWIj4XZNKhKf6Tj8bUJV9Kcf7MBZ/cvdwzPiTkU5EleHid4bdZcUkrcNTt8wFI4
96NxU+CH8m3mzP2/EF+amxoqHc5+9qMYWtu2iou1E8oY5M7s+QujTHPipzgPQkkMLSocyoO1ntezd1
97YhUu2GkXw2IRmmgVvaMjmXcE+voGNsT+W2CeHqQNvZvip3KDKvQG3G8tX9Sf6V7NSjqn1+IwpJpS
98Bx61HphfypyHbho7KgYcSYhdqCQF8XiLwQ3pWtE6l654I3kHzuzyrYKR+Ts1byHB+w5sLx4fw7xV
997/CbvzO6konCrW00bG3UGKHZk4zJGBlv5cfGQxuq5GNaaDWZ9Pg5awSrgB8kShAGzOpdABle8eYg
100RVUbPo53cZO3ditJ+B7VT1pA0on1BiyQGKQUQplr+glZtplPsu6q+A0HzLZ3Wg7EFUdrSThhDh6N
10146jdhxSAwRSPVHtzHm/fgUcGXuMJ
diff --git a/snapstore_client/logic/tests/test-snap-map.json b/snapstore_client/logic/tests/test-snap-map.json
0new file mode 100644102new file mode 100644
index 0000000..0b28b16
--- /dev/null
+++ b/snapstore_client/logic/tests/test-snap-map.json
@@ -0,0 +1,73 @@
1{
2 "channel-map": [{
3 "architectures": ["amd64"],
4 "base": "core18",
5 "channel": {
6 "architecture": "amd64",
7 "name": "stable",
8 "released-at": "2018-11-08T18:30:43.652281+00:00",
9 "risk": "stable",
10 "track": "latest"
11 },
12 "common-ids": [],
13 "confinement": "strict",
14 "created-at": "2018-10-14T20:48:12.808884+00:00",
15 "download": {
16 "deltas": [],
17 "sha3-384": "32b3f7c734c11fc4902c2d6839f30d22281e27fd06504d72aae80a673c7674a76a3b67c76f361ad927c9b714b925631e",
18 "size": 9678848,
19 "url": "https://api.snapcraft.io/api/v1/snaps/download/p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM_3.snap"
20 },
21 "epoch": {
22 "read": [0],
23 "write": [0]
24 },
25 "revision": 3,
26 "snap-yaml": "name: gif-cli\nversion: 0.0.3\nsummary: CLI tool to search for gifs\ndescription: 'CLI tool to search for gifs\n\n '\nbase: core18\narchitectures:\n- amd64\nconfinement: strict\ngrade: stable\napps:\n gif-cli:\n command: command-gif-cli.wrapper\n plugs:\n - network-bind\n - network\n",
27 "type": "app",
28 "version": "0.0.3"
29 }, {
30 "architectures": ["amd64"],
31 "base": "core18",
32 "channel": {
33 "architecture": "amd64",
34 "name": "edge",
35 "released-at": "2018-10-14T20:49:19.242435+00:00",
36 "risk": "edge",
37 "track": "latest"
38 },
39 "common-ids": [],
40 "confinement": "strict",
41 "created-at": "2018-10-14T20:48:12.808884+00:00",
42 "download": {
43 "deltas": [],
44 "sha3-384": "32b3f7c734c11fc4902c2d6839f30d22281e27fd06504d72aae80a673c7674a76a3b67c76f361ad927c9b714b925631e",
45 "size": 9678848,
46 "url": "https://api.snapcraft.io/api/v1/snaps/download/p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM_3.snap"
47 },
48 "epoch": {
49 "read": [0],
50 "write": [0]
51 },
52 "revision": 3,
53 "snap-yaml": "name: gif-cli\nversion: 0.0.3\nsummary: CLI tool to search for gifs\ndescription: 'CLI tool to search for gifs\n\n '\nbase: core18\narchitectures:\n- amd64\nconfinement: strict\ngrade: stable\napps:\n gif-cli:\n command: command-gif-cli.wrapper\n plugs:\n - network-bind\n - network\n",
54 "type": "app",
55 "version": "0.0.3"
56 }],
57 "name": "gif-cli",
58 "snap": {
59 "license": "unset",
60 "name": "gif-cli",
61 "prices": {},
62 "publisher": {
63 "display-name": "Tom Wardill",
64 "id": "1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX",
65 "username": "twom",
66 "validation": "unproven"
67 },
68 "snap-id": "p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM",
69 "summary": "CLI tool to search for gifs",
70 "title": "gif-cli"
71 },
72 "snap-id": "p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM"
73}
diff --git a/snapstore_client/logic/tests/test_login.py b/snapstore_client/logic/tests/test_login.py
index 25ef14f..d7854cc 100644
--- a/snapstore_client/logic/tests/test_login.py
+++ b/snapstore_client/logic/tests/test_login.py
@@ -54,11 +54,13 @@ class LoginTests(TestCase):
54 iter_responses = iter(full_responses)54 iter_responses = iter(full_responses)
55 return lambda request: next(iter_responses)55 return lambda request: next(iter_responses)
5656
57 def make_args(self, store_url=None, sso_url=None, email=None):57 def make_args(self, store_url=None,
58 sso_url=None, email=None, offline=False):
58 return factory.Args(59 return factory.Args(
59 store_url=store_url or self.default_gw_url,60 store_url=store_url or self.default_gw_url,
60 sso_url=sso_url or self.default_sso_url,61 sso_url=sso_url or self.default_sso_url,
61 email=email,62 email=email,
63 offline=offline,
62 )64 )
6365
64 def add_issue_store_admin_response(self, *response_templates, gw_url=None):66 def add_issue_store_admin_response(self, *response_templates, gw_url=None):
diff --git a/snapstore_client/logic/tests/test_overrides.py b/snapstore_client/logic/tests/test_overrides.py
index 1f2f2d0..05e32fc 100644
--- a/snapstore_client/logic/tests/test_overrides.py
+++ b/snapstore_client/logic/tests/test_overrides.py
@@ -32,7 +32,7 @@ class OverridesTests(TestCase):
32 'Have you run "snap-store-proxy-client login"?\n')32 'Have you run "snap-store-proxy-client login"?\n')
3333
34 @responses.activate34 @responses.activate
35 def test_list_overrides(self):35 def test_list_overrides_online(self):
36 self.useFixture(testfixtures.ConfigFixture())36 self.useFixture(testfixtures.ConfigFixture())
37 logger = self.useFixture(fixtures.FakeLogger())37 logger = self.useFixture(fixtures.FakeLogger())
38 snap_id = factory.generate_snap_id()38 snap_id = factory.generate_snap_id()
@@ -52,18 +52,55 @@ class OverridesTests(TestCase):
52 responses.add(52 responses.add(
53 'GET', overrides_url, status=200, json={'overrides': overrides})53 'GET', overrides_url, status=200, json={'overrides': overrides})
5454
55 list_overrides(factory.Args(snap_name='mysnap', series='16'))55 list_overrides(
56 factory.Args(snap_name='mysnap', series='16', password=False))
56 self.assertEqual(57 self.assertEqual(
57 'mysnap stable amd64 1 (upstream 2)\n'58 'mysnap stable amd64 1 (upstream 2)\n'
58 'mysnap foo/stable i386 3 (upstream 4)\n',59 'mysnap foo/stable i386 3 (upstream 4)\n',
59 logger.output)60 logger.output)
61 # We shouldn't have Basic Authorization headers, but Macaroon
62 self.assertNotIn(
63 'Basic',
64 responses.calls[0].request.headers['Authorization'])
65
66 @responses.activate
67 def test_list_overrides_offline(self):
68 self.useFixture(testfixtures.ConfigFixture())
69 logger = self.useFixture(fixtures.FakeLogger())
70 snap_id = factory.generate_snap_id()
71 overrides = [
72 factory.SnapDeviceGateway.Override(
73 snap_id=snap_id, snap_name='mysnap'),
74 factory.SnapDeviceGateway.Override(
75 snap_id=snap_id, snap_name='mysnap', revision=3,
76 upstream_revision=4, channel='foo/stable',
77 architecture='i386'),
78 ]
79 # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once
80 # they exist.
81 overrides_url = urljoin(
82 config.Config().store_section('default').get('gw_url'),
83 '/v2/metadata/overrides/mysnap')
84 responses.add(
85 'GET', overrides_url, status=200, json={'overrides': overrides})
86
87 list_overrides(
88 factory.Args(snap_name='mysnap', series='16', password='test'))
89 self.assertEqual(
90 'mysnap stable amd64 1 (upstream 2)\n'
91 'mysnap foo/stable i386 3 (upstream 4)\n',
92 logger.output)
93 self.assertEqual(
94 'Basic YWRtaW46dGVzdA==',
95 responses.calls[0].request.headers['Authorization'])
6096
61 def test_override_no_store_config(self):97 def test_override_no_store_config(self):
62 self.useFixture(testfixtures.ConfigFixture(empty=True))98 self.useFixture(testfixtures.ConfigFixture(empty=True))
63 logger = self.useFixture(fixtures.FakeLogger())99 logger = self.useFixture(fixtures.FakeLogger())
64 rc = override(factory.Args(100 rc = override(factory.Args(
65 snap_name='some-snap', channel_map_entries=['stable=1'],101 snap_name='some-snap', channel_map_entries=['stable=1'],
66 series='16'))102 series='16',
103 password=False))
67 self.assertEqual(rc, 1)104 self.assertEqual(rc, 1)
68 self.assertEqual(105 self.assertEqual(
69 logger.output,106 logger.output,
@@ -71,7 +108,56 @@ class OverridesTests(TestCase):
71 'Have you run "snap-store-proxy-client login"?\n')108 'Have you run "snap-store-proxy-client login"?\n')
72109
73 @responses.activate110 @responses.activate
74 def test_override(self):111 def test_override_online(self):
112 self.useFixture(testfixtures.ConfigFixture())
113 logger = self.useFixture(fixtures.FakeLogger())
114 snap_id = factory.generate_snap_id()
115 overrides = [
116 factory.SnapDeviceGateway.Override(
117 snap_id=snap_id, snap_name='mysnap'),
118 factory.SnapDeviceGateway.Override(
119 snap_id=snap_id, snap_name='mysnap', revision=3,
120 upstream_revision=4, channel='foo/stable',
121 architecture='i386'),
122 ]
123 # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once
124 # they exist.
125 overrides_url = urljoin(
126 config.Config().store_section('default').get('gw_url'),
127 '/v2/metadata/overrides')
128 responses.add(
129 'POST', overrides_url, status=200, json={'overrides': overrides})
130
131 override(factory.Args(
132 snap_name='mysnap',
133 channel_map_entries=['stable=1', 'foo/stable=3'],
134 series='16',
135 password=False))
136 self.assertEqual([
137 {
138 'snap_name': 'mysnap',
139 'revision': 1,
140 'channel': 'stable',
141 'series': '16',
142 },
143 {
144 'snap_name': 'mysnap',
145 'revision': 3,
146 'channel': 'foo/stable',
147 'series': '16',
148 },
149 ], json.loads(responses.calls[0].request.body.decode()))
150 self.assertEqual(
151 'mysnap stable amd64 1 (upstream 2)\n'
152 'mysnap foo/stable i386 3 (upstream 4)\n',
153 logger.output)
154 # We shouldn't have Basic Authorization headers, but Macaroon
155 self.assertNotIn(
156 'Basic',
157 responses.calls[0].request.headers['Authorization'])
158
159 @responses.activate
160 def test_override_offline(self):
75 self.useFixture(testfixtures.ConfigFixture())161 self.useFixture(testfixtures.ConfigFixture())
76 logger = self.useFixture(fixtures.FakeLogger())162 logger = self.useFixture(fixtures.FakeLogger())
77 snap_id = factory.generate_snap_id()163 snap_id = factory.generate_snap_id()
@@ -94,7 +180,8 @@ class OverridesTests(TestCase):
94 override(factory.Args(180 override(factory.Args(
95 snap_name='mysnap',181 snap_name='mysnap',
96 channel_map_entries=['stable=1', 'foo/stable=3'],182 channel_map_entries=['stable=1', 'foo/stable=3'],
97 series='16'))183 series='16',
184 password='test'))
98 self.assertEqual([185 self.assertEqual([
99 {186 {
100 'snap_name': 'mysnap',187 'snap_name': 'mysnap',
@@ -113,12 +200,16 @@ class OverridesTests(TestCase):
113 'mysnap stable amd64 1 (upstream 2)\n'200 'mysnap stable amd64 1 (upstream 2)\n'
114 'mysnap foo/stable i386 3 (upstream 4)\n',201 'mysnap foo/stable i386 3 (upstream 4)\n',
115 logger.output)202 logger.output)
203 self.assertEqual(
204 'Basic YWRtaW46dGVzdA==',
205 responses.calls[0].request.headers['Authorization'])
116206
117 def test_delete_override_no_store_config(self):207 def test_delete_override_no_store_config(self):
118 self.useFixture(testfixtures.ConfigFixture(empty=True))208 self.useFixture(testfixtures.ConfigFixture(empty=True))
119 logger = self.useFixture(fixtures.FakeLogger())209 logger = self.useFixture(fixtures.FakeLogger())
120 rc = delete_override(factory.Args(210 rc = delete_override(factory.Args(
121 snap_name='some-snap', channels=['stable'], series='16'))211 snap_name='some-snap', channels=['stable'],
212 series='16', password=False))
122 self.assertEqual(rc, 1)213 self.assertEqual(rc, 1)
123 self.assertEqual(214 self.assertEqual(
124 logger.output,215 logger.output,
@@ -126,7 +217,56 @@ class OverridesTests(TestCase):
126 'Have you run "snap-store-proxy-client login"?\n')217 'Have you run "snap-store-proxy-client login"?\n')
127218
128 @responses.activate219 @responses.activate
129 def test_delete_override(self):220 def test_delete_override_online(self):
221 self.useFixture(testfixtures.ConfigFixture())
222 logger = self.useFixture(fixtures.FakeLogger())
223 snap_id = factory.generate_snap_id()
224 overrides = [
225 factory.SnapDeviceGateway.Override(
226 snap_id=snap_id, snap_name='mysnap', revision=None),
227 factory.SnapDeviceGateway.Override(
228 snap_id=snap_id, snap_name='mysnap', revision=None,
229 upstream_revision=4, channel='foo/stable',
230 architecture='i386'),
231 ]
232 # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once
233 # they exist.
234 overrides_url = urljoin(
235 config.Config().store_section('default').get('gw_url'),
236 '/v2/metadata/overrides')
237 responses.add(
238 'POST', overrides_url, status=200, json={'overrides': overrides})
239
240 delete_override(factory.Args(
241 snap_name='mysnap',
242 channels=['stable', 'foo/stable'],
243 series='16',
244 password=False))
245 self.assertEqual([
246 {
247 'snap_name': 'mysnap',
248 'revision': None,
249 'channel': 'stable',
250 'series': '16',
251 },
252 {
253 'snap_name': 'mysnap',
254 'revision': None,
255 'channel': 'foo/stable',
256 'series': '16',
257 },
258 ], json.loads(responses.calls[0].request.body.decode()))
259 self.assertEqual(
260 'mysnap stable amd64 is tracking upstream (revision 2)\n'
261 'mysnap foo/stable i386 is tracking upstream (revision 4)\n',
262 logger.output)
263 # We shouldn't have Basic Authorization headers, but Macaroon
264 self.assertNotIn(
265 'Basic',
266 responses.calls[0].request.headers['Authorization'])
267
268 @responses.activate
269 def test_delete_override_offline(self):
130 self.useFixture(testfixtures.ConfigFixture())270 self.useFixture(testfixtures.ConfigFixture())
131 logger = self.useFixture(fixtures.FakeLogger())271 logger = self.useFixture(fixtures.FakeLogger())
132 snap_id = factory.generate_snap_id()272 snap_id = factory.generate_snap_id()
@@ -149,7 +289,8 @@ class OverridesTests(TestCase):
149 delete_override(factory.Args(289 delete_override(factory.Args(
150 snap_name='mysnap',290 snap_name='mysnap',
151 channels=['stable', 'foo/stable'],291 channels=['stable', 'foo/stable'],
152 series='16'))292 series='16',
293 password='test'))
153 self.assertEqual([294 self.assertEqual([
154 {295 {
155 'snap_name': 'mysnap',296 'snap_name': 'mysnap',
@@ -168,3 +309,6 @@ class OverridesTests(TestCase):
168 'mysnap stable amd64 is tracking upstream (revision 2)\n'309 'mysnap stable amd64 is tracking upstream (revision 2)\n'
169 'mysnap foo/stable i386 is tracking upstream (revision 4)\n',310 'mysnap foo/stable i386 is tracking upstream (revision 4)\n',
170 logger.output)311 logger.output)
312 self.assertEqual(
313 'Basic YWRtaW46dGVzdA==',
314 responses.calls[0].request.headers['Authorization'])
diff --git a/snapstore_client/logic/tests/test_push.py b/snapstore_client/logic/tests/test_push.py
171new file mode 100644315new file mode 100644
index 0000000..7fedbbd
--- /dev/null
+++ b/snapstore_client/logic/tests/test_push.py
@@ -0,0 +1,169 @@
1import json
2from pathlib import Path
3from urllib.parse import urljoin
4
5import fixtures
6import responses
7from testtools import TestCase
8
9from snapstore_client.logic.push import (
10 ChannelMapExistsException,
11 pushException,
12 _add_assertion_to_service,
13 _split_assertions,
14 _push_channelmap,
15 _push_ident,
16 _push_revs,
17)
18
19
20class pushTests(TestCase):
21
22 def setUp(self):
23 super().setUp()
24 self.default_gw_url = 'http://store.local'
25 self.logger = self.useFixture(fixtures.FakeLogger())
26 current_path = Path(__file__).resolve().parent
27 with (current_path / 'test-snap-map.json').open() as fh:
28 self.snap_map = json.load(fh)
29 with (current_path / 'test-snap-assert.assert').open() as fh:
30 self.snap_assert = fh.read().splitlines()
31 self.store = {'gw_url': self.default_gw_url}
32
33 @responses.activate
34 def test_push_ident(self):
35 ident_url = urljoin(self.default_gw_url, '/snaps/update')
36 responses.add('POST', ident_url, status=200)
37
38 _push_ident(self.store, 'test', self.snap_map)
39
40 self.assertEqual(
41 'Basic YWRtaW46dGVzdA==',
42 responses.calls[0].request.headers['Authorization'])
43
44 @responses.activate
45 def test_push_ident_failed(self):
46 ident_url = urljoin(self.default_gw_url, '/snaps/update')
47 responses.add('POST', ident_url, status=500)
48
49 self.assertRaises(
50 pushException, _push_ident, self.store, 'test', self.snap_map)
51
52 @responses.activate
53 def test_push_revs(self):
54 revs_url = urljoin(self.default_gw_url, '/revisions/create')
55 responses.add('POST', revs_url, status=201)
56
57 _push_revs(self.store, 'test', self.snap_map)
58
59 self.assertEqual(
60 'Basic YWRtaW46dGVzdA==',
61 responses.calls[0].request.headers['Authorization'])
62
63 @responses.activate
64 def test_push_revs_unexpected_status_code(self):
65 revs_url = urljoin(self.default_gw_url, '/revisions/create')
66 responses.add('POST', revs_url, status=302)
67
68 self.assertRaises(
69 pushException, _push_revs, self.store, 'test', self.snap_map)
70
71 @responses.activate
72 def test_push_revs_failed(self):
73 revs_url = urljoin(self.default_gw_url, '/revisions/create')
74 responses.add('POST', revs_url, status=500)
75
76 self.assertRaises(
77 pushException, _push_revs, self.store, 'test', self.snap_map)
78
79 @responses.activate
80 def test_push_map(self):
81 filter_url = urljoin(self.default_gw_url, '/channelmaps/filter')
82 update_url = urljoin(self.default_gw_url, '/channelmaps/update')
83 responses.add('POST', filter_url, status=200, json={})
84 responses.add('POST', update_url, status=200)
85
86 _push_channelmap(self.store, 'test', self.snap_map)
87
88 self.assertEqual(
89 'Basic YWRtaW46dGVzdA==',
90 responses.calls[0].request.headers['Authorization'])
91
92 @responses.activate
93 def test_push_map_failed(self):
94 filter_url = urljoin(self.default_gw_url, '/channelmaps/filter')
95 update_url = urljoin(self.default_gw_url, '/channelmaps/update')
96 responses.add('POST', filter_url, status=200, json={})
97 responses.add('POST', update_url, status=500)
98
99 self.assertRaises(
100 pushException, _push_channelmap,
101 self.store, 'test', self.snap_map)
102
103 @responses.activate
104 def test_push_map_exists(self):
105 filter_url = urljoin(self.default_gw_url, '/channelmaps/filter')
106 update_url = urljoin(self.default_gw_url, '/channelmaps/update')
107 exists = {'channelmaps': ['thing that exists']}
108 responses.add('POST', filter_url, status=200, json=exists)
109 responses.add('POST', update_url, status=200)
110
111 self.assertRaises(
112 ChannelMapExistsException, _push_channelmap,
113 self.store, 'test', self.snap_map)
114
115 @responses.activate
116 def test_push_map_exists_force_push(self):
117 filter_url = urljoin(self.default_gw_url, '/channelmaps/filter')
118 update_url = urljoin(self.default_gw_url, '/channelmaps/update')
119 exists = {'channelmaps': ['thing that exists']}
120 responses.add('POST', filter_url, status=200, json=exists)
121 responses.add('POST', update_url, status=200)
122
123 _push_channelmap(self.store, 'test', self.snap_map, True)
124
125 self.assertEqual(
126 'Basic YWRtaW46dGVzdA==',
127 responses.calls[0].request.headers['Authorization'])
128
129 def test_split_assertions(self):
130 result = _split_assertions(self.snap_assert)
131 assert len(result) == 4
132
133 # We should have 4 different types of assertion
134 assert result[0][0] == 'type: account-key'
135 assert result[1][0] == 'type: account'
136 assert result[2][0] == 'type: snap-declaration'
137 assert result[3][0] == 'type: snap-revision'
138
139 @responses.activate
140 def test_add_assertion_to_service(self):
141 assert_url = urljoin(self.default_gw_url, '/v1/assertions')
142 responses.add('POST', assert_url, status=201)
143
144 split_assertions = _split_assertions(self.snap_assert)
145 _add_assertion_to_service(self.store, 'test', split_assertions)
146
147 self.assertEqual(
148 'Basic YWRtaW46dGVzdA==',
149 responses.calls[0].request.headers['Authorization'])
150
151 @responses.activate
152 def test_add_assertion_to_service_unexpected_status_code(self):
153 assert_url = urljoin(self.default_gw_url, '/v1/assertions')
154 responses.add('POST', assert_url, status=302)
155
156 split_assertions = _split_assertions(self.snap_assert)
157 self.assertRaises(
158 pushException,
159 _add_assertion_to_service, self.store, 'test', split_assertions)
160
161 @responses.activate
162 def test_add_assertion_to_service_failed(self):
163 assert_url = urljoin(self.default_gw_url, '/v1/assertions')
164 responses.add('POST', assert_url, status=500)
165
166 split_assertions = _split_assertions(self.snap_assert)
167 self.assertRaises(
168 pushException,
169 _add_assertion_to_service, self.store, 'test', split_assertions)
diff --git a/snapstore_client/utils.py b/snapstore_client/utils.py
0new file mode 100644170new file mode 100644
index 0000000..461a1c3
--- /dev/null
+++ b/snapstore_client/utils.py
@@ -0,0 +1,26 @@
1import logging
2
3logger = logging.getLogger(__name__)
4
5
6def _log_credentials_error(e):
7 logger.error('%s', e)
8 logger.error('Try to "snap-store-proxy-client login" again.')
9
10
11def _log_authorized_error():
12 logger.error(("Perhaps you have not been registered as an "
13 "admin with the proxy."))
14 logger.error("Try 'snap-proxy add-admin' on the proxy host.")
15
16
17def _check_default_store(cfg):
18 """Load the default store from the config."""
19 store = cfg.store_section('default')
20 # If the gw URL is configured then everything else should be too.
21 if not store.get('gw_url'):
22 logger.error(
23 'No store configuration found. '
24 'Have you run "snap-store-proxy-client login"?')
25 return None
26 return store
diff --git a/snapstore_client/webservices.py b/snapstore_client/webservices.py
index e8e618f..3ebf149 100644
--- a/snapstore_client/webservices.py
+++ b/snapstore_client/webservices.py
@@ -1,6 +1,7 @@
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 base64
4import json5import json
5import logging6import logging
6import urllib.parse7import urllib.parse
@@ -96,6 +97,19 @@ def _get_macaroon_auth(store):
96 root_raw, bound_discharge_raw)97 root_raw, bound_discharge_raw)
9798
9899
100def _get_basic_auth(password):
101 """Build the basic auth for interacting with an offline proxy"""
102 # XXX twom 2019-03-15 Hardcoded username, awaiting user management
103 username = 'admin'
104 credentials = '{}:{}'.format(username, password)
105 try:
106 encoded_credentials = base64.b64encode(credentials.encode('UTF-8'))
107 except UnicodeEncodeError:
108 logger.error('Unable to encode password to UTF-8')
109 raise
110 return 'Basic {}'.format(encoded_credentials.decode())
111
112
99def _raise_needs_refresh(response):113def _raise_needs_refresh(response):
100 if (response.status_code == 401 and114 if (response.status_code == 401 and
101 response.headers.get('WWW-Authenticate') == (115 response.headers.get('WWW-Authenticate') == (
@@ -115,15 +129,18 @@ def refresh_if_necessary(store, func, *args, **kwargs):
115 return func(*args, **kwargs)129 return func(*args, **kwargs)
116130
117131
118def get_overrides(store, snap_name, series='16'):132def get_overrides(store, snap_name, series='16', password=None):
119 """Get all overrides for a snap."""133 """Get all overrides for a snap."""
120 overrides_url = urllib.parse.urljoin(134 overrides_url = urllib.parse.urljoin(
121 store.get('gw_url'),135 store.get('gw_url'),
122 '/v2/metadata/overrides/{}'.format(urllib.parse.quote_plus(snap_name)))136 '/v2/metadata/overrides/{}'.format(urllib.parse.quote_plus(snap_name)))
123 headers = {137 headers = {
124 'Authorization': _get_macaroon_auth(store),
125 'X-Ubuntu-Series': series,138 'X-Ubuntu-Series': series,
126 }139 }
140 if password:
141 headers['Authorization'] = _get_basic_auth(password)
142 else:
143 headers['Authorization'] = _get_macaroon_auth(store)
127 resp = requests.get(overrides_url, headers=headers)144 resp = requests.get(overrides_url, headers=headers)
128 _raise_needs_refresh(resp)145 _raise_needs_refresh(resp)
129 if resp.status_code != 200:146 if resp.status_code != 200:
@@ -132,11 +149,14 @@ def get_overrides(store, snap_name, series='16'):
132 return resp.json()149 return resp.json()
133150
134151
135def set_overrides(store, overrides):152def set_overrides(store, overrides, password=None):
136 """Add or remove channel map overrides for a snap."""153 """Add or remove channel map overrides for a snap."""
137 overrides_url = urllib.parse.urljoin(154 overrides_url = urllib.parse.urljoin(
138 store.get('gw_url'), '/v2/metadata/overrides')155 store.get('gw_url'), '/v2/metadata/overrides')
139 headers = {'Authorization': _get_macaroon_auth(store)}156 if password:
157 headers = {'Authorization': _get_basic_auth(password)}
158 else:
159 headers = {'Authorization': _get_macaroon_auth(store)}
140 resp = requests.post(overrides_url, headers=headers, json=overrides)160 resp = requests.post(overrides_url, headers=headers, json=overrides)
141 _raise_needs_refresh(resp)161 _raise_needs_refresh(resp)
142 if resp.status_code != 200:162 if resp.status_code != 200:

Subscribers

People subscribed via source and target branches

to all changes: