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

Proposed by Tom Wardill on 2019-03-21
Status: Merged
Approved by: Tom Wardill on 2019-06-24
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 2019-03-21 Approve on 2019-06-23
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.
12c75da... by Tom Wardill on 2019-04-18

Use env password as default

dd76e1e... by Tom Wardill on 2019-04-18

Lint error

Tom Wardill (twom) :
0d85442... by Tom Wardill on 2019-06-19

Add basic README instructions

935fb22... by Tom Wardill on 2019-06-21

Rename upload to push

00476d8... by Tom Wardill on 2019-06-21

Extract a tar file before upload

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
1diff --git a/README b/README
2index 6171594..ab1584e 100644
3--- a/README
4+++ b/README
5@@ -6,3 +6,26 @@ This repo contains a client to adminster a snapstore.
6 After authenticating with a snapstore the administrator can:
7
8 - manage revision overrides
9+
10+Building
11+--------
12+
13+You may wish to perform these installations inside a LXD container.
14+
15+Install the dependencies::
16+
17+ cat dependencies | xargs sudo apt install -y
18+
19+Create the environment
20+----------------------
21+
22+Use the Makefile::
23+
24+ make bootstrap
25+
26+Running for development
27+-----------------------
28+
29+The virtualenv environment is setup in /tmp::
30+
31+ /tmp/snapstore-client.tmp/env/bin/python snapstore
32diff --git a/snapstore b/snapstore
33index a21a8a1..cd67b71 100755
34--- a/snapstore
35+++ b/snapstore
36@@ -7,6 +7,7 @@
37
38 import argparse
39 import logging
40+import os
41 import sys
42
43 from snapstore_client.cli import configure_logging
44@@ -16,6 +17,7 @@ from snapstore_client.logic.overrides import (
45 list_overrides,
46 override,
47 )
48+from snapstore_client.logic.push import push_snap
49
50
51 DEFAULT_SSO_URL = 'https://login.ubuntu.com/'
52@@ -44,6 +46,8 @@ def parse_args():
53 login_parser.add_argument('email', help='Ubuntu One SSO email', nargs='?')
54 login_parser.add_argument('--sso-url', help='Ubuntu One SSO URL',
55 default=DEFAULT_SSO_URL)
56+ login_parser.add_argument('--offline', help="Use offline mode interaction",
57+ action='store_true')
58 login_parser.set_defaults(func=login)
59
60 list_overrides_parser = subparsers.add_parser(
61@@ -55,6 +59,11 @@ def parse_args():
62 list_overrides_parser.add_argument(
63 'snap_name',
64 help='The name of the snap whose channel map should be listed.')
65+ list_overrides_parser.add_argument(
66+ '--password',
67+ help='Password for interacting with an offline proxy',
68+ default=os.environ.get('SNAP_PROXY_PASSWORD')
69+ )
70 list_overrides_parser.set_defaults(func=list_overrides)
71
72 override_parser = subparsers.add_parser(
73@@ -69,6 +78,11 @@ def parse_args():
74 override_parser.add_argument(
75 'channel_map_entries', nargs='+', metavar='channel_map_entry',
76 help='A channel map override, in the form <channel>=<revision>.')
77+ override_parser.add_argument(
78+ '--password',
79+ help='Password for interacting with an offline proxy',
80+ default=os.environ.get('SNAP_PROXY_PASSWORD')
81+ )
82 override_parser.set_defaults(func=override)
83
84 delete_override_parser = subparsers.add_parser(
85@@ -83,8 +97,30 @@ def parse_args():
86 delete_override_parser.add_argument(
87 'channels', nargs='+', metavar='channel',
88 help='A channel whose overrides should be deleted.')
89+ delete_override_parser.add_argument(
90+ '--password',
91+ help='Password for interacting with an offline proxy',
92+ default=os.environ.get('SNAP_PROXY_PASSWORD')
93+ )
94 delete_override_parser.set_defaults(func=delete_override)
95
96+ push_snap_parser = subparsers.add_parser(
97+ 'push-snap', help='push a snap to an offline proxy')
98+ push_snap_parser.add_argument(
99+ 'snap_tar_file',
100+ help='The .tar.gz file of a bundled downloaded snap')
101+ push_snap_parser.add_argument(
102+ '--push-channel-map',
103+ action='store_true',
104+ help="Force push of the channel map,"
105+ " removing any existing overrides")
106+ push_snap_parser.add_argument(
107+ '--password',
108+ help='Password for interacting with an offline proxy',
109+ default=os.environ.get('SNAP_PROXY_PASSWORD')
110+ )
111+ push_snap_parser.set_defaults(func=push_snap)
112+
113 if len(sys.argv) == 1:
114 # Display help if no arguments are provided.
115 parser.print_help()
116diff --git a/snapstore_client/logic/login.py b/snapstore_client/logic/login.py
117index 7d3a313..9550988 100644
118--- a/snapstore_client/logic/login.py
119+++ b/snapstore_client/logic/login.py
120@@ -31,6 +31,13 @@ def login(args):
121 gw_url = args.store_url
122 sso_url = args.sso_url
123
124+ if args.offline:
125+ cfg = config.Config()
126+ store = cfg.store_section('default')
127+ store.set('gw_url', gw_url)
128+ cfg.save()
129+ return
130+
131 logger.info('Enter your Ubuntu One SSO credentials.')
132 email = args.email
133 if not email:
134diff --git a/snapstore_client/logic/overrides.py b/snapstore_client/logic/overrides.py
135index 667bd0a..d3c8356 100644
136--- a/snapstore_client/logic/overrides.py
137+++ b/snapstore_client/logic/overrides.py
138@@ -13,34 +13,16 @@ from snapstore_client.presentation_helpers import (
139 channel_map_string_to_tuple,
140 override_to_string,
141 )
142+from snapstore_client.utils import (
143+ _check_default_store,
144+ _log_authorized_error,
145+ _log_credentials_error,
146+)
147
148
149 logger = logging.getLogger(__name__)
150
151
152-def _log_credentials_error(e):
153- logger.error('%s', e)
154- logger.error('Try to "snap-store-proxy-client login" again.')
155-
156-
157-def _log_authorized_error():
158- logger.error(("Perhaps you have not been registered as an "
159- "admin with the proxy."))
160- logger.error("Try 'snap-proxy add-admin' on the proxy host.")
161-
162-
163-def _check_default_store(cfg):
164- """Load the default store from the config."""
165- store = cfg.store_section('default')
166- # If the gw URL is configured then everything else should be too.
167- if not store.get('gw_url'):
168- logger.error(
169- 'No store configuration found. '
170- 'Have you run "snap-store-proxy-client login"?')
171- return None
172- return store
173-
174-
175 def list_overrides(args):
176 cfg = config.Config()
177 store = _check_default_store(cfg)
178@@ -48,9 +30,14 @@ def list_overrides(args):
179 return 1
180
181 try:
182- response = ws.refresh_if_necessary(
183- store, ws.get_overrides,
184- store, args.snap_name, series=args.series)
185+ if args.password:
186+ response = ws.get_overrides(
187+ store, args.snap_name,
188+ series=args.series, password=args.password)
189+ else:
190+ response = ws.refresh_if_necessary(
191+ store, ws.get_overrides,
192+ store, args.snap_name, series=args.series)
193 except exceptions.InvalidCredentials as e:
194 _log_credentials_error(e)
195 return 1
196@@ -77,9 +64,13 @@ def override(args):
197 'series': args.series,
198 })
199 try:
200- response = ws.refresh_if_necessary(
201- store, ws.set_overrides,
202- store, overrides)
203+ if args.password:
204+ response = ws.set_overrides(
205+ store, overrides, password=args.password)
206+ else:
207+ response = ws.refresh_if_necessary(
208+ store, ws.set_overrides,
209+ store, overrides)
210 except exceptions.InvalidCredentials as e:
211 _log_credentials_error(e)
212 return 1
213@@ -105,9 +96,13 @@ def delete_override(args):
214 'series': args.series,
215 })
216 try:
217- response = ws.refresh_if_necessary(
218- store, ws.set_overrides,
219- store, overrides)
220+ if args.password:
221+ response = ws.set_overrides(
222+ store, overrides, password=args.password)
223+ else:
224+ response = ws.refresh_if_necessary(
225+ store, ws.set_overrides,
226+ store, overrides)
227 except exceptions.InvalidCredentials as e:
228 _log_credentials_error(e)
229 return 1
230diff --git a/snapstore_client/logic/push.py b/snapstore_client/logic/push.py
231new file mode 100644
232index 0000000..abd06e8
233--- /dev/null
234+++ b/snapstore_client/logic/push.py
235@@ -0,0 +1,306 @@
236+import json
237+import logging
238+from pathlib import Path
239+import tarfile
240+import tempfile
241+from urllib.parse import urlsplit, urlunsplit
242+
243+import requests
244+from requests.exceptions import HTTPError
245+
246+from snapstore_client import (
247+ config,
248+ exceptions,
249+)
250+from snapstore_client.utils import (
251+ _check_default_store,
252+ _log_authorized_error,
253+ _log_credentials_error,
254+)
255+
256+logger = logging.getLogger(__name__)
257+
258+
259+# XXX twom 2019-03-19 hardcoded for now, awaiting RBAC integration
260+USERNAME = 'admin'
261+
262+
263+class pushException(Exception):
264+ pass
265+
266+
267+class ChannelMapExistsException(Exception):
268+ pass
269+
270+
271+def _push_ident(store, password, downloaded_map):
272+ """push the snap details to snapident"""
273+
274+ snap_id = downloaded_map['snap-id']
275+ snap_details = downloaded_map['snap']
276+ push_details = {
277+ 'snap_id': snap_id,
278+ 'snap_name': snap_details['name'],
279+ 'private': False, # If you're pushing to your own proxy
280+ 'stores': ['ubuntu'], # ditto
281+ 'blob': snap_details,
282+ 'publisher_id': snap_details['publisher']['id'],
283+ 'publisher_name': snap_details['publisher']['display-name'],
284+ 'publisher_title': snap_details['publisher']['username'],
285+ 'publisher_validation': snap_details['publisher']['validation'],
286+ 'license': snap_details['license'],
287+ 'prices': snap_details['prices'],
288+ 'summary': snap_details['summary'],
289+ 'title': snap_details['title'],
290+ }
291+
292+ url = store.get('gw_url')
293+
294+ response = requests.post(
295+ url + '/snaps/update',
296+ json={'snaps': [push_details]},
297+ auth=requests.auth.HTTPBasicAuth(USERNAME, password)
298+ )
299+ if response.status_code != 200:
300+ raise pushException(
301+ "Failure in pushing to snapident: {}".format(
302+ response.content))
303+
304+
305+def _push_revs(store, password, downloaded_map):
306+ """push the revision information to snaprevs"""
307+ store_url = store.get('gw_url')
308+
309+ snap_id = downloaded_map['snap-id']
310+ for instance in downloaded_map['channel-map']:
311+ # rewrite the download_url
312+ download_url = instance['download']['url']
313+
314+ url = urlsplit(download_url)
315+ fqdn = urlsplit(store_url)
316+ # Rewrite scheme and location
317+ download_url = urlunsplit(url._replace(scheme=fqdn[0], netloc=fqdn[1]))
318+
319+ # create the revision
320+ rev = {
321+ 'snap_id': snap_id,
322+ 'revision': instance['revision'],
323+ 'version': instance['version'],
324+ 'confinement': instance['confinement'],
325+ 'architectures': instance['architectures'],
326+ 'created_at': instance['created-at'],
327+ # This is required, but isn't available to retrieve from /info
328+ 'created_by': '',
329+ 'binary_path': download_url,
330+ 'binary_filesize': instance['download']['size'],
331+ 'binary_sha3_384': instance['download']['sha3-384'],
332+ 'snap_yaml': instance.get('snap-yaml', ''),
333+ 'epoch': instance['epoch'],
334+ 'type': instance['type'],
335+ 'base': instance['base'],
336+ 'common_ids': instance['common-ids'],
337+ }
338+ revs_response = requests.post(
339+ store_url + '/revisions/create',
340+ json=[rev],
341+ auth=requests.auth.HTTPBasicAuth(USERNAME, password)
342+ )
343+ # 409 / Conflict - already exists
344+ if revs_response.status_code not in [201, 409]:
345+ raise pushException(
346+ "Failure to push revisions to snaprevs: {}".format(
347+ revs_response.content))
348+
349+
350+def _push_channelmap(store, password, downloaded_map, force_push=False):
351+ """push the channel map to snaprevs"""
352+ store_url = store.get('gw_url')
353+
354+ snap_id = downloaded_map['snap-id']
355+
356+ # Check if we have an existing channel map
357+ existing = requests.post(
358+ store_url + '/channelmaps/filter',
359+ json={'filters': [{'series': '16', 'snap_id': snap_id}]},
360+ auth=requests.auth.HTTPBasicAuth(USERNAME, password))
361+ if existing.status_code != 200:
362+ raise pushException("Error retrieving current channelmap")
363+ if existing.json().get('channelmaps') and not force_push:
364+ raise ChannelMapExistsException("Channel map already exists.")
365+
366+ for instance in downloaded_map['channel-map']:
367+ if instance['channel']['track'] == 'latest':
368+ track = None
369+ else:
370+ track = instance['channel']['track']
371+ chan_map = {
372+ # This is wrong, it should be the current developer id
373+ # but the pusher might not be a developer
374+ # XXX (twom): can we get this from upstream?
375+ 'developer_id': downloaded_map['snap']['publisher']['id'],
376+ 'release_requests': [
377+ {
378+ 'snap_id': snap_id,
379+ 'channel': [
380+ track,
381+ instance['channel']['risk'],
382+ None, # We can't deal in 'branch', it's not on the API
383+ ],
384+ 'architecture': instance['channel']['architecture'],
385+ 'series': '16',
386+ 'revision': instance['revision']
387+ }
388+ ]
389+ }
390+ map_response = requests.post(
391+ store_url + '/channelmaps/update',
392+ json=chan_map,
393+ auth=requests.auth.HTTPBasicAuth(USERNAME, password)
394+ )
395+ if map_response.status_code != 200:
396+ raise pushException("Error pushing channel map")
397+
398+
399+def _load_assertion_file(assert_path):
400+ with open(str(assert_path)) as assert_file:
401+ raw_contents = assert_file.read().splitlines()
402+ return raw_contents
403+
404+
405+def _split_assertions(raw_contents):
406+ # split the file into separate asserts
407+ available_asserts = []
408+ current_assert = []
409+ for line in raw_contents:
410+ # if we've encountered a new assert, save the old one, start a new one
411+ if line.startswith('type: ') and current_assert:
412+ available_asserts.append(current_assert)
413+ current_assert = []
414+ current_assert.append(line)
415+ # account for the last one in the file
416+ available_asserts.append(current_assert)
417+
418+ # Because we've split the file, we've ended up with the newlines
419+ # from between the concatenated asserts.
420+ # Remove it from the ones that have it so they're clean to push
421+ for assertion in available_asserts:
422+ assertion[:] = assertion[:-1] if assertion[-1] == '' else assertion
423+ return available_asserts
424+
425+
426+def _add_assertion_to_service(store, password, list_assertions):
427+ store_url = store.get('gw_url')
428+
429+ # push the split asserts
430+ for assertion in list_assertions:
431+ # get the type
432+ assert_type = assertion[0].split('type: ')[1]
433+ # ignore the account-key of canonical, it's already a trusted assert
434+ # and will error on push
435+ canonical_key = 'account-id: canonical'
436+ if (assert_type == 'account-key' and canonical_key in assertion):
437+ continue
438+ if (assert_type == 'account' and canonical_key in assertion):
439+ continue
440+ response = requests.post(
441+ store_url + '/v1/assertions',
442+ '\n'.join(assertion),
443+ headers={'Content-Type': 'application/x.ubuntu.assertion'},
444+ auth=requests.auth.HTTPBasicAuth(USERNAME, password))
445+ if response.status_code != 201:
446+ raise pushException(
447+ "Failed to push: {} - {}".format(
448+ response.status_code, response.content))
449+
450+
451+def _push_assertions(store, password, assert_path):
452+ """Split a .assert and push individual assertions to snapassert"""
453+
454+ # load the file
455+ raw_contents = _load_assertion_file(assert_path)
456+ # split the assert file into single assertions for push
457+ current_asserts = _split_assertions(raw_contents)
458+ # push the assertions to the internal snap-assertion-service
459+ try:
460+ _add_assertion_to_service(store, password, current_asserts)
461+ except pushException:
462+ raise pushException("Assertion file: {}".format(assert_path))
463+
464+
465+def _push_file_to_nginx_cache(store, password, snap_path, snap_id, revision):
466+ """Add the file to the nginx cache via the snapproxy service"""
467+ store_url = store.get('gw_url')
468+ target_filename = '{}_{}.snap'.format(snap_id, revision)
469+ files = {target_filename: open(snap_path, 'rb')}
470+ response = requests.post(
471+ store_url + '/files/upload',
472+ files=files,
473+ auth=requests.auth.HTTPBasicAuth(USERNAME, password))
474+ if response.status_code != 200:
475+ print(response.status_code)
476+ raise pushException("Failed to push file to proxy cache")
477+
478+
479+def _push(store, tar_file, password, force_channel_map=False):
480+ """
481+ push a given .snap with matching .assert and .json
482+ to an offline proxy
483+ """
484+ extract_dir = Path(tempfile.mkdtemp())
485+ with tarfile.open(str(tar_file), 'r:gz') as tar:
486+ tar.extractall(str(extract_dir))
487+
488+ json_file = extract_dir / 'channel-map.json'
489+ json_path = Path(json_file)
490+ if not json_path.exists():
491+ logger.error("Snap information file not found at {}".format(json_path))
492+ return
493+
494+ with json_path.open() as fh:
495+ downloaded_map = json.load(fh)
496+
497+ _push_ident(store, password, downloaded_map)
498+ _push_revs(store, password, downloaded_map)
499+ try:
500+ _push_channelmap(
501+ store, password, downloaded_map, force_channel_map)
502+ except ChannelMapExistsException as e:
503+ logger.info(str(e))
504+ logger.info(
505+ "Not updating the channel map, either manage revisions using "
506+ "`snap-proxy override` or try again with `--push-channel-map`")
507+
508+ for instance in downloaded_map['channel-map']:
509+ snap_name = downloaded_map['snap']['name']
510+ snap_file_name = '{}_{}.snap'.format(
511+ snap_name, instance['revision'])
512+ assert_file_name = '{}_{}.assert'.format(
513+ snap_name, instance['revision'])
514+ snap_path = str(json_path.parent / snap_file_name)
515+ assert_path = str(json_path.parent / assert_file_name)
516+ _push_file_to_nginx_cache(
517+ store, password, snap_path,
518+ downloaded_map['snap-id'], instance['revision'])
519+ _push_assertions(store, password, assert_path)
520+
521+
522+def push_snap(args):
523+ cfg = config.Config()
524+ store = _check_default_store(cfg)
525+ if not store:
526+ return 1
527+
528+ try:
529+ if args.password:
530+ _push(store, args.snap_tar_file,
531+ args.password, args.push_channel_map)
532+ else:
533+ logger.error(
534+ "This command only works with a supplied password"
535+ " and a proxy in offline mode")
536+ except exceptions.InvalidCredentials as e:
537+ _log_credentials_error(e)
538+ return 1
539+ except HTTPError:
540+ _log_authorized_error()
541+ return 1
542diff --git a/snapstore_client/logic/tests/test-snap-assert.assert b/snapstore_client/logic/tests/test-snap-assert.assert
543new file mode 100644
544index 0000000..b6b2ebd
545--- /dev/null
546+++ b/snapstore_client/logic/tests/test-snap-assert.assert
547@@ -0,0 +1,101 @@
548+type: account-key
549+authority-id: canonical
550+revision: 2
551+public-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul
552+account-id: canonical
553+name: store
554+since: 2016-04-01T00:00:00.0Z
555+body-length: 717
556+sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk
557+
558+AcbBTQRWhcGAARAA0KKYYQWuHOrsFVi4p4l7ZzSvX7kLgJFFeFgOkzdWKBTHEnsMKjl5mefFe9ji
559+qe8NlmJdfY7BenP7XeBtwKp700H/t9lLrZbpTNAPHXYxEWFJp5bPqIcJYBZ+29oLVLN1Tc5X482R
560+vCiDqL8+pPYqBrK2fNlyPlNNSum9wI70rDDL4r6FVvr+osTnGejibdV8JphWX+lrSQDnRSdM8KJi
561+UM43vTgLGTi9W54oRhsA2OFexRfRksTrnqGoonCjqX5wO3OFSaMDzMsO2MJ/hPfLgDqw53qjzuKL
562+Iec9OL3k5basvu2cj5u9tKwVFDsCKK2GbKUsWWpx2KTpOifmhmiAbzkTHbH9KaoMS7p0kJwhTQGA
563+o9aJ9VMTWHJc/NCBx7eu451u6d46sBPCXS/OMUh2766fQmoRtO1OwCTxsRKG2kkjbMn54UdFULl9
564+VfzvyghMNRKIezsEkmM8wueTqGUGZWa6CEZqZKwhe/PROxOPYzqtDH18XZknbU1n5lNb7vNfem9F
565+2ai+3+JyFnW9UhfvpVF7gzAgdyCqNli4C6BIN43uwoS8HkykocZS/+Gv52aUQ/NZ8BKOHLw+7ant
566+Q0o8W9ltSLZbEMxFIPSN0stiZlkXAp6DLyvh1Y4wXSynDjUondTpej2fSvSlCz/W5v5V7qA4nIcG
567+vUvV7RjVzv17ut0AEQEAAQ==
568+
569+AcLDXAQAAQoABgUCV83k9QAKCRDUpVvql9g3IBT8IACKZ7XpiBZ3W4lqbPssY6On81WmxQLtvsMV
570+WTp6zZpl/wWOSt2vMNUk9pvcmrNq1jG9CuhDfWFLGXEjcrrmVkN3YuCOajMSPFCGrxsIBLSRt/bP
571+nrKykdLAAzMfG8rP1d82bjFFiIieE+urQ0Kcv09Jtdvavq3JT1Tek5mFyyfhHNlQEKOzWqmRWiLg
572+3c3VOZUs1ZD8TSlnuq/x+5T0X0YtOyGjSlVxk7UybbyMNd6MZfNaMpIG4x+mxD3KHFtBAC7O6kLe
573+eX3i6j5nCY5UABfA3DZEAkWP4zlmdBEOvZ9t293NaDdOpzsUHRkoi0Zez/9BHQ/kwx/uNc2WqrYm
574+inCmu16JGNeXqsyinnLl7Ghn2RwhvDMlLxF6RTx8xdx1yk6p3PBTwhZMUvuZGjUtN/AG8BmVJQ19
575+rsGSRkkSywvnhVJRB2sudnrMBmNS2goJbzSbmJnOlBrd2WsV0T9SgNMWZBiov3LvU4o2SmAb6b+k
576+rYwh8H5QHcuuYJuxDjFhPswIp6Wes5T6hUicf3SWtObcDS4HSkVS4ImBjjX9YgCuFy7QdnooOWEY
577+aPvkRw3XCVeYq0K6w9GRsk1YFErD4XmXXZjDYY650MX9v42Sz5MmphHV8jdIY5ssbadwFSe2rCQI
578+6UX08zy7RsIb19hTndE6ncvSNDChUR9eEnCm73eYaWTWTnq1cxdVP/s52r8uss++OYOkPWqh5nOu
579+haRn7INjH/yZX4qXjNXlTjo0PnHH0q08vNKDwLhxS+D9du+70FeacXFyLIbcWllSbJ7DmbumGpFo
580+yYbtj3FDDPzachFQdIG3lSt+cSUGeyfSs6wVtc3cIPka/2Urx7RprfmoWSI6+a5NcLdj0u2z8O96
581+HxeIgxDpg/3gT8ZIuFKePMcLDM19Fh/p0ysCsX+84B9chNWtsMSmIaE57V+959MVtsLu7SLb9gi7
582+skrju0pQCwsu2wHMLTNd1f3PTHmrr49hxetTus07HSQUApMtAGKzQilF5zqFjbyaTd4xgQbd+PKW
583+CjFyzQTDOcUhXpuUGt/IzlqiFfsCsmbj2K4KdSNYMlqIgZ3Azu8KvZLIhsyN7v5vNIZSPfEbjdeu
584+ClU9r0VRiJmtYBUjcSghD9LWn+yRLwOxhfQVjm0cBwIt5R/yPF/qC76yIVuWUtM5Y2/zJR1J8OFq
585+qWchvlImHtvDzS9FQeLyzJAOjvZ2CnWp2gILgUz0WQdOk1Dq8ax7KS9BQ42zxw9EZAEPw3PEFqRy
586+IQsRTONp+iVS8YxSmoYZjDlCgRMWUmawez/Fv5b9Fb/XkO5Eq4e+KfrpUujXItaipb+tV8h5v3tr
587+oG3Ie3WOHrVjCLXIdYslpL1O4nadqR6Xv58pHj6k
588+
589+type: account
590+authority-id: canonical
591+account-id: 1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX
592+display-name: Tom Wardill
593+timestamp: 2018-02-06T09:22:49.118291Z
594+username: twom
595+validation: unproven
596+sign-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul
597+
598+AcLBUgQAAQoABgUCWnlz6QAA40sQALjt4pGWbG9icFARLbNSnx0ldc9cEytJ5ctLVvG0x3P7hP3u
599+6VntHcurt5mkQmW9nTJ8CY3R5uAPVkVda1gFabFlIqZPl4fAv0KqH9vziqSsc1rn05nyLcPMEu2P
600+WmWsOh/F7O3IqJrvdX1Xpuv0BsDjZi9merN20BHcY8LDVPd5qUDyN/BISbNzogzHC3MdilYAP75p
601+YoYYhH7dHruLgPREOKEhAjIoFguvPtML1xAUFz5XDpvm0sHDURuDYN/cwpFOLsOkPCDokyYC/Udb
602+Bh9foT4o1FYvBQOi2DjFBs0UFgAk/2ud8Rv9N10O2MnsQ0ZGerAsGAVIARkz5zjSHZPFokL5PJQ/
603+AzJBVRR2NPtRvBEnF/bG0xR5JR/5AZK2K7mIKrQt22CFL4M4iL7uQI1Q1i7PBpysYT7vZwyyFkEP
604+M2NXmvpZ34i+5JnyEtdn2H+JVhvQS1RpqdwDwfpqn+lYkJBvDN8B9aDtyp+yMdDTLoLt74JZKP4V
605+YeptgPiaMUHOQJx6gdp/Fa9FJAQiNUFjXe9/lfF1yebu0CVXdO6CaSRGBXrQfk2E/WpZNsmZOTq+
606+Rc0dGtlMeulEo8fY9RcJlsKe7hjlzX1Yk7JFkGqb60Fzqy0AzoJwDXjTnQPo0AKyNz+ryqly3Sur
607+c58OOgdNDw9KbjYoVXvjFLzAyA/k
608+
609+type: snap-declaration
610+authority-id: canonical
611+series: 16
612+snap-id: p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM
613+publisher-id: 1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX
614+snap-name: gif-cli
615+timestamp: 2018-10-02T15:43:27.762767Z
616+sign-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul
617+
618+AcLBUgQAAQoABgUCW7OSHwAAV/cQAB9wlV5pNd/+20BSCv6tsBAUugrHbFRVjIMHsYoj7Can0mSB
619+9vRSbDnFB91VWk+6gBpR+xXtHJithiyCl3fitf2tNuRKm/W6M4fhdctQXX8pcPSsonfSq1+1apgT
620+vy7BZaChVEMROY3y5/NZNj4x/+1t38cA4dqxOHIXbhc1B/SifKpFQWq8hu+GMSFFVSxWCJp7rLor
621+sqfBFUY1cFeCGsW/iawAPTXk3z7tZhkhoi1FQeu9KrQGMCY0aaD1uZhPLfCwP4mDmsuW3lxJ5QIt
622+fhQdO46jh6Ea9nahjAMWrIYPL6G1Yk5aFH6cQ7LfcQq/JHEcDvFUXbtM5jQJU4gPcjnSkFlRCSNA
623+aig1trd/RjqQyVMiIBDGRvOPZZij7AdnQ+P88CH1k5oWppNTgk4VM0UsSCdFFXVw7sQlPUWjRaYP
624+PHfy1xkMZBw11jLDytOVwLKjPIZs8dCTHKkPfAD4oTP0+W/OvOBerUoHGeaxlZ8Cx17wFcfllEcO
625+fiYmnYOAd0JRIXeOXH9/LTbSnb9dMPaaL9jIz0YhmNJOPuzUPpfGeoEQMlt61OfTtEuvjDAq+Nh/
626+mhCDl0RnvH+8rjLv0ksYLETsFCkKlOYYMugoj0ruBjl3ss6GU/36RFX9quKEdDyyyus71C1V6ohF
627+VzUiaij6jfTkWC4rMPyl69L3Z7fw
628+
629+type: snap-revision
630+authority-id: canonical
631+snap-sha3-384: MrP3xzTBH8SQLC1oOfMNIigeJ_0GUE1yqugKZzx2dKdqO2fHbzYa2SfJtxS5JWMe
632+developer-id: 1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX
633+snap-id: p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM
634+snap-revision: 3
635+snap-size: 9678848
636+timestamp: 2018-10-14T20:48:18.733551Z
637+sign-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0hqUel3m8ul
638+
639+AcLBUgQAAQoABgUCW8OrkgAAm/oQAAnCsfsbDBJt59mPG9A1BRHYZHV7EqYis75QxZPzMLlkpoUG
640+Xqc8g0aHgEt+STwrrAqUli9G5Yg3Tk8rWb7CV7MlB3c/pBgnW8XLWm3zp1S4ZonDPpKYmgeBcmui
641+hH9lXPlHHnL9dGbObhPk7jbAFHOmqgC9S5lD6Uuj7RTn8Hv+hT7YbvZ8LpjcRfLfUJAIhfCCcxHm
642+euwRMyGP5NFwOLWIj4XZNKhKf6Tj8bUJV9Kcf7MBZ/cvdwzPiTkU5EleHid4bdZcUkrcNTt8wFI4
643+NxU+CH8m3mzP2/EF+amxoqHc5+9qMYWtu2iou1E8oY5M7s+QujTHPipzgPQkkMLSocyoO1ntezd1
644+YhUu2GkXw2IRmmgVvaMjmXcE+voGNsT+W2CeHqQNvZvip3KDKvQG3G8tX9Sf6V7NSjqn1+IwpJpS
645+Bx61HphfypyHbho7KgYcSYhdqCQF8XiLwQ3pWtE6l654I3kHzuzyrYKR+Ts1byHB+w5sLx4fw7xV
646+7/CbvzO6konCrW00bG3UGKHZk4zJGBlv5cfGQxuq5GNaaDWZ9Pg5awSrgB8kShAGzOpdABle8eYg
647+RVUbPo53cZO3ditJ+B7VT1pA0on1BiyQGKQUQplr+glZtplPsu6q+A0HzLZ3Wg7EFUdrSThhDh6N
648+46jdhxSAwRSPVHtzHm/fgUcGXuMJ
649diff --git a/snapstore_client/logic/tests/test-snap-map.json b/snapstore_client/logic/tests/test-snap-map.json
650new file mode 100644
651index 0000000..0b28b16
652--- /dev/null
653+++ b/snapstore_client/logic/tests/test-snap-map.json
654@@ -0,0 +1,73 @@
655+{
656+ "channel-map": [{
657+ "architectures": ["amd64"],
658+ "base": "core18",
659+ "channel": {
660+ "architecture": "amd64",
661+ "name": "stable",
662+ "released-at": "2018-11-08T18:30:43.652281+00:00",
663+ "risk": "stable",
664+ "track": "latest"
665+ },
666+ "common-ids": [],
667+ "confinement": "strict",
668+ "created-at": "2018-10-14T20:48:12.808884+00:00",
669+ "download": {
670+ "deltas": [],
671+ "sha3-384": "32b3f7c734c11fc4902c2d6839f30d22281e27fd06504d72aae80a673c7674a76a3b67c76f361ad927c9b714b925631e",
672+ "size": 9678848,
673+ "url": "https://api.snapcraft.io/api/v1/snaps/download/p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM_3.snap"
674+ },
675+ "epoch": {
676+ "read": [0],
677+ "write": [0]
678+ },
679+ "revision": 3,
680+ "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",
681+ "type": "app",
682+ "version": "0.0.3"
683+ }, {
684+ "architectures": ["amd64"],
685+ "base": "core18",
686+ "channel": {
687+ "architecture": "amd64",
688+ "name": "edge",
689+ "released-at": "2018-10-14T20:49:19.242435+00:00",
690+ "risk": "edge",
691+ "track": "latest"
692+ },
693+ "common-ids": [],
694+ "confinement": "strict",
695+ "created-at": "2018-10-14T20:48:12.808884+00:00",
696+ "download": {
697+ "deltas": [],
698+ "sha3-384": "32b3f7c734c11fc4902c2d6839f30d22281e27fd06504d72aae80a673c7674a76a3b67c76f361ad927c9b714b925631e",
699+ "size": 9678848,
700+ "url": "https://api.snapcraft.io/api/v1/snaps/download/p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM_3.snap"
701+ },
702+ "epoch": {
703+ "read": [0],
704+ "write": [0]
705+ },
706+ "revision": 3,
707+ "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",
708+ "type": "app",
709+ "version": "0.0.3"
710+ }],
711+ "name": "gif-cli",
712+ "snap": {
713+ "license": "unset",
714+ "name": "gif-cli",
715+ "prices": {},
716+ "publisher": {
717+ "display-name": "Tom Wardill",
718+ "id": "1RgHGj7Zp3nyycrGa5sODDbZznzJAwAX",
719+ "username": "twom",
720+ "validation": "unproven"
721+ },
722+ "snap-id": "p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM",
723+ "summary": "CLI tool to search for gifs",
724+ "title": "gif-cli"
725+ },
726+ "snap-id": "p5QNTakkyYmjqa9TRKQWHtZ58GTftzrM"
727+}
728diff --git a/snapstore_client/logic/tests/test_login.py b/snapstore_client/logic/tests/test_login.py
729index 25ef14f..d7854cc 100644
730--- a/snapstore_client/logic/tests/test_login.py
731+++ b/snapstore_client/logic/tests/test_login.py
732@@ -54,11 +54,13 @@ class LoginTests(TestCase):
733 iter_responses = iter(full_responses)
734 return lambda request: next(iter_responses)
735
736- def make_args(self, store_url=None, sso_url=None, email=None):
737+ def make_args(self, store_url=None,
738+ sso_url=None, email=None, offline=False):
739 return factory.Args(
740 store_url=store_url or self.default_gw_url,
741 sso_url=sso_url or self.default_sso_url,
742 email=email,
743+ offline=offline,
744 )
745
746 def add_issue_store_admin_response(self, *response_templates, gw_url=None):
747diff --git a/snapstore_client/logic/tests/test_overrides.py b/snapstore_client/logic/tests/test_overrides.py
748index 1f2f2d0..05e32fc 100644
749--- a/snapstore_client/logic/tests/test_overrides.py
750+++ b/snapstore_client/logic/tests/test_overrides.py
751@@ -32,7 +32,7 @@ class OverridesTests(TestCase):
752 'Have you run "snap-store-proxy-client login"?\n')
753
754 @responses.activate
755- def test_list_overrides(self):
756+ def test_list_overrides_online(self):
757 self.useFixture(testfixtures.ConfigFixture())
758 logger = self.useFixture(fixtures.FakeLogger())
759 snap_id = factory.generate_snap_id()
760@@ -52,18 +52,55 @@ class OverridesTests(TestCase):
761 responses.add(
762 'GET', overrides_url, status=200, json={'overrides': overrides})
763
764- list_overrides(factory.Args(snap_name='mysnap', series='16'))
765+ list_overrides(
766+ factory.Args(snap_name='mysnap', series='16', password=False))
767 self.assertEqual(
768 'mysnap stable amd64 1 (upstream 2)\n'
769 'mysnap foo/stable i386 3 (upstream 4)\n',
770 logger.output)
771+ # We shouldn't have Basic Authorization headers, but Macaroon
772+ self.assertNotIn(
773+ 'Basic',
774+ responses.calls[0].request.headers['Authorization'])
775+
776+ @responses.activate
777+ def test_list_overrides_offline(self):
778+ self.useFixture(testfixtures.ConfigFixture())
779+ logger = self.useFixture(fixtures.FakeLogger())
780+ snap_id = factory.generate_snap_id()
781+ overrides = [
782+ factory.SnapDeviceGateway.Override(
783+ snap_id=snap_id, snap_name='mysnap'),
784+ factory.SnapDeviceGateway.Override(
785+ snap_id=snap_id, snap_name='mysnap', revision=3,
786+ upstream_revision=4, channel='foo/stable',
787+ architecture='i386'),
788+ ]
789+ # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once
790+ # they exist.
791+ overrides_url = urljoin(
792+ config.Config().store_section('default').get('gw_url'),
793+ '/v2/metadata/overrides/mysnap')
794+ responses.add(
795+ 'GET', overrides_url, status=200, json={'overrides': overrides})
796+
797+ list_overrides(
798+ factory.Args(snap_name='mysnap', series='16', password='test'))
799+ self.assertEqual(
800+ 'mysnap stable amd64 1 (upstream 2)\n'
801+ 'mysnap foo/stable i386 3 (upstream 4)\n',
802+ logger.output)
803+ self.assertEqual(
804+ 'Basic YWRtaW46dGVzdA==',
805+ responses.calls[0].request.headers['Authorization'])
806
807 def test_override_no_store_config(self):
808 self.useFixture(testfixtures.ConfigFixture(empty=True))
809 logger = self.useFixture(fixtures.FakeLogger())
810 rc = override(factory.Args(
811 snap_name='some-snap', channel_map_entries=['stable=1'],
812- series='16'))
813+ series='16',
814+ password=False))
815 self.assertEqual(rc, 1)
816 self.assertEqual(
817 logger.output,
818@@ -71,7 +108,56 @@ class OverridesTests(TestCase):
819 'Have you run "snap-store-proxy-client login"?\n')
820
821 @responses.activate
822- def test_override(self):
823+ def test_override_online(self):
824+ self.useFixture(testfixtures.ConfigFixture())
825+ logger = self.useFixture(fixtures.FakeLogger())
826+ snap_id = factory.generate_snap_id()
827+ overrides = [
828+ factory.SnapDeviceGateway.Override(
829+ snap_id=snap_id, snap_name='mysnap'),
830+ factory.SnapDeviceGateway.Override(
831+ snap_id=snap_id, snap_name='mysnap', revision=3,
832+ upstream_revision=4, channel='foo/stable',
833+ architecture='i386'),
834+ ]
835+ # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once
836+ # they exist.
837+ overrides_url = urljoin(
838+ config.Config().store_section('default').get('gw_url'),
839+ '/v2/metadata/overrides')
840+ responses.add(
841+ 'POST', overrides_url, status=200, json={'overrides': overrides})
842+
843+ override(factory.Args(
844+ snap_name='mysnap',
845+ channel_map_entries=['stable=1', 'foo/stable=3'],
846+ series='16',
847+ password=False))
848+ self.assertEqual([
849+ {
850+ 'snap_name': 'mysnap',
851+ 'revision': 1,
852+ 'channel': 'stable',
853+ 'series': '16',
854+ },
855+ {
856+ 'snap_name': 'mysnap',
857+ 'revision': 3,
858+ 'channel': 'foo/stable',
859+ 'series': '16',
860+ },
861+ ], json.loads(responses.calls[0].request.body.decode()))
862+ self.assertEqual(
863+ 'mysnap stable amd64 1 (upstream 2)\n'
864+ 'mysnap foo/stable i386 3 (upstream 4)\n',
865+ logger.output)
866+ # We shouldn't have Basic Authorization headers, but Macaroon
867+ self.assertNotIn(
868+ 'Basic',
869+ responses.calls[0].request.headers['Authorization'])
870+
871+ @responses.activate
872+ def test_override_offline(self):
873 self.useFixture(testfixtures.ConfigFixture())
874 logger = self.useFixture(fixtures.FakeLogger())
875 snap_id = factory.generate_snap_id()
876@@ -94,7 +180,8 @@ class OverridesTests(TestCase):
877 override(factory.Args(
878 snap_name='mysnap',
879 channel_map_entries=['stable=1', 'foo/stable=3'],
880- series='16'))
881+ series='16',
882+ password='test'))
883 self.assertEqual([
884 {
885 'snap_name': 'mysnap',
886@@ -113,12 +200,16 @@ class OverridesTests(TestCase):
887 'mysnap stable amd64 1 (upstream 2)\n'
888 'mysnap foo/stable i386 3 (upstream 4)\n',
889 logger.output)
890+ self.assertEqual(
891+ 'Basic YWRtaW46dGVzdA==',
892+ responses.calls[0].request.headers['Authorization'])
893
894 def test_delete_override_no_store_config(self):
895 self.useFixture(testfixtures.ConfigFixture(empty=True))
896 logger = self.useFixture(fixtures.FakeLogger())
897 rc = delete_override(factory.Args(
898- snap_name='some-snap', channels=['stable'], series='16'))
899+ snap_name='some-snap', channels=['stable'],
900+ series='16', password=False))
901 self.assertEqual(rc, 1)
902 self.assertEqual(
903 logger.output,
904@@ -126,7 +217,56 @@ class OverridesTests(TestCase):
905 'Have you run "snap-store-proxy-client login"?\n')
906
907 @responses.activate
908- def test_delete_override(self):
909+ def test_delete_override_online(self):
910+ self.useFixture(testfixtures.ConfigFixture())
911+ logger = self.useFixture(fixtures.FakeLogger())
912+ snap_id = factory.generate_snap_id()
913+ overrides = [
914+ factory.SnapDeviceGateway.Override(
915+ snap_id=snap_id, snap_name='mysnap', revision=None),
916+ factory.SnapDeviceGateway.Override(
917+ snap_id=snap_id, snap_name='mysnap', revision=None,
918+ upstream_revision=4, channel='foo/stable',
919+ architecture='i386'),
920+ ]
921+ # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once
922+ # they exist.
923+ overrides_url = urljoin(
924+ config.Config().store_section('default').get('gw_url'),
925+ '/v2/metadata/overrides')
926+ responses.add(
927+ 'POST', overrides_url, status=200, json={'overrides': overrides})
928+
929+ delete_override(factory.Args(
930+ snap_name='mysnap',
931+ channels=['stable', 'foo/stable'],
932+ series='16',
933+ password=False))
934+ self.assertEqual([
935+ {
936+ 'snap_name': 'mysnap',
937+ 'revision': None,
938+ 'channel': 'stable',
939+ 'series': '16',
940+ },
941+ {
942+ 'snap_name': 'mysnap',
943+ 'revision': None,
944+ 'channel': 'foo/stable',
945+ 'series': '16',
946+ },
947+ ], json.loads(responses.calls[0].request.body.decode()))
948+ self.assertEqual(
949+ 'mysnap stable amd64 is tracking upstream (revision 2)\n'
950+ 'mysnap foo/stable i386 is tracking upstream (revision 4)\n',
951+ logger.output)
952+ # We shouldn't have Basic Authorization headers, but Macaroon
953+ self.assertNotIn(
954+ 'Basic',
955+ responses.calls[0].request.headers['Authorization'])
956+
957+ @responses.activate
958+ def test_delete_override_offline(self):
959 self.useFixture(testfixtures.ConfigFixture())
960 logger = self.useFixture(fixtures.FakeLogger())
961 snap_id = factory.generate_snap_id()
962@@ -149,7 +289,8 @@ class OverridesTests(TestCase):
963 delete_override(factory.Args(
964 snap_name='mysnap',
965 channels=['stable', 'foo/stable'],
966- series='16'))
967+ series='16',
968+ password='test'))
969 self.assertEqual([
970 {
971 'snap_name': 'mysnap',
972@@ -168,3 +309,6 @@ class OverridesTests(TestCase):
973 'mysnap stable amd64 is tracking upstream (revision 2)\n'
974 'mysnap foo/stable i386 is tracking upstream (revision 4)\n',
975 logger.output)
976+ self.assertEqual(
977+ 'Basic YWRtaW46dGVzdA==',
978+ responses.calls[0].request.headers['Authorization'])
979diff --git a/snapstore_client/logic/tests/test_push.py b/snapstore_client/logic/tests/test_push.py
980new file mode 100644
981index 0000000..7fedbbd
982--- /dev/null
983+++ b/snapstore_client/logic/tests/test_push.py
984@@ -0,0 +1,169 @@
985+import json
986+from pathlib import Path
987+from urllib.parse import urljoin
988+
989+import fixtures
990+import responses
991+from testtools import TestCase
992+
993+from snapstore_client.logic.push import (
994+ ChannelMapExistsException,
995+ pushException,
996+ _add_assertion_to_service,
997+ _split_assertions,
998+ _push_channelmap,
999+ _push_ident,
1000+ _push_revs,
1001+)
1002+
1003+
1004+class pushTests(TestCase):
1005+
1006+ def setUp(self):
1007+ super().setUp()
1008+ self.default_gw_url = 'http://store.local'
1009+ self.logger = self.useFixture(fixtures.FakeLogger())
1010+ current_path = Path(__file__).resolve().parent
1011+ with (current_path / 'test-snap-map.json').open() as fh:
1012+ self.snap_map = json.load(fh)
1013+ with (current_path / 'test-snap-assert.assert').open() as fh:
1014+ self.snap_assert = fh.read().splitlines()
1015+ self.store = {'gw_url': self.default_gw_url}
1016+
1017+ @responses.activate
1018+ def test_push_ident(self):
1019+ ident_url = urljoin(self.default_gw_url, '/snaps/update')
1020+ responses.add('POST', ident_url, status=200)
1021+
1022+ _push_ident(self.store, 'test', self.snap_map)
1023+
1024+ self.assertEqual(
1025+ 'Basic YWRtaW46dGVzdA==',
1026+ responses.calls[0].request.headers['Authorization'])
1027+
1028+ @responses.activate
1029+ def test_push_ident_failed(self):
1030+ ident_url = urljoin(self.default_gw_url, '/snaps/update')
1031+ responses.add('POST', ident_url, status=500)
1032+
1033+ self.assertRaises(
1034+ pushException, _push_ident, self.store, 'test', self.snap_map)
1035+
1036+ @responses.activate
1037+ def test_push_revs(self):
1038+ revs_url = urljoin(self.default_gw_url, '/revisions/create')
1039+ responses.add('POST', revs_url, status=201)
1040+
1041+ _push_revs(self.store, 'test', self.snap_map)
1042+
1043+ self.assertEqual(
1044+ 'Basic YWRtaW46dGVzdA==',
1045+ responses.calls[0].request.headers['Authorization'])
1046+
1047+ @responses.activate
1048+ def test_push_revs_unexpected_status_code(self):
1049+ revs_url = urljoin(self.default_gw_url, '/revisions/create')
1050+ responses.add('POST', revs_url, status=302)
1051+
1052+ self.assertRaises(
1053+ pushException, _push_revs, self.store, 'test', self.snap_map)
1054+
1055+ @responses.activate
1056+ def test_push_revs_failed(self):
1057+ revs_url = urljoin(self.default_gw_url, '/revisions/create')
1058+ responses.add('POST', revs_url, status=500)
1059+
1060+ self.assertRaises(
1061+ pushException, _push_revs, self.store, 'test', self.snap_map)
1062+
1063+ @responses.activate
1064+ def test_push_map(self):
1065+ filter_url = urljoin(self.default_gw_url, '/channelmaps/filter')
1066+ update_url = urljoin(self.default_gw_url, '/channelmaps/update')
1067+ responses.add('POST', filter_url, status=200, json={})
1068+ responses.add('POST', update_url, status=200)
1069+
1070+ _push_channelmap(self.store, 'test', self.snap_map)
1071+
1072+ self.assertEqual(
1073+ 'Basic YWRtaW46dGVzdA==',
1074+ responses.calls[0].request.headers['Authorization'])
1075+
1076+ @responses.activate
1077+ def test_push_map_failed(self):
1078+ filter_url = urljoin(self.default_gw_url, '/channelmaps/filter')
1079+ update_url = urljoin(self.default_gw_url, '/channelmaps/update')
1080+ responses.add('POST', filter_url, status=200, json={})
1081+ responses.add('POST', update_url, status=500)
1082+
1083+ self.assertRaises(
1084+ pushException, _push_channelmap,
1085+ self.store, 'test', self.snap_map)
1086+
1087+ @responses.activate
1088+ def test_push_map_exists(self):
1089+ filter_url = urljoin(self.default_gw_url, '/channelmaps/filter')
1090+ update_url = urljoin(self.default_gw_url, '/channelmaps/update')
1091+ exists = {'channelmaps': ['thing that exists']}
1092+ responses.add('POST', filter_url, status=200, json=exists)
1093+ responses.add('POST', update_url, status=200)
1094+
1095+ self.assertRaises(
1096+ ChannelMapExistsException, _push_channelmap,
1097+ self.store, 'test', self.snap_map)
1098+
1099+ @responses.activate
1100+ def test_push_map_exists_force_push(self):
1101+ filter_url = urljoin(self.default_gw_url, '/channelmaps/filter')
1102+ update_url = urljoin(self.default_gw_url, '/channelmaps/update')
1103+ exists = {'channelmaps': ['thing that exists']}
1104+ responses.add('POST', filter_url, status=200, json=exists)
1105+ responses.add('POST', update_url, status=200)
1106+
1107+ _push_channelmap(self.store, 'test', self.snap_map, True)
1108+
1109+ self.assertEqual(
1110+ 'Basic YWRtaW46dGVzdA==',
1111+ responses.calls[0].request.headers['Authorization'])
1112+
1113+ def test_split_assertions(self):
1114+ result = _split_assertions(self.snap_assert)
1115+ assert len(result) == 4
1116+
1117+ # We should have 4 different types of assertion
1118+ assert result[0][0] == 'type: account-key'
1119+ assert result[1][0] == 'type: account'
1120+ assert result[2][0] == 'type: snap-declaration'
1121+ assert result[3][0] == 'type: snap-revision'
1122+
1123+ @responses.activate
1124+ def test_add_assertion_to_service(self):
1125+ assert_url = urljoin(self.default_gw_url, '/v1/assertions')
1126+ responses.add('POST', assert_url, status=201)
1127+
1128+ split_assertions = _split_assertions(self.snap_assert)
1129+ _add_assertion_to_service(self.store, 'test', split_assertions)
1130+
1131+ self.assertEqual(
1132+ 'Basic YWRtaW46dGVzdA==',
1133+ responses.calls[0].request.headers['Authorization'])
1134+
1135+ @responses.activate
1136+ def test_add_assertion_to_service_unexpected_status_code(self):
1137+ assert_url = urljoin(self.default_gw_url, '/v1/assertions')
1138+ responses.add('POST', assert_url, status=302)
1139+
1140+ split_assertions = _split_assertions(self.snap_assert)
1141+ self.assertRaises(
1142+ pushException,
1143+ _add_assertion_to_service, self.store, 'test', split_assertions)
1144+
1145+ @responses.activate
1146+ def test_add_assertion_to_service_failed(self):
1147+ assert_url = urljoin(self.default_gw_url, '/v1/assertions')
1148+ responses.add('POST', assert_url, status=500)
1149+
1150+ split_assertions = _split_assertions(self.snap_assert)
1151+ self.assertRaises(
1152+ pushException,
1153+ _add_assertion_to_service, self.store, 'test', split_assertions)
1154diff --git a/snapstore_client/utils.py b/snapstore_client/utils.py
1155new file mode 100644
1156index 0000000..461a1c3
1157--- /dev/null
1158+++ b/snapstore_client/utils.py
1159@@ -0,0 +1,26 @@
1160+import logging
1161+
1162+logger = logging.getLogger(__name__)
1163+
1164+
1165+def _log_credentials_error(e):
1166+ logger.error('%s', e)
1167+ logger.error('Try to "snap-store-proxy-client login" again.')
1168+
1169+
1170+def _log_authorized_error():
1171+ logger.error(("Perhaps you have not been registered as an "
1172+ "admin with the proxy."))
1173+ logger.error("Try 'snap-proxy add-admin' on the proxy host.")
1174+
1175+
1176+def _check_default_store(cfg):
1177+ """Load the default store from the config."""
1178+ store = cfg.store_section('default')
1179+ # If the gw URL is configured then everything else should be too.
1180+ if not store.get('gw_url'):
1181+ logger.error(
1182+ 'No store configuration found. '
1183+ 'Have you run "snap-store-proxy-client login"?')
1184+ return None
1185+ return store
1186diff --git a/snapstore_client/webservices.py b/snapstore_client/webservices.py
1187index e8e618f..3ebf149 100644
1188--- a/snapstore_client/webservices.py
1189+++ b/snapstore_client/webservices.py
1190@@ -1,6 +1,7 @@
1191 # Copyright 2017 Canonical Ltd. This software is licensed under the
1192 # GNU General Public License version 3 (see the file LICENSE).
1193
1194+import base64
1195 import json
1196 import logging
1197 import urllib.parse
1198@@ -96,6 +97,19 @@ def _get_macaroon_auth(store):
1199 root_raw, bound_discharge_raw)
1200
1201
1202+def _get_basic_auth(password):
1203+ """Build the basic auth for interacting with an offline proxy"""
1204+ # XXX twom 2019-03-15 Hardcoded username, awaiting user management
1205+ username = 'admin'
1206+ credentials = '{}:{}'.format(username, password)
1207+ try:
1208+ encoded_credentials = base64.b64encode(credentials.encode('UTF-8'))
1209+ except UnicodeEncodeError:
1210+ logger.error('Unable to encode password to UTF-8')
1211+ raise
1212+ return 'Basic {}'.format(encoded_credentials.decode())
1213+
1214+
1215 def _raise_needs_refresh(response):
1216 if (response.status_code == 401 and
1217 response.headers.get('WWW-Authenticate') == (
1218@@ -115,15 +129,18 @@ def refresh_if_necessary(store, func, *args, **kwargs):
1219 return func(*args, **kwargs)
1220
1221
1222-def get_overrides(store, snap_name, series='16'):
1223+def get_overrides(store, snap_name, series='16', password=None):
1224 """Get all overrides for a snap."""
1225 overrides_url = urllib.parse.urljoin(
1226 store.get('gw_url'),
1227 '/v2/metadata/overrides/{}'.format(urllib.parse.quote_plus(snap_name)))
1228 headers = {
1229- 'Authorization': _get_macaroon_auth(store),
1230 'X-Ubuntu-Series': series,
1231 }
1232+ if password:
1233+ headers['Authorization'] = _get_basic_auth(password)
1234+ else:
1235+ headers['Authorization'] = _get_macaroon_auth(store)
1236 resp = requests.get(overrides_url, headers=headers)
1237 _raise_needs_refresh(resp)
1238 if resp.status_code != 200:
1239@@ -132,11 +149,14 @@ def get_overrides(store, snap_name, series='16'):
1240 return resp.json()
1241
1242
1243-def set_overrides(store, overrides):
1244+def set_overrides(store, overrides, password=None):
1245 """Add or remove channel map overrides for a snap."""
1246 overrides_url = urllib.parse.urljoin(
1247 store.get('gw_url'), '/v2/metadata/overrides')
1248- headers = {'Authorization': _get_macaroon_auth(store)}
1249+ if password:
1250+ headers = {'Authorization': _get_basic_auth(password)}
1251+ else:
1252+ headers = {'Authorization': _get_macaroon_auth(store)}
1253 resp = requests.post(overrides_url, headers=headers, json=overrides)
1254 _raise_needs_refresh(resp)
1255 if resp.status_code != 200:

Subscribers

People subscribed via source and target branches

to all changes: