diff --git a/extensions/PhabBugz/t/bmo/approval-flags.t b/extensions/PhabBugz/t/bmo/approval-flags.t new file mode 100644 index 0000000000..a53de95fa8 --- /dev/null +++ b/extensions/PhabBugz/t/bmo/approval-flags.t @@ -0,0 +1,367 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# PhabBugz::Util::set_attachment_approval_flags: the approval-mozilla- +# flags PhabBugz sets on revision attachments for uplift repositories. +# +# scripts/generate_bmo_data.pl seeds approval-mozilla-{beta,release,esr140} +# for Firefox :: General only, and approval-mozilla-esr115 disabled. A bug +# in any other product or component therefore reproduces the production +# failure where the flag is not enabled where the bug lives. + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Bugzilla::Attachment; +use Bugzilla::FlagType; +use Mojo::JSON qw(false); +use Storable qw(dclone); +use Test::More; +use Test2::Tools::Exception qw(lives); + +use Bugzilla::Extension::PhabBugz::Constants; +use Bugzilla::Extension::PhabBugz::Feed; +use Bugzilla::Extension::PhabBugz::Revision; +use Bugzilla::Extension::PhabBugz::User; +use Bugzilla::Extension::PhabBugz::Util qw( + create_revision_attachment + is_attachment_phab_revision + set_attachment_approval_flags + set_phab_user +); +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; +my $dbh = Bugzilla->dbh; +my $feed = Bugzilla::Extension::PhabBugz::Feed->new; + +# The flag types come from scripts/generate_bmo_data.pl. Skip only this file +# if they are missing: BAIL_OUT would stop every other t/bmo test too. +my @missing = grep { !Bugzilla::FlagType->new({name => $_}) } + map {"approval-mozilla-$_"} qw(beta release esr140 esr115); +if (@missing) { + $env->teardown; + plan skip_all => "@missing not seeded; rerun generate_bmo_data.pl"; +} +ok(!Bugzilla::FlagType->new({name => 'approval-mozilla-esr115'})->is_active, + 'approval-mozilla-esr115 is disabled'); + +my $author = $env->bmo_user('author'); +my $author_phab = $env->link_user($author, username => 'phabtest-author'); +my $relman = $env->bmo_user('relman'); +my $relman_phab = $env->link_user($relman, username => 'phabtest-relman'); +my $dev = $env->bmo_user('dev'); +my $dev_phab = $env->link_user($dev, username => 'phabtest-dev'); + +$conduit->add_project( + project_obj(name => 'release-managers', members => [$relman_phab->{phid}])); +my $uplift = $conduit->add_project(project_obj(name => 'uplift')); +my %repo = map { + $_ => $conduit->add_repository( + repository_obj(short_name => $_, projects => [$uplift->{phid}])) +} qw(firefox-beta firefox-release firefox-esr140 firefox-esr115 firefox-unknown); + +my ($timestamp) = $dbh->selectrow_array('SELECT NOW()'); + +# A revision on a new bug, with its BMO attachment. +sub revision_attachment { + my (%args) = @_; + my $bug = $env->bug( + product => $args{product} // 'Firefox', + component => $args{component} // 'General', + ); + my $raw = revision_obj( + bug_id => $bug->id, + author_phid => $author_phab->{phid}, + repository_phid => $repo{$args{repo} // 'firefox-beta'}{phid}, + status => $args{status} // 'needs-review', + reviewers => $args{reviewers} // [], + ); + my $revision = $conduit->load_revision($raw); + my $attachment + = create_revision_attachment($bug, $revision, $timestamp, $author); + $bug->update($timestamp); + return ($revision, $attachment, $raw); +} + +# Call set_attachment_approval_flags the way the feed does (as phab-bot) +# and save the attachment. +sub apply_flags { + my ($attachment, $revision, $changer_phab, $is_new) = @_; + my $changer = Bugzilla::Extension::PhabBugz::User->new(dclone($changer_phab)); + my $restore = set_phab_user(); + set_attachment_approval_flags($attachment, $revision, $changer, $is_new); + $attachment->update($timestamp); +} + +sub reload { + my ($revision) = @_; + return Bugzilla::Extension::PhabBugz::Revision->new_from_query( + {phids => [$revision->phid]}); +} + +# {flag name => status} of the approval flags on an attachment +sub approval_flags { + my ($attachment) = @_; + my $fresh = Bugzilla::Attachment->new($attachment->id); + return {map { $_->name => $_->status } + grep { $_->name =~ /^approval-mozilla-/ } @{$fresh->flags}}; +} + +# A new revision requests approval +{ + my ($revision, $attachment, $raw) = revision_attachment(); + apply_flags($attachment, $revision, $author_phab, 1); + is_deeply( + approval_flags($attachment), + {'approval-mozilla-beta' => '?'}, + 'new revision on firefox-beta requests approval-mozilla-beta' + ); + my ($flag) = grep { $_->name eq 'approval-mozilla-beta' } + @{Bugzilla::Attachment->new($attachment->id)->flags}; + is($flag->setter->id, $author->id, 'requested by the revision author'); + + # A release manager accepting the revision grants it + $raw->{attachments}{reviewers}{reviewers} = [{ + reviewerPHID => $relman_phab->{phid}, + status => 'accepted', + isBlocking => false, + actorPHID => $relman_phab->{phid} + }]; + apply_flags($attachment, reload($revision), $relman_phab, 0); + is_deeply( + approval_flags($attachment), + {'approval-mozilla-beta' => '+'}, + 'release manager acceptance sets approval-mozilla-beta+' + ); +} + +# Other reviewers cannot grant approval +{ + my ($revision, $attachment) + = revision_attachment( + reviewers => [{phid => $dev_phab->{phid}, status => 'accepted'}]); + apply_flags($attachment, $revision, $author_phab, 1); + apply_flags($attachment, $revision, $dev_phab, 0); + is_deeply( + approval_flags($attachment), + {'approval-mozilla-beta' => '?'}, + 'acceptance by a non release manager leaves the request alone' + ); + + my ($fresh_revision, $fresh_attachment) + = revision_attachment( + reviewers => [{phid => $dev_phab->{phid}, status => 'accepted'}]); + apply_flags($fresh_attachment, $fresh_revision, $dev_phab, 0); + is_deeply(approval_flags($fresh_attachment), + {}, 'a non release manager cannot create a + flag'); + + my ($rejected_revision, $rejected_attachment) + = revision_attachment( + reviewers => [{phid => $relman_phab->{phid}, status => 'rejected'}]); + apply_flags($rejected_attachment, $rejected_revision, $relman_phab, 0); + is_deeply(approval_flags($rejected_attachment), + {}, 'a rejecting review has no flag mapping and changes nothing'); + + my ($other_revision, $other_attachment) + = revision_attachment( + reviewers => [{phid => $relman_phab->{phid}, status => 'accepted'}]); + apply_flags($other_attachment, $other_revision, $dev_phab, 0); + is_deeply(approval_flags($other_attachment), + {}, 'a changer who is not a reviewer changes nothing'); +} + +# Revision status mapping for new revisions +{ + my ($revision, $attachment) = revision_attachment(status => 'needs-revision'); + apply_flags($attachment, $revision, $author_phab, 1); + is_deeply( + approval_flags($attachment), + {'approval-mozilla-beta' => '-'}, + 'new revision in needs-revision creates a - flag (no release manager check)' + ); + + my ($planned, $planned_attachment, $planned_raw) = revision_attachment(); + apply_flags($planned_attachment, $planned, $author_phab, 1); + $planned_raw->{fields}{status}{value} = 'changes-planned'; + apply_flags($planned_attachment, reload($planned), $author_phab, 1); + is_deeply(approval_flags($planned_attachment), + {}, 'changes-planned clears the pending request'); + + my ($fresh_planned, $fresh_planned_attachment) + = revision_attachment(status => 'changes-planned'); + apply_flags($fresh_planned_attachment, $fresh_planned, $author_phab, 1); + is_deeply(approval_flags($fresh_planned_attachment), + {}, 'changes-planned never creates a flag'); +} + +# Repository name -> flag name +{ + my ($release, $release_attachment) + = revision_attachment(repo => 'firefox-release'); + apply_flags($release_attachment, $release, $author_phab, 1); + is_deeply( + approval_flags($release_attachment), + {'approval-mozilla-release' => '?'}, + 'firefox-release maps to approval-mozilla-release' + ); + + my ($esr, $esr_attachment) = revision_attachment(repo => 'firefox-esr140'); + apply_flags($esr_attachment, $esr, $author_phab, 1); + is_deeply( + approval_flags($esr_attachment), + {'approval-mozilla-esr140' => '?'}, + 'firefox-esr140 maps to approval-mozilla-esr140' + ); + + my ($disabled, $disabled_attachment) + = revision_attachment(repo => 'firefox-esr115'); + ok( + lives { apply_flags($disabled_attachment, $disabled, $author_phab, 1) }, + 'a disabled approval flag does not fail' + ); + is_deeply(approval_flags($disabled_attachment), + {}, 'disabled approval flag is skipped'); + + my ($unknown, $unknown_attachment) + = revision_attachment(repo => 'firefox-unknown'); + ok( + lives { apply_flags($unknown_attachment, $unknown, $author_phab, 1) }, + 'a repository without an approval flag type does not fail' + ); + is_deeply(approval_flags($unknown_attachment), {}, 'no flag set'); +} + +# The flag is not enabled for the bug's product or component +foreach my $case ( + ['product', 'bugzilla.mozilla.org', 'General'], + ['component', 'Firefox', 'Installer'], + ) +{ + my ($what, $product, $component) = @$case; + my ($revision, $attachment) + = revision_attachment(product => $product, component => $component); + my $error = throws_error( + sub { apply_flags($attachment, $revision, $author_phab, 1) }, + 'bugzilla.code.invalid_flag_association', + "approval flag not enabled for the $what ($product :: $component) throws" + ); + is($error && $error->info->{attach_id}, + $attachment->id, 'error names the attachment') + if $error; +} + +# Through the feed daemon +{ + my $last_story_id = 8_000_000; + my $story = sub { + my ($raw, $author_phid, $text) = @_; + return $conduit->add_story(feed_story( + id => ++$last_story_id, + author_phid => $author_phid, + object_phid => $raw->{phid}, + text => $text, + )); + }; + my $run + = sub { with_typed_errors(sub { $env->run_sync(sub { $feed->feed_query }) }) }; + my $attachments = sub { + my ($bug) = @_; + return [grep { is_attachment_phab_revision($_) } + @{Bugzilla::Bug->new($bug->id)->attachments}]; + }; + + # New revision, then a release manager accepts it + my $bug = $env->bug; + my $raw = $conduit->add_revision(revision_obj( + bug_id => $bug->id, + author_phid => $author_phab->{phid}, + repository_phid => $repo{'firefox-beta'}{phid}, + )); + $env->save_cursor('feed', $last_story_id); + $story->( + $raw, $author_phab->{phid}, "phabtest-author created D$raw->{id}: uplift." + ); + ok(!$run->(), 'feed: new uplift revision'); + my ($attachment) = @{$attachments->($bug)}; + is_deeply( + $attachment ? approval_flags($attachment) : {}, + {'approval-mozilla-beta' => '?'}, + 'feed: approval requested' + ); + + $raw->{attachments}{reviewers}{reviewers} = [{ + reviewerPHID => $relman_phab->{phid}, + status => 'accepted', + isBlocking => false, + actorPHID => $relman_phab->{phid} + }]; + $story->($raw, $relman_phab->{phid}, "phabtest-relman accepted D$raw->{id}."); + ok(!$run->(), 'feed: release manager accepts'); + is_deeply( + $attachment ? approval_flags($attachment) : {}, + {'approval-mozilla-beta' => '+'}, + 'feed: approval granted' + ); + + # Revision on a bug where the approval flag is not enabled + foreach my $case ( + ['product', 'bugzilla.mozilla.org', 'General'], + ['component', 'Firefox', 'Installer'], + ) + { + my ($what, $product, $component) = @$case; + my $other = $env->bug(product => $product, component => $component); + my $bad = $conduit->add_revision(revision_obj( + bug_id => $other->id, + author_phid => $author_phab->{phid}, + repository_phid => $repo{'firefox-beta'}{phid}, + )); + my $cursor = $last_story_id; + $env->save_cursor('feed', $cursor); + $story->( + $bad, $author_phab->{phid}, "phabtest-author created D$bad->{id}: uplift." + ); + + my $error = $run->(); + is( + ref $error && $error->type, + 'bugzilla.code.invalid_flag_association', + "feed: flag not enabled for the $what fails the story" + ); + is($env->cursor('feed'), $cursor, "feed ($what): cursor not advanced"); + is(scalar @{$attachments->($other)}, + 0, "feed ($what): revision attachment rolled back"); + is($conduit->call_count('differential.revision.edit'), + 0, "feed ($what): revision not updated"); + + TODO: { + local $TODO + = 'a missing approval flag fails the whole story instead of being skipped'; + $run->(); + is(scalar @{$attachments->($other)}, + 1, "feed ($what): revision still synced to the bug without the flag"); + cmp_ok($env->cursor('feed') // 0, + '>', $cursor, "feed ($what): cursor moves past the story"); + } + } +} + +$env->fresh_request; +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/conduit-request.t b/extensions/PhabBugz/t/bmo/conduit-request.t new file mode 100644 index 0000000000..259223e8e4 --- /dev/null +++ b/extensions/PhabBugz/t/bmo/conduit-request.t @@ -0,0 +1,209 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# PhabBugz::Util::request(): how Conduit calls are encoded and how transport, +# HTTP and Conduit failures surface to callers. + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Bugzilla::Config qw(SetParam); +use Test::More; +use Test2::Tools::Exception qw(dies); + +use Bugzilla::Extension::PhabBugz::Project; +use Bugzilla::Extension::PhabBugz::Util qw(request); +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; + +# Successful call: URL, token and params encoding +{ + $conduit->reset_calls; + my $user = $env->phab_bot_phab; + my $result + = request('user.search', {constraints => {phids => [$user->{phid}]}}); + + ok(!$result->{error_code}, 'successful call has no error_code'); + is($result->{result}{data}[0]{phid}, $user->{phid}, 'result payload returned'); + + my ($call) = $conduit->calls; + is($call->{method}, 'user.search', 'method taken from the URL path'); + is_deeply( + $call->{params}, + {constraints => {phids => [$user->{phid}]}}, + 'params sent as JSON in the `params` form field' + ); + + # The base URI param must end in a slash (check_urlbase) and request() + # appends "/api/", so the path has a double slash. Phabricator accepts it, + # but it is not intended; the fake Conduit accepts either form. + like( + $call->{url}, + qr{^\Qhttps://phabricator.test.example.com/\E/?api/user\.search$}, + 'URL is /api/' + ); +TODO: { + local $TODO = 'request() joins base_uri and "/api/" with a double slash'; + unlike($call->{url}, qr{//api/}, 'URL has a single slash before "api"'); + } + + ok( + Bugzilla->request_cache->{phabricator_ua}, + 'user agent cached in the request cache' + ); +} + +# The caller's data hash gets the API token added to it +{ + my $data = {constraints => {phids => []}}; + request('project.search', $data); + is_deeply( + $data->{__conduit__}, + {token => $conduit->api_key}, + 'request() adds __conduit__ token to the caller data hash' + ); +} + +# Missing configuration +{ + SetParam('phabricator_api_key', ''); + $conduit->reset_calls; + throws_error( + sub { request('user.search', {}) }, + 'bugzilla.user.invalid_phabricator_api_key', + 'missing API key' + ); + is($conduit->call_count, 0, 'no HTTP request without an API key'); + $conduit->apply_params; + + SetParam('phabricator_base_uri', ''); + throws_error( + sub { request('user.search', {}) }, + 'bugzilla.user.invalid_phabricator_uri', + 'missing base URI' + ); + is($conduit->call_count, 0, 'no HTTP request without a base URI'); + $conduit->apply_params; + + like( + dies { SetParam('phabricator_base_uri', 'https://phabricator.example.com') }, + qr/ends with a slash/, + 'base URI param must end with a slash' + ); + $conduit->apply_params; +} + +# HTTP errors always throw, even with $no_die +foreach my $code (400, 403, 404, 500, 502, 503, 504) { + $conduit->queue('user.search' => http_error($code)) for 1 .. 2; + + my $error = throws_error( + sub { request('user.search', {}) }, + 'bugzilla.code.phabricator_api_error', + "HTTP $code throws" + ); + like($error->info->{reason}, qr/\w/, "HTTP $code reason is the status message") + if $error; + + throws_error( + sub { request('user.search', {}, 1) }, + 'bugzilla.code.phabricator_api_error', + "HTTP $code throws with no_die" + ); +} + +# Body that is not JSON (proxy error page, truncated response) +foreach my $no_die (0, 1) { + $conduit->queue('user.search' => invalid_json()); + my $error = throws_error( + sub { request('user.search', {}, $no_die) }, + 'bugzilla.code.phabricator_api_error', + "non-JSON body (no_die=$no_die)" + ); + is($error->info->{reason}, 'JSON decode failure', 'reason for non-JSON body') + if $error; +} + +$conduit->queue( + 'user.search' => raw_reply('{"result": {"data": [', 'application/json')); +throws_error( + sub { request('user.search', {}) }, + 'bugzilla.code.phabricator_api_error', + 'truncated JSON body' +); + +# Conduit-level errors (HTTP 200 with error_code) +{ + $conduit->queue('user.search' => + conduit_error('ERR-INVALID-AUTH', 'API token "api-x" has the wrong length.')); + my $error = throws_error( + sub { request('user.search', {}) }, + 'bugzilla.code.phabricator_api_error', + 'Conduit error_code throws' + ); + if ($error) { + is($error->info->{code}, 'ERR-INVALID-AUTH', 'error code passed on'); + like($error->info->{reason}, qr/wrong length/, 'error info passed on'); + } + + $conduit->queue( + 'user.search' => conduit_error('ERR-CONDUIT-CORE', 'Some failure')); + my $result = request('user.search', {}, 1); + is($result->{error_code}, 'ERR-CONDUIT-CORE', 'no_die returns the error_code'); + is($result->{error_info}, 'Some failure', 'no_die returns the error_info'); + + request('no.such.method', {}, 1); + is_deeply($conduit->unexpected, ['no.such.method'], + 'unknown method answered with ERR-CONDUIT-CALL'); + @{$conduit->unexpected} = (); +} + +# Transport failures: Mojo::UserAgent croaks from $tx->result. request() +# does not catch these, so callers see a plain die, not a Bugzilla error. +foreach my $message ( + 'Connection refused', + 'Inactivity timeout', + "Can't connect: Name or service not known" + ) +{ + $conduit->queue('user.search' => network_error($message)); + like( + dies { request('user.search', {}) }, + qr/^\Q$message\E/, + "network failure '$message' propagates" + ); +} + +# A 200 response without a `result` key is returned as-is; the model +# classes treat it as "nothing found". +{ + $conduit->queue( + 'project.search' => raw_reply('{"error_code":null,"error_info":null}')); + my $result = request('project.search', {}); + ok(!defined $result->{result}, 'missing result key does not throw'); + + $conduit->queue( + 'project.search' => raw_reply('{"error_code":null,"error_info":null}')); + ok( + !Bugzilla::Extension::PhabBugz::Project->new_from_query({name => 'anything'}), + 'Project->new_from_query finds nothing for a missing result'); +} + +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/feed-groups.t b/extensions/PhabBugz/t/bmo/feed-groups.t new file mode 100644 index 0000000000..0d5dfbd902 --- /dev/null +++ b/extensions/PhabBugz/t/bmo/feed-groups.t @@ -0,0 +1,216 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# The feed daemon's group sync (PhabBugz::Feed::group_query): every active +# BMO bug group gets a locked down `bmo-` Phabricator project whose +# members match the group (plus phab-bot and lando). +# +# The sync walks every active bug group in the shared test database, so the +# assertions only look at the projects of the groups this file creates. + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Test::More; + +use Bugzilla::Extension::PhabBugz::Feed; +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; +my $feed = Bugzilla::Extension::PhabBugz::Feed->new; + +my $secure = $conduit->add_project(project_obj(name => 'secure-revision')); +my $bot = $env->phab_bot_phab->{phid}; +my $lando = $env->lando_phab->{phid}; + +my $alice = $env->bmo_user('alice'); +my $alice_phab = $env->link_user($alice); +my $bob = $env->bmo_user('bob'); +my $bob_phab = $env->link_user($bob); +my $carol = $env->bmo_user('carol-not-in-phab'); + +my $group = $env->bug_group('sync'); +$env->add_to_group($_, $group) for $alice, $carol; +my $project_name = 'bmo-' . $group->name; + +sub run_groups { $env->run_sync(sub { $feed->group_query }) } + +sub project_edits { + my ($project) = @_; + return [$conduit->transactions_for($project->{phid}, 'project.edit')]; +} + +sub members_of { + my ($project) = @_; + return [sort map { $_->{phid} } @{$project->{attachments}{members}{members}}]; +} + +# Sync disabled +$env->sync_disabled_ok(sub { $feed->group_query }); + +# First sync creates the project with locked down policies +{ + ok(!run_groups(), 'group_query creates missing projects'); + my $project = $conduit->find_project(name => $project_name); + ok($project, "$project_name created"); + + my ($create) = grep { + !exists $_->{objectIdentifier} && $_->{transactions}[0]{value} eq $project_name + } $conduit->calls('project.edit'); + is_deeply( + $create->{transactions}, + [ + {type => 'name', value => $project_name}, + {type => 'description', value => 'BMO Security Group for ' . $group->name}, + {type => 'edit', value => $secure->{phid}}, + {type => 'join', value => $secure->{phid}}, + {type => 'view', value => $secure->{phid}}, + {type => 'icon', value => 'group'}, + {type => 'color', value => 'red'}, + ], + 'project.edit create transactions' + ); + + my ($add) = grep { $_->{type} eq 'members.add' } @{project_edits($project)}; + is_deeply( + [sort @{$add->{value}}], + [sort $alice_phab->{phid}, $bot, $lando], + 'group members with Phabricator accounts, phab-bot and lando added' + ); + is_deeply( + members_of($project), + [sort $alice_phab->{phid}, $bot, $lando], + 'project membership matches the group' + ); + + cmp_ok( + scalar( + grep { ($_->{constraints}{name} // '') =~ /^bmo-/ } + $conduit->calls('project.search') + ), + '>', 1, + 'the seeded bug groups are synced too' + ); +} + +# Second sync with nothing changed makes no edits to the project +{ + my $project = $conduit->find_project(name => $project_name); + ok(!run_groups(), 'group_query with membership in sync'); + is_deeply(project_edits($project), [], + 'no project.edit when membership matches'); + + # The policies are only sent along with membership changes + $project->{fields}{policy}{view} = 'public'; + ok(!run_groups(), 'group_query with a drifted view policy'); +TODO: { + local $TODO = 'group_query only updates projects whose membership changed'; + ok((grep { $_->{type} eq 'view' } @{project_edits($project)}), + 'drifted policy is reset to secure-revision'); + } + $project->{fields}{policy}{view} = $secure->{phid}; +} + +# Membership drift is corrected with add/remove, and policies are re-sent +{ + my $project = $conduit->find_project(name => $project_name); + $env->remove_from_group($alice, $group); + $env->add_to_group($bob, $group); + @{$project->{attachments}{members}{members}} + = grep { $_->{phid} ne $lando } @{$project->{attachments}{members}{members}}; + + ok(!run_groups(), 'group_query with membership drift'); + my %by_type = map { $_->{type} => $_->{value} } @{project_edits($project)}; + is_deeply( + [sort @{$by_type{'members.add'}}], + [sort $bob_phab->{phid}, $lando], + 'new group member added and lando restored' + ); + is_deeply( + $by_type{'members.remove'}, + [$alice_phab->{phid}], + 'former group member removed' + ); + is($by_type{view}, $secure->{phid}, 'view policy set'); + is($by_type{edit}, $secure->{phid}, 'edit policy set'); + +TODO: { + local $TODO = 'Project::update drops the join policy'; + is($by_type{join}, $secure->{phid}, 'join policy set'); + } + + is_deeply( + members_of($project), + [sort $bob_phab->{phid}, $bot, $lando], + 'project membership matches the group again' + ); +} + +# Inactive and non-bug groups are not synced +{ + my $inactive = $env->bug_group('inactive'); + $inactive->set_is_active(0); + $inactive->update; + my $system = $env->group('system', isbuggroup => 0); + + ok(!run_groups(), 'group_query with inactive and system groups'); + my %searched = map { ($_->{constraints}{name} // '') => 1 } + $conduit->calls('project.search'); + ok($searched{$project_name}, 'active bug group synced'); + ok(!$searched{'bmo-' . $inactive->name}, 'inactive bug group skipped'); + ok(!$searched{'bmo-' . $system->name}, 'non-bug group skipped'); +} + +# Missing secure-revision project +{ + $secure->{fields}{name} = 'secure-revision-renamed'; + my $error = run_groups(); + + # Project->new_from_query returns a false value (not an object) here. + like( + $error // '', + qr/Can't call method "phid"/, + 'group_query dies without the secure-revision project' + ); + $secure->{fields}{name} = 'secure-revision'; +} + +# Conduit failures abort the sync +foreach my $case ( + ['project.search', conduit_error('ERR-CONDUIT-CORE', 'Database is read-only')], + ['project.search', http_error(500)], + ['project.edit', network_error('Connection refused')], + ['user.search', invalid_json()], + ) +{ + my ($method, $reply) = @$case; + + # Give the sync a membership change to send for this file's group. + $env->add_to_group($alice, $group); + $conduit->on($method => $reply); + my $error = with_typed_errors(sub { run_groups() }); + ok($error, "$method failure (" . ($reply->{type}) . ') aborts group_query'); + $conduit->off($method); + $env->remove_from_group($alice, $group); +} + +ok(!run_groups(), 'group_query succeeds again after the failures'); + +$env->fresh_request; +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/feed-run-query.t b/extensions/PhabBugz/t/bmo/feed-run-query.t new file mode 100644 index 0000000000..0ee4e73a3f --- /dev/null +++ b/extensions/PhabBugz/t/bmo/feed-run-query.t @@ -0,0 +1,123 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# PhabBugz::Feed::run_query, the wrapper the daemon's timers call for the +# feed, user and group loops: errors are logged and swallowed so the daemon +# keeps running, the watchdog alarm is armed during a query and cleared +# afterwards, and the request is cleaned up every time. + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Test2::Tools::Mock qw(mock); +use Test::More; + +use Bugzilla::Extension::PhabBugz::Constants; +use Bugzilla::Extension::PhabBugz::Feed; +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; +my $feed = Bugzilla::Extension::PhabBugz::Feed->new; + +$conduit->add_project(project_obj(name => 'secure-revision')); +$env->save_cursor('feed', 9_000_000); +$env->save_cursor('user', 900_000_000); + +my @fatal; +my $logger = mock 'Bugzilla::Extension::PhabBugz::Feed' => + (override => [FATAL => sub { push @fatal, join('', @_) }]); + +# Bugzilla->_cleanup clears the request cache (and with it the test user +# and params), so count the calls and restore the test request instead. +my $cleanups = 0; +my $cleanup + = mock 'Bugzilla' => (override => [_cleanup => sub { $cleanups++ }]); + +# Methods each loop calls first, so one failing reply hits every loop. +my @first_methods + = qw(feed.query_id user.search bugzilla.account.search project.search); + +sub run { + my ($name) = @_; + @fatal = (); + $cleanups = 0; + return $env->run_sync(sub { $feed->run_query($name) }); +} + +foreach my $case ( + ['network error', network_error('Connection refused')], + ['HTTP 500', http_error(500)], + ['non-JSON body', invalid_json()], + ['Conduit error', conduit_error('ERR-CONDUIT-CORE', 'Database is read-only')], + ) +{ + my ($name, $reply) = @$case; + $conduit->on($_ => $reply) for @first_methods; + + foreach my $query (qw(feed user group)) { + my $error = run($query); + ok(!$error, "$query, $name: run_query does not die"); + is(scalar @fatal, 1, "$query, $name: error logged as FATAL"); + is(alarm(0), 0, "$query, $name: watchdog alarm cleared"); + is($cleanups, 1, "$query, $name: request cleaned up"); + } + + $conduit->off($_) for @first_methods; +} + +# A successful run arms the watchdog alarm for the duration of the query +{ + my $armed; + $conduit->on( + 'feed.query_id' => sub { + my ($params, $c) = @_; + $armed = alarm(0); + alarm($armed); + return $c->default_reply('feed.query_id', $params); + } + ); + ok(!run('feed'), 'feed run_query succeeds'); + is_deeply(\@fatal, [], 'nothing logged as FATAL'); + ok( + $armed > 0 && $armed <= PHAB_TIMEOUT, + 'alarm set to PHAB_TIMEOUT (' + . PHAB_TIMEOUT + . "s) during the query, saw $armed" + ); + is(alarm(0), 0, 'alarm cleared afterwards'); + is($cleanups, 1, 'request cleaned up after success'); + $conduit->off('feed.query_id'); +} + +# The daemon keeps going after failures +{ + ok(!run('user'), 'user run_query succeeds after earlier failures'); + is_deeply(\@fatal, [], 'nothing logged as FATAL'); + ok(!run('group'), 'group run_query succeeds after earlier failures'); + is_deeply(\@fatal, [], 'nothing logged as FATAL'); +} + +# An unknown query name is logged, not fatal to the caller +{ + ok(!run('bogus'), 'unknown query does not die'); + like($fatal[0] // '', qr/bogus_query/, 'unknown query method logged'); +} + +$env->fresh_request; +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/feed-stories.t b/extensions/PhabBugz/t/bmo/feed-stories.t new file mode 100644 index 0000000000..14c6dba2c1 --- /dev/null +++ b/extensions/PhabBugz/t/bmo/feed-stories.t @@ -0,0 +1,591 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# The feed daemon's story loop (PhabBugz::Feed::feed_query): polling +# feed.query_id from the saved cursor, skipping stories it does not handle, +# syncing revisions to their bugs (attachments, privacy policies, project +# tags, subscribers, assignee, uplift requests) and the failure modes of +# the feed (invalid cursor, Conduit/HTTP/network errors, a story that keeps +# failing). + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +# Constants are inlined when PhabBugz::Feed compiles, so set this first. +BEGIN { $ENV{PHAB_FEED_MAX_RETRIES} = 3 } + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Digest::SHA qw(sha1_hex); +use Mojo::JSON qw(encode_json); +use Test2::Tools::Mock qw(mock); +use Test::More; + +use Bugzilla::Extension::PhabBugz::Constants; +use Bugzilla::Extension::PhabBugz::Feed; +use Bugzilla::Extension::PhabBugz::Util qw(is_attachment_phab_revision); +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; +my $dbh = Bugzilla->dbh; +my $feed = Bugzilla::Extension::PhabBugz::Feed->new; + +my $author = $env->bmo_user('author'); +my $author_phab = $env->link_user($author, username => 'phabtest-author'); +my $reviewer = $env->bmo_user('reviewer'); +my $reviewer_phab = $env->link_user($reviewer); +my $cc_user = $env->bmo_user('cc'); +my $cc_phab = $env->link_user($cc_user); +my $unlinked = $conduit->add_user(user_obj(username => 'phabtest-unlinked')); + +my $editbugs = $conduit->add_project(project_obj(name => 'bmo-editbugs-team')); +my $secure = $conduit->add_project(project_obj(name => 'secure-revision')); +my $tag = $conduit->add_project(project_obj(name => $env->unique('tag'))); + +# Story ids grow through the file; each scenario starts the cursor at the +# last id used so it only sees its own stories. +my $last_story_id = 7_000_000; + +sub story { + my (%args) = @_; + $last_story_id += $args{gap} // 1; + delete $args{gap}; + return $conduit->add_story(feed_story( + id => $last_story_id, author_phid => $author_phab->{phid}, %args,)); +} + +sub start_scenario { + $env->save_cursor('feed', $last_story_id); + return $last_story_id; +} + +sub run_feed { $env->run_sync(sub { $feed->feed_query }) } + +sub revision { + my (%args) = @_; + return $conduit->add_revision( + revision_obj(author_phid => $author_phab->{phid}, %args)); +} + +sub created_text { + return "phabtest-author created D$_[0]{id}: $_[0]{fields}{title}."; +} +sub updated_text { return "phabtest-author updated D$_[0]{id}." } + +sub phab_attachments { + my ($bug_id) = @_; + return + grep { is_attachment_phab_revision($_) } + @{Bugzilla::Bug->new($bug_id)->attachments}; +} + +# Sync disabled +{ + start_scenario(); + $env->sync_disabled_ok(sub { $feed->feed_query }); +} + +# A cursor of 0 asks for everything after story 1 +{ + $env->save_cursor('feed', 0); + ok(!run_feed(), 'feed_query with an empty feed'); + is_deeply( + [$conduit->calls('feed.query_id')], + [{view => 'text', after => 1}], + 'cursor 0 sends after => 1' + ); +} + +# No new stories +{ + my $cursor = start_scenario(); + ok(!run_feed(), 'feed_query with no new stories'); + is_deeply( + [$conduit->calls('feed.query_id')], + [{view => 'text', after => $cursor}], + 'feed.query_id from the saved cursor' + ); + is($env->cursor('feed'), $cursor, 'cursor unchanged'); +} + +# Stories are processed oldest first; non-revision and phab-bot stories are +# skipped but still advance the cursor +{ + start_scenario(); + my @saved; + my $spy = mock 'Bugzilla::Extension::PhabBugz::Feed' => + (before => [save_last_id => sub { push @saved, $_[1] }]); + + my $task = story(object_phid => 'PHID-TASK-sometask'); + my $by_bot = story( + object_phid => revision()->{phid}, + author_phid => $env->phab_bot_phab->{phid} + ); + my $commit = story(object_phid => 'PHID-CMIT-somecommit'); + + ok(!run_feed(), 'feed_query with skipped stories'); + is_deeply( + \@saved, + [$task->{id}, $by_bot->{id}, $commit->{id}], + 'stories handled in ascending id order (Conduit returns newest first)' + ); + is($env->cursor('feed'), $commit->{id}, 'cursor saved after the last story'); + is($conduit->call_count('differential.revision.search'), + 0, 'skipped stories do not load revisions'); + is_deeply( + [map { $_->{constraints} } $conduit->calls('user.search')], + [{phids => [$env->phab_bot_phab->{phid}]}], + 'only the revision story author is looked up' + ); +} + +# New revision without a bug is made public and leaves draft +{ + start_scenario(); + my $raw = revision( + title => 'No bug yet', + status => 'draft', + is_draft => 1, + projects => [$secure->{phid}, $tag->{phid}], + ); + my $story = story(object_phid => $raw->{phid}, text => created_text($raw)); + + ok(!run_feed(), 'feed_query for a new revision without a bug'); + my @transactions = $conduit->transactions_for($raw->{phid}); + $_->{value} = 'true' for grep { $_->{type} eq 'request-review' } @transactions; + is_deeply( + \@transactions, + [ + {type => 'view', value => 'public'}, + {type => 'edit', value => $editbugs->{phid}}, + {type => 'request-review', value => 'true'}, + {type => 'projects.remove', value => [$secure->{phid}]}, + ], + 'made public, secure-revision tag removed, moved out of draft' + ); + is($env->cursor('feed'), $story->{id}, 'cursor advanced'); +} + +# Updates to a revision without a bug are ignored +{ + start_scenario(); + my $raw = revision(); + my $story = story(object_phid => $raw->{phid}, text => updated_text($raw)); + ok(!run_feed(), 'feed_query for an updated revision without a bug'); + is($conduit->call_count('differential.revision.edit'), 0, + 'revision not edited'); + is($env->cursor('feed'), $story->{id}, 'cursor advanced'); +} + +# Revision on a public bug +{ + start_scenario(); + my $bug = $env->bug; + my $raw = revision( + title => 'Public fix', + summary => 'Fixes the public bug', + bug_id => $bug->id, + reviewers => [{phid => $reviewer_phab->{phid}}], + projects => [$tag->{phid}], + ); + my $story = story(object_phid => $raw->{phid}, text => updated_text($raw)); + + ok(!run_feed(), 'feed_query for a revision on a public bug'); + my @attachments = phab_attachments($bug->id); + is(scalar @attachments, 1, 'revision attachment created'); + my $attachment = $attachments[0]; + is( + $attachment->data, + $conduit->base_uri . "D$raw->{id}", + 'attachment points to the revision' + ); + is($attachment->description, 'Public fix', + 'attachment description is the title'); + is($attachment->attacher->id, $author->id, 'attached by the revision author'); + ok(!$attachment->isobsolete, 'attachment is not obsolete'); + + is_deeply( + [ + grep { $_->{type} =~ /^(view|edit)$/ } $conduit->transactions_for($raw->{phid}) + ], + [ + {type => 'view', value => 'public'}, + {type => 'edit', value => $editbugs->{phid}} + ], + 'public bug keeps the revision public' + ); + + my $fresh = Bugzilla::Bug->new($bug->id); + is($fresh->assigned_to->id, + $author->id, 'unassigned bug assigned to the author'); + is( + $fresh->bug_status, + ($bug->bug_status =~ /^(?:NEW|UNCONFIRMED)$/ ? 'ASSIGNED' : $bug->bug_status), + 'NEW/UNCONFIRMED bugs move to ASSIGNED, others keep their status' + ); + is($env->cursor('feed'), $story->{id}, 'cursor advanced'); + + # The same change seen again does not duplicate the attachment + start_scenario(); + story(object_phid => $raw->{phid}, text => updated_text($raw)); + ok(!run_feed(), 'second story for the same revision'); + is(scalar(phab_attachments($bug->id)), 1, 'attachment reused'); + + # Abandoning the revision obsoletes the attachment + start_scenario(); + $raw->{fields}{status}{value} = 'abandoned'; + story( + object_phid => $raw->{phid}, + text => "phabtest-author abandoned D$raw->{id}." + ); + ok(!run_feed(), 'story for an abandoned revision'); + ok((phab_attachments($bug->id))[0]->isobsolete, 'attachment obsoleted'); + + # Moving the revision to another bug obsoletes the old attachment + start_scenario(); + $raw->{fields}{status}{value} = 'needs-review'; + my $new_bug = $env->bug; + $raw->{fields}{'bugzilla.bug-id'} = $new_bug->id; + my ($old) = phab_attachments($bug->id); + $old->set_is_obsolete(0); + $old->update; + story(object_phid => $raw->{phid}, text => updated_text($raw)); + ok(!run_feed(), 'story after the bug id changed'); + is(scalar(phab_attachments($new_bug->id)), + 1, 'attachment created on the new bug'); + ok( + (phab_attachments($bug->id))[0]->isobsolete, + 'attachment on the old bug obsoleted' + ); + my ($moved_comment) + = grep { $_->body =~ /was moved to bug/ } + @{Bugzilla::Bug->new($bug->id)->comments}; + like( + $moved_comment ? $moved_comment->body : '', + qr/Revision D$raw->{id} was moved to bug ${\ $new_bug->id}/, + 'old bug gets a comment' + ); +} + +# Revision on a private bug +{ + my $group = $env->bug_group('secret'); + my $project = $conduit->add_project(project_obj(name => 'bmo-' . $group->name)); + $env->add_to_group($author, $group); + my $bug = $env->bug(groups => [$group], cc => [$cc_user]); + my $admin_phab = $env->link_user($env->admin); + + start_scenario(); + my $raw = revision(title => 'Secret fix', bug_id => $bug->id); + story(object_phid => $raw->{phid}, text => updated_text($raw)); + + ok(!run_feed(), 'feed_query for a revision on a private bug'); + my ($create) = $conduit->calls('policy.create'); + ok($create, 'custom policy created'); + is_deeply( + $create->{policy}[-1]{value}, + [$project->{phid}], + 'policy allows the bmo- project' + ); + my ($query) = $conduit->calls('policy.query'); + my $policy_phid = $query->{phids}[0]; + + my %by_type + = map { $_->{type} => $_->{value} } $conduit->transactions_for($raw->{phid}); + is($by_type{view}, $policy_phid, 'view policy is the custom policy'); + is($by_type{edit}, $policy_phid, 'edit policy is the custom policy'); + is_deeply( + $by_type{'projects.add'}, + [$secure->{phid}, $project->{phid}], + 'secure-revision and group tags added' + ); + is_deeply( + [sort @{$by_type{'subscribers.set'}}], + [sort $admin_phab->{phid}, $cc_phab->{phid}], + 'subscribers set to the bug reporter and CCs' + ); + is((phab_attachments($bug->id))[0]->description, + '(secure)', 'attachment title hidden'); + + # The revision already has a matching policy: no new policy + start_scenario(); + $raw->{fields}{policy}{view} = $policy_phid; + story(object_phid => $raw->{phid}, text => updated_text($raw)); + ok(!run_feed(), 'second story for the private revision'); + is($conduit->call_count('policy.create'), 0, 'matching policy is kept'); + ok( + ( + grep { $_->{type} eq 'subscribers.set' } + $conduit->transactions_for($raw->{phid}) + ), + 'subscribers still synced' + ); + + # Another group on the bug: the policy no longer matches + my $group2 = $env->bug_group('secret2'); + my $project2 + = $conduit->add_project(project_obj(name => 'bmo-' . $group2->name)); + $dbh->do('INSERT INTO bug_group_map (bug_id, group_id) VALUES (?, ?)', + undef, $bug->id, $group2->id); + $env->add_to_group($author, $group2); + start_scenario(); + story(object_phid => $raw->{phid}, text => updated_text($raw)); + ok(!run_feed(), 'story after a group was added to the bug'); + my ($recreate) = $conduit->calls('policy.create'); + is_deeply( + [sort @{$recreate->{policy}[-1]{value}}], + [sort $project->{phid}, $project2->{phid}], + 'new policy covers both groups' + ); +} + +# Invalid bug id, or a bug the author cannot see +{ + start_scenario(); + my $missing = revision(bug_id => '2147483000'); + story(object_phid => $missing->{phid}, text => updated_text($missing)); + ok(!run_feed(), 'feed_query for a revision with an invalid bug id'); + my ($comment) + = grep { $_->{type} eq 'comment' } + $conduit->transactions_for($missing->{phid}); + like( + $comment->{value} // '', + qr/kept private due to invalid/, + 'author is told the bug id is invalid' + ); +TODO: { + local $TODO + = 'revision/comments.html.tmpl does not load terms, so "bug" is blank'; + like($comment->{value} // '', qr/invalid bug ID/, 'comment says "bug ID"'); + } + + start_scenario(); + my $new_missing = revision(bug_id => '2147483001'); + story(object_phid => $new_missing->{phid}, text => created_text($new_missing)); + ok(!run_feed(), 'feed_query for a new revision with an invalid bug id'); + is($conduit->call_count('differential.revision.edit'), + 0, 'new revisions are left alone until the next update'); + + my $group = $env->bug_group('hidden'); + my $hidden_bug = $env->bug(groups => [$group]); + start_scenario(); + my $hidden = revision(bug_id => $hidden_bug->id); + story(object_phid => $hidden->{phid}, text => updated_text($hidden)); + ok(!run_feed(), 'feed_query for a bug the author cannot see'); + ok( + (grep { $_->{type} eq 'comment' } $conduit->transactions_for($hidden->{phid})), + 'author is told they cannot access the bug' + ); + is(scalar(phab_attachments($hidden_bug->id)), + 0, 'no attachment on the hidden bug'); +} + +# Story author without a BMO account: phab-bot is used as the changer +{ + Bugzilla->memcached->clear({key => 'phab_user_bugzilla_id_' . $env->phab_bot->id + }); + start_scenario(); + my $bug = $env->bug; + my $raw = revision(bug_id => $bug->id); + story( + object_phid => $raw->{phid}, + author_phid => $unlinked->{phid}, + text => "phabtest-unlinked updated D$raw->{id}." + ); + ok(!run_feed(), 'feed_query for a story by an unlinked user'); + my ($lookup) = $conduit->calls('bugzilla.account.search'); + is_deeply( + $lookup, + {ids => [$env->phab_bot->id]}, + 'phab-bot looked up as the changer' + ); + is(scalar(phab_attachments($bug->id)), 1, 'revision still synced to the bug'); +} + +# Uplift request form +{ + my $relman = $conduit->add_project(project_obj(name => 'release-managers')); + my $repo + = $conduit->add_repository(repository_obj(short_name => 'firefox-beta')); + my $bug = $env->bug; + my $form = { + 'User impact if declined' => 'Crash on startup', + 'Needs manual QE testing?' => 'yes', + 'Is Android affected?' => '0', + 'Not a known question' => 'ignored', + }; + my $raw = revision( + bug_id => $bug->id, + repository_phid => $repo->{phid}, + uplift_request => $form, + ); + + start_scenario(); + story( + object_phid => $raw->{phid}, + text => "phabtest-author updated the uplift request field for D$raw->{id}." + ); + ok(!run_feed(), 'feed_query for an uplift request form change'); + + my $fresh = Bugzilla::Bug->new($bug->id); + my ($comment) + = grep { $_->body =~ /Uplift Approval Request/ } @{$fresh->comments}; + ok($comment, 'uplift form copied to the bug'); + my $body = $comment ? $comment->body : ''; + like($body, qr/^### firefox-beta Uplift Approval Request$/m, 'comment heading'); + like( + $body, + qr/^- \*\*User impact if declined\/Reason for urgency\*\*: Crash on startup$/m, + 'old form keys are mapped to the current label' + ); + like($body, qr/^- \*\*Is Android affected\?\*\*: no$/m, '0 is shown as "no"'); + unlike($body, qr/Not a known question/, 'unknown questions are left out'); + + my ($add) + = grep { $_->{type} eq 'reviewers.add' } + $conduit->transactions_for($raw->{phid}); + is_deeply( + $add->{value}, + ["blocking($relman->{phid})"], + '#release-managers added as a blocking reviewer' + ); + + my ($qe) = grep { $_->name eq 'qe-verify' } @{$fresh->flags}; +SKIP: { + skip 'qe-verify flag type is not available for Firefox', 1 + unless Bugzilla::FlagType->new({name => 'qe-verify'}); + is($qe ? $qe->status : '', '+', 'qe-verify+ set when manual QE is needed'); + } + + my ($hash) + = $dbh->selectrow_array( + 'SELECT uplift_form_hash FROM phab_uplift_form_state WHERE revision_phid = ?', + undef, $raw->{phid}); + is($hash, sha1_hex(encode_json($form)), 'form hash saved'); + is(scalar(phab_attachments($bug->id)), + 0, 'an uplift form story returns before the normal sync'); + + # Same form again: no second comment, normal sync runs + start_scenario(); + story(object_phid => $raw->{phid}, text => updated_text($raw)); + ok(!run_feed(), 'feed_query for a later change with the same form'); + is( + scalar( + grep { $_->body =~ /Uplift Approval Request/ } + @{Bugzilla::Bug->new($bug->id)->comments} + ), + 1, + 'form not copied again' + ); + is(scalar(phab_attachments($bug->id)), 1, 'normal sync ran'); + + $dbh->do('DELETE FROM phab_uplift_form_state WHERE revision_phid = ?', + undef, $raw->{phid}); +} + +# feed.query_id reports an invalid cursor: retry with the next id +{ + my $cursor = start_scenario(); + my $story = story(object_phid => 'PHID-TASK-retry', gap => 2); + $conduit->queue('feed.query_id' => cursor_invalid($cursor)); + ok(!run_feed(), 'feed_query recovers from an invalid cursor'); + is_deeply( + [map { $_->{after} } $conduit->calls('feed.query_id')], + [$cursor, $cursor + 1], + 'retried with after + 1' + ); + is($env->cursor('feed'), $story->{id}, 'story after the gap processed'); +} + +# ... but gives up after PHAB_FEED_MAX_RETRIES +{ + my $cursor = start_scenario(); + $conduit->queue('feed.query_id' => cursor_invalid($cursor + $_)) for 0 .. 2; + my $error = with_typed_errors(sub { run_feed() }); + is( + ref $error && $error->type, + 'bugzilla.code.phabricator_api_error', + 'feed_query throws after the retries' + ); + is_deeply( + [map { $_->{after} } $conduit->calls('feed.query_id')], + [$cursor, $cursor + 1, $cursor + 2], + 'tried PHAB_FEED_MAX_RETRIES (3) times' + ); + is($env->cursor('feed'), $cursor, 'cursor unchanged'); +} + +# Other feed.query_id failures throw at once and leave the cursor alone +foreach my $case ( + ['Conduit error', conduit_error('ERR-CONDUIT-CORE', 'Database is read-only')], + ['HTTP 500', http_error(500)], + ['HTTP 503', http_error(503)], + ['non-JSON body', invalid_json()], + ['network error', network_error('Connection refused')], + ) +{ + my ($name, $reply) = @$case; + my $cursor = start_scenario(); + story(object_phid => 'PHID-TASK-unseen'); + $conduit->queue('feed.query_id' => $reply); + my $error = with_typed_errors(sub { run_feed() }); + ok($error, "$name: feed_query throws"); + is($conduit->call_count('feed.query_id'), 1, "$name: not retried"); + is($env->cursor('feed'), $cursor, "$name: cursor unchanged"); +} + +# A story that fails is rolled back and the cursor stops before it +{ + my $bug = $env->bug; + my $raw = revision(bug_id => $bug->id); + start_scenario(); + my $before = story(object_phid => 'PHID-TASK-before'); + my $bad = story(object_phid => $raw->{phid}, text => updated_text($raw)); + my $after = story(object_phid => 'PHID-TASK-after'); + + $conduit->on( + 'differential.revision.edit' => sub { + my ($params, $c) = @_; + return conduit_error('ERR-CONDUIT-CORE', 'Edit rejected') + if $params->{objectIdentifier} eq $raw->{phid}; + return $c->default_reply('differential.revision.edit', $params); + } + ); + + ok(run_feed(), 'feed_query throws when a story fails'); + is($env->cursor('feed'), $before->{id}, + 'cursor stops before the failing story'); + is(scalar(phab_attachments($bug->id)), + 0, 'BMO changes for the failing story are rolled back'); + + ok(run_feed(), 'the failing story fails again on the next run'); + +TODO: { + local $TODO + = 'feed_query has no per-story error handling; one bad story stalls the feed'; + cmp_ok($env->cursor('feed') // 0, + '>=', $after->{id}, + 'stories after a persistently failing story are still processed'); + } + + $conduit->off('differential.revision.edit'); + ok(!run_feed(), 'feed resumes once the story can be processed'); + is($env->cursor('feed'), $after->{id}, 'cursor catches up'); +} + +$env->fresh_request; +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/feed-users.t b/extensions/PhabBugz/t/bmo/feed-users.t new file mode 100644 index 0000000000..abed9fd3df --- /dev/null +++ b/extensions/PhabBugz/t/bmo/feed-users.t @@ -0,0 +1,241 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# The feed daemon's new user loop (PhabBugz::Feed::user_query): polling +# user.search from the saved cursor, skipping users without a BMO account, +# subscribing new users to the private revisions of bugs they are involved +# in, and warning about possible username squatters. + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Test2::Tools::Mock qw(mock); +use Test::More; + +use Bugzilla::Extension::PhabBugz::Feed; +use Bugzilla::Extension::PhabBugz::Util qw(create_revision_attachment); +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; +my $feed = Bugzilla::Extension::PhabBugz::Feed->new; + +my @mail; +my $mailer = mock 'Bugzilla::Extension::PhabBugz::Feed' => + (override => [MessageToMTA => sub { push @mail, $_[0] }]); + +my @saved; +my $spy = mock 'Bugzilla::Extension::PhabBugz::Feed' => + (before => [save_last_id => sub { push @saved, $_[1] if $_[2] eq 'user' }]); + +# Phabricator ids for this file start above the ids PhabTest::Env gives +# linked users, so the bots and earlier fixtures are never "new". +my $last_user_id = 800_000_000; + +sub new_phab_user { + my (%args) = @_; + my $bmo = delete $args{bmo}; + $last_user_id++; + return $conduit->add_user(user_obj( + id => $last_user_id, + ($bmo ? (bmo_id => $bmo->id, phid => phid_for_bmo_user($bmo->id)) : ()), %args, + )); +} + +sub start_scenario { + $env->save_cursor('user', $last_user_id); + @saved = (); + @mail = (); + return $last_user_id; +} + +sub run_users { $env->run_sync(sub { $feed->user_query }) } + +# Sync disabled +{ + start_scenario(); + $env->sync_disabled_ok(sub { $feed->user_query }); +} + +# No cursor yet: the first page is requested without `before` +{ + $env->save_cursor('user', 0); + $conduit->queue('user.search' => search_result([])); + ok(!run_users(), 'user_query without a cursor'); + is_deeply( + [$conduit->calls('user.search')], + [{order => ['id'], attachments => {'external-accounts' => 1}}], + 'user.search without `before`' + ); +} + +# No new users +{ + my $cursor = start_scenario(); + ok(!run_users(), 'user_query with no new users'); + is_deeply( + [$conduit->calls('user.search')], + [{ + order => ['id'], + attachments => {'external-accounts' => 1}, + before => $cursor + }], + 'user.search pages forward with `before` the saved cursor' + ); + is($env->cursor('user'), $cursor, 'cursor unchanged'); + is_deeply(\@saved, [], 'nothing saved'); +} + +# New users are processed oldest first; users without a (valid) BMO +# account are skipped but still advance the cursor +{ + start_scenario(); + my $plain = new_phab_user(username => 'phabtest-noaccount'); + my $ghost + = new_phab_user(username => 'phabtest-ghost', bmo_id => 2_000_000_000); + my $member = $env->bmo_user('member'); + my $linked = new_phab_user(bmo => $member, username => 'phabtest-member'); + + ok(!run_users(), 'user_query with three new users'); + is_deeply( + \@saved, + [$plain->{id}, $ghost->{id}, $linked->{id}], + 'users handled in ascending id order (Conduit returns newest first)' + ); + is($env->cursor('user'), $linked->{id}, 'cursor saved after the last user'); + is($conduit->call_count('differential.revision.edit'), + 0, 'user without private revisions changes nothing'); +} + +# Only one page is read per run; the rest comes on the next run +{ + start_scenario(); + my $page_guard = $conduit->local_page_size(2); + my @users = map { new_phab_user() } 1 .. 3; + ok(!run_users(), 'first run'); + is($env->cursor('user'), $users[1]{id}, 'first run stops after one page'); + ok(!run_users(), 'second run'); + is($env->cursor('user'), $users[2]{id}, 'second run reads the next page'); +} + +# A new user is subscribed to private revisions on bugs they are involved in +{ + my $author = $env->bmo_user('author'); + my $author_phab = $env->link_user($author); + my $newcomer = $env->bmo_user('newcomer'); + my $group = $env->bug_group('users'); + + my $private = $env->bug(groups => [$group], cc => [$newcomer]); + my $public = $env->bug(cc => [$newcomer]); + my $other = $env->bug(groups => [$group]); + + my ($timestamp) = Bugzilla->dbh->selectrow_array('SELECT NOW()'); + my %revision_for; + foreach my $bug ($private, $public, $other) { + my $raw = $revision_for{$bug->id} = revision_obj( + bug_id => $bug->id, + author_phid => $author_phab->{phid}, + view_policy => 'PHID-PLCY-private', + ); + create_revision_attachment($bug, $conduit->load_revision($raw), + $timestamp, $author); + } + + start_scenario(); + my $phab + = new_phab_user(bmo => $newcomer, username => $env->unique('newcomer')); + ok(!run_users(), 'user_query for a user CCed on a private bug'); + + my @edits = $conduit->calls('differential.revision.edit'); + is(scalar @edits, 1, 'one revision updated'); + is( + $edits[0]{objectIdentifier}, + $revision_for{$private->id}{phid}, + 'the revision on the private bug the user is CCed on' + ); + is_deeply( + $edits[0]{transactions}, + [{type => 'subscribers.add', value => [$phab->{phid}]}], + 'user added as a subscriber' + ); + is_deeply( + [map { $_->{constraints} } $conduit->calls('differential.revision.search')], + [{ids => [$revision_for{$private->id}{id}]}], + 'revision looked up by the id in the attachment filename' + ); + + # A failure while updating revisions stops the loop before saving the + # cursor for that user + my $cursor = start_scenario(); + my $again = new_phab_user(bmo => $newcomer, username => $env->unique('again')); + $conduit->queue( + 'differential.revision.edit' => conduit_error('ERR-CONDUIT-CORE', 'Locked')); + ok(run_users(), 'user_query throws when a revision update fails'); + is($env->cursor('user'), $cursor, 'cursor not advanced past the failing user'); + ok(!run_users(), 'the user is processed on the next run'); + is($env->cursor('user'), $again->{id}, 'cursor advanced'); +} + +# Username squatting: another BMO account uses ":" +{ + my $username = 'phabtestsq' . lc sprintf('%x%x', time, $$); + my $owner = $env->bmo_user('owner', realname => "Real Owner :$username"); + my $squatter = $env->bmo_user('squatter', realname => "Not Them [:$username]"); + my $bystander + = $env->bmo_user('bystander', realname => "Someone :${username}extra"); + + start_scenario(); + new_phab_user(bmo => $owner, username => $username, realname => 'Real Owner'); + ok(!run_users(), 'user_query for a user with a squatted nick'); + is(scalar @mail, 1, 'one squatter alert sent'); + like( + $mail[0] // '', + qr/\Q${\ $squatter->login}\E/, + 'alert names the squatting account' + ); + unlike( + $mail[0] // '', + qr/\Q${\ $bystander->login}\E/, + 'a longer nick is not a squatter' + ); + + start_scenario(); + my $clean = $env->bmo_user('clean'); + new_phab_user(bmo => $clean, username => $env->unique('unique-nick')); + ok(!run_users(), 'user_query for a user with a unique nick'); + is(scalar @mail, 0, 'no alert'); +} + +# user.search failures leave the cursor alone +foreach my $case ( + ['Conduit error', conduit_error('ERR-CONDUIT-CORE', 'Database is read-only')], + ['HTTP 502', http_error(502)], + ['non-JSON body', invalid_json()], + ['network error', network_error('Inactivity timeout')], + ) +{ + my ($name, $reply) = @$case; + my $cursor = start_scenario(); + $conduit->queue('user.search' => $reply); + my $error = with_typed_errors(sub { run_users() }); + ok($error, "$name: user_query throws"); + is($env->cursor('user'), $cursor, "$name: cursor unchanged"); +} + +$env->fresh_request; +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/lib/PhabTest/Conduit.pm b/extensions/PhabBugz/t/bmo/lib/PhabTest/Conduit.pm new file mode 100644 index 0000000000..c8ce42ee9a --- /dev/null +++ b/extensions/PhabBugz/t/bmo/lib/PhabTest/Conduit.pm @@ -0,0 +1,696 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# An in-process fake Phabricator Conduit server. +# +# PhabBugz::Util::request() sends every Conduit call through +# Mojo::UserAgent->post. This module overrides that method, decodes the +# `params` form field, records the call and answers it from an in-memory +# directory of users, projects, revisions, repositories, policies, feed +# stories, transactions and edges. Posts to any other URL go to the real +# Mojo::UserAgent->post, so other extensions' HTTP calls are not mistaken +# for Conduit calls. +# +# Usage: +# +# my $conduit = PhabTest::Conduit->new; +# my $user = $conduit->add_user(user_obj(...)); +# $conduit->on('user.search' => sub { my ($params) = @_; ... }); +# $conduit->queue('feed.query_id' => conduit_error('ERR', 'boom')); +# my @calls = $conduit->calls('differential.revision.edit'); +# +# Precedence for answering a call: queued replies, then `on` handlers, then +# the built-in directory handlers. A reply is either a hashref (the Conduit +# `result` payload) or one of the failure objects built by conduit_error(), +# http_error(), network_error(), invalid_json() or raw_reply(). + +package PhabTest::Conduit; + +use 5.10.1; +use strict; +use warnings; + +use base qw(Exporter); +our @EXPORT_OK = qw( + conduit_error + cursor_invalid + http_error + invalid_json + network_error + raw_reply + throws_error + with_typed_errors +); +our %EXPORT_TAGS = (all => \@EXPORT_OK); + +use Bugzilla; +use Bugzilla::Config qw(SetParam); +use Bugzilla::Extension::PhabBugz::Revision; +use List::Util qw(first); +use Mojo::JSON qw(decode_json encode_json); +use Mojo::Transaction::HTTP; +use Mojo::UserAgent; +use Scalar::Util qw(blessed weaken); +use Scope::Guard; +use Storable qw(dclone); +use Test2::Tools::Mock qw(mock); +use Test::More (); +use Try::Tiny; + +use PhabTest::Fixtures qw(:all); + +use constant DEFAULT_BASE_URI => 'https://phabricator.test.example.com/'; +use constant DEFAULT_API_KEY => 'api-phabtestfakeconduitkey000'; + +sub new { + my ($class) = @_; + my $self = bless { + base_uri => DEFAULT_BASE_URI, + api_key => DEFAULT_API_KEY, + page_size => 100, + handlers => {}, + queues => {}, + calls => [], + unexpected => [], + bad_auth => [], + users => [], + projects => [], + revisions => [], + repos => [], + policies => [], + stories => [], + xactions => {}, + edges => [], + }, $class; + + $self->apply_params; + + my $weak_self = $self; + weaken($weak_self); + my $orig_post = Mojo::UserAgent->can('post'); + $self->{mock} = mock 'Mojo::UserAgent' => ( + override => [ + post => sub { + my ($ua, $url, @form) = @_; + return $orig_post->(@_) unless $weak_self && $weak_self->_is_conduit_url($url); + return $weak_self->_dispatch($url, @form); + }, + ], + ); + + return $self; +} + +# Set the PhabBugz params in memory only. They live in the request cache, so +# call this again after anything that clears it (Bugzilla->_cleanup, +# Bugzilla->clear_request_cache). +sub apply_params { + my ($self) = @_; + SetParam('phabricator_enabled', 1); + SetParam('phabricator_base_uri', $self->{base_uri}); + SetParam('phabricator_api_key', $self->{api_key}); + return $self; +} + +sub base_uri { + my ($self) = @_; + return $self->{base_uri}; +} + +sub api_key { + my ($self) = @_; + return $self->{api_key}; +} + +# Set the page size until the returned guard goes out of scope, even if the +# block dies: my $guard = $conduit->local_page_size(2); +sub local_page_size { + my ($self, $size) = @_; + my $old = $self->{page_size}; + $self->{page_size} = $size; + return Scope::Guard->new(sub { $self->{page_size} = $old }); +} + +################# +# Configuration # +################# + +sub on { + my ($self, $method, $reply) = @_; + $self->{handlers}{$method} = $reply; + return $self; +} + +sub off { + my ($self, $method) = @_; + delete $self->{handlers}{$method}; + return $self; +} + +sub queue { + my ($self, $method, @replies) = @_; + push @{$self->{queues}{$method}}, @replies; + return $self; +} + +sub add_user { + my ($self, $obj) = @_; + return $self->_add(users => $obj); +} + +sub add_project { + my ($self, $obj) = @_; + return $self->_add(projects => $obj); +} + +sub add_revision { + my ($self, $obj) = @_; + return $self->_add(revisions => $obj); +} + +# Add $raw (a revision_obj) to the directory if it is not there yet and load +# it as a PhabBugz::Revision. +sub load_revision { + my ($self, $raw) = @_; + $self->add_revision($raw) unless grep { $_ == $raw } @{$self->{revisions}}; + return Bugzilla::Extension::PhabBugz::Revision->new_from_query({ + phids => [$raw->{phid}] + }); +} + +sub add_repository { + my ($self, $obj) = @_; + return $self->_add(repos => $obj); +} + +sub add_policy { + my ($self, $obj) = @_; + return $self->_add(policies => $obj); +} + +sub add_story { + my ($self, $obj) = @_; + return $self->_add(stories => $obj); +} + +sub _add { + my ($self, $kind, $obj) = @_; + push @{$self->{$kind}}, $obj; + return $obj; +} + +sub add_transaction { + my ($self, $object_phid, $xact) = @_; + push @{$self->{xactions}{$object_phid}}, $xact; + return $xact; +} + +sub add_edge { + my ($self, @edge) = @_; + push @{$self->{edges}}, edge_obj(@edge); + return $self; +} + +sub find_project { + my ($self, $key, $value) = @_; + return + first { $key eq 'name' ? $_->{fields}{name} eq $value : $_->{$key} eq $value } + @{$self->{projects}}; +} + +############### +# Inspection # +############### + +# Decoded params (without the __conduit__ token) of every call to $method, +# oldest first. Without a method, every call as +# {method => ..., url => ..., params => ...}. +sub calls { + my ($self, $method) = @_; + return @{$self->{calls}} unless defined $method; + return map { $_->{params} } grep { $_->{method} eq $method } @{$self->{calls}}; +} + +sub call_count { + my ($self, $method) = @_; + return scalar $self->calls($method); +} + +sub reset_calls { + my ($self) = @_; + $self->{calls} = []; + return $self; +} + +# Transactions sent by *.edit calls for one object, flattened in order. +sub transactions_for { + my ($self, $phid, $method) = @_; + $method //= 'differential.revision.edit'; + return + map { @{$_->{transactions}} } + grep { ($_->{objectIdentifier} // '') eq $phid } $self->calls($method); +} + +sub unexpected { + my ($self) = @_; + return $self->{unexpected}; +} + +# Every test file should end with this: no unhandled methods, and every call +# carried the configured API token to the expected URL. +sub verify { + my ($self, $name) = @_; + $name //= 'fake Conduit'; + local $Test::Builder::Level = $Test::Builder::Level + 1; + Test::More::is_deeply($self->{unexpected}, [], + "$name: no calls to unhandled Conduit methods"); + Test::More::is_deeply($self->{bad_auth}, [], + "$name: every call used the API token and /api/ URL"); +} + +############ +# Replies # +############ + +sub conduit_error { + my ($code, $info) = @_; + return bless {type => 'conduit', code => $code, info => $info}, + 'PhabTest::Conduit::Reply'; +} + +# The error feed.query_id gives when `after` names a story id that is gone. +sub cursor_invalid { + my ($id) = @_; + return conduit_error('ERR-CONDUIT-CORE', + "The value \"$id\" does not identify a valid object in query."); +} + +sub http_error { + my ($code, $body) = @_; + return bless {type => 'http', code => $code, body => $body // ''}, + 'PhabTest::Conduit::Reply'; +} + +# What Mojo::UserAgent does for connection refused, DNS failures, TLS errors +# and timeouts: the transaction has an error without an HTTP code, so +# $tx->result croaks. +sub network_error { + my ($message) = @_; + return bless {type => 'network', message => $message // 'Connection refused'}, + 'PhabTest::Conduit::Reply'; +} + +sub invalid_json { + return raw_reply('502 Bad Gateway', 'text/html'); +} + +sub raw_reply { + my ($body, $content_type) = @_; + return bless {type => 'raw', body => $body, content_type => $content_type}, + 'PhabTest::Conduit::Reply'; +} + +# Run $code and check that it throws a Bugzilla error of $type, for example +# 'bugzilla.code.phabricator_api_error'. Returns the exception (or undef). +sub throws_error { + my ($code, $type, $name) = @_; + local $Test::Builder::Level = $Test::Builder::Level + 1; + my $error; + try { with_typed_errors($code) } catch { $error = $_ }; + if (!defined $error) { + Test::More::fail("$name (no exception thrown)"); + return undef; + } + my $got = blessed $error && $error->can('type') ? $error->type : "$error"; + Test::More::is($got, $type, $name); + return $error; +} + +# Run $code with Bugzilla errors thrown as Template::Exception objects that +# have a ->type, instead of rendered error pages. Returns what $code returns. +sub with_typed_errors { + my ($code) = @_; + local $Bugzilla::Template::is_processing = 1; + return $code->(); +} + +############### +# Dispatching # +############### + +sub _dispatch { + my ($self, $url, $form_type, $form) = @_; + + my $params = {}; + try { + $params = decode_json($form->{params}); + } + catch { + push @{$self->{bad_auth}}, "undecodable params for $url"; + }; + + my $token = delete($params->{__conduit__}) // {}; + + # Accept /api/ with one or more slashes before "api", so the + # fake does not depend on whether base_uri ends in a slash. + my ($method) = $url =~ m{^\Q${\ $self->_base}\E/+api/([\w.]+)$}; + if (!$method || ($token->{token} // '') ne $self->{api_key}) { + push @{$self->{bad_auth}}, $url; + } + ($method) = $url =~ m{/api/([\w.]+)$} unless $method; + $method //= $url; + + push @{$self->{calls}}, + {method => $method, url => $url, params => dclone($params)}; + + my $reply; + if (@{$self->{queues}{$method} // []}) { + $reply = shift @{$self->{queues}{$method}}; + } + elsif (my $handler = $self->{handlers}{$method}) { + $reply = ref $handler eq 'CODE' ? $handler->($params, $self) : $handler; + } + else { + $reply = $self->default_reply($method, $params); + } + + return _transaction($reply); +} + +sub _base { + my ($self) = @_; + return $self->{base_uri} =~ s{/+$}{}r; +} + +sub _is_conduit_url { + my ($self, $url) = @_; + return index("$url", $self->_base . '/') == 0; +} + +# The built-in directory answer for $method. `on` handlers can call this to +# fail only some requests and pass the rest through. +sub default_reply { + my ($self, $method, $params) = @_; + if (my $builtin = $self->can('reply_' . ($method =~ s/\./_/gr))) { + return $self->$builtin($params); + } + push @{$self->{unexpected}}, $method; + return conduit_error('ERR-CONDUIT-CALL', + "Conduit method \"$method\" does not exist."); +} + +sub _transaction { + my ($reply) = @_; + my $tx = Mojo::Transaction::HTTP->new; + my $res = $tx->res; + + if (blessed $reply && $reply->isa('PhabTest::Conduit::Reply')) { + if ($reply->{type} eq 'network') { + $res->error({message => $reply->{message}}); + return $tx; + } + if ($reply->{type} eq 'http') { + $res->code($reply->{code}); + $res->message($res->default_message); + $res->headers->content_type('text/html'); + $res->body($reply->{body}); + return $tx; + } + if ($reply->{type} eq 'raw') { + $res->code(200); + $res->headers->content_type($reply->{content_type} // 'application/json'); + $res->body($reply->{body}); + return $tx; + } + $res->code(200); + $res->headers->content_type('application/json'); + $res->body(encode_json( + {result => undef, error_code => $reply->{code}, error_info => $reply->{info}} + )); + return $tx; + } + + $res->code(200); + $res->headers->content_type('application/json'); + $res->body( + encode_json({result => $reply, error_code => undef, error_info => undef})); + return $tx; +} + +####################################### +# Built-in replies (fake directory) # +####################################### + +# Return one page of @items (sorted by id descending, the Conduit default +# order) honouring the `after` cursor and the configured page size. +sub _page { + my ($self, $params, @items) = @_; + @items = reverse sort { $a->{id} <=> $b->{id} } @items; + if (my $after = $params->{after}) { + @items = grep { $_->{id} < $after } @items; + } + my $limit = $self->{page_size}; + my $more = @items > $limit; + @items = @items[0 .. $limit - 1] if $more; + return search_result( + [map { dclone($_) } @items], + limit => $limit, + after => ($more ? $items[-1]{id} : undef) + ); +} + +sub _filter { + my ($constraints, @items) = @_; + if (my $phids = $constraints->{phids}) { + my %want = map { $_ => 1 } @$phids; + @items = grep { $want{$_->{phid}} } @items; + } + if (my $ids = $constraints->{ids}) { + my %want = map { $_ => 1 } @$ids; + @items = grep { $want{$_->{id}} } @items; + } + return @items; +} + +sub _bmo_id_of { + my ($user) = @_; + my $accounts = $user->{attachments}{'external-accounts'}{'external-accounts'}; + my $bmo = first { $_->{type} eq 'bmo' } @{$accounts // []}; + return $bmo ? $bmo->{id} : undef; +} + +sub reply_user_search { + my ($self, $params) = @_; + my @users = _filter($params->{constraints} // {}, @{$self->{users}}); + + # PhabBugz::Feed::new_users pages forward with `before`: Conduit returns + # the page of users created right after that id. + if (my $before = $params->{before}) { + my @newer = sort { $a->{id} <=> $b->{id} } grep { $_->{id} > $before } @users; + @newer = @newer[0 .. $self->{page_size} - 1] if @newer > $self->{page_size}; + return search_result([map { dclone($_) } reverse @newer], + limit => $self->{page_size}); + } + + return $self->_page($params, @users); +} + +# bugzilla.account.search items ({id => BMO id as a string, phid => user +# PHID}) for the users linked to @$ids. +sub _accounts_by_bmo_id { + my ($self, $ids) = @_; + my %want = map { $_ => 1 } @{$ids // []}; + return [ + map { {id => "$_->[0]", phid => $_->[1]{phid}} } + grep { defined $_->[0] && $want{$_->[0]} } + map { [_bmo_id_of($_), $_] } @{$self->{users}} + ]; +} + +sub reply_bugzilla_account_search { + my ($self, $params) = @_; + return $self->_accounts_by_bmo_id($params->{ids}); +} + +sub reply_bmoexternalaccount_search { + my ($self, $params) = @_; + return $self->_accounts_by_bmo_id($params->{accountids}); +} + +# The legacy user.query method returns a flat list. +sub reply_user_query { + my ($self, $params) = @_; + return [ + map { + +{ + phid => $_->{phid}, + userName => $_->{fields}{username}, + realName => $_->{fields}{realName}, + image => $self->_base . '/file/data/profile.png', + uri => $self->_base . "/p/$_->{fields}{username}/", + roles => $_->{fields}{roles}, + } + } _filter({phids => $params->{phids} // []}, @{$self->{users}}) + ]; +} + +sub reply_user_edit { + my ($self, $params) = @_; + return edit_result($params->{objectIdentifier}); +} + +# Conduit tokenizes the `name` constraint, so a search for "bmo-foo" also +# returns "bmo-foo-bar". PhabBugz::Project::new_from_query has to pick the +# exact match; this fake behaves the same way. +sub reply_project_search { + my ($self, $params) = @_; + my $constraints = $params->{constraints} // {}; + my @projects = _filter($constraints, @{$self->{projects}}); + if (defined(my $name = $constraints->{name})) { + @projects = grep { index($_->{fields}{name}, $name) >= 0 } @projects; + } + return $self->_page($params, @projects); +} + +sub reply_project_edit { + my ($self, $params) = @_; + my $project; + + if (my $phid = $params->{objectIdentifier}) { + $project = $self->find_project(phid => $phid); + return conduit_error('ERR-CONDUIT-CORE', + "Monogram, PHID, or ID \"$phid\" does not identify a valid object.") + unless $project; + } + else { + $project = $self->add_project(project_obj(name => 'phabtest-unnamed')); + } + + my $fields = $project->{fields}; + my $members = $project->{attachments}{members}{members}; + foreach my $xact (@{$params->{transactions} // []}) { + my ($type, $value) = @$xact{qw(type value)}; + if ($type eq 'name') { $fields->{name} = $fields->{slug} = $value } + elsif ($type eq 'description') { $fields->{description} = $value } + elsif ($type =~ /^(view|edit|join)$/) { $fields->{policy}{$type} = $value } + elsif ($type eq 'members.set') { + @$members = map { {phid => $_} } @$value; + } + elsif ($type eq 'members.add') { + push @$members, map { {phid => $_} } @$value; + } + elsif ($type eq 'members.remove') { + my %drop = map { $_ => 1 } @$value; + @$members = grep { !$drop{$_->{phid}} } @$members; + } + } + + return edit_result($project->{phid}, $project->{id}); +} + +sub reply_differential_revision_search { + my ($self, $params) = @_; + my $result = $self->_page($params, + _filter($params->{constraints} // {}, @{$self->{revisions}})); + + # Like Conduit, only return the attachments the caller asked for + my $wanted = $params->{attachments} // {}; + foreach my $revision (@{$result->{data}}) { + my $attachments = $revision->{attachments}; + delete @$attachments{grep { !$wanted->{$_} } keys %$attachments}; + } + return $result; +} + +sub reply_differential_revision_edit { + my ($self, $params) = @_; + my $phid = $params->{objectIdentifier}; + my $revision = first { $_->{phid} eq $phid } @{$self->{revisions}}; + return conduit_error('ERR-CONDUIT-CORE', + "Monogram, PHID, or ID \"$phid\" does not identify a valid object.") + unless $revision; + return edit_result($phid, $revision->{id}); +} + +sub reply_diffusion_repository_search { + my ($self, $params) = @_; + return $self->_page($params, + _filter($params->{constraints} // {}, @{$self->{repos}})); +} + +sub reply_policy_query { + my ($self, $params) = @_; + my %want = map { $_ => 1 } @{$params->{phids} // []}; + return search_result( + [map { dclone($_) } grep { $want{$_->{phid}} } @{$self->{policies}}]); +} + +# Rule classes the fake policy.create accepts. +use constant POLICY_RULES => { + map { $_ => 1 } qw( + PhabricatorAdministratorsPolicyRule + PhabricatorDifferentialReviewersPolicyRule + PhabricatorProjectsAllPolicyRule + PhabricatorProjectsPolicyRule + PhabricatorSubscriptionsSubscribersPolicyRule + PhabricatorUsersPolicyRule + ) +}; + +# policy.create (a BMO-specific Conduit method, CreatePolicyConduitAPIMethod +# in mozilla-conduit/phabricator-private) checks each rule and stores it in +# the shape policy.query returns: `action`, `rule` and `value` always set. +# Like the real method, a rule with a bad action or a missing or unknown +# rule class is an ERR-INVALID-PARAMETER error. It is not repaired. +sub reply_policy_create { + my ($self, $params) = @_; + my @rules; + foreach my $rule (@{$params->{policy} // []}) { + my $action = $rule->{action} // ''; + return conduit_error('ERR-INVALID-PARAMETER', "Invalid action '$action'!") + unless $action eq 'allow' || $action eq 'deny'; + my $class = $rule->{rule} // ''; + return conduit_error('ERR-INVALID-PARAMETER', "Invalid rule class '$class'!") + unless POLICY_RULES->{$class}; + my $value = $rule->{value}; + push @rules, + { + action => $action, + rule => $class, + value => (ref $value ? dclone($value) : defined $value ? [$value] : undef), + }; + } + my $policy + = $self->add_policy(policy_obj(default => $params->{default}, rules => \@rules)); + return {phid => $policy->{phid}}; +} + +sub reply_feed_query_id { + my ($self, $params) = @_; + my $after = $params->{after} // 0; + return search_result([ + map { dclone($_) } + reverse sort { $a->{id} <=> $b->{id} } + grep { $_->{id} > $after } @{$self->{stories}} + ]); +} + +sub reply_transaction_search { + my ($self, $params) = @_; + return search_result($self->{xactions}{$params->{objectIdentifier}} // []); +} + +sub reply_edge_search { + my ($self, $params) = @_; + my %source = map { $_ => 1 } @{$params->{sourcePHIDs} // []}; + my %types = map { $_ => 1 } @{$params->{types} // []}; + return search_result([ + grep { $source{$_->{sourcePHID}} && $types{$_->{edgeType}} } @{$self->{edges}} + ]); +} + +1; diff --git a/extensions/PhabBugz/t/bmo/lib/PhabTest/Env.pm b/extensions/PhabBugz/t/bmo/lib/PhabTest/Env.pm new file mode 100644 index 0000000000..70b8aa317e --- /dev/null +++ b/extensions/PhabBugz/t/bmo/lib/PhabTest/Env.pm @@ -0,0 +1,361 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# Per-file setup and teardown for the PhabBugz t/bmo tests. +# +# The t/bmo tests run against the shared MySQL database that `test_bmo` +# seeds once per container, so each file must leave it as it found it: +# +# * fixtures get unique names (unique()), +# * the phabbugz cursor rows (feed_last_id, user_last_id) are restored, +# * bug groups created by the test are deactivated so later group syncs +# do not see them, +# * users created with bmo_user() are re-enabled if the test disabled them, +# * anything else is cleaned up by on_teardown() callbacks, +# * PhabBugz params are only ever set in memory (PhabTest::Conduit). +# +# Teardown runs from finish(), or from an END block if the test file dies +# first. END blocks run before global destruction, so the database handle +# is still usable; DESTROY is only a last resort. +# +# Usage: +# +# my $env = PhabTest::Env->new; # also creates the fake Conduit +# my $c = $env->conduit; +# my $user = $env->bmo_user('author'); +# my $phab = $env->link_user($user); # Phabricator user for a BMO user +# ... +# $env->finish; # verify Conduit usage, restore DB +# done_testing; + +package PhabTest::Env; + +use 5.10.1; +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Bug; +use Bugzilla::Config qw(SetParam); +use Bugzilla::Constants; +use Bugzilla::Group; +use Bugzilla::Product; +use Bugzilla::User; +use Bugzilla::Test::Util qw(create_bug create_group create_user); +use Bugzilla::Extension::PhabBugz::Constants; + +use PhabTest::Conduit; +use PhabTest::Fixtures qw(phid_for_bmo_user user_obj); +use Scalar::Util qw(refaddr weaken); +use Test::More (); +use Try::Tiny; + +my $UNIQUE = 0; + +# Envs that have not been torn down yet, for the END block. +my %LIVE; + +sub new { + my ($class) = @_; + + my $self = bless { + groups => [], + linked => [], + users => [], + cleanups => [], + finished => 0, + }, $class; + weaken($LIVE{refaddr $self} = $self); + $self->{conduit} = PhabTest::Conduit->new; + $self->_set_modes; + + my $dbh = Bugzilla->dbh; + $self->{saved_cursors} = $dbh->selectall_arrayref( + q{SELECT name, value FROM phabbugz + WHERE name IN ('feed_last_id', 'user_last_id')} + ); + + $self->{admin} = Bugzilla::User->check({id => 1}); + Bugzilla->set_user($self->{admin}); + + $self->{phab_bot} = create_user(PHAB_AUTOMATION_USER, '*', + realname => 'Phabricator Automation'); + $self->{lando} + = create_user(LANDO_AUTOMATION_USER, '*', realname => 'Lando Automation'); + + # The automation users get linked Phabricator accounts by default, as the + # feed daemon looks them up on every run. + $self->{phab_bot_phab} + = $self->link_user($self->{phab_bot}, username => 'phab-bot'); + $self->{lando_phab} = $self->link_user($self->{lando}, username => 'lobot'); + + return $self; +} + +sub conduit { + my ($self) = @_; + return $self->{conduit}; +} + +sub admin { + my ($self) = @_; + return $self->{admin}; +} + +sub phab_bot { + my ($self) = @_; + return $self->{phab_bot}; +} + +sub phab_bot_phab { + my ($self) = @_; + return $self->{phab_bot_phab}; +} + +sub lando_phab { + my ($self) = @_; + return $self->{lando_phab}; +} + +sub _set_modes { + Bugzilla->usage_mode(USAGE_MODE_TEST); + Bugzilla->error_mode(ERROR_MODE_DIE); +} + +# A name no other test file or earlier run has used. +sub unique { + my ($self, $suffix) = @_; + return sprintf('phabtest-%x-%d-%d-%s', time, $$, ++$UNIQUE, $suffix // 'x'); +} + +sub bmo_user { + my ($self, $suffix, %extra) = @_; + my $login = $self->unique($suffix) . '@phabtest.example.com'; + my $user = create_user($login, '*', realname => "PhabTest $suffix", %extra); + push @{$self->{users}}, $user->id; + return $user; +} + +# Register a Phabricator user linked to $bmo_user in the fake Conduit and +# drop any memcached BMO id -> PHID mapping left from an earlier test. +sub link_user { + my ($self, $bmo_user, %args) = @_; + Bugzilla->memcached->clear({key => 'phab_user_bugzilla_id_' . $bmo_user->id}); + push @{$self->{linked}}, $bmo_user->id; + my ($local_part) = split /@/, $bmo_user->login; + return $self->{conduit}->add_user(user_obj( + id => $args{id} // 100_000 + $bmo_user->id, + phid => phid_for_bmo_user($bmo_user->id), + username => $args{username} // $local_part, + realname => $args{realname} // $bmo_user->name, + bmo_id => $bmo_user->id, + )); +} + +# An active bug group, deactivated again by teardown(). +sub bug_group { + my ($self, $suffix) = @_; + return $self->group($suffix, isbuggroup => 1); +} + +# Any group (pass `isbuggroup => 0` for a system group), deactivated again +# by teardown(). +sub group { + my ($self, $suffix, %args) = @_; + my $group = create_group( + $self->unique($suffix), + isactive => 1, + owner_user_id => $self->{admin}->login, + %args, + ); + push @{$self->{groups}}, $group; + return $group; +} + +# Register cleanup for rows the Env does not know about. Callbacks run in +# teardown(), newest first, even if the test died part way through. +sub on_teardown { + my ($self, $code) = @_; + push @{$self->{cleanups}}, $code; + return $self; +} + +sub add_to_group { + my ($self, $user, $group) = @_; + Bugzilla->dbh->do( + 'INSERT INTO user_group_map (user_id, group_id, isbless, grant_type) + VALUES (?, ?, 0, ?)', undef, $user->id, $group->id, GRANT_DIRECT + ); + return $self; +} + +sub remove_from_group { + my ($self, $user, $group) = @_; + Bugzilla->dbh->do( + 'DELETE FROM user_group_map WHERE user_id = ? AND group_id = ? AND isbless = 0', + undef, $user->id, $group->id + ); + return $self; +} + +# A bug filed by the admin, in Firefox :: General unless `product` and +# `component` are given. Pass `groups => [$group, ...]` to make it private, +# `cc => [$user, ...]` and `assigned_to => $user`. +sub bug { + my ($self, %args) = @_; + my $restore = Bugzilla->set_user($self->{admin}, scope_guard => 1); + my $product = $args{product} // 'Firefox'; + my $bug = create_bug( + short_desc => $args{summary} // $self->unique('bug'), + product => $product, + component => $args{component} // 'General', + bug_type => 'defect', + version => $product eq 'Firefox' + ? 'Trunk' + : Bugzilla::Product->new({name => $product})->versions->[0]->name, + cc => [map { $_->login } @{$args{cc} // []}], + comment => 'PhabBugz test bug', + assigned_to => + ($args{assigned_to} ? $args{assigned_to}->login : 'nobody@mozilla.org'), + ); + + # Add groups straight to bug_group_map: Firefox's group controls do not + # allow arbitrary test groups through Bug->create. + if (my $groups = $args{groups}) { + my $dbh = Bugzilla->dbh; + $dbh->do('INSERT INTO bug_group_map (bug_id, group_id) VALUES (?, ?)', + undef, $bug->id, $_->id) + foreach @$groups; + } + + return Bugzilla::Bug->new($bug->id); +} + +sub save_cursor { + my ($self, $type, $value) = @_; + Bugzilla->dbh->do('REPLACE INTO phabbugz (name, value) VALUES (?, ?)', + undef, "${type}_last_id", $value); +} + +sub cursor { + my ($self, $type) = @_; + my ($value) + = Bugzilla->dbh->selectrow_array('SELECT value FROM phabbugz WHERE name = ?', + undef, "${type}_last_id"); + return $value; +} + +# Start a new "request": clear the request cache (cached bugs, users and +# params) and put back the test user, error mode and PhabBugz params. +sub fresh_request { + my ($self, $user) = @_; + Bugzilla->clear_request_cache; + _set_modes(); + Bugzilla->set_user($user // $self->{admin}); + $self->{conduit}->apply_params; + return $self; +} + +# Run one feed daemon sync ($code, e.g. sub { $feed->feed_query }) as a +# fresh request with no calls recorded yet. Returns the exception, if any. +sub run_sync { + my ($self, $code) = @_; + $self->fresh_request; + $self->{conduit}->reset_calls; + my $error; + try { $code->() } catch { $error = $_ }; + return $error; +} + +# Check that the sync in $code makes no Conduit calls with +# phabricator_enabled off. The next fresh_request turns it back on. +sub sync_disabled_ok { + my ($self, $code) = @_; + local $Test::Builder::Level = $Test::Builder::Level + 1; + SetParam('phabricator_enabled', 0); + $self->{conduit}->reset_calls; + $code->(); + Test::More::is($self->{conduit}->call_count, + 0, 'no Conduit calls with phabricator_enabled off'); +} + +sub finish { + my ($self) = @_; + local $Test::Builder::Level = $Test::Builder::Level + 1; + $self->{conduit}->verify; + $self->teardown; +} + +sub teardown { + my ($self) = @_; + return if $self->{finished}++; + delete $LIVE{refaddr $self}; + + my $dbh = Bugzilla->dbh; + $dbh->bz_rollback_transaction() if $dbh->bz_in_transaction; + + # Each step runs on its own so one failure does not skip the rest. + _try_step($_) foreach reverse @{$self->{cleanups}}; + + _try_step(sub { + $dbh->do( + q{DELETE FROM phabbugz WHERE name IN ('feed_last_id', 'user_last_id')}); + $dbh->do('INSERT INTO phabbugz (name, value) VALUES (?, ?)', undef, @$_) + foreach @{$self->{saved_cursors}}; + }); + + foreach my $group (@{$self->{groups}}) { + _try_step(sub { + my $fresh = Bugzilla::Group->new($group->id) or return; + $fresh->set_is_active(0); + $fresh->update; + }); + } + + # Plain SQL rather than User->update, so the PhabBugz hook does not send + # a user.edit to the fake Conduit after verify() has run. + if (my @users = @{$self->{users}}) { + _try_step(sub { + $dbh->do( + 'UPDATE profiles SET disabledtext = ?, is_enabled = 1 WHERE userid IN (' + . join(',', ('?') x @users) . ')', + undef, '', @users + ); + Bugzilla->memcached->clear({table => 'profiles', id => $_}) foreach @users; + }); + } + + _try_step(sub { + Bugzilla->memcached->clear({key => "phab_user_bugzilla_id_$_"}) + foreach @{$self->{linked}}; + }); +} + +sub _try_step { + my ($code) = @_; + local $@ = undef; + eval { $code->(); 1 } or warn "PhabTest::Env teardown step failed: $@"; +} + +# Runs before global destruction, while Bugzilla->dbh still works, for test +# files that die before calling finish(). +END { + local $? = $?; + foreach my $env (grep {defined} values %LIVE) { + local $@ = undef; + eval { $env->teardown; 1 } or warn "PhabTest::Env teardown failed: $@"; + } +} + +sub DESTROY { + my ($self) = @_; + return if $self->{finished}; + local $@ = undef; + eval { $self->teardown; 1 } or warn "PhabTest::Env teardown failed: $@"; +} + +1; diff --git a/extensions/PhabBugz/t/bmo/lib/PhabTest/Fixtures.pm b/extensions/PhabBugz/t/bmo/lib/PhabTest/Fixtures.pm new file mode 100644 index 0000000000..af9668d651 --- /dev/null +++ b/extensions/PhabBugz/t/bmo/lib/PhabTest/Fixtures.pm @@ -0,0 +1,311 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# Builders for Conduit response objects. The shapes follow the Conduit API +# documentation (`/conduit/method//` on a Phabricator install) and the +# example payloads documented in the PhabBugz model modules. Every builder +# returns a fully populated object, because the Moo models type check each +# field and reject undefined values for most of them. + +package PhabTest::Fixtures; + +use 5.10.1; +use strict; +use warnings; + +use base qw(Exporter); +our @EXPORT_OK = qw( + edge_obj + edit_result + feed_story + next_phid + phid_for_bmo_user + policy_obj + project_obj + repository_obj + revision_obj + search_result + transaction_obj + user_obj +); +our %EXPORT_TAGS = (all => \@EXPORT_OK); + +use Mojo::JSON qw(false true); + +# Prefix generated PHIDs and ids with a per-run tag. Some PHIDs end up in +# BMO tables (phab_reviewer_rotation, phab_uplift_form_state), so they must +# not collide with rows left behind by earlier runs against the same DB. +my $RUN_TAG = lc sprintf('%x%x', time, $$); +my $COUNTER = 0; + +# Revision ids also become BMO attachment filenames (phabricator-D-url.txt) +# and Feed.pm matches attachments on other bugs by that filename, so they get +# their own counter seeded from the time and pid (1000 revisions per process). +my $NEXT_REVISION_ID = (time * 100_000 + $$ % 100_000) * 1_000; + +sub next_phid { + my ($type) = @_; + return sprintf('PHID-%s-pt%s%06d', $type, $RUN_TAG, ++$COUNTER); +} + +# A Phabricator user linked to a BMO account always gets the same PHID. The +# BMO id -> PHID mapping is cached in memcached by +# PhabBugz::User::get_phab_bugzilla_ids, so a stable PHID keeps that cache +# valid across test files and runs. +sub phid_for_bmo_user { + my ($bmo_id) = @_; + return sprintf('PHID-USER-bmo%017d', $bmo_id); +} + +sub search_result { + my ($data, %cursor) = @_; + return { + data => $data, + maps => {}, + query => {queryKey => undef}, + cursor => { + limit => $cursor{limit} // 100, + after => $cursor{after}, + before => $cursor{before}, + order => $cursor{order}, + }, + }; +} + +# user.search item +sub user_obj { + my (%args) = @_; + my $username = $args{username} // 'phabtest-user-' . ++$COUNTER; + return { + id => $args{id} // ++$COUNTER, + type => 'USER', + phid => $args{phid} // next_phid('USER'), + fields => { + username => $username, + realName => $args{realname} // "Phab Test $username", + roles => $args{roles} // [qw(verified approved activated)], + dateCreated => $args{created} // 1_512_573_120, + dateModified => $args{modified} // 1_512_574_523, + policy => {view => 'public', edit => 'no-one'}, + }, + attachments => { + 'external-accounts' => { + 'external-accounts' => + (defined $args{bmo_id} ? [{id => "$args{bmo_id}", type => 'bmo'}] : []), + }, + }, + }; +} + +# project.search item (with the `members` attachment) +sub project_obj { + my (%args) = @_; + my $name = $args{name} // 'phabtest-project-' . ++$COUNTER; + return { + id => $args{id} // ++$COUNTER, + type => 'PROJ', + phid => $args{phid} // next_phid('PROJ'), + fields => { + name => $name, + slug => $name, + milestone => undef, + depth => 0, + parent => undef, + icon => {key => 'group', name => 'Group', icon => 'fa-users'}, + color => {key => 'red', name => 'Red'}, + dateCreated => 1_500_403_964, + dateModified => 1_505_248_862, + description => $args{description} // "Description for $name", + policy => { + view => $args{view_policy} // 'public', + edit => $args{edit_policy} // 'admin', + join => $args{join_policy} // 'admin', + }, + }, + attachments => + {members => {members => [map { {phid => $_} } @{$args{members} // []}]}}, + }; +} + +# differential.revision.search item (projects, reviewers, subscribers and, +# optionally, reviewers-extra attachments) +sub revision_obj { + my (%args) = @_; + my $id = $args{id} // ++$NEXT_REVISION_ID; + my $phid = $args{phid} // next_phid('DREV'); + my $status = $args{status} // 'needs-review'; + + my $revision = { + id => $id, + type => 'DREV', + phid => $phid, + fields => { + title => $args{title} // "Revision D$id", + summary => $args{summary} // "Summary for D$id", + authorPHID => $args{author_phid}, + diffPHID => $args{diff_phid} // 'PHID-DIFF-current', + repositoryPHID => $args{repository_phid}, + status => {value => $status, name => $status, closed => false}, + isDraft => ($args{is_draft} ? true : false), + holdAsDraft => ($args{hold_as_draft} ? true : false), + dateCreated => 1_507_666_113, + dateModified => 1_508_514_027, + policy => { + view => $args{view_policy} // 'public', + edit => $args{edit_policy} // 'users', + }, + 'bugzilla.bug-id' => $args{bug_id} // '', + 'uplift.request' => $args{uplift_request} // [], + stackGraph => $args{stack_graph} // {$phid => []}, + }, + attachments => { + reviewers => { + reviewers => [ + map { + +{ + reviewerPHID => $_->{phid}, + status => $_->{status} // 'added', + isBlocking => ($_->{blocking} ? true : false), + actorPHID => $_->{actor}, + } + } @{$args{reviewers} // []} + ], + }, + subscribers => { + subscriberPHIDs => $args{subscribers} // [], + subscriberCount => scalar @{$args{subscribers} // []}, + viewerIsSubscribed => true, + }, + projects => {projectPHIDs => $args{projects} // []}, + }, + }; + + if ($args{reviewers_extra}) { + $revision->{attachments}{'reviewers-extra'}{'reviewers-extra'} = [ + map { + +{ + reviewerPHID => $_->{phid}, + voidedPHID => $_->{voided}, + diffPHID => $_->{diff}, + } + } @{$args{reviewers_extra}} + ]; + } + + return $revision; +} + +# diffusion.repository.search item (with the `projects` attachment) +sub repository_obj { + my (%args) = @_; + my $short_name = $args{short_name} // 'phabtest-repo-' . ++$COUNTER; + return { + id => $args{id} // ++$COUNTER, + type => 'REPO', + phid => $args{phid} // next_phid('REPO'), + fields => { + name => $args{name} // $short_name, + vcs => 'git', + callsign => $args{callsign} // uc $short_name, + shortName => $short_name, + status => 'active', + isImporting => false, + defaultBranch => 'main', + description => {raw => "Repository $short_name"}, + dateCreated => 1_507_666_113, + dateModified => 1_508_514_027, + policy => {view => 'public', edit => 'admin', 'diffusion.push' => 'no-one'}, + }, + attachments => {projects => {projectPHIDs => $args{projects} // []}}, + }; +} + +# policy.query item +sub policy_obj { + my (%args) = @_; + return { + phid => $args{phid} // next_phid('PLCY'), + type => 'custom', + name => 'Custom Policy', + shortName => 'Custom Policy', + fullName => 'Custom Policy', + href => undef, + workflow => undef, + icon => 'fa-certificate', + default => $args{default} // 'deny', + rules => $args{rules} // [], + }; +} + +# feed.query_id item +sub feed_story { + my (%args) = @_; + my $id = $args{id} // ++$COUNTER; + return { + id => $id, + phid => next_phid('STRY'), + chronologicalKey => "6${id}000000", + authorPHID => $args{author_phid}, + objectPHID => $args{object_phid}, + epoch => 1_507_666_113 + $id, + text => $args{text} // "someone updated D$id.", + }; +} + +# transaction.search item. Pass `comments => [text, ...]` for a comment +# transaction. +sub transaction_obj { + my (%args) = @_; + my $author = $args{author_phid} // next_phid('USER'); + return { + id => ++$COUNTER, + phid => next_phid('XACT'), + type => (exists $args{type} ? $args{type} : 'comment'), + authorPHID => $author, + objectPHID => $args{object_phid}, + dateCreated => 1_507_666_113, + dateModified => 1_507_666_113, + groupID => 'phabtestgroup', + comments => [ + map { + +{ + id => ++$COUNTER, + phid => next_phid('XCMT'), + version => 1, + authorPHID => $author, + dateCreated => 1_507_666_113, + dateModified => 1_507_666_113, + removed => false, + content => {raw => $_}, + } + } @{$args{comments} // []} + ], + fields => {}, + }; +} + +# edge.search item +sub edge_obj { + my ($source, $destination, $type) = @_; + return { + sourcePHID => $source, + edgeType => $type // 'revision.child', + destinationPHID => $destination, + }; +} + +# Result of the *.edit methods (project.edit, differential.revision.edit, +# user.edit) +sub edit_result { + my ($phid, $id) = @_; + return { + object => {id => $id // ++$COUNTER, phid => $phid}, + transactions => [{phid => next_phid('XACT')}], + }; +} + +1; diff --git a/extensions/PhabBugz/t/bmo/model-policy.t b/extensions/PhabBugz/t/bmo/model-policy.t new file mode 100644 index 0000000000..47d89ae15f --- /dev/null +++ b/extensions/PhabBugz/t/bmo/model-policy.t @@ -0,0 +1,206 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# PhabBugz::Policy: policy.query lookups, custom policy creation with +# policy.create, and resolving the projects named in policy rules. + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Storable qw(dclone); +use Test::More; +use Test2::Tools::Exception qw(dies); +use Try::Tiny; + +use Bugzilla::Extension::PhabBugz::Policy; +use Bugzilla::Extension::PhabBugz::Project; +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +use constant Policy => 'Bugzilla::Extension::PhabBugz::Policy'; +use constant Project => 'Bugzilla::Extension::PhabBugz::Project'; + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; + +my $group_a = $conduit->add_project(project_obj(name => $env->unique('bmo-a'))); +my $group_b = $conduit->add_project(project_obj(name => $env->unique('bmo-b'))); +my $secure = $conduit->add_project(project_obj(name => 'secure-revision')); + +my $existing = $conduit->add_policy(policy_obj( + rules => [ + { + action => 'allow', + rule => 'PhabricatorSubscriptionsSubscribersPolicyRule', + value => undef, + }, + { + action => 'allow', + rule => 'PhabricatorProjectsPolicyRule', + value => [$group_a->{phid}, $group_b->{phid}], + }, + ], +)); + +# new_from_query +{ + $conduit->reset_calls; + my $policy = Policy->new_from_query({phids => [$existing->{phid}]}); + is_deeply( + [$conduit->calls('policy.query')], + [{phids => [$existing->{phid}]}], + 'policy.query params' + ); + is($policy->phid, $existing->{phid}, 'phid'); + is($policy->type, 'custom', 'type'); + is($policy->default, 'deny', 'default action'); + is($policy->icon, 'fa-certificate', 'icon'); + is(scalar @{$policy->rules}, 2, 'rules'); + + ok(!Policy->new_from_query({phids => ['PHID-PLCY-missing']}), + 'unknown policy finds nothing'); + + ok(dies { Policy->new_from_query({ids => [1]}) }, + 'new_from_query only accepts phids'); +} + +# rule_projects resolves the project rule +{ + my $policy = Policy->new_from_query({phids => [$existing->{phid}]}); + $conduit->reset_calls; + is_deeply( + [map { $_->phid } @{$policy->rule_projects}], + [$group_a->{phid}, $group_b->{phid}], + 'rule_projects returns the projects of the projects rule' + ); + is($conduit->call_count('project.search'), 2, 'one project.search per project'); + + my $no_projects = Policy->new(policy_obj( + rules => [{ + action => 'allow', + rule => 'PhabricatorDifferentialReviewersPolicyRule', + value => undef, + }] + )); + is_deeply($no_projects->rule_projects, [], 'policy without a projects rule'); + + my $all_rule = Policy->new(policy_obj( + rules => [{ + action => 'allow', + rule => 'PhabricatorProjectsAllPolicyRule', + value => [$group_b->{phid}], + }] + )); + is_deeply( + [map { $_->phid } @{$all_rule->rule_projects}], + [$group_b->{phid}], + 'PhabricatorProjectsAllPolicyRule is recognised' + ); +} + +# create with projects +{ + my @projects + = map { Project->new_from_query({phids => [$_->{phid}]}) } $group_a, $group_b; + + $conduit->reset_calls; + my $policy = Policy->create(\@projects); + + my ($create) = $conduit->calls('policy.create'); + is_deeply( + $create, + { + objectType => 'DREV', + default => 'deny', + policy => [ + {action => 'allow', rule => 'PhabricatorSubscriptionsSubscribersPolicyRule'}, + {action => 'allow', rule => 'PhabricatorDifferentialReviewersPolicyRule'}, + { + action => 'allow', + rule => 'PhabricatorProjectsAllPolicyRule', + value => [$group_a->{phid}, $group_b->{phid}], + }, + ], + }, + 'policy.create params' + ); + + my ($query) = $conduit->calls('policy.query'); + ok($policy && $query, 'create reloads the policy'); + is($query->{phids}[0], + $policy->phid, 'reloaded by the PHID policy.create returned'); + is_deeply( + [map { $_->phid } @{$policy->rule_projects}], + [$group_a->{phid}, $group_b->{phid}], + 'created policy has the projects' + ); +} + +# create without projects falls back to secure-revision +{ + $conduit->reset_calls; + my $create_error; + try { Policy->create([]) } catch { $create_error = $_ }; + is_deeply( + [map { $_->{constraints} } $conduit->calls('project.search')], + [{name => 'secure-revision'}], + 'secure-revision project looked up' + ); + my ($create) = $conduit->calls('policy.create'); + my $fallback = $create->{policy}[-1]; + is($fallback->{action}, 'allow', 'fallback rule is an allow rule'); + is_deeply([ref $fallback->{value} ? @{$fallback->{value}} : $fallback->{value}], + [$secure->{phid}], 'fallback rule names the secure-revision project'); +TODO: { + local $TODO = 'Policy::create sends the fallback rule without a `rule` key, ' + . 'which the real policy.create rejects as an invalid rule class'; + ok(!$create_error, 'create without projects succeeds'); + is_deeply( + $fallback, + { + action => 'allow', + rule => 'PhabricatorProjectsAllPolicyRule', + value => [$secure->{phid}], + }, + 'fallback rule has the same shape as the other project rules' + ); + } +} + +# Errors propagate +{ + my $project = Project->new_from_query({phids => [$group_a->{phid}]}); + $conduit->queue( + 'policy.create' => conduit_error('ERR-CONDUIT-CORE', 'Invalid rule')); + throws_error( + sub { Policy->create([$project]) }, + 'bugzilla.code.phabricator_api_error', + 'policy.create error propagates' + ); + + $conduit->queue('policy.query' => http_error(502)); + throws_error( + sub { Policy->new_from_query({phids => [$existing->{phid}]}) }, + 'bugzilla.code.phabricator_api_error', + 'policy.query error propagates' + ); + + ok(dies { Policy->create(['not a project']) }, + 'create requires Project objects'); +} + +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/model-project.t b/extensions/PhabBugz/t/bmo/model-project.t new file mode 100644 index 0000000000..fe5f6d3d2a --- /dev/null +++ b/extensions/PhabBugz/t/bmo/model-project.t @@ -0,0 +1,266 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# PhabBugz::Project: project.search lookups, project.edit create/update +# transactions and the lazy members list. + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Storable qw(dclone); +use Test::More; +use Test2::Tools::Exception qw(dies); + +use Bugzilla::Extension::PhabBugz::Project; +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +use constant Project => 'Bugzilla::Extension::PhabBugz::Project'; + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; + +my $alice = $env->bmo_user('alice'); +my $alice_phab = $env->link_user($alice); +my $unlinked = $conduit->add_user(user_obj(username => 'phabtest-unlinked')); + +my $name = $env->unique('proj'); +my $exact = $conduit->add_project(project_obj( + name => $name, + description => 'The exact match', + view_policy => 'PHID-PROJ-viewers', + edit_policy => 'PHID-PROJ-editors', + join_policy => 'PHID-PROJ-joiners', + members => [$alice_phab->{phid}, $unlinked->{phid}], +)); + +# Created after the exact match, so Conduit returns it first. +my $longer = $conduit->add_project(project_obj(name => "$name-and-more")); + +# new_from_query by name picks the exact match out of the tokenized results +{ + $conduit->reset_calls; + my $project = Project->new_from_query({name => $name}); + is($project->phid, $exact->{phid}, 'exact name match wins over a longer name'); + is_deeply( + [$conduit->calls('project.search')], + [{ + queryKey => 'all', + attachments => {members => 1}, + constraints => {name => $name} + }], + 'project.search params' + ); + + ok(!Project->new_from_query({name => substr($name, 0, -1)}), + 'a prefix of a project name is not a match'); + ok(!Project->new_from_query({name => $env->unique('missing')}), + 'unknown name finds nothing'); + + is(Project->new_from_query({phids => [$longer->{phid}]})->phid, + $longer->{phid}, 'lookup by phids returns the first result'); +} + +# Constructor maps Conduit fields +{ + my $project = Project->new(dclone($exact)); + is($project->id, $exact->{id}, 'id'); + is($project->name, $name, 'name'); + is($project->description, 'The exact match', 'description'); + is($project->view_policy, 'PHID-PROJ-viewers', 'view policy'); + is($project->edit_policy, 'PHID-PROJ-editors', 'edit policy'); + is($project->join_policy, 'PHID-PROJ-joiners', 'join policy'); + is($project->creation_ts, 1500403964, 'creation_ts'); + is($project->modification_ts, 1505248862, 'modification_ts'); + is_deeply($project->members_raw, + [{phid => $alice_phab->{phid}}, {phid => $unlinked->{phid}}], + 'members_raw'); +} + +# members is loaded lazily with user.search +{ + my $project = Project->new(dclone($exact)); + $conduit->reset_calls; + my $members = $project->members; + is_deeply([sort map { $_->phid } @$members], + [sort $alice_phab->{phid}, $unlinked->{phid}], 'members'); + is($conduit->call_count('user.search'), 1, 'one user.search for members'); + $project->members; + is($conduit->call_count('user.search'), 1, 'members is cached'); + + my $empty = Project->new(project_obj(members => [])); + $conduit->reset_calls; + is_deeply($empty->members, [], 'project without members'); + is($conduit->call_count, 0, 'no user.search for an empty project'); +} + +# create +{ + $conduit->reset_calls; + my $new_name = $env->unique('created'); + my $project = Project->create({ + name => " $new_name ", + description => 'Created by the test', + view_policy => 'PHID-PROJ-view', + edit_policy => 'PHID-PROJ-edit', + join_policy => 'PHID-PROJ-join', + }); + + my ($edit) = $conduit->calls('project.edit'); + ok(!exists $edit->{objectIdentifier}, 'create sends no objectIdentifier'); + is_deeply( + $edit->{transactions}, + [ + {type => 'name', value => $new_name}, + {type => 'description', value => 'Created by the test'}, + {type => 'edit', value => 'PHID-PROJ-edit'}, + {type => 'join', value => 'PHID-PROJ-join'}, + {type => 'view', value => 'PHID-PROJ-view'}, + {type => 'icon', value => 'group'}, + {type => 'color', value => 'red'}, + ], + 'create transactions (name trimmed)' + ); + + my $created = $conduit->find_project(name => $new_name); + is_deeply( + [map { $_->{constraints} } $conduit->calls('project.search')], + [{phids => [$created->{phid}]}], + 'create reloads the project by the PHID project.edit returned' + ); + is($project->name, $new_name, 'returned project has the name'); + is($project->join_policy, 'PHID-PROJ-join', + 'returned project has the policies'); + + ok( + dies { Project->create({name => 'incomplete'}) }, + 'create requires all policies and a description' + ); + + $conduit->queue( + 'project.edit' => conduit_error('ERR-CONDUIT-CORE', 'Name already used')); + throws_error( + sub { + Project->create({ + name => $new_name, + description => 'dupe', + view_policy => 'x', + edit_policy => 'x', + join_policy => 'x', + }); + }, + 'bugzilla.code.phabricator_api_error', + 'project.edit error during create propagates' + ); +} + +# update with add/remove members and policies +{ + my $project = Project->new(dclone($exact)); + my $bob_phab = $env->link_user($env->bmo_user('bob')); + my $bob_user = Bugzilla::Extension::PhabBugz::User->new(dclone($bob_phab)); + + $project->set_name(' renamed '); + $project->set_description(' new description '); + $project->add_member($bob_user); + $project->add_member('PHID-USER-bystring'); + $project->remove_member($unlinked->{phid}); + $project->set_policy('view', 'PHID-PLCY-view'); + $project->set_policy('edit', 'PHID-PLCY-edit'); + + $conduit->reset_calls; + $project->update; + my ($edit) = $conduit->calls('project.edit'); + is($edit->{objectIdentifier}, $exact->{phid}, 'update targets the project'); + is_deeply( + $edit->{transactions}, + [ + {type => 'name', value => 'renamed'}, + {type => 'description', value => 'new description'}, + {type => 'members.add', value => [$bob_phab->{phid}, 'PHID-USER-bystring']}, + {type => 'members.remove', value => [$unlinked->{phid}]}, + {type => 'view', value => 'PHID-PLCY-view'}, + {type => 'edit', value => 'PHID-PLCY-edit'}, + ], + 'update transactions' + ); +} + +# set_members replaces add/remove +{ + my $project = Project->new(dclone($exact)); + $project->add_member('PHID-USER-ignored'); + $project->remove_member('PHID-USER-ignored-too'); + $project->set_members([$alice_phab->{phid}, 'PHID-USER-other']); + + $conduit->reset_calls; + $project->update; + my ($edit) = $conduit->calls('project.edit'); + is_deeply( + $edit->{transactions}, + [{type => 'members.set', value => [$alice_phab->{phid}, 'PHID-USER-other']}], + 'members.set is sent instead of members.add/members.remove' + ); +} + +# An update with no pending changes still calls project.edit +{ + my $project = Project->new(dclone($longer)); + $conduit->reset_calls; + $project->update; + my ($edit) = $conduit->calls('project.edit'); + is_deeply($edit->{transactions}, [], + 'no-op update sends an empty transaction list'); +} + +# The join policy set by the group sync is not sent +{ + my $project = Project->new(dclone($longer)); + $project->set_policy('join', 'PHID-PLCY-join'); + $conduit->reset_calls; + $project->update; + my ($edit) = $conduit->calls('project.edit'); + +TODO: { + local $TODO = 'Project::update only sends view and edit policies'; + ok( + (grep { $_->{type} eq 'join' } @{$edit->{transactions}}), + 'set_policy("join") produces a join transaction' + ); + } +} + +# Errors from project.edit propagate +{ + my $project = Project->new(dclone($longer)); + $project->set_name('will fail'); + $conduit->queue('project.edit' => http_error(500)); + throws_error( + sub { $project->update }, + 'bugzilla.code.phabricator_api_error', + 'project.edit HTTP error propagates' + ); + + $conduit->queue('project.search' => network_error('Connection refused')); + like( + dies { Project->new_from_query({name => $name}) }, + qr/Connection refused/, + 'network error during project.search' + ); +} + +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/model-repository-comment.t b/extensions/PhabBugz/t/bmo/model-repository-comment.t new file mode 100644 index 0000000000..8115147bcf --- /dev/null +++ b/extensions/PhabBugz/t/bmo/model-repository-comment.t @@ -0,0 +1,139 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# PhabBugz::Repository (diffusion.repository.search, uplift detection) and +# PhabBugz::Comment (transaction.search comments). + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Storable qw(dclone); +use Test::More; + +use Bugzilla::Extension::PhabBugz::Comment; +use Bugzilla::Extension::PhabBugz::Repository; +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +use constant Repository => 'Bugzilla::Extension::PhabBugz::Repository'; +use constant Comment => 'Bugzilla::Extension::PhabBugz::Comment'; + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; + +my $uplift_tag = $conduit->add_project(project_obj(name => 'uplift')); +my $other_tag = $conduit->add_project(project_obj(name => $env->unique('tag'))); + +my $beta = $conduit->add_repository(repository_obj( + short_name => 'firefox-beta', + callsign => 'BETA', + projects => [$other_tag->{phid}, $uplift_tag->{phid}], +)); +my $central = $conduit->add_repository(repository_obj( + short_name => 'mozilla-central', + projects => [$other_tag->{phid}] +)); + +# new_from_query +{ + $conduit->reset_calls; + my $repo = Repository->new_from_query({phids => [$beta->{phid}]}); + is_deeply( + [$conduit->calls('diffusion.repository.search')], + [{ + queryKey => 'all', + constraints => {phids => [$beta->{phid}]}, + attachments => {projects => 1}, + }], + 'diffusion.repository.search params' + ); + is($repo->phid, $beta->{phid}, 'phid'); + is($repo->name, 'firefox-beta', 'name'); + is($repo->short_name, 'firefox-beta', 'short name'); + is($repo->call_sign, 'BETA', 'callsign'); + is($repo->vcs, 'git', 'vcs'); + is($repo->status, 'active', 'status'); + is($repo->default_branch, 'main', 'default branch'); + is( + $repo->description, + 'Repository firefox-beta', + 'description from description.raw' + ); + is($repo->view_policy, 'public', 'view policy'); + is($repo->edit_policy, 'admin', 'edit policy'); + is($repo->push_policy, 'no-one', 'diffusion.push policy'); + + is(Repository->new_from_query({phids => ['PHID-REPO-missing']}), + undef, 'unknown repository returns undef'); +} + +# projects and is_uplift_repo +{ + my $beta_repo = Repository->new_from_query({phids => [$beta->{phid}]}); + $conduit->reset_calls; + is_deeply( + [map { $_->name } @{$beta_repo->projects}], + [$other_tag->{fields}{name}, 'uplift'], + 'repository projects' + ); + is($conduit->call_count('project.search'), 2, 'one project.search per project'); + ok($beta_repo->is_uplift_repo, 'repository tagged #uplift is an uplift repo'); + + ok(!Repository->new_from_query({phids => [$central->{phid}]})->is_uplift_repo, + 'repository without #uplift is not an uplift repo'); + + my $untagged = Repository->new(repository_obj()); + $conduit->reset_calls; + ok(!$untagged->is_uplift_repo, 'repository without projects'); + is($conduit->call_count, 0, 'no project lookups for an untagged repository'); +} + +# Errors propagate +{ + $conduit->queue('diffusion.repository.search' => http_error(500)); + throws_error( + sub { Repository->new_from_query({phids => [$beta->{phid}]}) }, + 'bugzilla.code.phabricator_api_error', + 'diffusion.repository.search error' + ); +} + +# Comment +{ + my $author = $env->bmo_user('commenter'); + my $author_phab = $env->link_user($author); + my $xact = transaction_obj( + author_phid => $author_phab->{phid}, + comments => ["A comment\nwith two lines"], + ); + my $comment = Comment->new(dclone($xact->{comments}[0])); + + is($comment->id, $xact->{comments}[0]{id}, 'id'); + is($comment->phid, $xact->{comments}[0]{phid}, 'phid'); + is($comment->text, "A comment\nwith two lines", 'text from content.raw'); + is($comment->author_phid, $author_phab->{phid}, 'author PHID'); + is($comment->creation_ts, 1507666113, 'creation_ts'); + is($comment->modification_ts, 1507666113, 'modification_ts'); + + $conduit->reset_calls; + is($comment->author->bugzilla_user->id, + $author->id, 'author loads the linked user'); + $comment->author; + is($conduit->call_count('user.search'), 1, 'author is loaded once'); +} + +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/model-revision.t b/extensions/PhabBugz/t/bmo/model-revision.t new file mode 100644 index 0000000000..a725eb7ab6 --- /dev/null +++ b/extensions/PhabBugz/t/bmo/model-revision.t @@ -0,0 +1,424 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# PhabBugz::Revision: differential.revision.search lookups, the lazy +# builders (author, reviews, subscribers, projects, repository, comments), +# the transactions built by the mutators and the privacy helpers. + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Digest::SHA qw(sha1_hex); +use Storable qw(dclone); +use Test::More; +use Try::Tiny; + +use Bugzilla::Extension::PhabBugz::Policy; +use Bugzilla::Extension::PhabBugz::Revision; +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +use constant Revision => 'Bugzilla::Extension::PhabBugz::Revision'; + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; + +my $author = $env->bmo_user('author'); +my $author_phab = $env->link_user($author); +my $reviewer = $env->bmo_user('reviewer'); +my $reviewer_phab = $env->link_user($reviewer); +my $subscriber + = $conduit->add_user(user_obj(username => 'phabtest-subscriber')); + +my $review_group + = $conduit->add_project(project_obj(name => $env->unique('reviewers'))); +my $tag = $conduit->add_project(project_obj(name => $env->unique('tag'))); +my $bmo_group + = $conduit->add_project(project_obj(name => 'bmo-' . $env->unique('grp'))); +my $secure = $conduit->add_project(project_obj(name => 'secure-revision')); +my $editbugs = $conduit->add_project(project_obj(name => 'bmo-editbugs-team')); +my $repo + = $conduit->add_repository(repository_obj(short_name => 'firefox-beta')); + +my $bug = $env->bug; + +my $raw = $conduit->add_revision(revision_obj( + title => 'Bug fix', + summary => 'Fixes the bug', + status => 'needs-review', + bug_id => ' ' . $bug->id . ' ', + author_phid => $author_phab->{phid}, + diff_phid => 'PHID-DIFF-current', + repository_phid => $repo->{phid}, + is_draft => 0, + hold_as_draft => 1, + subscribers => [$subscriber->{phid}, $reviewer_phab->{phid}], + projects => [$tag->{phid}, $bmo_group->{phid}, $secure->{phid}], + reviewers => [ + { + phid => $reviewer_phab->{phid}, + status => 'accepted', + blocking => 1, + actor => $reviewer_phab->{phid} + }, + {phid => $review_group->{phid}, status => 'added'}, + {phid => 'PHID-USER-notinphabricator', status => 'added'}, + {phid => 'PHID-OPKG-somepackage', status => 'added'}, + ], + reviewers_extra => [ + {phid => $reviewer_phab->{phid}, diff => 'PHID-DIFF-previous', voided => undef}, + ], +)); + +sub load { return Revision->new_from_query({phids => [$raw->{phid}]}) } + +# new_from_query +{ + $conduit->reset_calls; + my $revision = load(); + is_deeply( + [$conduit->calls('differential.revision.search')], + [{ + queryKey => 'all', + attachments => {projects => 1, reviewers => 1, subscribers => 1}, + constraints => {phids => [$raw->{phid}]}, + }], + 'differential.revision.search params' + ); + is($revision->bug_id, $bug->id, 'whitespace around bugzilla.bug-id is removed'); + + is(Revision->new_from_query({ids => [$raw->{id}]})->phid, + $raw->{phid}, 'lookup by ids'); + is(Revision->new_from_query({phids => ['PHID-DREV-missing']}), + undef, 'unknown revision returns undef'); + + my $no_bug + = $conduit->add_revision(revision_obj(author_phid => $author_phab->{phid})); + is(Revision->new_from_query({phids => [$no_bug->{phid}]})->bug_id, + '', 'missing bug id becomes an empty string'); +} + +# Constructor maps Conduit fields +{ + my $revision = load(); + is($revision->id, $raw->{id}, 'id'); + is($revision->title, 'Bug fix', 'title'); + is($revision->summary, 'Fixes the bug', 'summary'); + is($revision->status, 'needs-review', 'status from status.value'); + ok(!$revision->is_draft, 'isDraft'); + ok($revision->hold_as_draft, 'holdAsDraft'); + is($revision->author_phid, $author_phab->{phid}, 'author_phid'); + is($revision->diff_phid, 'PHID-DIFF-current', 'diff_phid'); + is($revision->repository_phid, $repo->{phid}, 'repository_phid'); + is($revision->view_policy, 'public', 'view policy'); + is($revision->edit_policy, 'users', 'edit policy'); + is($revision->subscriber_count, 2, 'subscriber_count'); + is($revision->bug->id, $bug->id, 'bug loads the BMO bug'); + ok(!$revision->is_private, 'public revision is not private'); + is($revision->secured_title, 'Bug fix', 'secured_title of a public revision'); +} + +# author +{ + my $revision = load(); + $conduit->reset_calls; + is($revision->author->phid, $author_phab->{phid}, 'author'); + is($revision->author->bugzilla_user->id, $author->id, 'author BMO account'); + $revision->author; + is($conduit->call_count('user.search'), 1, 'author is loaded once'); +} + +# reviews. new_from_query does not request reviewers-extra, so build the +# revision from the full fixture (as WebService does with its own search). +{ + my $revision = Revision->new(dclone($raw)); + $conduit->reset_calls; + my $reviews = $revision->reviews; + is(scalar @$reviews, + 2, 'unknown users and non user/project reviewers (packages) are skipped'); + + my ($user_review) = grep { !$_->{is_project} } @$reviews; + my ($project_review) = grep { $_->{is_project} } @$reviews; + is($user_review->{user}->phid, $reviewer_phab->{phid}, 'user reviewer'); + is($user_review->{status}, + 'accepted-prior', 'accepted on an older diff becomes accepted-prior'); + is($user_review->{is_blocking}, 1, 'blocking reviewer'); + is($project_review->{user}->phid, $review_group->{phid}, 'project reviewer'); + is($project_review->{status}, 'added', 'project reviewer status'); + is($project_review->{is_blocking}, 0, 'non-blocking reviewer'); + + my $current = dclone($raw); + $current->{attachments}{'reviewers-extra'}{'reviewers-extra'}[0]{diffPHID} + = 'PHID-DIFF-current'; + my $same_diff = Revision->new($current); + my ($accepted) = grep { !$_->{is_project} } @{$same_diff->reviews}; + is($accepted->{status}, 'accepted', + 'accepted on the current diff stays accepted'); + + my ($loaded) = grep { !$_->{is_project} } @{load()->reviews}; + is($loaded->{status}, 'accepted', + 'without reviewers-extra (new_from_query) accepted stays accepted'); +} + +# subscribers and projects +{ + my $revision = load(); + $conduit->reset_calls; + is_deeply( + [sort map { $_->phid } @{$revision->subscribers}], + [sort $subscriber->{phid}, $reviewer_phab->{phid}], + 'subscribers' + ); + is($conduit->call_count('user.search'), 1, 'subscribers in one user.search'); + + is_deeply([map { $_->phid } @{$revision->projects}], + [$tag->{phid}, $bmo_group->{phid}, $secure->{phid}], 'projects'); + is($conduit->call_count('project.search'), 3, 'one project.search per project'); +} + +# repository is cached per request +{ + my $first = load(); + my $second = load(); + delete Bugzilla->request_cache->{phab_repo_cache}; + $conduit->reset_calls; + is($first->repository->phid, $repo->{phid}, 'repository'); + is($first->repository->short_name, 'firefox-beta', 'repository short name'); + is($second->repository->phid, + $repo->{phid}, 'same repository for a second revision'); + is($conduit->call_count('diffusion.repository.search'), + 1, 'repository fetched once per request'); + + my $no_repo = Revision->new(revision_obj(author_phid => $author_phab->{phid})); + $conduit->reset_calls; + is($no_repo->repository, undef, 'revision without a repository'); + is($conduit->call_count, 0, 'no Conduit call without a repository PHID'); +} + +# comments +{ + $conduit->add_transaction( + $raw->{phid}, + transaction_obj( + object_phid => $raw->{phid}, + author_phid => $reviewer_phab->{phid}, + comments => ['Looks good to me'], + ) + ); + $conduit->add_transaction($raw->{phid}, + transaction_obj(object_phid => $raw->{phid}, type => 'accept')); + $conduit->add_transaction($raw->{phid}, + transaction_obj(object_phid => $raw->{phid}, type => undef)); + + my $revision = load(); + $conduit->reset_calls; + my $comments = $revision->comments; + is_deeply( + [$conduit->calls('transaction.search')], + [{objectIdentifier => $raw->{phid}}], + 'transaction.search params' + ); + is(scalar @$comments, 1, 'only comment transactions are returned'); + is($comments->[0]->text, 'Looks good to me', 'comment text from content.raw'); + is($comments->[0]->author_phid, $reviewer_phab->{phid}, 'comment author PHID'); + is($comments->[0]->author->phid, $reviewer_phab->{phid}, + 'comment author loads'); +} + +# stack_graph +{ + my $parent = 'PHID-DREV-parent'; + my $child = $raw->{phid}; + my $stacked = Revision->new(revision_obj( + phid => $child, + author_phid => $author_phab->{phid}, + stack_graph => {$child => [$parent], $parent => []}, + )); + my $graph = $stacked->stack_graph; + is_deeply( + [sort @{$graph->{phids}}], + [sort $parent, $child], + 'stack graph nodes' + ); + is_deeply($graph->{edges}, [[$child, $parent]], 'stack graph edges'); +} + +# Mutators build the transactions sent by update +{ + my $revision = load(); + my $reviewer_user + = Bugzilla::Extension::PhabBugz::User->new(dclone($reviewer_phab)); + my $tag_project = Bugzilla::Extension::PhabBugz::Project->new(dclone($tag)); + + $revision->add_comment(" First comment\n"); + $revision->add_comment('Second comment'); + $revision->set_subscribers(['PHID-USER-set']); + $revision->add_subscriber($reviewer_user); + $revision->remove_subscriber('PHID-USER-gone'); + $revision->set_reviewers([$reviewer_user]); + $revision->add_reviewer('blocking(PHID-PROJ-x)'); + $revision->remove_reviewer($reviewer_user); + $revision->set_policy('view', 'PHID-PLCY-view'); + $revision->set_policy('edit', 'PHID-PLCY-edit'); + $revision->set_status('request-review'); + $revision->add_project($tag_project); + ok(!defined $revision->add_project(undef), 'add_project ignores undef'); + $revision->remove_project('PHID-PROJ-old'); + + $conduit->reset_calls; + $revision->update; + my ($edit) = $conduit->calls('differential.revision.edit'); + is($edit->{objectIdentifier}, $raw->{phid}, 'update targets the revision'); + + my @transactions = @{$edit->{transactions}}; + my $status = first_of(\@transactions, 'request-review'); + ok($status && $status->{value}, 'status transaction has value true'); + $_->{value} = 'true' for grep { $_->{type} eq 'request-review' } @transactions; + + is_deeply( + \@transactions, + [ + {type => 'comment', value => 'First comment'}, + {type => 'comment', value => 'Second comment'}, + {type => 'subscribers.set', value => ['PHID-USER-set']}, + {type => 'subscribers.add', value => [$reviewer_phab->{phid}]}, + {type => 'subscribers.remove', value => ['PHID-USER-gone']}, + {type => 'reviewers.set', value => [$reviewer_phab->{phid}]}, + {type => 'reviewers.add', value => ['blocking(PHID-PROJ-x)']}, + {type => 'reviewers.remove', value => [$reviewer_phab->{phid}]}, + {type => 'view', value => 'PHID-PLCY-view'}, + {type => 'edit', value => 'PHID-PLCY-edit'}, + {type => 'request-review', value => 'true'}, + {type => 'projects.add', value => [$tag->{phid}]}, + {type => 'projects.remove', value => ['PHID-PROJ-old']}, + ], + 'update sends every pending change, in order' + ); + + $conduit->queue( + 'differential.revision.edit' => conduit_error('ERR-CONDUIT-CORE', 'Locked')); + throws_error( + sub { $revision->update }, + 'bugzilla.code.phabricator_api_error', + 'differential.revision.edit error propagates' + ); +} + +# make_private +{ + my $revision = load(); + $conduit->reset_calls; + $revision->make_private([$bmo_group->{fields}{name}]); + + is_deeply( + [map { $_->{constraints} } $conduit->calls('project.search')], + [{name => $bmo_group->{fields}{name}}], + 'group project looked up by name' + ); + my ($create) = $conduit->calls('policy.create'); + is_deeply( + $create->{policy}[-1]{value}, + [$bmo_group->{phid}], + 'new policy allows the group project' + ); + + my ($policy_query) = $conduit->calls('policy.query'); + my $policy_phid = $policy_query->{phids}[0]; + ok($revision->is_private, 'revision is now private'); + is($revision->secured_title, '(secure)', 'secured_title hides the title'); + + $conduit->reset_calls; + $revision->update; + is_deeply( + [$conduit->transactions_for($raw->{phid})], + [ + {type => 'view', value => $policy_phid}, + {type => 'edit', value => $policy_phid} + ], + 'view and edit policies set to the new custom policy' + ); +} + +# make_public +{ + my $revision = load(); + $conduit->reset_calls; + $revision->make_public; + $revision->update; + is_deeply( + [$conduit->transactions_for($raw->{phid})], + [ + {type => 'view', value => 'public'}, + {type => 'edit', value => $editbugs->{phid}}, + {type => 'projects.remove', value => [$bmo_group->{phid}, $secure->{phid}]}, + ], + 'public policy, editbugs edit policy, bmo-* and secure-revision tags removed' + ); + ok(!$revision->is_private, 'revision is public'); + + $editbugs->{fields}{name} = 'bmo-editbugs-team-renamed'; + my $fallback = load(); + $conduit->reset_calls; + $fallback->make_public; + $fallback->update; + my ($edit_policy) + = grep { $_->{type} eq 'edit' } $conduit->transactions_for($raw->{phid}); + is($edit_policy->{value}, 'users', + 'edit policy falls back to "users" without bmo-editbugs-team'); + $editbugs->{fields}{name} = 'bmo-editbugs-team'; +} + +# set_private_project_tags +{ + my $revision = load(); + $conduit->reset_calls; + $revision->set_private_project_tags([$bmo_group->{fields}{name}]); + $revision->update; + is_deeply( + [$conduit->transactions_for($raw->{phid})], + [{type => 'projects.add', value => [$secure->{phid}, $bmo_group->{phid}]}], + 'secure-revision and group project tags added' + ); +} + +# uplift_hash +{ + my $form = { + 'Is Android affected?' => 'yes', + 'Explanation of risk level' => 'low', + 'Steps to reproduce' => 'none', + 'Needs manual QE?' => 'no', + 'Risk to taking this patch' => 'medium', + 'String changes made?' => 'no', + }; + my $revision + = Revision->new(revision_obj(author_phid => 'x', uplift_request => $form)); + + # Perl hash order is random per process, so the hash only matches this + # hand-written, key-sorted JSON if the encoder sorts keys. + my $canonical = '{' . join(',', map {qq{"$_":"$form->{$_}"}} sort keys %$form) . '}'; + is($revision->uplift_hash, sha1_hex($canonical), + 'uplift_hash is sha1 of the form as key-sorted JSON'); +} + +sub first_of { + my ($transactions, $type) = @_; + my ($found) = grep { $_->{type} eq $type } @$transactions; + return $found; +} + +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/model-user.t b/extensions/PhabBugz/t/bmo/model-user.t new file mode 100644 index 0000000000..cd7457a770 --- /dev/null +++ b/extensions/PhabBugz/t/bmo/model-user.t @@ -0,0 +1,292 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# PhabBugz::User: user.search / bugzilla.account.search lookups, paging, +# release manager checks and enabling/disabling users in Phabricator when the +# BMO account is disabled (the object_end_of_update hook). + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Bugzilla::Config qw(SetParam); +use Storable qw(dclone); +use Test2::Tools::Mock qw(mock); +use Test::More; +use Test2::Tools::Exception qw(dies lives); + +use Bugzilla::Extension::PhabBugz::Project; +use Bugzilla::Extension::PhabBugz::User; +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +use constant PhabUser => 'Bugzilla::Extension::PhabBugz::User'; + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; + +my $alice = $env->bmo_user('alice'); +my $alice_phab = $env->link_user($alice); +my $bob = $env->bmo_user('bob'); +my $bob_phab = $env->link_user($bob); +my $unlinked = $conduit->add_user(user_obj(username => 'phabtest-unlinked')); + +# Constructor maps Conduit fields +{ + my $user = PhabUser->new(dclone($alice_phab)); + is($user->id, $alice_phab->{id}, 'id'); + is($user->phid, $alice_phab->{phid}, 'phid'); + is($user->name, $alice_phab->{fields}{username}, 'name from fields.username'); + is($user->realname, $alice->name, 'realname from fields.realName'); + is($user->creation_ts, 1512573120, 'creation_ts from fields.dateCreated'); + is($user->modification_ts, 1512574523, + 'modification_ts from fields.dateModified'); + is_deeply($user->roles, [qw(verified approved activated)], 'roles'); + is($user->view_policy, 'public', 'view policy'); + is($user->edit_policy, 'no-one', 'edit policy'); + is($user->bugzilla_id, $alice->id, 'bugzilla_id from the bmo external account'); + is($user->bugzilla_user->id, $alice->id, 'bugzilla_user loads the BMO user'); + + my $plain = PhabUser->new(dclone($unlinked)); + ok(!defined $plain->bugzilla_id, 'no bmo external account: no bugzilla_id'); + ok(!defined $plain->bugzilla_user, 'no bmo external account: no bugzilla_user'); + + my $github = user_obj(); + $github->{attachments}{'external-accounts'}{'external-accounts'} + = [{id => '1234', type => 'github'}]; + ok( + !defined PhabUser->new($github)->bugzilla_id, + 'only `bmo` external accounts link to BMO' + ); + + ok( + dies { PhabUser->new({%{user_obj()}, fields => {}}) }, + 'constructor rejects a user without the required fields' + ); +} + +# match({phids}) +{ + $conduit->reset_calls; + my $users + = PhabUser->match({phids => [$alice_phab->{phid}, $unlinked->{phid}]}); + is_deeply( + [sort map { $_->phid } @$users], + [sort $alice_phab->{phid}, $unlinked->{phid}], + 'match by phids returns both users' + ); + is_deeply( + [$conduit->calls('user.search')], + [{ + constraints => {phids => [$alice_phab->{phid}, $unlinked->{phid}]}, + attachments => {'external-accounts' => 1}, + }], + 'user.search params for a phids match' + ); + + $conduit->reset_calls; + is_deeply(PhabUser->match({phids => []}), [], + 'empty phid list matches nothing'); + is($conduit->call_count, 0, 'empty phid list makes no Conduit call'); + + is(PhabUser->new_from_query({phids => ['PHID-USER-doesnotexist']}), + undef, 'new_from_query returns undef when nothing matches'); + + ok(dies { PhabUser->match({names => ['alice']}) }, + 'match rejects unknown constraint keys'); +} + +# match() follows cursor.after until every page is fetched +{ + my $page_guard = $conduit->local_page_size(1); + $conduit->reset_calls; + my $users = PhabUser->match( + {phids => [$alice_phab->{phid}, $bob_phab->{phid}, $unlinked->{phid}]}); + is(scalar @$users, 3, 'paged match returns all users'); + my @calls = $conduit->calls('user.search'); + is(scalar @calls, 3, 'one user.search call per page'); + ok(!exists $calls[0]{after}, 'first page has no cursor'); + ok($calls[1]{after} && $calls[2]{after}, 'later pages send cursor.after'); +} + +# match({ids}) maps BMO ids to PHIDs with bugzilla.account.search +{ + Bugzilla->memcached->clear({key => 'phab_user_bugzilla_id_' . $alice->id}); + $conduit->reset_calls; + my $user = PhabUser->new_from_query({ids => [$alice->id]}); + is($user->phid, $alice_phab->{phid}, 'match by BMO id finds the linked user'); + is_deeply( + [$conduit->calls('bugzilla.account.search')], + [{ids => [$alice->id]}], + 'bugzilla.account.search params' + ); + is_deeply( + [map { $_->{constraints} } $conduit->calls('user.search')], + [{phids => [$alice_phab->{phid}]}], + 'user.search for the PHID returned by bugzilla.account.search' + ); + +SKIP: { + skip 'memcached is not configured', 1 unless Bugzilla->memcached->enabled; + $conduit->reset_calls; + PhabUser->new_from_query({ids => [$alice->id]}); + is($conduit->call_count('bugzilla.account.search'), + 0, 'BMO id -> PHID mapping is served from memcached'); + } + + my $nobody = $env->bmo_user('not-in-phab'); + $conduit->reset_calls; + is_deeply(PhabUser->match({ids => [$nobody->id]}), + [], 'BMO user without a Phabricator account matches nothing'); + is($conduit->call_count('user.search'), 0, 'no user.search without PHIDs'); +} + +# Errors from Conduit propagate +{ + $conduit->queue('user.search' => conduit_error('ERR-CONDUIT-CORE', 'boom')); + throws_error( + sub { PhabUser->match({phids => [$alice_phab->{phid}]}) }, + 'bugzilla.code.phabricator_api_error', + 'user.search error propagates' + ); + + Bugzilla->memcached->clear({key => 'phab_user_bugzilla_id_' . $bob->id}); + $conduit->queue('bugzilla.account.search' => http_error(503)); + throws_error( + sub { PhabUser->match({ids => [$bob->id]}) }, + 'bugzilla.code.phabricator_api_error', + 'bugzilla.account.search error propagates' + ); +} + +# is_release_manager +{ + my $relman = $conduit->add_project( + project_obj(name => 'release-managers', members => [$alice_phab->{phid}])); + + my $alice_user = PhabUser->new(dclone($alice_phab)); + my $bob_user = PhabUser->new(dclone($bob_phab)); + $conduit->reset_calls; + ok($alice_user->is_release_manager, 'member of #release-managers'); + ok(!$bob_user->is_release_manager, 'not a member of #release-managers'); + is_deeply( + [map { $_->{constraints} } $conduit->calls('project.search')], + [{name => 'release-managers'}, {name => 'release-managers'}], + 'release-managers project looked up by name' + ); + + $conduit->reset_calls; + ok($alice_user->is_release_manager, 'cached answer'); + is($conduit->call_count, 0, 'is_release_manager is cached on the object'); + + $relman->{fields}{name} = 'release-managers-renamed'; + ok( + !PhabUser->new(dclone($alice_phab))->is_release_manager, + 'no #release-managers project: not a release manager' + ); + $relman->{fields}{name} = 'release-managers'; +} + +# set_user_enabled_status +{ + my $user = PhabUser->new(dclone($alice_phab)); + foreach my $enabled (0, 1) { + $conduit->reset_calls; + $user->set_user_enabled_status($enabled); + my ($call) = $conduit->calls('user.edit'); + is($call->{objectIdentifier}, $alice_phab->{phid}, + 'user.edit objectIdentifier'); + is(scalar @{$call->{transactions}}, 1, 'one transaction'); + is($call->{transactions}[0]{type}, 'disabled', 'disabled transaction'); + is( + ($call->{transactions}[0]{value} ? 1 : 0), + ($enabled ? 0 : 1), + "disabled = " . ($enabled ? 'false' : 'true') . " when enabled=$enabled" + ); + ok(ref $call->{transactions}[0]{value}, 'value is a JSON boolean'); + } + + $conduit->queue('user.edit' => conduit_error('ERR-CONDUIT-CORE', 'nope')); + throws_error( + sub { $user->set_user_enabled_status(0) }, + 'bugzilla.code.phabricator_api_error', + 'user.edit error propagates' + ); +} + +# Disabling a BMO account disables the linked Phabricator user +{ + my @mail; + my $mailer = mock 'Bugzilla::Extension::PhabBugz' => + (override => [MessageToMTA => sub { push @mail, $_[0] }]); + + my $carol = $env->bmo_user('carol'); + my $carol_phab = $env->link_user($carol); + + $conduit->reset_calls; + $carol->set_disabledtext('Disabled by PhabBugz test'); + $carol->update; + my ($disable) = $conduit->calls('user.edit'); + ok($disable, 'disabling the BMO account calls user.edit'); + is($disable->{objectIdentifier}, $carol_phab->{phid}, 'for the linked user'); + ok($disable->{transactions}[0]{value}, 'disabled = true'); + + $conduit->reset_calls; + $carol->set_disabledtext(''); + $carol->update; + my ($enable) = $conduit->calls('user.edit'); + ok( + $enable && !$enable->{transactions}[0]{value}, + 're-enabling the BMO account sets disabled = false' + ); + + $conduit->reset_calls; + $carol->set_name('Carol Renamed'); + $carol->update; + is($conduit->call_count('user.edit'), 0, 'other user changes are not synced'); + + my $dave = $env->bmo_user('dave-no-phab'); + $conduit->reset_calls; + $dave->set_disabledtext('Disabled by PhabBugz test'); + $dave->update; + is($conduit->call_count('user.edit'), + 0, 'no user.edit when the BMO user has no Phabricator account'); + + SetParam('phabricator_enabled', 0); + $conduit->reset_calls; + $carol->set_disabledtext('Disabled while sync is off'); + $carol->update; + is($conduit->call_count, 0, 'no Conduit calls with phabricator_enabled off'); + $conduit->apply_params; + $carol->set_disabledtext(''); + $carol->update; + + @mail = (); + $conduit->queue('user.edit' => network_error('Connection refused')); + ok( + lives { + $carol->set_disabledtext('Disabled during an outage'); + $carol->update; + }, + 'a Phabricator outage does not block the BMO update' + ); + is(scalar @mail, 1, 'the maintainer is emailed about the failure'); + like($mail[0] // '', qr/\Q${\ $carol->login}\E/, 'email names the user'); + ok(Bugzilla::User->new($carol->id)->disabledtext, + 'the BMO account is still disabled'); +} + +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/util.t b/extensions/PhabBugz/t/bmo/util.t new file mode 100644 index 0000000000..b5883e6c60 --- /dev/null +++ b/extensions/PhabBugz/t/bmo/util.t @@ -0,0 +1,376 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# PhabBugz::Util helpers that combine BMO data with Conduit calls: bug role +# PHIDs, revision attachments, bug auto-assignment, reviewer rotation and +# intermittent reviewer handling. + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Test::More; + +use Bugzilla::Extension::PhabBugz::Constants; +use Bugzilla::Extension::PhabBugz::Revision; +use Bugzilla::Extension::PhabBugz::Util qw( + create_revision_attachment + get_attachment_revisions + get_bug_role_phids + set_bug_assignee + set_intermittent_reviewers + set_reviewer_rotation +); +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +use constant Revision => 'Bugzilla::Extension::PhabBugz::Revision'; + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; +my $dbh = Bugzilla->dbh; + +my $admin_phab = $env->link_user($env->admin); +my $author = $env->bmo_user('author'); +my $author_phab = $env->link_user($author); +my $alice = $env->bmo_user('alice'); +my $alice_phab = $env->link_user($alice); +my $bob = $env->bmo_user('bob'); +my $bob_phab = $env->link_user($bob); +my $carol = $env->bmo_user('carol-not-in-phab'); + +my ($timestamp) = $dbh->selectrow_array('SELECT NOW()'); +my @rotation_projects; +$env->on_teardown(sub { + $dbh->do('DELETE FROM phab_reviewer_rotation WHERE project_phid = ?', undef, $_) + foreach @rotation_projects; +}); + +sub revision_for { + my ($bug, %args) = @_; + return $conduit->load_revision(revision_obj( + bug_id => ($bug ? $bug->id : ''), + author_phid => $author_phab->{phid}, + %args, + )); +} + +# get_bug_role_phids +{ + my $bug = $env->bug(assigned_to => $alice, cc => [$bob, $carol]); + $conduit->reset_calls; + is_deeply( + [sort @{get_bug_role_phids($bug)}], + [sort $admin_phab->{phid}, $alice_phab->{phid}, $bob_phab->{phid}], + 'reporter, assignee and CCs with Phabricator accounts' + ); + + my $unassigned = $env->bug(cc => [$bob]); + is_deeply( + [sort @{get_bug_role_phids($unassigned)}], + [sort $admin_phab->{phid}, $bob_phab->{phid}], + 'nobody@mozilla.org is not a role' + ); +} + +# create_revision_attachment and get_attachment_revisions +{ + my $bug = $env->bug; + my $revision + = revision_for($bug, title => 'Attachment test', summary => 'Patch summary'); + + my $attachment + = create_revision_attachment($bug, $revision, $timestamp, $author); + $bug->update($timestamp); # the caller saves the attachment comment + my $uri = $conduit->base_uri . 'D' . $revision->id; + is($attachment->data, $uri, 'attachment data is the revision URL'); + is($attachment->filename, 'phabricator-D' . $revision->id . '-url.txt', + 'filename'); + is($attachment->contenttype, PHAB_CONTENT_TYPE, 'Phabricator content type'); + is($attachment->description, 'Attachment test', 'description is the title'); + is($attachment->attacher->id, $author->id, 'attached by the revision author'); + + my $fresh = Bugzilla::Bug->new($bug->id); + my ($comment) + = grep { ($_->extra_data // '') eq $attachment->id } @{$fresh->comments}; + ok($comment, 'attachment comment added'); + is( + $comment->body, + 'Patch summary', + 'attachment comment is the revision summary' + ) if $comment; + + my $again = create_revision_attachment($fresh, $revision, $timestamp, $author); + is($again->id, $attachment->id, 'existing attachment is reused'); + is(scalar @{Bugzilla::Bug->new($bug->id)->attachments}, + 1, 'no duplicate attachment'); + + my $second = revision_for($bug); + create_revision_attachment(Bugzilla::Bug->new($bug->id), + $second, $timestamp, $author); + + $conduit->reset_calls; + my $revisions = get_attachment_revisions(Bugzilla::Bug->new($bug->id)); + is_deeply( + [sort map { $_->id } @$revisions], + [sort $revision->id, $second->id], + 'get_attachment_revisions' + ); + is_deeply( + [map { $_->{constraints} } $conduit->calls('differential.revision.search')], + [map { {ids => [$_]} } $revision->id, $second->id], + 'one differential.revision.search per attachment' + ); + + ok(!get_attachment_revisions($env->bug), 'bug without revision attachments'); + + my $private = revision_for($bug, view_policy => 'PHID-PLCY-private'); + is( + create_revision_attachment(Bugzilla::Bug->new($bug->id), + $private, $timestamp, $author)->description, + '(secure)', + 'private revision attachments hide the title' + ); +} + +# set_bug_assignee +{ + my $bug = $env->bug; + my $initial = $bug->bug_status; + my $revision = revision_for($bug, reviewers => [{phid => $alice_phab->{phid}}]); + set_bug_assignee($revision, $bug, 'author updated D1'); + $bug->update; + $bug = Bugzilla::Bug->new($bug->id); + is($bug->assigned_to->id, $author->id, + 'unassigned bug is assigned to the author'); + + # Only NEW and UNCONFIRMED bugs move to ASSIGNED; BMO files bugs as + # CONFIRMED, which is left alone. + is( + $bug->bug_status, + ($initial =~ /^(?:NEW|UNCONFIRMED)$/ ? 'ASSIGNED' : $initial), + "bug status after assignment (was $initial)" + ); + + my $no_reviews = $env->bug; + set_bug_assignee(revision_for($no_reviews), $no_reviews, 'author updated D1'); + $no_reviews->update; + is( + Bugzilla::Bug->new($no_reviews->id)->assigned_to->login, + 'nobody@mozilla.org', + 'revision without reviewers does not assign the bug' + ); + + my $assigned = $env->bug(assigned_to => $bob); + set_bug_assignee( + revision_for($assigned, reviewers => [{phid => $alice_phab->{phid}}]), + $assigned, 'author updated D1'); + $assigned->update; + is(Bugzilla::Bug->new($assigned->id)->assigned_to->id, + $bob->id, 'an assigned bug keeps its assignee'); +} + +# set_reviewer_rotation +{ + my $bug = $env->bug; + my $project = $conduit->add_project(project_obj( + name => $env->unique('a') . '-reviewers-rotation', + members => [$alice_phab->{phid}, $bob_phab->{phid}], + )); + push @rotation_projects, $project->{phid}; + + my $pick = sub { + my (%args) = @_; + my $revision = revision_for( + $bug, + reviewers => [{phid => $project->{phid}, blocking => 1}], + %args + ); + $conduit->reset_calls; + set_reviewer_rotation($revision); + return [$conduit->transactions_for($revision->phid)]; + }; + + is_deeply( + $pick->(), + [ + {type => 'subscribers.add', value => [$project->{phid}]}, + {type => 'reviewers.add', value => ["blocking($alice_phab->{phid})"]}, + {type => 'reviewers.remove', value => [$project->{phid}]}, + ], + 'first rotation picks the first member, keeps blocking, swaps the group' + ); + my ($last) + = $dbh->selectrow_array( + 'SELECT user_phid FROM phab_reviewer_rotation WHERE project_phid = ?', + undef, $project->{phid}); + is($last, $alice_phab->{phid}, + 'last reviewer stored in phab_reviewer_rotation'); + + my ($next) = grep { $_->{type} eq 'reviewers.add' } @{$pick->()}; + is_deeply( + $next->{value}, + ["blocking($bob_phab->{phid})"], + 'rotation moves to the next member' + ); + + ($next) = grep { $_->{type} eq 'reviewers.add' } @{$pick->()}; + is_deeply( + $next->{value}, + ["blocking($alice_phab->{phid})"], + 'rotation wraps around' + ); + + ($next) + = grep { $_->{type} eq 'reviewers.add' } + @{$pick->(author_phid => $bob_phab->{phid})}; + is_deeply( + $next->{value}, + ["blocking($alice_phab->{phid})"], + 'the revision author is never picked' + ); + + my $manual = $pick->(reviewers => + [{phid => $project->{phid}, blocking => 1}, {phid => $bob_phab->{phid}},]); + is_deeply( + $manual, + [ + {type => 'subscribers.add', value => [$project->{phid}]}, + {type => 'reviewers.remove', value => [$project->{phid}]}, + ], + 'a member already set as reviewer replaces the group without a new pick' + ); + + my $loose = $conduit->add_project(project_obj( + name => $env->unique('b') . '-reviewer-rotation', + members => [$bob_phab->{phid}], + )); + push @rotation_projects, $loose->{phid}; + my $revision = revision_for($bug, reviewers => [{phid => $loose->{phid}}]); + $conduit->reset_calls; + set_reviewer_rotation($revision); + my ($add) + = grep { $_->{type} eq 'reviewers.add' } + $conduit->transactions_for($revision->phid); + is_deeply( + $add->{value}, + [$bob_phab->{phid}], + 'non-blocking group gives a non-blocking reviewer (single member)' + ); +} + +# set_reviewer_rotation when nobody is available +{ + my $bug = $env->bug; + my $project = $conduit->add_project(project_obj( + name => $env->unique('c') . '-reviewers-rotation', + members => [$author_phab->{phid}], + )); + push @rotation_projects, $project->{phid}; + + my $revision = revision_for($bug, reviewers => [{phid => $project->{phid}}]); + $conduit->reset_calls; + set_reviewer_rotation($revision); + my ($comment) + = grep { $_->{type} eq 'comment' } + $conduit->transactions_for($revision->phid); + like( + $comment->{value} // '', + qr/^REVIEWER ROTATION: .* \Q$project->{fields}{name}\E/, + 'comment explains that no reviewer was found' + ); + + $conduit->add_transaction( + $revision->phid, + transaction_obj( + object_phid => $revision->phid, + comments => [$comment->{value} // ''], + ) + ); + $conduit->reset_calls; + set_reviewer_rotation(Revision->new_from_query({phids => [$revision->phid]})); + ok( + !(grep { $_->{type} eq 'comment' } $conduit->transactions_for($revision->phid)), + 'the comment is not repeated' + ); + + my $empty = $conduit->add_project(project_obj( + name => $env->unique('d') . '-reviewers-rotation', members => [],)); + $conduit->reset_calls; + set_reviewer_rotation( + revision_for($bug, reviewers => [{phid => $empty->{phid}}])); + ok( + !( + grep { $_->{type} eq 'reviewers.add' } + map { @{$_->{transactions}} } $conduit->calls('differential.revision.edit') + ), + 'a group without members is skipped' + ); + + my $plain + = $conduit->add_project(project_obj(name => $env->unique('plain-reviewers'))); + my $not_rotation = revision_for($bug, reviewers => [{phid => $plain->{phid}}]); + $conduit->reset_calls; + set_reviewer_rotation($not_rotation); + is($conduit->call_count('differential.revision.edit'), + 0, 'no edit without a reviewer rotation group'); +} + +# set_intermittent_reviewers +{ + my $intermittent = $conduit->find_project(name => 'intermittent-reviewers') + // $conduit->add_project(project_obj(name => 'intermittent-reviewers')); + my $taskgraph = $conduit->find_project(name => 'taskgraph-reviewers') + // $conduit->add_project(project_obj(name => 'taskgraph-reviewers')); + my $other_blocking + = $conduit->add_project(project_obj(name => $env->unique('other'))); + my $non_blocking + = $conduit->add_project(project_obj(name => $env->unique('nonblock'))); + + my $revision = revision_for( + undef, + reviewers => [ + {phid => $intermittent->{phid}, blocking => 1}, + {phid => $taskgraph->{phid}, blocking => 1}, + {phid => $other_blocking->{phid}, blocking => 1}, + {phid => $non_blocking->{phid}}, + ] + ); + $conduit->reset_calls; + set_intermittent_reviewers($revision); + is_deeply( + [$conduit->transactions_for($revision->phid)], + [ + {type => 'subscribers.add', value => [$other_blocking->{phid}]}, + {type => 'reviewers.remove', value => [$other_blocking->{phid}]}, + ], + 'other blocking groups move to subscribers' + ); + + my $only_one = revision_for( + undef, + reviewers => [ + {phid => $intermittent->{phid}, blocking => 1}, + {phid => $other_blocking->{phid}, blocking => 1}, + ] + ); + $conduit->reset_calls; + set_intermittent_reviewers($only_one); + is($conduit->call_count('differential.revision.edit'), + 0, 'nothing changes without both intermittent and taskgraph reviewers'); +} + +$env->finish; +done_testing; diff --git a/extensions/PhabBugz/t/bmo/webservice.t b/extensions/PhabBugz/t/bmo/webservice.t new file mode 100644 index 0000000000..ee480c4e8d --- /dev/null +++ b/extensions/PhabBugz/t/bmo/webservice.t @@ -0,0 +1,296 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# PhabBugz::WebService methods, called in-process so the fake Conduit +# answers their Phabricator requests: bug_revisions (with edge.search for +# stacks), user, and the permission checks used by Phabricator. + +use 5.10.1; +use strict; +use warnings; +use lib qw(. lib local/lib/perl5); +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Bugzilla; +BEGIN { Bugzilla->extensions } + +use Bugzilla::Config qw(SetParam); +use Test::More; + +use Bugzilla::Extension::PhabBugz::Util qw(create_revision_attachment); +use Bugzilla::Extension::PhabBugz::WebService; +use PhabTest::Conduit qw(:all); +use PhabTest::Env; +use PhabTest::Fixtures qw(:all); + +my $env = PhabTest::Env->new; +my $conduit = $env->conduit; +my $ws = bless {}, 'Bugzilla::Extension::PhabBugz::WebService'; + +my $author = $env->bmo_user('author'); +my $author_phab = $env->link_user($author, username => 'phabtest-author'); +my $reviewer = $env->bmo_user('reviewer'); +my $reviewer_phab = $env->link_user($reviewer, username => 'phabtest-reviewer'); +my $repo + = $conduit->add_repository(repository_obj(callsign => 'MOZILLACENTRAL')); + +my $bug = $env->bug; +my $other_bug = $env->bug; + +my $parent = $conduit->add_revision(revision_obj( + title => 'Parent revision', + status => 'accepted', + bug_id => $bug->id, + author_phid => $author_phab->{phid}, + repository_phid => $repo->{phid}, + reviewers => [{phid => $reviewer_phab->{phid}, status => 'accepted'}], +)); +my $child = $conduit->add_revision(revision_obj( + title => 'Secret child', + status => 'needs-review', + bug_id => $bug->id, + author_phid => $author_phab->{phid}, + view_policy => 'PHID-PLCY-secret', + reviewers => [{phid => $reviewer_phab->{phid}, status => 'blocking'}], +)); +my $moved = $conduit->add_revision(revision_obj( + title => 'Moved to another bug', + bug_id => $other_bug->id, + author_phid => $author_phab->{phid}, +)); +$conduit->add_edge($parent->{phid}, $child->{phid}); +$conduit->add_edge($parent->{phid}, 'PHID-DREV-notonthisbug'); + +my ($timestamp) = Bugzilla->dbh->selectrow_array('SELECT NOW()'); +foreach my $raw ($parent, $child, $moved) { + create_revision_attachment(Bugzilla::Bug->new($bug->id), + $conduit->load_revision($raw), $timestamp, $author); +} + +# bug_revisions +{ + $env->fresh_request; + $conduit->reset_calls; + my $result = $ws->bug_revisions({bug_id => $bug->id}); + + my ($search) = $conduit->calls('differential.revision.search'); + is_deeply( + $search, + { + attachments => + {projects => 1, reviewers => 1, subscribers => 1, 'reviewers-extra' => 1,}, + constraints => {ids => [$parent->{id}, $child->{id}, $moved->{id}]}, + order => 'newest', + }, + 'differential.revision.search for the attached revision ids' + ); + is_deeply( + [$conduit->calls('edge.search')], + [{ + sourcePHIDs => [map { $_->{phid} } $moved, $child, $parent], + types => ['revision.child'], + }], + 'edge.search for stack children' + ); + + is_deeply( + $result, + { + revisions => [ + { + id => "D$parent->{id}", + sortkey => $parent->{id}, + status => 'accepted', + long_status => 'Accepted', + title => 'Parent revision', + call_sign => 'MOZILLACENTRAL', + children => ["D$child->{id}"], + reviews => [ + {user => 'phabtest-reviewer', status => 'accepted', long_status => 'Accepted'}, + ], + }, + { + id => "D$child->{id}", + sortkey => $child->{id}, + status => 'needs-review', + long_status => 'Needs Review', + title => '(secured)', + call_sign => '(secured)', + children => [], + reviews => [ + { + user => 'phabtest-reviewer', + status => 'blocking', + long_status => 'Blocking Review' + }, + ], + }, + ], + }, + 'revisions sorted by id, moved revision skipped, private title hidden' + ); +} + +# bug_revisions: malformed and failed responses +{ + $env->fresh_request; + $conduit->queue('differential.revision.search' => {data => 'not a list'}); + throws_error( + sub { $ws->bug_revisions({bug_id => $bug->id}) }, + 'bugzilla.code.phabricator_api_error', + 'malformed search result' + ); + + $conduit->queue('edge.search' => http_error(500)); + throws_error( + sub { $ws->bug_revisions({bug_id => $bug->id}) }, + 'bugzilla.code.phabricator_api_error', + 'edge.search failure' + ); + + my $empty = $env->bug; + $conduit->reset_calls; + is_deeply( + $ws->bug_revisions({bug_id => $empty->id}), + {revisions => []}, + 'bug without revisions' + ); +} + +# user +{ + $env->fresh_request; + $conduit->reset_calls; + my $result = $ws->user({user_id => $author->id}); + is_deeply( + [$conduit->calls('bmoexternalaccount.search')], + [{accountids => [$author->id]}], + 'bmoexternalaccount.search params' + ); + is_deeply( + [$conduit->calls('user.query')], + [{phids => [$author_phab->{phid}]}], + 'user.query params' + ); + + (my $base = $conduit->base_uri) =~ s{/$}{}; + is_deeply( + $result, + { + user => { + phid => $author_phab->{phid}, + userName => 'phabtest-author', + realName => $author->name, + userURL => "$base/p/phabtest-author/", + revisionsURL => "$base/differential/?responsiblePHIDs%5B0%5D=" + . $author_phab->{phid} + . '&statuses%5B0%5D=open()&order=newest&bucket=action', + }, + }, + 'user details and Phabricator URLs' + ); + + my $stranger = $env->bmo_user('no-phab-account'); + $conduit->reset_calls; + is_deeply($ws->user({user_id => $stranger->id}), {}, 'unknown user returns {}'); + is($conduit->call_count('user.query'), 0, 'no user.query without a PHID'); + + $conduit->queue('user.query' => []); + is_deeply($ws->user({user_id => $author->id}), {}, + 'user.query without results'); +} + +# Permission checks are only for phab-bot +{ + my $group = $env->bug_group('ws'); + my $private = $env->bug(groups => [$group]); + + $env->fresh_request($env->phab_bot); + is_deeply( + $ws->check_user_permission_for_bug({bug_id => $bug->id, user_id => $author->id + }), + {result => 1}, + 'user can see a public bug' + ); + is_deeply( + $ws->check_user_permission_for_bug({ + bug_id => $private->id, user_id => $author->id + }), + {result => 0}, + 'user cannot see a private bug' + ); + is_deeply( + $ws->check_user_enter_bug_permission({ + product => 'Firefox', user_id => $author->id + }), + {result => 1}, + 'user can file bugs in Firefox' + ); + + throws_error( + sub { $ws->check_user_permission_for_bug({bug_id => $bug->id}) }, + 'bugzilla.user.phabricator_invalid_request_params', + 'user_id is required' + ); + throws_error( + sub { $ws->check_user_enter_bug_permission({user_id => $author->id}) }, + 'bugzilla.user.phabricator_invalid_request_params', + 'product is required' + ); + + $env->fresh_request($author); + throws_error( + sub { + $ws->check_user_permission_for_bug({bug_id => $bug->id, user_id => $author->id + }); + }, + 'bugzilla.user.phabricator_unauthorized_user', + 'only phab-bot may call check_bug' + ); + throws_error( + sub { + $ws->check_user_enter_bug_permission({ + product => 'Firefox', user_id => $author->id + }); + }, + 'bugzilla.user.phabricator_unauthorized_user', + 'only phab-bot may call check_enter_bug' + ); +} + +# Everything is refused while the integration is disabled +{ + $env->fresh_request($env->phab_bot); + SetParam('phabricator_enabled', 0); + $conduit->reset_calls; + throws_error( + sub { $ws->bug_revisions({bug_id => $bug->id}) }, + 'bugzilla.user.phabricator_not_enabled', + 'bug_revisions disabled' + ); + throws_error( + sub { $ws->user({user_id => $author->id}) }, + 'bugzilla.user.phabricator_not_enabled', + 'user disabled' + ); + throws_error( + sub { + $ws->check_user_permission_for_bug({bug_id => $bug->id, user_id => $author->id + }); + }, + 'bugzilla.user.phabricator_not_enabled', + 'check_bug disabled' + ); + is($conduit->call_count, 0, 'no Conduit calls while disabled'); + $conduit->apply_params; +} + +$env->fresh_request; +$env->finish; +done_testing; diff --git a/scripts/generate_bmo_data.pl b/scripts/generate_bmo_data.pl index dd2ee0b448..269a3f1501 100755 --- a/scripts/generate_bmo_data.pl +++ b/scripts/generate_bmo_data.pl @@ -717,6 +717,26 @@ cc_list => '', inclusions => ['Firefox:'] }, + + # Uplift approval flags set by PhabBugz on revision attachments. Only + # enabled for Firefox :: General so the other products and components + # exercise the "flag not enabled here" case. esr115 is disabled globally. + ( + map { + +{ + name => "approval-mozilla-$_", + desc => "Approval for landing the patch on mozilla-$_.", + is_active => ($_ eq 'esr115' ? 0 : 1), + is_requestable => 1, + is_requesteeble => 0, + is_multiplicable => 0, + grant_group => '', + target_type => 'a', + cc_list => '', + inclusions => ['Firefox:General'], + } + } qw(beta release esr140 esr115) + ), ); print "creating flag types...\n"; @@ -733,15 +753,16 @@ $dbh->do( 'INSERT INTO flagtypes (name, description, cc_list, target_type, is_requestable, - is_requesteeble, is_multiplicable, grant_group_id, request_group_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + is_requesteeble, is_multiplicable, grant_group_id, request_group_id, + is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', undef, ( $flag->{name}, $flag->{desc}, $flag->{cc_list}, $flag->{target_type}, $flag->{is_requestable}, $flag->{is_requesteeble}, $flag->{is_multiplicable}, $grant_group_id, - $request_group_id + $request_group_id, $flag->{is_active} // 1 ) );