#!/usr/bin/env perl
# Copyright 2014-2020 SUSE LLC
# SPDX-License-Identifier: GPL-2.0-or-later

use Test::Most;
use Mojo::Base -signatures;

use FindBin;
use lib "$FindBin::Bin/../lib", "$FindBin::Bin/../../external/os-autoinst-common/lib";
use Test::MockModule;
use Test::Mojo;
use Test::Warnings qw(:all :report_warnings);
use Mojo::URL;
use Mojo::Util qw(encode hmac_sha1_sum);
use OpenQA::Shared::Controller::Auth;
use OpenQA::Test::TimeLimit '10';
use OpenQA::Test::Case;
use OpenQA::Test::Client 'client';
use OpenQA::UserAgent;

my $webapi_mock = Test::MockModule->new('OpenQA::WebAPI');
$webapi_mock->redefine(
    config => sub (@args) {
        my $config = $webapi_mock->original('config')->(@args);
        $config->{global}->{access_control_allow_origin_header} = '*';
        return $config;
    });

OpenQA::Test::Case->new->init_data((fixtures_glob => '01-jobs.pl 03-users.pl'));
my $t = Test::Mojo->new('OpenQA::WebAPI');
undef $webapi_mock;

# we don't want to *actually* delete any assets when we're testing
# whether we're allowed to or not, so let's mock that out
my $mock_asset_remove_callcount = 0;
my $mock_asset = Test::MockModule->new('OpenQA::Schema::Result::Assets');
$mock_asset->redefine(remove_from_disk => sub { $mock_asset_remove_callcount++; return 1; });

subtest 'authentication routes for plugins' => sub {
    ok my $public = $t->app->routes->find('api_public'), 'api_pubic route found';
    $public->put('/public_plugin' => sub { shift->render(text => 'API public plugin works!') });
    ok my $ensure_user = $t->app->routes->find('api_ensure_user'), 'api_ensure_user route found';
    $ensure_user->put('/user_plugin' => sub { shift->render(text => 'API user plugin works!') });
    ok my $ensure_admin = $t->app->routes->find('api_ensure_admin'), 'api_ensure_admin route found';
    $ensure_admin->put('/admin_plugin' => sub { shift->render(text => 'API admin plugin works!') });
    ok my $ensure_operator = $t->app->routes->find('api_ensure_operator'), 'api_ensure_operator route found';
    $ensure_operator->put('/operator_plugin' => sub { shift->render(text => 'API operator plugin works!') });
};

client($t, apikey => undef, apisecret => undef);

subtest 'key+secret from env variables' => sub {
    my $t_new = Test::Mojo->new('OpenQA::WebAPI');
    $t_new->delete_ok('/api/v1/assets/1')->status_is(403, 'clean user agent has no credentials');
    local $ENV{OPENQA_API_KEY} = 'LANCELOTKEY01';
    local $ENV{OPENQA_API_SECRET} = 'MANYPEOPLEKNOW';
    client($t_new, apikey => undef, apisecret => undef);
    $t_new->delete_ok('/api/v1/assets/1')->status_is(403)
      ->json_is('/error' => 'Administrator level required', 'key read from env (but missing administrator level)');
    $t_new->post_ok('/api/v1/isos')->status_is(403)
      ->json_is('/error' => 'Operator level required', 'key read from env (but missing operator level)');
};

subtest 'access limiting for non authenticated users' => sub {
    $t->get_ok('/api/v1/jobs')->status_is(200);
    is $t->tx->res->headers->access_control_allow_origin, '*', 'CORS header set for non authenticated routes';
    $t->get_ok('/api/v1/products')->status_is(200);
    $t->delete_ok('/api/v1/assets/1')->status_is(403, 'delete forbidden');
    is_deeply
      $t->tx->res->json,
      {
        error => 'no api key',
        error_status => 403,
      },
      'error returned as JSON';
    is $mock_asset_remove_callcount, 0, 'asset deletion function was not called';
    $t->put_ok('/api/v1/public_plugin')->status_is(200)->content_is('API public plugin works!');
    $t->put_ok('/api/v1/user_plugin')->status_is(403);
    $t->put_ok('/api/v1/admin_plugin')->status_is(403);
    $t->put_ok('/api/v1/operator_plugin')->status_is(403);
};

client($t);

subtest 'access limiting for authenticated users but not operators nor admins' => sub {
    $t->ua->apikey('LANCELOTKEY01');
    $t->ua->apisecret('MANYPEOPLEKNOW');
    $t->get_ok('/api/v1/jobs')->status_is(200, 'accessible (public)');
    $t->post_ok('/api/v1/assets')->status_is(403, 'restricted (operator and admin only)');
    $t->delete_ok('/api/v1/assets/1')->status_is(403, 'restricted (admin only)');
    is $mock_asset_remove_callcount, 0, 'asset deletion function was not called';
    $t->put_ok('/api/v1/public_plugin')->status_is(200)->content_is('API public plugin works!');
    $t->put_ok('/api/v1/user_plugin')->status_is(200)->content_is('API user plugin works!');
    $t->put_ok('/api/v1/admin_plugin')->status_is(403);
    $t->put_ok('/api/v1/operator_plugin')->status_is(403);
};

subtest 'access limiting for authenticated operators but not admins' => sub {
    $t->ua->apikey('PERCIVALKEY01');
    $t->ua->apisecret('PERCIVALSECRET01');
    $t->get_ok('/api/v1/jobs')->status_is(200, 'accessible (public)');
    $t->post_ok('/api/v1/jobs/99927/set_done')->status_is(200, 'accessible (operator and admin only)');
    $t->delete_ok('/api/v1/assets/1')->status_is(403, 'restricted (admin only)');
    is $mock_asset_remove_callcount, 0, 'asset deletion function was not called';
    $t->put_ok('/api/v1/public_plugin')->status_is(200)->content_is('API public plugin works!');
    $t->put_ok('/api/v1/user_plugin')->status_is(200)->content_is('API user plugin works!');
    $t->put_ok('/api/v1/admin_plugin')->status_is(403);
    $t->put_ok('/api/v1/operator_plugin')->status_is(200)->content_is('API operator plugin works!');
};

subtest 'access granted for admins' => sub {
    $t->ua->apikey('ARTHURKEY01');
    $t->ua->apisecret('EXCALIBUR');
    $t->get_ok('/api/v1/jobs')->status_is(200, 'accessible (public)');
    $t->post_ok('/api/v1/jobs/99927/set_done')->status_is(200, 'accessible (operator and admin only)');
    $t->delete_ok('/api/v1/assets/1')->status_is(200, 'accessible (admin only)');
    is $t->tx->res->headers->access_control_allow_origin, '*', 'CORS header set for authenticated routes';
    is $mock_asset_remove_callcount, 1, 'asset deletion function was called';
    # reset the call count
    $mock_asset_remove_callcount = 0;
    $t->put_ok('/api/v1/public_plugin')->status_is(200)->content_is('API public plugin works!');
    $t->put_ok('/api/v1/user_plugin')->status_is(200)->content_is('API user plugin works!');
    $t->put_ok('/api/v1/admin_plugin')->status_is(200)->content_is('API admin plugin works!');
    $t->put_ok('/api/v1/operator_plugin')->status_is(200)->content_is('API operator plugin works!');
};

subtest 'wrong api key - expired' => sub {
    $t->ua->apikey('EXPIREDKEY01');
    $t->ua->apisecret('WHOCARESAFTERALL');
    $t->get_ok('/api/v1/jobs')->status_is(200);
    $t->post_ok('/api/v1/products/1')->status_is(403);
    is $t->tx->res->json->{error}, 'api key expired', 'key expired error';
    $t->delete_ok('/api/v1/assets/1')->status_is(403);
    is $t->tx->res->json->{error}, 'api key expired', 'key expired error';
    is $mock_asset_remove_callcount, 0, 'asset deletion function was not called';
};

subtest 'wrong api key - not maching key + secret' => sub {
    $t->ua->apikey('EXPIREDKEY01');
    $t->ua->apisecret('INVALIDSECRET');
    $t->get_ok('/api/v1/jobs')->status_is(200);
    $t->post_ok('/api/v1/products/1')->status_is(403);
    $t->delete_ok('/api/v1/assets/1')->status_is(403);
    is $mock_asset_remove_callcount, 0, 'asset deletion function was not called';
};

subtest 'correct key but wrong secret' => sub {
    $t->ua->apikey('ARTHURKEY01');
    $t->ua->apisecret('INVALIDSECRET');
    $t->post_ok('/api/v1/products/1')->status_is(403);
    is $t->tx->res->json->{error}, 'unknown error (wrong secret?)', 'wrong secret error';
};

subtest 'no key, no secret' => sub {
    $t->ua->apikey('NOTEXISTINGKEY');
    $t->delete_ok('/api/v1/assets/1')->status_is(403);
    is $mock_asset_remove_callcount, 0, 'asset deletion function was not called';
};

subtest 'wrong api key - replay attack' => sub {
    $t->ua->apikey('ARTHURKEY01');
    $t->ua->apisecret('EXCALIBUR');
    $t->ua->unsubscribe('start');
    $t->ua->on(
        start => sub ($ua, $tx) {
            my $timestamp = 0;
            my $headers = $tx->req->headers;
            my $hash = hmac_sha1_sum(OpenQA::UserAgent::_path_query($tx->req->url)) . $timestamp, $ua->apisecret;
            $headers->header('X-API-Microtime', $timestamp);
            $headers->header('X-API-Key', $ua->apikey);
            $headers->header('X-API-Hash', $hash);
        });
    $t->get_ok('/api/v1/jobs')->status_is(200);
    $t->post_ok('/api/v1/products/1')->status_is(403)->json_is(
        '/error' => 'timestamp mismatch - check whether clocks on the local host and the web UI host are in sync',
        'timestamp mismatch error'
    );
    $t->delete_ok('/api/v1/assets/1')->status_is(403)->json_is(
        '/error' => 'timestamp mismatch - check whether clocks on the local host and the web UI host are in sync',
        'timestamp mismatch error'
    );
    is $mock_asset_remove_callcount, 0, 'asset deletion function was not called';
};

subtest 'personal access token' => sub {
    my $userinfo = sub ($t, $userinfo) {
        $t->ua->once(start => sub ($ua, $tx) { $tx->req->url->userinfo($userinfo) });
        return $t;
    };
    my $t = Test::Mojo->new('OpenQA::WebAPI');
    $t->delete_ok('/api/v1/assets/1')->status_is(403)
      ->json_is({error => 'no api key'}, undef, 'access token is required');
    $t->$userinfo('artie:ARTHURKEY01:EXCALIBUR')->delete_ok('/api/v1/assets/1')->status_is(404, 'valid access token');
    $t->$userinfo('lance:LANCELOTKEY01:MANYPEOPLEKNOW')->post_ok('/api/v1/feature' => form => {version => 100})
      ->status_is(200, 'valid access token (OpenID user)');
    $t->$userinfo('invalid:invalid')->delete_ok('/api/v1/assets/1')->status_is(403)
      ->json_is({error => 'invalid personal access token'}, undef, 'invalid access token denied');
    $t->$userinfo('invalid:ARTHURKEY01:EXCALIBUR')->delete_ok('/api/v1/assets/1')->status_is(403)
      ->json_is({error => 'invalid personal access token'}, undef, 'invalid username denied');
    $t->$userinfo('artie:INVALID:EXCALIBUR')->delete_ok('/api/v1/assets/1')->status_is(403)
      ->json_is({error => 'invalid personal access token'}, undef, 'invalid key denied');
    $t->$userinfo('artie:ARTHURKEY01:INVALID')->delete_ok('/api/v1/assets/1')->status_is(403)
      ->json_is({error => 'invalid personal access token'}, undef, 'invalid secret denied');
    $t->$userinfo('lance:LANCELOTKEY01:INVALIDTOO')->post_ok('/api/v1/feature' => form => {version => 100})
      ->status_is(403)->json_is({error => 'invalid personal access token'}, undef, 'invalid secret (OpenID) denied');
    $t->$userinfo('artie:ARTHURKEY01:EXCALIBUR')->delete_ok('/api/v1/assets/1')
      ->status_is(404, 'valid access token (again)');

    subtest 'Bearer token' => sub {
        subtest 'Valid token' => sub {
            $t->post_ok('/api/v1/feature' => {Authorization => 'Bearer lance:LANCELOTKEY01:MANYPEOPLEKNOW'} => form =>
                  {version => 100})->status_is(200);
        };
        subtest 'Invalid username' => sub {
            $t->post_ok('/api/v1/feature' => {Authorization => 'Bearer invalid:LANCELOTKEY01:MANYPEOPLEKNOW'} => form =>
                  {version => 100})->status_is(403)->json_is({error => 'invalid personal access token'});
        };
        subtest 'Invalid key' => sub {
            $t->post_ok('/api/v1/feature' => {Authorization => 'Bearer lance:LANCELOTKEY02:MANYPEOPLEKNOW'} => form =>
                  {version => 100})->status_is(403)->json_is({error => 'invalid personal access token'});
        };
        subtest 'Invalid secret' => sub {
            $t->post_ok('/api/v1/feature' => {Authorization => 'Bearer lance:LANCELOTKEY01:MANYPEOPLEKNOWS'} => form =>
                  {version => 100})->status_is(403)->json_is({error => 'invalid personal access token'});
        };
    };
};

subtest 'personal access token (with reverse proxy)' => sub {
    my $forwarded = sub ($t, $userinfo, $for, $proto) {
        $t->ua->once(
            start => sub ($ua, $tx) {
                $tx->req->url->userinfo($userinfo);
                $tx->req->headers->header('X-Forwarded-For' => $for);
                $tx->req->headers->header('X-Forwarded-Proto' => $proto);
            });
        return $t;
    };
    local $ENV{MOJO_REVERSE_PROXY} = 1;
    my $t = Test::Mojo->new('OpenQA::WebAPI');
    $t->$forwarded('artie:ARTHURKEY01:EXCALIBUR', '192.168.2.1', 'http')->delete_ok('/api/v1/assets/1')->status_is(403)
      ->json_is({error => 'personal access token can only be used via HTTPS or from localhost'},
        undef, 'not https or localhost denied');
    $t->$forwarded('artie:ARTHURKEY01:EXCALIBUR', '192.168.2.1', 'https')->delete_ok('/api/v1/assets/1')
      ->status_is(404, 'https not found');
    $t->$forwarded('artie:ARTHURKEY01:EXCALIBUR', '127.0.0.1', 'http')->delete_ok('/api/v1/assets/1')
      ->status_is(404, 'localhost not found');
    $t->$forwarded('artie:ARTHURKEY01:EXCALIBUR', '::1', 'http')->delete_ok('/api/v1/assets/1')
      ->status_is(404, 'localhost ipv6 not found');
    $t->$forwarded('artie:ARTHURKEY01:EXCALIBUR', '127.0.0.1', 'https')->delete_ok('/api/v1/assets/1')
      ->status_is(404, 'https and localhost not found');
    $t->$forwarded('artie:INVALID:EXCALIBUR', '192.168.2.1', 'https')->delete_ok('/api/v1/assets/1')->status_is(403)
      ->json_is({error => 'invalid personal access token'}, undef, 'HTTPS but invalid key');
};

subtest 'auth forbidden via domain' => sub {
    my $rendered;
    my $req = Mojo::Message::Request->new;
    $req->url->parse('http://foobar-openqa.de/test/42');
    my $controller_mock = Test::MockModule->new('Mojolicious::Controller');
    $controller_mock->redefine(req => $req);
    $controller_mock->redefine(render => sub ($c, @args) { $rendered = [@args] });
    my $c = OpenQA::Shared::Controller::Auth->new(app => $t->app, req => $req);
    $c->config->{global}->{file_domain} = 'foobar-openqa.de';
    is $c->auth, 0, 'auth denied via domain';
    my %expected_json = (error => 'Forbidden via file domain');
    my @expected = (json => \%expected_json, status => 403);
    is_deeply $rendered, \@expected, 'expected error and status';
    $req->url->parse('http://openqa.de/test/42');
    is $c->auth, 0, 'auth denied via regular domain';
    $expected_json{error} = 'no api key';
    is_deeply $rendered, \@expected, 'normal auth error via regular domain';
};

done_testing();
