← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-webservice into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-webservice into lp:launchpad with lp:~cjwatson/launchpad/snap-builds as a prerequisite.

Commit message:
Export Snap and SnapBuild on the webservice.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1476405 in Launchpad itself: "Add support for building snaps"
  https://bugs.launchpad.net/launchpad/+bug/1476405

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-webservice/+merge/265700

Export Snap and SnapBuild on the webservice.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-webservice into lp:launchpad.
=== modified file 'lib/lp/_schema_circular_imports.py'
--- lib/lp/_schema_circular_imports.py	2015-06-05 11:05:03 +0000
+++ lib/lp/_schema_circular_imports.py	2015-07-23 16:45:08 +0000
@@ -182,6 +182,11 @@
     ILanguage,
     ILanguageSet,
     )
+from lp.snappy.interfaces.snap import ISnapView
+from lp.snappy.interfaces.snapbuild import (
+    ISnapBuild,
+    ISnapFile,
+    )
 from lp.soyuz.enums import (
     PackagePublishingStatus,
     PackageUploadCustomFormat,
@@ -606,6 +611,15 @@
 patch_reference_property(IPackageUpload, 'archive', IArchive)
 patch_reference_property(IPackageUpload, 'copy_source_archive', IArchive)
 
+# ISnapFile
+patch_reference_property(ISnapFile, 'snapbuild', ISnapBuild)
+
+# ISnapView
+patch_entry_return_type(ISnapView, 'requestBuild', ISnapBuild)
+patch_collection_property(ISnapView, 'builds', ISnapBuild)
+patch_collection_property(ISnapView, 'completed_builds', ISnapBuild)
+patch_collection_property(ISnapView, 'pending_builds', ISnapBuild)
+
 # IStructuralSubscription
 patch_collection_property(
     IStructuralSubscription, 'bug_filters', IBugSubscriptionFilter)

=== modified file 'lib/lp/app/browser/launchpad.py'
--- lib/lp/app/browser/launchpad.py	2015-07-08 16:05:11 +0000
+++ lib/lp/app/browser/launchpad.py	2015-07-23 16:45:08 +0000
@@ -157,6 +157,7 @@
 from lp.services.webapp.url import urlappend
 from lp.services.worlddata.interfaces.country import ICountrySet
 from lp.services.worlddata.interfaces.language import ILanguageSet
+from lp.snappy.interfaces.snap import ISnapSet
 from lp.soyuz.interfaces.archive import IArchiveSet
 from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
 from lp.soyuz.interfaces.livefs import ILiveFSSet
@@ -797,6 +798,7 @@
         '+processors': IProcessorSet,
         'projects': IProductSet,
         'projectgroups': IProjectGroupSet,
+        '+snaps': ISnapSet,
         'sourcepackagenames': ISourcePackageNameSet,
         'specs': ISpecificationSet,
         'sprints': ISprintSet,

=== modified file 'lib/lp/app/browser/tests/test_webservice.py'
--- lib/lp/app/browser/tests/test_webservice.py	2014-05-06 12:54:34 +0000
+++ lib/lp/app/browser/tests/test_webservice.py	2015-07-23 16:45:08 +0000
@@ -153,6 +153,12 @@
     object_type = 'questions'
 
 
+class TestMissingSnaps(BaseMissingObjectWebService, TestCaseWithFactory):
+    """Test NotFound for webservice snaps requests."""
+
+    object_type = '+snaps'
+
+
 class TestMissingTemporaryBlobs(
     BaseMissingObjectWebService, TestCaseWithFactory):
     """Test NotFound for webservice temporary_blobs requests."""

=== modified file 'lib/lp/buildmaster/browser/builder.py'
--- lib/lp/buildmaster/browser/builder.py	2015-03-24 09:59:20 +0000
+++ lib/lp/buildmaster/browser/builder.py	2015-07-23 16:45:08 +0000
@@ -55,6 +55,7 @@
     )
 from lp.services.webapp.batching import StormRangeFactory
 from lp.services.webapp.breadcrumb import Breadcrumb
+from lp.snappy.interfaces.snapbuild import ISnapBuildSet
 from lp.soyuz.browser.build import (
     BuildRecordsView,
     get_build_by_id_str,
@@ -88,6 +89,13 @@
             return None
         return self.redirectSubTree(canonical_url(build))
 
+    @stepthrough('+snapbuild')
+    def traverse_snapbuild(self, name):
+        build = get_build_by_id_str(ISnapBuildSet, name)
+        if build is None:
+            return None
+        return self.redirectSubTree(canonical_url(build))
+
 
 class BuilderSetBreadcrumb(Breadcrumb):
     """Builds a breadcrumb for an `IBuilderSet`."""

=== modified file 'lib/lp/snappy/browser/configure.zcml'
--- lib/lp/snappy/browser/configure.zcml	2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/browser/configure.zcml	2015-07-23 16:45:08 +0000
@@ -17,6 +17,10 @@
             module="lp.snappy.browser.snap"
             classes="SnapNavigation" />
         <browser:url
+            for="lp.snappy.interfaces.snap.ISnapSet"
+            path_expression="string:+snaps"
+            parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
+        <browser:url
             for="lp.snappy.interfaces.snapbuild.ISnapBuild"
             path_expression="string:+build/${id}"
             attribute_to_parent="snap" />

=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/configure.zcml	2015-07-23 16:45:08 +0000
@@ -70,4 +70,6 @@
         <allow interface="lp.snappy.interfaces.snapbuild.ISnapFile" />
     </class>
 
+    <webservice:register module="lp.snappy.interfaces.webservice" />
+
 </configure>

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/interfaces/snap.py	2015-07-23 16:45:08 +0000
@@ -19,16 +19,33 @@
     'NoSuchSnap',
     ]
 
+import httplib
+
+from lazr.lifecycle.snapshot import doNotSnapshot
+from lazr.restful.declarations import (
+    call_with,
+    collection_default_content,
+    error_status,
+    export_as_webservice_collection,
+    export_as_webservice_entry,
+    export_destructor_operation,
+    export_factory_operation,
+    export_read_operation,
+    exported,
+    operation_for_version,
+    operation_parameters,
+    operation_returns_entry,
+    REQUEST_USER,
+    )
 from lazr.restful.fields import (
+    CollectionField,
     Reference,
     ReferenceChoice,
     )
-from zope.interface import (
-    Attribute,
-    Interface,
-    )
+from zope.interface import Interface
 from zope.schema import (
     Bool,
+    Choice,
     Datetime,
     Int,
     Text,
@@ -45,16 +62,21 @@
 from lp.code.interfaces.branch import IBranch
 from lp.code.interfaces.gitrepository import IGitRepository
 from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.person import IPerson
+from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.role import IHasOwner
 from lp.services.fields import (
     PersonChoice,
     PublicPersonChoice,
     )
+from lp.soyuz.interfaces.archive import IArchive
+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
 
 
 SNAP_FEATURE_FLAG = u"snap.allow_new"
 
 
+@error_status(httplib.BAD_REQUEST)
 class SnapBuildAlreadyPending(Exception):
     """A build was requested when an identical build was already pending."""
 
@@ -63,6 +85,7 @@
             "An identical build of this snap package is already pending.")
 
 
+@error_status(httplib.FORBIDDEN)
 class SnapBuildArchiveOwnerMismatch(Forbidden):
     """Builds against private archives require that owners match.
 
@@ -80,6 +103,7 @@
             "if the snap package owner and the archive owner are equal.")
 
 
+@error_status(httplib.UNAUTHORIZED)
 class SnapFeatureDisabled(Unauthorized):
     """Only certain users can create new snap-related objects."""
 
@@ -89,6 +113,7 @@
             "builds.")
 
 
+@error_status(httplib.BAD_REQUEST)
 class DuplicateSnapName(Exception):
     """Raised for snap packages with duplicate name/owner."""
 
@@ -97,6 +122,7 @@
             "There is already a snap package with the same name and owner.")
 
 
+@error_status(httplib.UNAUTHORIZED)
 class SnapNotOwner(Unauthorized):
     """The registrant/requester is not the owner or a member of its team."""
 
@@ -106,6 +132,7 @@
     _message_prefix = "No such snap package with this owner"
 
 
+@error_status(httplib.BAD_REQUEST)
 class CannotDeleteSnap(Exception):
     """This snap package cannot be deleted."""
 
@@ -115,14 +142,22 @@
 
     id = Int(title=_("ID"), required=True, readonly=True)
 
-    date_created = Datetime(
-        title=_("Date created"), required=True, readonly=True)
+    date_created = exported(Datetime(
+        title=_("Date created"), required=True, readonly=True))
 
-    registrant = PublicPersonChoice(
+    registrant = exported(PublicPersonChoice(
         title=_("Registrant"), required=True, readonly=True,
         vocabulary="ValidPersonOrTeam",
-        description=_("The person who registered this snap package."))
+        description=_("The person who registered this snap package.")))
 
+    @call_with(requester=REQUEST_USER)
+    @operation_parameters(
+        archive=Reference(schema=IArchive),
+        distro_arch_series=Reference(schema=IDistroArchSeries),
+        pocket=Choice(vocabulary=PackagePublishingPocket))
+    # Really ISnapBuild, patched in _schema_circular_imports.py.
+    @export_factory_operation(Interface, [])
+    @operation_for_version("devel")
     def requestBuild(requester, archive, distro_arch_series, pocket):
         """Request that the snap package be built.
 
@@ -133,16 +168,36 @@
         :return: `ISnapBuild`.
         """
 
-    builds = Attribute("All builds of this snap package.")
-
-    completed_builds = Attribute("Completed builds of this snap package.")
-
-    pending_builds = Attribute("Pending builds of this snap package.")
+    builds = exported(doNotSnapshot(CollectionField(
+        title=_("All builds of this snap package."),
+        description=_(
+            "All builds of this snap package, sorted in descending order "
+            "of finishing (or starting if not completed successfully)."),
+        # Really ISnapBuild, patched in _schema_circular_imports.py.
+        value_type=Reference(schema=Interface), readonly=True)))
+
+    completed_builds = exported(doNotSnapshot(CollectionField(
+        title=_("Completed builds of this snap package."),
+        description=_(
+            "Completed builds of this snap package, sorted in descending "
+            "order of finishing."),
+        # Really ISnapBuild, patched in _schema_circular_imports.py.
+        value_type=Reference(schema=Interface), readonly=True)))
+
+    pending_builds = exported(doNotSnapshot(CollectionField(
+        title=_("Pending builds of this snap package."),
+        description=_(
+            "Pending builds of this snap package, sorted in descending "
+            "order of creation."),
+        # Really ISnapBuild, patched in _schema_circular_imports.py.
+        value_type=Reference(schema=Interface), readonly=True)))
 
 
 class ISnapEdit(Interface):
     """`ISnap` methods that require launchpad.Edit permission."""
 
+    @export_destructor_operation()
+    @operation_for_version("devel")
     def destroySelf():
         """Delete this snap package, provided that it has no builds."""
 
@@ -152,48 +207,48 @@
 
     These attributes need launchpad.View to see, and launchpad.Edit to change.
     """
-    date_last_modified = Datetime(
-        title=_("Date last modified"), required=True, readonly=True)
+    date_last_modified = exported(Datetime(
+        title=_("Date last modified"), required=True, readonly=True))
 
-    owner = PersonChoice(
+    owner = exported(PersonChoice(
         title=_("Owner"), required=True, readonly=False,
         vocabulary="AllUserTeamsParticipationPlusSelf",
-        description=_("The owner of this snap package."))
+        description=_("The owner of this snap package.")))
 
-    distro_series = Reference(
+    distro_series = exported(Reference(
         IDistroSeries, title=_("Distro Series"), required=True, readonly=False,
         description=_(
-            "The series for which the snap package should be built."))
+            "The series for which the snap package should be built.")))
 
-    name = TextLine(
+    name = exported(TextLine(
         title=_("Name"), required=True, readonly=False,
         constraint=name_validator,
-        description=_("The name of the snap package."))
+        description=_("The name of the snap package.")))
 
-    description = Text(
+    description = exported(Text(
         title=_("Description"), required=False, readonly=False,
-        description=_("A description of the snap package."))
+        description=_("A description of the snap package.")))
 
-    branch = ReferenceChoice(
+    branch = exported(ReferenceChoice(
         title=_("Bazaar branch"), schema=IBranch, vocabulary="Branch",
         required=False, readonly=False,
         description=_(
             "A Bazaar branch containing a snapcraft.yaml recipe at the top "
-            "level."))
+            "level.")))
 
-    git_repository = ReferenceChoice(
+    git_repository = exported(ReferenceChoice(
         title=_("Git repository"),
         schema=IGitRepository, vocabulary="GitRepository",
         required=False, readonly=False,
         description=_(
             "A Git repository with a branch containing a snapcraft.yaml "
-            "recipe at the top level."))
+            "recipe at the top level.")))
 
-    git_path = TextLine(
+    git_path = exported(TextLine(
         title=_("Git branch path"), required=False, readonly=False,
         description=_(
             "The path of the Git branch containing a snapcraft.yaml recipe at "
-            "the top level."))
+            "the top level.")))
 
 
 class ISnapAdminAttributes(Interface):
@@ -201,19 +256,32 @@
 
     These attributes need launchpad.View to see, and launchpad.Admin to change.
     """
-    require_virtualized = Bool(
+    require_virtualized = exported(Bool(
         title=_("Require virtualized builders"), required=True, readonly=False,
-        description=_("Only build this snap package on virtual builders."))
+        description=_("Only build this snap package on virtual builders.")))
 
 
 class ISnap(
     ISnapView, ISnapEdit, ISnapEditableAttributes, ISnapAdminAttributes):
     """A buildable snap package."""
 
+    # XXX cjwatson 2015-07-17 bug=760849: "beta" is a lie to get WADL
+    # generation working.  Individual attributes must set their version to
+    # "devel".
+    export_as_webservice_entry(as_of="beta")
+
 
 class ISnapSet(Interface):
     """A utility to create and access snap packages."""
 
+    export_as_webservice_collection(ISnap)
+
+    @call_with(registrant=REQUEST_USER)
+    @export_factory_operation(
+        ISnap, [
+            "owner", "distro_series", "name", "description", "branch",
+            "git_repository", "git_path"])
+    @operation_for_version("devel")
     def new(registrant, owner, distro_series, name, description=None,
             branch=None, git_repository=None, git_path=None,
             require_virtualized=True, date_created=None):
@@ -222,12 +290,19 @@
     def exists(owner, name):
         """Check to see if a matching snap exists."""
 
+    @operation_parameters(
+        owner=Reference(IPerson, title=_("Owner"), required=True),
+        name=TextLine(title=_("Snap name"), required=True))
+    @operation_returns_entry(ISnap)
+    @export_read_operation()
+    @operation_for_version("devel")
     def getByName(owner, name):
         """Return the appropriate `ISnap` for the given objects."""
 
     def getByPerson(owner):
         """Return all snap packages with the given `owner`."""
 
+    @collection_default_content()
     def empty_list():
         """Return an empty collection of snap packages.
 

=== modified file 'lib/lp/snappy/interfaces/snapbuild.py'
--- lib/lp/snappy/interfaces/snapbuild.py	2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/interfaces/snapbuild.py	2015-07-23 16:45:08 +0000
@@ -11,11 +11,16 @@
     'ISnapFile',
     ]
 
+from lazr.restful.declarations import (
+    export_as_webservice_entry,
+    export_read_operation,
+    export_write_operation,
+    exported,
+    operation_for_version,
+    operation_parameters,
+    )
 from lazr.restful.fields import Reference
-from zope.interface import (
-    Attribute,
-    Interface,
-    )
+from zope.interface import Interface
 from zope.schema import (
     Bool,
     Choice,
@@ -37,7 +42,11 @@
 class ISnapFile(Interface):
     """A file produced by a snap package build."""
 
-    snapbuild = Attribute("The snap package build producing this file.")
+    snapbuild = Reference(
+        # Really ISnapBuild, patched in _schema_circular_imports.py.
+        Interface,
+        title=_("The snap package build producing this file."),
+        required=True, readonly=True)
 
     libraryfile = Reference(
         ILibraryFileAlias, title=_("The library file alias for this file."),
@@ -47,46 +56,46 @@
 class ISnapBuildView(IPackageBuild):
     """`ISnapBuild` attributes that require launchpad.View permission."""
 
-    requester = Reference(
+    requester = exported(Reference(
         IPerson,
         title=_("The person who requested this build."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    snap = Reference(
+    snap = exported(Reference(
         ISnap,
         title=_("The snap package to build."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    archive = Reference(
+    archive = exported(Reference(
         IArchive,
         title=_("The archive from which to build the snap package."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    distro_arch_series = Reference(
+    distro_arch_series = exported(Reference(
         IDistroArchSeries,
         title=_("The series and architecture for which to build."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    pocket = Choice(
+    pocket = exported(Choice(
         title=_("The pocket for which to build."),
-        vocabulary=PackagePublishingPocket, required=True, readonly=True)
+        vocabulary=PackagePublishingPocket, required=True, readonly=True))
 
     virtualized = Bool(
         title=_("If True, this build is virtualized."), readonly=True)
 
-    score = Int(
+    score = exported(Int(
         title=_("Score of the related build farm job (if any)."),
-        required=False, readonly=True)
+        required=False, readonly=True))
 
-    can_be_rescored = Bool(
+    can_be_rescored = exported(Bool(
         title=_("Can be rescored"),
         required=True, readonly=True,
-        description=_("Whether this build record can be rescored manually."))
+        description=_("Whether this build record can be rescored manually.")))
 
-    can_be_cancelled = Bool(
+    can_be_cancelled = exported(Bool(
         title=_("Can be cancelled"),
         required=True, readonly=True,
-        description=_("Whether this build record can be cancelled."))
+        description=_("Whether this build record can be cancelled.")))
 
     def getFiles():
         """Retrieve the build's `ISnapFile` records.
@@ -111,6 +120,8 @@
         :return: The corresponding `ILibraryFileAlias`.
         """
 
+    @export_read_operation()
+    @operation_for_version("devel")
     def getFileUrls():
         """URLs for all the files produced by this build.
 
@@ -127,6 +138,8 @@
         :return: An `ISnapFile`.
         """
 
+    @export_write_operation()
+    @operation_for_version("devel")
     def cancel():
         """Cancel the build if it is either pending or in progress.
 
@@ -145,6 +158,9 @@
 class ISnapBuildAdmin(Interface):
     """`ISnapBuild` attributes that require launchpad.Admin."""
 
+    @operation_parameters(score=Int(title=_("Score"), required=True))
+    @export_write_operation()
+    @operation_for_version("devel")
     def rescore(score):
         """Change the build's score."""
 
@@ -152,6 +168,11 @@
 class ISnapBuild(ISnapBuildView, ISnapBuildEdit, ISnapBuildAdmin):
     """Build information for snap package builds."""
 
+    # XXX cjwatson 2014-05-06 bug=760849: "beta" is a lie to get WADL
+    # generation working.  Individual attributes must set their version to
+    # "devel".
+    export_as_webservice_entry(as_of="beta")
+
 
 class ISnapBuildSet(ISpecificBuildFarmJobSource):
     """Utility for `ISnapBuild`."""

=== added file 'lib/lp/snappy/interfaces/webservice.py'
--- lib/lp/snappy/interfaces/webservice.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/interfaces/webservice.py	2015-07-23 16:45:08 +0000
@@ -0,0 +1,28 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""All the interfaces that are exposed through the webservice.
+
+There is a declaration in ZCML somewhere that looks like:
+  <webservice:register module="lp.snappy.interfaces.webservice" />
+
+which tells `lazr.restful` that it should look for webservice exports here.
+"""
+
+__all__ = [
+    'ISnap',
+    'ISnapBuild',
+    'ISnapSet',
+    ]
+
+# XXX: JonathanLange 2010-11-09 bug=673083: Legacy work-around for circular
+# import bugs.  Break this up into a per-package thing.
+from lp import _schema_circular_imports
+from lp.snappy.interfaces.snap import (
+    ISnap,
+    ISnapSet,
+    )
+from lp.snappy.interfaces.snapbuild import ISnapBuild
+
+
+_schema_circular_imports

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/tests/test_snap.py	2015-07-23 16:45:08 +0000
@@ -5,11 +5,15 @@
 
 __metaclass__ = type
 
-from datetime import datetime
+from datetime import (
+    datetime,
+    timedelta,
+    )
 
 from lazr.lifecycle.event import ObjectModifiedEvent
 import pytz
 from storm.locals import Store
+from testtools.matchers import Equals
 import transaction
 from zope.component import getUtility
 from zope.event import notify
@@ -21,26 +25,39 @@
     )
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.model.buildqueue import BuildQueue
+from lp.registry.interfaces.distribution import IDistributionSet
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.interfaces import OAuthPermission
 from lp.snappy.interfaces.snap import (
     CannotDeleteSnap,
     ISnap,
     ISnapSet,
+    ISnapView,
     SNAP_FEATURE_FLAG,
     SnapBuildAlreadyPending,
     SnapFeatureDisabled,
     )
 from lp.snappy.interfaces.snapbuild import ISnapBuild
 from lp.testing import (
+    ANONYMOUS,
+    api_url,
+    login,
+    logout,
     person_logged_in,
+    StormStatementRecorder,
     TestCaseWithFactory,
     )
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadZopelessLayer,
     )
+from lp.testing.matchers import (
+    DoesNotSnapshot,
+    HasQueryCount,
+    )
+from lp.testing.pages import webservice_for_person
 
 
 class TestSnapFeatureFlag(TestCaseWithFactory):
@@ -69,6 +86,12 @@
         with person_logged_in(snap.owner):
             self.assertProvides(snap, ISnap)
 
+    def test_avoids_problematic_snapshots(self):
+        self.assertThat(
+            self.factory.makeSnap(),
+            DoesNotSnapshot(
+                ["builds", "completed_builds", "pending_builds"], ISnapView))
+
     def test_initial_date_last_modified(self):
         # The initial value of date_last_modified is date_created.
         snap = self.factory.makeSnap(
@@ -349,3 +372,358 @@
             snaps[:2], getUtility(ISnapSet).getByPerson(owners[0]))
         self.assertContentEqual(
             snaps[2:], getUtility(ISnapSet).getByPerson(owners[1]))
+
+
+class TestSnapWebservice(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapWebservice, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+        self.person = self.factory.makePerson(displayname="Test Person")
+        self.webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PUBLIC)
+        self.webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+
+    def getURL(self, obj):
+        return self.webservice.getAbsoluteUrl(api_url(obj))
+
+    def makeSnap(self, owner=None, distroseries=None, branch=None,
+                 git_ref=None, webservice=None):
+        if owner is None:
+            owner = self.person
+        if distroseries is None:
+            distroseries = self.factory.makeDistroSeries(registrant=owner)
+        if branch is None and git_ref is None:
+            branch = self.factory.makeAnyBranch()
+        kwargs = {}
+        if webservice is None:
+            webservice = self.webservice
+        transaction.commit()
+        distroseries_url = api_url(distroseries)
+        owner_url = api_url(owner)
+        if branch is not None:
+            kwargs["branch"] = api_url(branch)
+        if git_ref is not None:
+            kwargs["git_repository"] = api_url(git_ref.repository)
+            kwargs["git_path"] = git_ref.path
+        logout()
+        response = webservice.named_post(
+            "/+snaps", "new", owner=owner_url, distro_series=distroseries_url,
+            name="mir", **kwargs)
+        self.assertEqual(201, response.status)
+        return webservice.get(response.getHeader("Location")).jsonBody()
+
+    def getCollectionLinks(self, entry, member):
+        """Return a list of self_link attributes of entries in a collection."""
+        collection = self.webservice.get(
+            entry["%s_collection_link" % member]).jsonBody()
+        return [entry["self_link"] for entry in collection["entries"]]
+
+    def test_new_bzr(self):
+        # Ensure Snap creation based on a Bazaar branch works.
+        team = self.factory.makeTeam(owner=self.person)
+        distroseries = self.factory.makeDistroSeries(registrant=team)
+        branch = self.factory.makeAnyBranch()
+        snap = self.makeSnap(
+            owner=team, distroseries=distroseries, branch=branch)
+        with person_logged_in(self.person):
+            self.assertEqual(self.getURL(self.person), snap["registrant_link"])
+            self.assertEqual(self.getURL(team), snap["owner_link"])
+            self.assertEqual(
+                self.getURL(distroseries), snap["distro_series_link"])
+            self.assertEqual("mir", snap["name"])
+            self.assertEqual(self.getURL(branch), snap["branch_link"])
+            self.assertIsNone(snap["git_repository_link"])
+            self.assertIsNone(snap["git_path"])
+            self.assertTrue(snap["require_virtualized"])
+
+    def test_new_git(self):
+        # Ensure Snap creation based on a Git branch works.
+        team = self.factory.makeTeam(owner=self.person)
+        distroseries = self.factory.makeDistroSeries(registrant=team)
+        [ref] = self.factory.makeGitRefs()
+        snap = self.makeSnap(
+            owner=team, distroseries=distroseries, git_ref=ref)
+        with person_logged_in(self.person):
+            self.assertEqual(self.getURL(self.person), snap["registrant_link"])
+            self.assertEqual(self.getURL(team), snap["owner_link"])
+            self.assertEqual(
+                self.getURL(distroseries), snap["distro_series_link"])
+            self.assertEqual("mir", snap["name"])
+            self.assertIsNone(snap["branch_link"])
+            self.assertEqual(
+                self.getURL(ref.repository), snap["git_repository_link"])
+            self.assertEqual(ref.path, snap["git_path"])
+            self.assertTrue(snap["require_virtualized"])
+
+    def test_duplicate(self):
+        # An attempt to create a duplicate Snap fails.
+        team = self.factory.makeTeam(owner=self.person)
+        branch = self.factory.makeAnyBranch()
+        branch_url = api_url(branch)
+        self.makeSnap(owner=team)
+        with person_logged_in(self.person):
+            owner_url = api_url(team)
+            distroseries_url = api_url(self.factory.makeDistroSeries())
+        response = self.webservice.named_post(
+            "/+snaps", "new", owner=owner_url, distro_series=distroseries_url,
+            name="mir", branch=branch_url)
+        self.assertEqual(400, response.status)
+        self.assertEqual(
+            "There is already a snap package with the same name and owner.",
+            response.body)
+
+    def test_not_owner(self):
+        # If the registrant is not the owner or a member of the owner team,
+        # Snap creation fails.
+        other_person = self.factory.makePerson(displayname="Other Person")
+        other_team = self.factory.makeTeam(
+            owner=other_person, displayname="Other Team")
+        distroseries = self.factory.makeDistroSeries(registrant=self.person)
+        branch = self.factory.makeAnyBranch()
+        transaction.commit()
+        other_person_url = api_url(other_person)
+        other_team_url = api_url(other_team)
+        distroseries_url = api_url(distroseries)
+        branch_url = api_url(branch)
+        logout()
+        response = self.webservice.named_post(
+            "/+snaps", "new", owner=other_person_url,
+            distro_series=distroseries_url, name="dummy", branch=branch_url)
+        self.assertEqual(401, response.status)
+        self.assertEqual(
+            "Test Person cannot create snap packages owned by Other Person.",
+            response.body)
+        response = self.webservice.named_post(
+            "/+snaps", "new", owner=other_team_url,
+            distro_series=distroseries_url, name="dummy", branch=branch_url)
+        self.assertEqual(401, response.status)
+        self.assertEqual(
+            "Test Person is not a member of Other Team.", response.body)
+
+    def test_getByName(self):
+        # lp.snaps.getByName returns a matching Snap.
+        snap = self.makeSnap()
+        with person_logged_in(self.person):
+            owner_url = api_url(self.person)
+        response = self.webservice.named_get(
+            "/+snaps", "getByName", owner=owner_url, name="mir")
+        self.assertEqual(200, response.status)
+        self.assertEqual(snap, response.jsonBody())
+
+    def test_getByName_missing(self):
+        # lp.snaps.getByName returns 404 for a non-existent Snap.
+        logout()
+        with person_logged_in(self.person):
+            owner_url = api_url(self.person)
+        response = self.webservice.named_get(
+            "/+snaps", "getByName", owner=owner_url, name="nonexistent")
+        self.assertEqual(404, response.status)
+        self.assertEqual(
+            "No such snap package with this owner: 'nonexistent'.",
+            response.body)
+
+    def test_requestBuild(self):
+        # Build requests can be performed and end up in snap.builds and
+        # snap.pending_builds.
+        distroseries = self.factory.makeDistroSeries(registrant=self.person)
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, owner=self.person)
+        distroarchseries_url = api_url(distroarchseries)
+        archive_url = api_url(distroseries.main_archive)
+        snap = self.makeSnap(distroseries=distroseries)
+        response = self.webservice.named_post(
+            snap["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=distroarchseries_url, pocket="Release")
+        self.assertEqual(201, response.status)
+        build = self.webservice.get(response.getHeader("Location")).jsonBody()
+        self.assertEqual(
+            [build["self_link"]], self.getCollectionLinks(snap, "builds"))
+        self.assertEqual([], self.getCollectionLinks(snap, "completed_builds"))
+        self.assertEqual(
+            [build["self_link"]],
+            self.getCollectionLinks(snap, "pending_builds"))
+
+    def test_requestBuild_rejects_repeats(self):
+        # Build requests are rejected if already pending.
+        distroseries = self.factory.makeDistroSeries(registrant=self.person)
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, owner=self.person)
+        distroarchseries_url = api_url(distroarchseries)
+        archive_url = api_url(distroseries.main_archive)
+        snap = self.makeSnap(distroseries=distroseries)
+        response = self.webservice.named_post(
+            snap["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=distroarchseries_url, pocket="Release")
+        self.assertEqual(201, response.status)
+        response = self.webservice.named_post(
+            snap["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=distroarchseries_url, pocket="Release")
+        self.assertEqual(400, response.status)
+        self.assertEqual(
+            "An identical build of this snap package is already pending.",
+            response.body)
+
+    def test_requestBuild_not_owner(self):
+        # If the requester is not the owner or a member of the owner team,
+        # build requests are rejected.
+        other_team = self.factory.makeTeam(displayname="Other Team")
+        distroseries = self.factory.makeDistroSeries(registrant=self.person)
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, owner=self.person)
+        distroarchseries_url = api_url(distroarchseries)
+        archive_url = api_url(distroseries.main_archive)
+        other_webservice = webservice_for_person(
+            other_team.teamowner, permission=OAuthPermission.WRITE_PUBLIC)
+        other_webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+        snap = self.makeSnap(
+            owner=other_team, distroseries=distroseries,
+            webservice=other_webservice)
+        response = self.webservice.named_post(
+            snap["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=distroarchseries_url, pocket="Release")
+        self.assertEqual(401, response.status)
+        self.assertEqual(
+            "Test Person cannot create snap package builds owned by Other "
+            "Team.", response.body)
+
+    def test_requestBuild_archive_disabled(self):
+        # Build requests against a disabled archive are rejected.
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(IDistributionSet)['ubuntu'],
+            registrant=self.person)
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, owner=self.person)
+        distroarchseries_url = api_url(distroarchseries)
+        archive = self.factory.makeArchive(
+            distribution=distroseries.distribution, owner=self.person,
+            enabled=False, displayname="Disabled Archive")
+        archive_url = api_url(archive)
+        snap = self.makeSnap(distroseries=distroseries)
+        response = self.webservice.named_post(
+            snap["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=distroarchseries_url, pocket="Release")
+        self.assertEqual(403, response.status)
+        self.assertEqual("Disabled Archive is disabled.", response.body)
+
+    def test_requestBuild_archive_private_owners_match(self):
+        # Build requests against a private archive are allowed if the Snap
+        # and Archive owners match exactly.
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(IDistributionSet)['ubuntu'],
+            registrant=self.person)
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, owner=self.person)
+        distroarchseries_url = api_url(distroarchseries)
+        archive = self.factory.makeArchive(
+            distribution=distroseries.distribution, owner=self.person,
+            private=True)
+        archive_url = api_url(archive)
+        snap = self.makeSnap(distroseries=distroseries)
+        response = self.webservice.named_post(
+            snap["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=distroarchseries_url, pocket="Release")
+        self.assertEqual(201, response.status)
+
+    def test_requestBuild_archive_private_owners_mismatch(self):
+        # Build requests against a private archive are rejected if the Snap
+        # and Archive owners do not match exactly.
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(IDistributionSet)['ubuntu'],
+            registrant=self.person)
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, owner=self.person)
+        distroarchseries_url = api_url(distroarchseries)
+        archive = self.factory.makeArchive(
+            distribution=distroseries.distribution, private=True)
+        archive_url = api_url(archive)
+        snap = self.makeSnap(distroseries=distroseries)
+        response = self.webservice.named_post(
+            snap["self_link"], "requestBuild", archive=archive_url,
+            distro_arch_series=distroarchseries_url, pocket="Release")
+        self.assertEqual(403, response.status)
+        self.assertEqual(
+            "Snap package builds against private archives are only allowed "
+            "if the snap package owner and the archive owner are equal.",
+            response.body)
+
+    def test_getBuilds(self):
+        # The builds, completed_builds, and pending_builds properties are as
+        # expected.
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(IDistributionSet)['ubuntu'],
+            registrant=self.person)
+        distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, owner=self.person)
+        distroarchseries_url = api_url(distroarchseries)
+        archives = [
+            self.factory.makeArchive(
+                distribution=distroseries.distribution, owner=self.person)
+            for x in range(4)]
+        archive_urls = [api_url(archive) for archive in archives]
+        snap = self.makeSnap(distroseries=distroseries)
+        builds = []
+        for archive_url in archive_urls:
+            response = self.webservice.named_post(
+                snap["self_link"], "requestBuild", archive=archive_url,
+                distro_arch_series=distroarchseries_url, pocket="Proposed")
+            self.assertEqual(201, response.status)
+            build = self.webservice.get(
+                response.getHeader("Location")).jsonBody()
+            builds.insert(0, build["self_link"])
+        self.assertEqual(builds, self.getCollectionLinks(snap, "builds"))
+        self.assertEqual([], self.getCollectionLinks(snap, "completed_builds"))
+        self.assertEqual(
+            builds, self.getCollectionLinks(snap, "pending_builds"))
+        snap = self.webservice.get(snap["self_link"]).jsonBody()
+
+        with person_logged_in(self.person):
+            db_snap = getUtility(ISnapSet).getByName(self.person, snap["name"])
+            db_builds = list(db_snap.builds)
+            db_builds[0].updateStatus(
+                BuildStatus.BUILDING, date_started=db_snap.date_created)
+            db_builds[0].updateStatus(
+                BuildStatus.FULLYBUILT,
+                date_finished=db_snap.date_created + timedelta(minutes=10))
+        snap = self.webservice.get(snap["self_link"]).jsonBody()
+        # Builds that have not yet been started are listed last.  This does
+        # mean that pending builds that have never been started are sorted
+        # to the end, but means that builds that were cancelled before
+        # starting don't pollute the start of the collection forever.
+        self.assertEqual(builds, self.getCollectionLinks(snap, "builds"))
+        self.assertEqual(
+            builds[:1], self.getCollectionLinks(snap, "completed_builds"))
+        self.assertEqual(
+            builds[1:], self.getCollectionLinks(snap, "pending_builds"))
+
+        with person_logged_in(self.person):
+            db_builds[1].updateStatus(
+                BuildStatus.BUILDING, date_started=db_snap.date_created)
+            db_builds[1].updateStatus(
+                BuildStatus.FULLYBUILT,
+                date_finished=db_snap.date_created + timedelta(minutes=20))
+        snap = self.webservice.get(snap["self_link"]).jsonBody()
+        self.assertEqual(
+            [builds[1], builds[0], builds[2], builds[3]],
+            self.getCollectionLinks(snap, "builds"))
+        self.assertEqual(
+            [builds[1], builds[0]],
+            self.getCollectionLinks(snap, "completed_builds"))
+        self.assertEqual(
+            builds[2:], self.getCollectionLinks(snap, "pending_builds"))
+
+    def test_query_count(self):
+        # Snap has a reasonable query count.
+        snap = self.factory.makeSnap(registrant=self.person, owner=self.person)
+        url = api_url(snap)
+        logout()
+        store = Store.of(snap)
+        store.flush()
+        store.invalidate()
+        with StormStatementRecorder() as recorder:
+            self.webservice.get(url)
+        self.assertThat(recorder, HasQueryCount(Equals(15)))

=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
--- lib/lp/snappy/tests/test_snapbuild.py	2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/tests/test_snapbuild.py	2015-07-23 16:45:08 +0000
@@ -5,17 +5,32 @@
 
 __metaclass__ = type
 
-from datetime import timedelta
+from datetime import (
+    datetime,
+    timedelta,
+    )
+from urllib2 import (
+    HTTPError,
+    urlopen,
+    )
 
+import pytz
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import Browser
+from zope.testbrowser.testing import PublisherMechanizeBrowser
 
 from lp.app.errors import NotFoundError
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.registry.enums import PersonVisibility
+from lp.services.config import config
 from lp.services.features.testing import FeatureFixture
+from lp.services.librarian.browser import ProxiedLibraryFileAlias
+from lp.services.webapp.interfaces import OAuthPermission
 from lp.snappy.interfaces.snap import (
     SNAP_FEATURE_FLAG,
     SnapFeatureDisabled,
@@ -26,10 +41,19 @@
     )
 from lp.soyuz.enums import ArchivePurpose
 from lp.testing import (
+    ANONYMOUS,
+    api_url,
+    login,
+    logout,
     person_logged_in,
     TestCaseWithFactory,
     )
-from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.layers import (
+    LaunchpadFunctionalLayer,
+    LaunchpadZopelessLayer,
+    )
+from lp.testing.mail_helpers import pop_notifications
+from lp.testing.pages import webservice_for_person
 
 
 class TestSnapBuildFeatureFlag(TestCaseWithFactory):
@@ -224,3 +248,179 @@
     def test_getByBuildFarmJobs_works_empty(self):
         self.assertContentEqual(
             [], getUtility(ISnapBuildSet).getByBuildFarmJobs([]))
+
+
+class NonRedirectingMechanizeBrowser(PublisherMechanizeBrowser):
+    """A `mechanize.Browser` that does not handle redirects."""
+
+    default_features = [
+        feature for feature in PublisherMechanizeBrowser.default_features
+        if feature != "_redirect"]
+
+
+class TestSnapBuildWebservice(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapBuildWebservice, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+        self.person = self.factory.makePerson()
+        self.webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PRIVATE)
+        self.webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+
+    def getURL(self, obj):
+        return self.webservice.getAbsoluteUrl(api_url(obj))
+
+    def test_properties(self):
+        # The basic properties of a SnapBuild are sensible.
+        db_build = self.factory.makeSnapBuild(
+            requester=self.person,
+            date_created=datetime(2014, 04, 25, 10, 38, 0, tzinfo=pytz.UTC))
+        build_url = api_url(db_build)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        with person_logged_in(self.person):
+            self.assertEqual(self.getURL(self.person), build["requester_link"])
+            self.assertEqual(self.getURL(db_build.snap), build["snap_link"])
+            self.assertEqual(
+                self.getURL(db_build.archive), build["archive_link"])
+            self.assertEqual(
+                self.getURL(db_build.distro_arch_series),
+                build["distro_arch_series_link"])
+            self.assertEqual("Release", build["pocket"])
+            self.assertIsNone(build["score"])
+            self.assertFalse(build["can_be_rescored"])
+            self.assertFalse(build["can_be_cancelled"])
+
+    def test_public(self):
+        # A SnapBuild with a public Snap and archive is itself public.
+        db_build = self.factory.makeSnapBuild()
+        build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        self.assertEqual(200, self.webservice.get(build_url).status)
+        self.assertEqual(200, unpriv_webservice.get(build_url).status)
+
+    def test_private_snap(self):
+        # A SnapBuild with a private Snap is private.
+        db_team = self.factory.makeTeam(
+            owner=self.person, visibility=PersonVisibility.PRIVATE)
+        with person_logged_in(self.person):
+            db_build = self.factory.makeSnapBuild(
+                requester=self.person, owner=db_team)
+            build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        self.assertEqual(200, self.webservice.get(build_url).status)
+        # 404 since we aren't allowed to know that the private team exists.
+        self.assertEqual(404, unpriv_webservice.get(build_url).status)
+
+    def test_private_archive(self):
+        # A SnapBuild with a private archive is private.
+        db_archive = self.factory.makeArchive(owner=self.person, private=True)
+        with person_logged_in(self.person):
+            db_build = self.factory.makeSnapBuild(archive=db_archive)
+            build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        self.assertEqual(200, self.webservice.get(build_url).status)
+        self.assertEqual(401, unpriv_webservice.get(build_url).status)
+
+    def test_cancel(self):
+        # The owner of a build can cancel it.
+        db_build = self.factory.makeSnapBuild(requester=self.person)
+        db_build.queueBuild()
+        build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertTrue(build["can_be_cancelled"])
+        response = unpriv_webservice.named_post(build["self_link"], "cancel")
+        self.assertEqual(401, response.status)
+        response = self.webservice.named_post(build["self_link"], "cancel")
+        self.assertEqual(200, response.status)
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertFalse(build["can_be_cancelled"])
+        with person_logged_in(self.person):
+            self.assertEqual(BuildStatus.CANCELLED, db_build.status)
+
+    def test_rescore(self):
+        # Buildd administrators can rescore builds.
+        db_build = self.factory.makeSnapBuild(requester=self.person)
+        db_build.queueBuild()
+        build_url = api_url(db_build)
+        buildd_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+        buildd_admin_webservice = webservice_for_person(
+            buildd_admin, permission=OAuthPermission.WRITE_PUBLIC)
+        buildd_admin_webservice.default_api_version = "devel"
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertEqual(2505, build["score"])
+        self.assertTrue(build["can_be_rescored"])
+        response = self.webservice.named_post(
+            build["self_link"], "rescore", score=5000)
+        self.assertEqual(401, response.status)
+        response = buildd_admin_webservice.named_post(
+            build["self_link"], "rescore", score=5000)
+        self.assertEqual(200, response.status)
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertEqual(5000, build["score"])
+
+    def makeNonRedirectingBrowser(self, person):
+        # The test browser can only work with the appserver, not the
+        # librarian, so follow one layer of redirection through the
+        # appserver and then ask the librarian for the real file.
+        browser = Browser(mech_browser=NonRedirectingMechanizeBrowser())
+        browser.handleErrors = False
+        with person_logged_in(person):
+            browser.addHeader(
+                "Authorization", "Basic %s:test" % person.preferredemail.email)
+        return browser
+
+    def assertCanOpenRedirectedUrl(self, browser, url):
+        redirection = self.assertRaises(HTTPError, browser.open, url)
+        self.assertEqual(303, redirection.code)
+        urlopen(redirection.hdrs["Location"]).close()
+
+    def test_logs(self):
+        # API clients can fetch the build and upload logs.
+        db_build = self.factory.makeSnapBuild(requester=self.person)
+        db_build.setLog(self.factory.makeLibraryFileAlias("buildlog.txt.gz"))
+        db_build.storeUploadLog("uploaded")
+        build_url = api_url(db_build)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        browser = self.makeNonRedirectingBrowser(self.person)
+        self.assertIsNotNone(build["build_log_url"])
+        self.assertCanOpenRedirectedUrl(browser, build["build_log_url"])
+        self.assertIsNotNone(build["upload_log_url"])
+        self.assertCanOpenRedirectedUrl(browser, build["upload_log_url"])
+
+    def test_getFileUrls(self):
+        # API clients can fetch files attached to builds.
+        db_build = self.factory.makeSnapBuild(requester=self.person)
+        db_files = [
+            self.factory.makeSnapFile(snapbuild=db_build) for i in range(2)]
+        build_url = api_url(db_build)
+        file_urls = [
+            ProxiedLibraryFileAlias(file.libraryfile, db_build).http_url
+            for file in db_files]
+        logout()
+        response = self.webservice.named_get(build_url, "getFileUrls")
+        self.assertEqual(200, response.status)
+        self.assertContentEqual(file_urls, response.jsonBody())
+        browser = self.makeNonRedirectingBrowser(self.person)
+        for file_url in file_urls:
+            self.assertCanOpenRedirectedUrl(browser, file_url)


Follow ups