Merge ~pappacena/launchpad:snap-pillar-subscribe into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: f5f27aab0499911221fd8cee9311f783ef1b9c97
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:snap-pillar-subscribe
Merge into: launchpad:master
Prerequisite: ~pappacena/launchpad:snap-pillar-reconcile-access
Diff against target: 666 lines (+324/-24)
9 files modified
lib/lp/registry/personmerge.py (+21/-4)
lib/lp/registry/tests/test_personmerge.py (+37/-2)
lib/lp/snappy/browser/tests/test_snap.py (+5/-0)
lib/lp/snappy/interfaces/snapsubscription.py (+42/-0)
lib/lp/snappy/model/snap.py (+62/-7)
lib/lp/snappy/model/snapsubscription.py (+62/-0)
lib/lp/snappy/tests/test_snap.py (+79/-7)
lib/lp/snappy/tests/test_snapbuild.py (+7/-2)
lib/lp/testing/factory.py (+9/-2)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+397755@code.launchpad.net

Commit message

Adding SnapSubscription model

To post a comment you must log in.
f3387eb... by Thiago F. Pappacena

Merge branch 'snap-pillar-reconcile-access' into snap-pillar-subscribe

7f125c7... by Thiago F. Pappacena

Merge branch 'snap-pillar-reconcile-access' into snap-pillar-subscribe

984531e... by Thiago F. Pappacena

Fixing personmerge test

4dacf01... by Thiago F. Pappacena

Removing SnapSubscriptions when deleting a Snap

6612dca... by Thiago F. Pappacena

Test fix: private snaps cannot be associated with non-moderated teams

a273e7a... by Thiago F. Pappacena

Fixing Snap.unsubscribe and improving performance on Snap.visibleByUser

858eaf1... by Thiago F. Pappacena

Merge branch 'snap-pillar-reconcile-access' into snap-pillar-subscribe

92bebbd... by Thiago F. Pappacena

Merge branch 'master' into snap-pillar-subscribe

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
f5f27aa... by Thiago F. Pappacena

Allowing snap owner to unsubscribe anyone and few minor adjustments

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Pushed the requested changes.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/registry/personmerge.py b/lib/lp/registry/personmerge.py
2index 949e8d9..3e51005 100644
3--- a/lib/lp/registry/personmerge.py
4+++ b/lib/lp/registry/personmerge.py
5@@ -660,18 +660,36 @@ def _mergeSnap(cur, from_person, to_person):
6 existing_names = [
7 s.name for s in getUtility(ISnapSet).findByOwner(to_person)]
8 for snap in snaps:
9- new_name = snap.name
10+ naked_snap = removeSecurityProxy(snap)
11+ new_name = naked_snap.name
12 count = 1
13 while new_name in existing_names:
14 new_name = '%s-%s' % (snap.name, count)
15 count += 1
16- naked_snap = removeSecurityProxy(snap)
17 naked_snap.owner = to_person
18 naked_snap.name = new_name
19 if not snaps.is_empty():
20 IStore(snaps[0]).flush()
21
22
23+def _mergeSnapSubscription(cur, from_id, to_id):
24+ # Update only the SnapSubscription that will not conflict.
25+ cur.execute('''
26+ UPDATE SnapSubscription
27+ SET person=%(to_id)d
28+ WHERE person=%(from_id)d AND snap NOT IN
29+ (
30+ SELECT snap
31+ FROM SnapSubscription
32+ WHERE person = %(to_id)d
33+ )
34+ ''' % vars())
35+ # and delete those left over.
36+ cur.execute('''
37+ DELETE FROM SnapSubscription WHERE person=%(from_id)d
38+ ''' % vars())
39+
40+
41 def _mergeOCIRecipe(cur, from_person, to_person):
42 # This shouldn't use removeSecurityProxy
43 oci_recipes = getUtility(IOCIRecipeSet).findByOwner(from_person)
44@@ -917,8 +935,7 @@ def merge_people(from_person, to_person, reviewer, delete=False):
45 _mergeSnap(cur, from_person, to_person)
46 skip.append(('snap', 'owner'))
47
48- # XXX pappacena 2021-02-18: add tests for this once we have
49- # SnapSubscription model in place.
50+ _mergeSnapSubscription(cur, from_id, to_id)
51 skip.append(('snapsubscription', 'person'))
52
53 _mergeOCIRecipe(cur, from_person, to_person)
54diff --git a/lib/lp/registry/tests/test_personmerge.py b/lib/lp/registry/tests/test_personmerge.py
55index 02fefcc..e69872f 100644
56--- a/lib/lp/registry/tests/test_personmerge.py
57+++ b/lib/lp/registry/tests/test_personmerge.py
58@@ -1,4 +1,4 @@
59-# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
60+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
61 # GNU Affero General Public License version 3 (see the file LICENSE).
62
63 """Tests for merge_people."""
64@@ -8,6 +8,7 @@ from operator import attrgetter
65
66 import pytz
67 from testtools.matchers import (
68+ Equals,
69 MatchesSetwise,
70 MatchesStructure,
71 )
72@@ -48,13 +49,17 @@ from lp.services.identity.interfaces.emailaddress import (
73 EmailAddressStatus,
74 IEmailAddressSet,
75 )
76-from lp.snappy.interfaces.snap import ISnapSet
77+from lp.snappy.interfaces.snap import (
78+ ISnapSet,
79+ SNAP_TESTING_FLAGS,
80+ )
81 from lp.soyuz.enums import ArchiveStatus
82 from lp.soyuz.interfaces.livefs import (
83 ILiveFSSet,
84 LIVEFS_FEATURE_FLAG,
85 )
86 from lp.testing import (
87+ admin_logged_in,
88 celebrity_logged_in,
89 login_person,
90 person_logged_in,
91@@ -664,6 +669,36 @@ class TestMergePeople(TestCaseWithFactory, KarmaTestMixin):
92 self.assertIsNone(snaps[1].git_path)
93 self.assertEqual(u'foo-1', snaps[1].name)
94
95+ def test_merge_snapsubscription(self):
96+ # Checks that merging users moves subscriptions.
97+ self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
98+ duplicate = self.factory.makePerson()
99+ mergee = self.factory.makePerson()
100+ snap = removeSecurityProxy(self.factory.makeSnap(
101+ owner=duplicate, registrant=duplicate,
102+ name=u'foo', private=True))
103+ with admin_logged_in():
104+ # Owner should have being subscribed automatically on creation.
105+ self.assertTrue(snap.visibleByUser(duplicate))
106+ self.assertThat(snap._getSubscription(duplicate), MatchesStructure(
107+ snap=Equals(snap),
108+ person=Equals(duplicate)
109+ ))
110+ self.assertFalse(snap.visibleByUser(mergee))
111+ self.assertIsNone(snap._getSubscription(mergee))
112+
113+ self._do_premerge(duplicate, mergee)
114+ login_person(mergee)
115+ duplicate, mergee = self._do_merge(duplicate, mergee)
116+
117+ self.assertTrue(snap.visibleByUser(mergee))
118+ self.assertThat(snap._getSubscription(mergee), MatchesStructure(
119+ snap=Equals(snap),
120+ person=Equals(mergee)
121+ ))
122+ self.assertFalse(snap.visibleByUser(duplicate))
123+ self.assertIsNone(snap._getSubscription(duplicate))
124+
125 def test_merge_moves_oci_recipes(self):
126 # When person/teams are merged, oci recipes owned by the from
127 # person are moved.
128diff --git a/lib/lp/snappy/browser/tests/test_snap.py b/lib/lp/snappy/browser/tests/test_snap.py
129index 50390e9..025574c 100644
130--- a/lib/lp/snappy/browser/tests/test_snap.py
131+++ b/lib/lp/snappy/browser/tests/test_snap.py
132@@ -59,6 +59,7 @@ from lp.code.tests.helpers import (
133 from lp.registry.enums import (
134 BranchSharingPolicy,
135 PersonVisibility,
136+ TeamMembershipPolicy,
137 )
138 from lp.registry.interfaces.pocket import PackagePublishingPocket
139 from lp.registry.interfaces.series import SeriesStatus
140@@ -388,6 +389,7 @@ class TestSnapAddView(BaseTestSnapView):
141 login_person(self.person)
142 self.factory.makeTeam(
143 name='super-private', owner=self.person,
144+ membership_policy=TeamMembershipPolicy.MODERATED,
145 visibility=PersonVisibility.PRIVATE)
146 branch = self.factory.makeAnyBranch()
147
148@@ -411,6 +413,7 @@ class TestSnapAddView(BaseTestSnapView):
149 login_person(self.person)
150 private_team = self.factory.makeTeam(
151 name='super-private', owner=self.person,
152+ membership_policy=TeamMembershipPolicy.MODERATED,
153 visibility=PersonVisibility.PRIVATE)
154 branch = self.factory.makeAnyBranch(
155 owner=self.person, registrant=self.person,
156@@ -440,6 +443,7 @@ class TestSnapAddView(BaseTestSnapView):
157 login_person(self.person)
158 private_team = self.factory.makeTeam(
159 name='super-private', owner=self.person,
160+ membership_policy=TeamMembershipPolicy.MODERATED,
161 visibility=PersonVisibility.PRIVATE)
162 [git_ref] = self.factory.makeGitRefs(
163 owner=self.person, registrant=self.person,
164@@ -762,6 +766,7 @@ class TestSnapAdminView(BaseTestSnapView):
165 # Cannot make snap public if it still contains private information.
166 login_person(self.person)
167 team = self.factory.makeTeam(
168+ membership_policy=TeamMembershipPolicy.MODERATED,
169 owner=self.person, visibility=PersonVisibility.PRIVATE)
170 project = self.factory.makeProduct(
171 information_type=InformationType.PUBLIC,
172diff --git a/lib/lp/snappy/interfaces/snapsubscription.py b/lib/lp/snappy/interfaces/snapsubscription.py
173new file mode 100644
174index 0000000..58206cc
175--- /dev/null
176+++ b/lib/lp/snappy/interfaces/snapsubscription.py
177@@ -0,0 +1,42 @@
178+# Copyright 2020-2021 Canonical Ltd. This software is licensed under the
179+# GNU Affero General Public License version 3 (see the file LICENSE).
180+
181+"""Snap subscription model."""
182+
183+from __future__ import absolute_import, print_function, unicode_literals
184+
185+__metaclass__ = type
186+__all__ = [
187+ 'ISnapSubscription'
188+]
189+
190+from lazr.restful.fields import Reference
191+from zope.interface import Interface
192+from zope.schema import (
193+ Datetime,
194+ Int,
195+ )
196+
197+from lp import _
198+from lp.services.fields import PersonChoice
199+from lp.snappy.interfaces.snap import ISnap
200+
201+
202+class ISnapSubscription(Interface):
203+ """A person subscription to a specific Snap recipe."""
204+
205+ id = Int(title=_('ID'), readonly=True, required=True)
206+ person = PersonChoice(
207+ title=_('Person'), required=True, vocabulary='ValidPersonOrTeam',
208+ readonly=True,
209+ description=_("The person subscribed to the related snap recipe."))
210+ snap = Reference(ISnap, title=_("Snap"), required=True, readonly=True)
211+ subscribed_by = PersonChoice(
212+ title=_('Subscribed by'), required=True,
213+ vocabulary='ValidPersonOrTeam', readonly=True,
214+ description=_("The person who created this subscription."))
215+ date_created = Datetime(
216+ title=_('Date subscribed'), required=True, readonly=True)
217+
218+ def canBeUnsubscribedByUser(user):
219+ """Can the user unsubscribe the subscriber from the snap recipe?"""
220diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
221index 6b9af0a..dabc6fe 100644
222--- a/lib/lp/snappy/model/snap.py
223+++ b/lib/lp/snappy/model/snap.py
224@@ -66,7 +66,11 @@ from lp.app.enums import (
225 PRIVATE_INFORMATION_TYPES,
226 PUBLIC_INFORMATION_TYPES,
227 )
228-from lp.app.errors import IncompatibleArguments
229+from lp.app.errors import (
230+ IncompatibleArguments,
231+ SubscriptionPrivacyViolation,
232+ UserCannotUnsubscribePerson,
233+ )
234 from lp.app.interfaces.security import IAuthorization
235 from lp.app.interfaces.services import IService
236 from lp.buildmaster.enums import BuildStatus
237@@ -108,7 +112,10 @@ from lp.code.model.branchnamespace import (
238 from lp.code.model.gitcollection import GenericGitCollection
239 from lp.code.model.gitrepository import GitRepository
240 from lp.registry.errors import PrivatePersonLinkageError
241-from lp.registry.interfaces.accesspolicy import IAccessArtifactSource
242+from lp.registry.interfaces.accesspolicy import (
243+ IAccessArtifactGrantSource,
244+ IAccessArtifactSource,
245+ )
246 from lp.registry.interfaces.person import (
247 IPerson,
248 IPersonSet,
249@@ -203,6 +210,7 @@ from lp.snappy.interfaces.snappyseries import ISnappyDistroSeriesSet
250 from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
251 from lp.snappy.model.snapbuild import SnapBuild
252 from lp.snappy.model.snapjob import SnapJob
253+from lp.snappy.model.snapsubscription import SnapSubscription
254 from lp.soyuz.interfaces.archive import ArchiveDisabled
255 from lp.soyuz.model.archive import (
256 Archive,
257@@ -1129,25 +1137,66 @@ class Snap(Storm, WebhookTargetMixin):
258
259 def visibleByUser(self, user):
260 """See `ISnap`."""
261+ if self.information_type in PUBLIC_INFORMATION_TYPES:
262+ return True
263+ if user is None:
264+ return False
265 store = IStore(self)
266 return not store.find(
267 Snap,
268 Snap.id == self.id,
269 get_snap_privacy_filter(user)).is_empty()
270
271+ def _getSubscription(self, person):
272+ """Returns person's subscription to this snap recipe, or None if no
273+ subscription is available.
274+ """
275+ if person is None:
276+ return None
277+ return Store.of(self).find(
278+ SnapSubscription,
279+ SnapSubscription.person == person,
280+ SnapSubscription.snap == self).one()
281+
282+ def _userCanBeSubscribed(self, person):
283+ """Checks if the given person can subscribe to this snap recipe."""
284+ return not (
285+ self.private and
286+ person.is_team and
287+ person.anyone_can_join())
288+
289 def subscribe(self, person, subscribed_by, ignore_permissions=False):
290 """See `ISnap`."""
291- # XXX pappacena 2021-02-05: We may need a "SnapSubscription" here.
292+ if not self._userCanBeSubscribed(person):
293+ raise SubscriptionPrivacyViolation(
294+ "Open and delegated teams cannot be subscribed to private "
295+ "snap recipes.")
296+ subscription = self._getSubscription(person)
297+ if subscription is None:
298+ subscription = SnapSubscription(
299+ person=person, snap=self, subscribed_by=subscribed_by)
300+ Store.of(subscription).flush()
301 service = getUtility(IService, "sharing")
302 service.ensureAccessGrants(
303 [person], subscribed_by, snaps=[self],
304 ignore_permissions=ignore_permissions)
305
306- def unsubscribe(self, person, unsubscribed_by):
307+ def unsubscribe(self, person, unsubscribed_by, ignore_permissions=False):
308 """See `ISnap`."""
309- service = getUtility(IService, "sharing")
310- service.revokeAccessGrants(
311- self.pillar, person, unsubscribed_by, snaps=[self])
312+ subscription = self._getSubscription(person)
313+ if subscription is None:
314+ return
315+ if (not ignore_permissions
316+ and not subscription.canBeUnsubscribedByUser(unsubscribed_by)):
317+ raise UserCannotUnsubscribePerson(
318+ '%s does not have permission to unsubscribe %s.' % (
319+ unsubscribed_by.displayname,
320+ person.displayname))
321+ artifact = getUtility(IAccessArtifactSource).find([self])
322+ getUtility(IAccessArtifactGrantSource).revokeByArtifact(
323+ artifact, [person])
324+ store = Store.of(subscription)
325+ store.remove(subscription)
326 IStore(self).flush()
327
328 def _reconcileAccess(self):
329@@ -1169,6 +1218,11 @@ class Snap(Storm, WebhookTargetMixin):
330 """Delete access grants for this snap recipe prior to deleting it."""
331 getUtility(IAccessArtifactSource).delete([self])
332
333+ def _deleteSnapSubscriptions(self):
334+ subscriptions = Store.of(self).find(
335+ SnapSubscription, SnapSubscription.snap == self)
336+ subscriptions.remove()
337+
338 def destroySelf(self):
339 """See `ISnap`."""
340 store = IStore(Snap)
341@@ -1206,6 +1260,7 @@ class Snap(Storm, WebhookTargetMixin):
342 store.find(Job, Job.id.is_in(affected_jobs)).remove()
343 getUtility(IWebhookSet).delete(self.webhooks)
344 self._deleteAccessGrants()
345+ self._deleteSnapSubscriptions()
346 store.remove(self)
347 store.find(
348 BuildFarmJob, BuildFarmJob.id.is_in(build_farm_job_ids)).remove()
349diff --git a/lib/lp/snappy/model/snapsubscription.py b/lib/lp/snappy/model/snapsubscription.py
350new file mode 100644
351index 0000000..89860d7
352--- /dev/null
353+++ b/lib/lp/snappy/model/snapsubscription.py
354@@ -0,0 +1,62 @@
355+# Copyright 2020-2021 Canonical Ltd. This software is licensed under the
356+# GNU Affero General Public License version 3 (see the file LICENSE).
357+
358+"""Snap subscription model."""
359+
360+from __future__ import absolute_import, print_function, unicode_literals
361+
362+__metaclass__ = type
363+__all__ = [
364+ 'SnapSubscription'
365+]
366+
367+import pytz
368+from storm.properties import (
369+ DateTime,
370+ Int,
371+ )
372+from storm.references import Reference
373+from zope.interface import implementer
374+
375+from lp.registry.interfaces.person import validate_person
376+from lp.registry.interfaces.role import IPersonRoles
377+from lp.services.database.constants import UTC_NOW
378+from lp.services.database.stormbase import StormBase
379+from lp.snappy.interfaces.snapsubscription import ISnapSubscription
380+
381+
382+@implementer(ISnapSubscription)
383+class SnapSubscription(StormBase):
384+ """A relationship between a person and a snap recipe."""
385+
386+ __storm_table__ = 'SnapSubscription'
387+
388+ id = Int(primary=True)
389+
390+ person_id = Int(
391+ "person", allow_none=False, validator=validate_person)
392+ person = Reference(person_id, "Person.id")
393+
394+ snap_id = Int("snap", allow_none=False)
395+ snap = Reference(snap_id, "Snap.id")
396+
397+ date_created = DateTime(allow_none=False, default=UTC_NOW, tzinfo=pytz.UTC)
398+
399+ subscribed_by_id = Int(
400+ "subscribed_by", allow_none=False, validator=validate_person)
401+ subscribed_by = Reference(subscribed_by_id, "Person.id")
402+
403+ def __init__(self, snap, person, subscribed_by):
404+ super(SnapSubscription, self).__init__()
405+ self.snap = snap
406+ self.person = person
407+ self.subscribed_by = subscribed_by
408+
409+ def canBeUnsubscribedByUser(self, user):
410+ """See `ISnapSubscription`."""
411+ if user is None:
412+ return False
413+ return (user.inTeam(self.snap.owner) or
414+ user.inTeam(self.person) or
415+ user.inTeam(self.subscribed_by) or
416+ IPersonRoles(user).in_admin)
417diff --git a/lib/lp/snappy/tests/test_snap.py b/lib/lp/snappy/tests/test_snap.py
418index e8ea61e..f66949b 100644
419--- a/lib/lp/snappy/tests/test_snap.py
420+++ b/lib/lp/snappy/tests/test_snap.py
421@@ -34,6 +34,7 @@ from testtools.matchers import (
422 Equals,
423 GreaterThan,
424 Is,
425+ IsInstance,
426 LessThan,
427 MatchesAll,
428 MatchesDict,
429@@ -46,6 +47,7 @@ from zope.security.interfaces import Unauthorized
430 from zope.security.proxy import removeSecurityProxy
431
432 from lp.app.enums import InformationType
433+from lp.app.errors import SubscriptionPrivacyViolation
434 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
435 from lp.buildmaster.enums import (
436 BuildQueueStatus,
437@@ -68,6 +70,7 @@ from lp.code.tests.helpers import (
438 from lp.registry.enums import (
439 BranchSharingPolicy,
440 PersonVisibility,
441+ TeamMembershipPolicy,
442 )
443 from lp.registry.interfaces.accesspolicy import IAccessPolicySource
444 from lp.registry.interfaces.distribution import IDistributionSet
445@@ -1353,6 +1356,9 @@ class TestSnapVisibility(TestCaseWithFactory):
446 AccessArtifactGrant.abstract_artifact_id == AccessArtifact.id,
447 *conditions)
448
449+ def getSnapSubscription(self, snap, person):
450+ return removeSecurityProxy(snap)._getSubscription(person)
451+
452 def test_only_owner_can_grant_access(self):
453 owner = self.factory.makePerson()
454 snap = self.factory.makeSnap(
455@@ -1373,7 +1379,8 @@ class TestSnapVisibility(TestCaseWithFactory):
456
457 def test_private_is_visible_by_team_member(self):
458 person = self.factory.makePerson()
459- team = self.factory.makeTeam(members=[person])
460+ team = self.factory.makeTeam(
461+ members=[person], membership_policy=TeamMembershipPolicy.MODERATED)
462 snap = self.factory.makeSnap(private=True, owner=team,
463 registrant=person)
464 with person_logged_in(team):
465@@ -1388,11 +1395,33 @@ class TestSnapVisibility(TestCaseWithFactory):
466 with person_logged_in(owner):
467 self.assertFalse(snap.visibleByUser(person))
468 snap.subscribe(person, snap.owner)
469+ self.assertThat(
470+ self.getSnapSubscription(snap, person),
471+ MatchesStructure(
472+ person=Equals(person),
473+ snap=Equals(snap),
474+ subscribed_by=Equals(snap.owner),
475+ date_created=IsInstance(datetime)))
476 # Calling again should be a no-op.
477 snap.subscribe(person, snap.owner)
478 self.assertTrue(snap.visibleByUser(person))
479+
480 snap.unsubscribe(person, snap.owner)
481 self.assertFalse(snap.visibleByUser(person))
482+ self.assertIsNone(self.getSnapSubscription(snap, person))
483+
484+ def test_snap_owner_can_unsubscribe_anyone(self):
485+ person = self.factory.makePerson()
486+ owner = self.factory.makePerson()
487+ admin = self.factory.makeAdministrator()
488+ snap = self.factory.makeSnap(
489+ registrant=owner, owner=owner, private=True)
490+ with person_logged_in(admin):
491+ snap.subscribe(person, admin)
492+ self.assertTrue(snap.visibleByUser(person))
493+ with person_logged_in(owner):
494+ snap.unsubscribe(person, owner)
495+ self.assertFalse(snap.visibleByUser(person))
496
497 def test_reconcile_set_public(self):
498 owner = self.factory.makePerson()
499@@ -1401,11 +1430,24 @@ class TestSnapVisibility(TestCaseWithFactory):
500 another_user = self.factory.makePerson()
501 with admin_logged_in():
502 snap.subscribe(another_user, snap.owner)
503+ self.assertEqual(1, self.getSnapGrants(snap, another_user).count())
504+ self.assertThat(
505+ self.getSnapSubscription(snap, another_user),
506+ MatchesStructure(
507+ person=Equals(another_user),
508+ snap=Equals(snap),
509+ subscribed_by=Equals(snap.owner),
510+ date_created=IsInstance(datetime)))
511
512- self.assertEqual(1, self.getSnapGrants(snap, another_user).count())
513- with admin_logged_in():
514 snap.information_type = InformationType.PUBLIC
515- self.assertEqual(0, self.getSnapGrants(snap, another_user).count())
516+ self.assertEqual(0, self.getSnapGrants(snap, another_user).count())
517+ self.assertThat(
518+ self.getSnapSubscription(snap, another_user),
519+ MatchesStructure(
520+ person=Equals(another_user),
521+ snap=Equals(snap),
522+ subscribed_by=Equals(snap.owner),
523+ date_created=IsInstance(datetime)))
524
525 def test_reconcile_permissions_setting_project(self):
526 owner = self.factory.makePerson()
527@@ -1429,10 +1471,24 @@ class TestSnapVisibility(TestCaseWithFactory):
528 snap.subscribe(another_person, owner)
529 self.assertTrue(snap.visibleByUser(another_person))
530 self.assertEqual(2, self.getSnapGrants(snap).count())
531+ self.assertThat(
532+ self.getSnapSubscription(snap, another_person),
533+ MatchesStructure(
534+ person=Equals(another_person),
535+ snap=Equals(snap),
536+ subscribed_by=Equals(snap.owner),
537+ date_created=IsInstance(datetime)))
538
539 snap.setProject(new_project)
540 self.assertTrue(snap.visibleByUser(another_person))
541 self.assertEqual(2, self.getSnapGrants(snap).count())
542+ self.assertThat(
543+ self.getSnapSubscription(snap, another_person),
544+ MatchesStructure(
545+ person=Equals(another_person),
546+ snap=Equals(snap),
547+ subscribed_by=Equals(snap.owner),
548+ date_created=IsInstance(datetime)))
549
550
551 class TestSnapSet(TestCaseWithFactory):
552@@ -1527,12 +1583,23 @@ class TestSnapSet(TestCaseWithFactory):
553 self.assertEqual(ref.path, snap.git_path)
554 self.assertEqual(ref, snap.git_ref)
555
556+ def test_create_private_snap_with_open_team_as_owner_fails(self):
557+ components = self.makeSnapComponents()
558+ with admin_logged_in():
559+ components['owner'].membership_policy = TeamMembershipPolicy.OPEN
560+ components['information_type'] = InformationType.PROPRIETARY
561+ self.assertRaises(
562+ SubscriptionPrivacyViolation,
563+ getUtility(ISnapSet).new, **components)
564+
565 def test_private_snap_information_type_compatibility(self):
566 login_admin()
567 private = InformationType.PROPRIETARY
568 public = InformationType.PUBLIC
569+ components = self.makeSnapComponents()
570+ components['owner'].membership_policy = TeamMembershipPolicy.MODERATED
571 private_snap = getUtility(ISnapSet).new(
572- information_type=private, **self.makeSnapComponents())
573+ information_type=private, **components)
574 self.assertEqual(
575 InformationType.PROPRIETARY, private_snap.information_type)
576
577@@ -1545,7 +1612,10 @@ class TestSnapSet(TestCaseWithFactory):
578 # Creating private snaps for public sources is allowed.
579 [ref] = self.factory.makeGitRefs()
580 components = self.makeSnapComponents(git_ref=ref)
581- components['information_type'] = InformationType.PROPRIETARY
582+ with admin_logged_in():
583+ components['information_type'] = InformationType.PROPRIETARY
584+ components['owner'].membership_policy = (
585+ TeamMembershipPolicy.MODERATED)
586 components['project'] = self.factory.makeProduct(
587 information_type=InformationType.PROPRIETARY,
588 branch_sharing_policy=BranchSharingPolicy.PROPRIETARY)
589@@ -2725,7 +2795,9 @@ class TestSnapWebservice(TestCaseWithFactory):
590
591 def test_new_private(self):
592 # Ensure private Snap creation works.
593- team = self.factory.makeTeam(owner=self.person)
594+ team = self.factory.makeTeam(
595+ membership_policy=TeamMembershipPolicy.MODERATED,
596+ owner=self.person)
597 distroseries = self.factory.makeDistroSeries(registrant=team)
598 [ref] = self.factory.makeGitRefs()
599 private_webservice = webservice_for_person(
600diff --git a/lib/lp/snappy/tests/test_snapbuild.py b/lib/lp/snappy/tests/test_snapbuild.py
601index e5dda05..7acff54 100644
602--- a/lib/lp/snappy/tests/test_snapbuild.py
603+++ b/lib/lp/snappy/tests/test_snapbuild.py
604@@ -1,4 +1,4 @@
605-# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
606+# Copyright 2015-2021 Canonical Ltd. This software is licensed under the
607 # GNU Affero General Public License version 3 (see the file LICENSE).
608
609 """Test snap package build features."""
610@@ -36,7 +36,10 @@ from lp.buildmaster.enums import BuildStatus
611 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
612 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
613 from lp.buildmaster.interfaces.processor import IProcessorSet
614-from lp.registry.enums import PersonVisibility
615+from lp.registry.enums import (
616+ PersonVisibility,
617+ TeamMembershipPolicy,
618+ )
619 from lp.registry.interfaces.series import SeriesStatus
620 from lp.services.authserver.xmlrpc import AuthServerAPIView
621 from lp.services.config import config
622@@ -172,6 +175,7 @@ class TestSnapBuild(TestCaseWithFactory):
623 # A SnapBuild is private iff its Snap and archive are.
624 self.assertFalse(self.build.is_private)
625 private_team = self.factory.makeTeam(
626+ membership_policy=TeamMembershipPolicy.MODERATED,
627 visibility=PersonVisibility.PRIVATE)
628 with person_logged_in(private_team.teamowner):
629 build = self.factory.makeSnapBuild(
630@@ -775,6 +779,7 @@ class TestSnapBuildWebservice(TestCaseWithFactory):
631 def test_private_snap(self):
632 # A SnapBuild with a private Snap is private.
633 db_team = self.factory.makeTeam(
634+ membership_policy=TeamMembershipPolicy.MODERATED,
635 owner=self.person, visibility=PersonVisibility.PRIVATE)
636 with person_logged_in(self.person):
637 db_build = self.factory.makeSnapBuild(
638diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
639index 6948419..034095d 100644
640--- a/lib/lp/testing/factory.py
641+++ b/lib/lp/testing/factory.py
642@@ -4764,7 +4764,14 @@ class BareLaunchpadObjectFactory(ObjectFactory):
643 if registrant is None:
644 registrant = self.makePerson()
645 if owner is None:
646- owner = self.makeTeam(registrant)
647+ is_private_snap = (
648+ private or information_type not in PUBLIC_INFORMATION_TYPES)
649+ # Private snaps cannot be owned by non-moderated teams.
650+ membership_policy = (
651+ TeamMembershipPolicy.OPEN if not is_private_snap
652+ else TeamMembershipPolicy.MODERATED)
653+ owner = self.makeTeam(
654+ registrant, membership_policy=membership_policy)
655 if distroseries is _DEFAULT:
656 distroseries = self.makeDistroSeries()
657 if name is None:
658@@ -4778,7 +4785,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
659 if auto_build_pocket is None:
660 auto_build_pocket = PackagePublishingPocket.UPDATES
661 if private and project is _DEFAULT:
662- # If we are creating a private snap and didn't explictly set a
663+ # If we are creating a private snap and didn't explicitly set a
664 # pillar for it, we must create a pillar.
665 branch_sharing = (
666 BranchSharingPolicy.PUBLIC_OR_PROPRIETARY if not private