Commit 01d2ad15 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'initial-secure-files-ui' into 'master'

Secure Files UI MVC

See merge request gitlab-org/gitlab!79784
parents 2f929d21 406444b4
......@@ -92,6 +92,7 @@ const Api = {
groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings',
notificationSettingsPath: '/api/:version/notification_settings',
deployKeysPath: '/api/:version/deploy_keys',
secureFilesPath: '/api/:version/projects/:project_id/secure_files',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
......@@ -957,6 +958,13 @@ const Api = {
return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...params } });
},
// TODO: replace this when GraphQL support has been added https://gitlab.com/gitlab-org/gitlab/-/issues/352184
projectSecureFiles(projectId, options = {}) {
const url = Api.buildUrl(this.secureFilesPath).replace(':project_id', projectId);
return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...options } });
},
async updateNotificationSettings(projectId, groupId, data = {}) {
let url = Api.buildUrl(this.notificationSettingsPath);
......
<script>
import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
GlLink,
GlLoadingIcon,
GlPagination,
GlTable,
TimeagoTooltip,
},
inject: ['projectId'],
docsLink: helpPagePath('ci/secure_files/index'),
DEFAULT_PER_PAGE,
i18n: {
pagination: {
next: __('Next'),
prev: __('Prev'),
},
title: __('Secure Files'),
overviewMessage: __(
'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.',
),
moreInformation: __('More information'),
},
data() {
return {
page: 1,
totalItems: 0,
loading: false,
projectSecureFiles: [],
};
},
fields: [
{
key: 'name',
label: __('Filename'),
},
{
key: 'permissions',
label: __('Permissions'),
},
{
key: 'created_at',
label: __('Uploaded'),
},
],
computed: {
fields() {
return this.$options.fields;
},
},
watch: {
page(newPage) {
this.getProjectSecureFiles(newPage);
},
},
created() {
this.getProjectSecureFiles();
},
methods: {
async getProjectSecureFiles(page) {
this.loading = true;
const response = await Api.projectSecureFiles(this.projectId, { page });
this.totalItems = parseInt(response.headers?.['x-total'], 10) || 0;
this.projectSecureFiles = response.data;
this.loading = false;
},
},
};
</script>
<template>
<div>
<h1 data-testid="title" class="gl-font-size-h1 gl-mt-3 gl-mb-0">{{ $options.i18n.title }}</h1>
<p>
<span data-testid="info-message" class="gl-mr-2">
{{ $options.i18n.overviewMessage }}
<gl-link :href="$options.docsLink" target="_blank">{{
$options.i18n.moreInformation
}}</gl-link>
</span>
</p>
<gl-table
:busy="loading"
:fields="fields"
:items="projectSecureFiles"
tbody-tr-class="js-ci-secure-files-row"
data-qa-selector="ci_secure_files_table_content"
sort-by="key"
sort-direction="asc"
stacked="lg"
table-class="text-secondary"
show-empty
sort-icon-left
no-sort-reset
>
<template #table-busy>
<gl-loading-icon size="lg" class="gl-my-5" />
</template>
<template #cell(name)="{ item }">
{{ item.name }}
</template>
<template #cell(permissions)="{ item }">
{{ item.permissions }}
</template>
<template #cell(created_at)="{ item }">
<timeago-tooltip :time="item.created_at" />
</template>
</gl-table>
<gl-pagination
v-if="!loading"
v-model="page"
:per-page="$options.DEFAULT_PER_PAGE"
:total-items="totalItems"
:next-text="$options.i18n.pagination.next"
:prev-text="$options.i18n.pagination.prev"
align="center"
/>
</div>
</template>
import Vue from 'vue';
import SecureFilesList from './components/secure_files_list.vue';
export const initCiSecureFiles = (selector = '#js-ci-secure-files') => {
const containerEl = document.querySelector(selector);
const { projectId } = containerEl.dataset;
return new Vue({
el: containerEl,
provide: {
projectId,
},
render(createElement) {
return createElement(SecureFilesList);
},
});
};
import { initCiSecureFiles } from '~/ci_secure_files';
initCiSecureFiles();
# frozen_string_literal: true
class Projects::Ci::SecureFilesController < Projects::ApplicationController
before_action :check_can_collaborate!
feature_category :pipeline_authoring
def show
end
private
def check_can_collaborate!
render_404 unless can_collaborate_with_project?(project)
end
end
- @content_class = "limit-container-width"
- page_title s_('Secure Files')
#js-ci-secure-files{ data: { project_id: @project.id } }
......@@ -96,6 +96,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
namespace :ci do
resource :lint, only: [:show, :create]
resource :pipeline_editor, only: [:show], controller: :pipeline_editor, path: 'editor'
resource :secure_files, only: [:show], controller: :secure_files, path: 'secure_files'
resources :daily_build_group_report_results, only: [:index], constraints: { format: /(csv|json)/ }
namespace :prometheus_metrics do
resources :histograms, only: [:create], constraints: { format: 'json' }
......
......@@ -9,6 +9,7 @@ module API
expose :permissions
expose :checksum
expose :checksum_algorithm
expose :created_at
end
end
end
......
......@@ -32383,6 +32383,9 @@ msgstr ""
msgid "Secret token"
msgstr ""
msgid "Secure Files"
msgstr ""
msgid "Secure token that identifies an external storage request."
msgstr ""
......@@ -39496,6 +39499,9 @@ msgstr ""
msgid "UploadLink|click to upload"
msgstr ""
msgid "Uploaded"
msgstr ""
msgid "Uploading changes to terminal"
msgstr ""
......@@ -39790,6 +39796,9 @@ msgstr ""
msgid "Use GitLab Runner in AWS"
msgstr ""
msgid "Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates."
msgstr ""
msgid "Use a one-time password authenticator on your mobile device or computer to enable two-factor authentication (2FA)."
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Ci::SecureFilesController do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
subject(:show_request) { get :show, params: { namespace_id: project.namespace, project_id: project } }
describe 'GET #show' do
context 'with enough privileges' do
before do
sign_in(user)
project.add_developer(user)
show_request
end
it { expect(response).to have_gitlab_http_status(:ok) }
it 'renders show page' do
expect(response).to render_template :show
end
end
context 'without enough privileges' do
before do
sign_in(user)
project.add_reporter(user)
show_request
end
it 'responds with 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'an unauthenticated user' do
before do
show_request
end
it 'redirects to sign in' do
expect(response).to have_gitlab_http_status(:found)
expect(response).to redirect_to('/users/sign_in')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Secure Files', :js do
let(:project) { create(:project) }
let(:user) { create(:user) }
before do
project.add_maintainer(user)
sign_in(user)
visit project_ci_secure_files_path(project)
end
it 'user sees the Secure Files list component' do
expect(page).to have_content('There are no records to show')
end
end
......@@ -1619,6 +1619,28 @@ describe('Api', () => {
});
});
describe('projectSecureFiles', () => {
it('fetches secure files for a project', async () => {
const projectId = 1;
const secureFiles = [
{
id: projectId,
title: 'File Name',
permissions: 'read_only',
checksum: '12345',
checksum_algorithm: 'sha256',
created_at: '2022-02-21T15:27:18',
},
];
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files`;
mock.onGet(expectedUrl).reply(httpStatus.OK, secureFiles);
const { data } = await Api.projectSecureFiles(projectId, {});
expect(data).toEqual(secureFiles);
});
});
describe('Feature Flag User List', () => {
let expectedUrl;
let projectId;
......
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { mount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import SecureFilesList from '~/ci_secure_files/components/secure_files_list.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import waitForPromises from 'helpers/wait_for_promises';
import { secureFiles } from '../mock_data';
const dummyApiVersion = 'v3000';
const dummyProjectId = 1;
const dummyUrlRoot = '/gitlab';
const dummyGon = {
api_version: dummyApiVersion,
relative_url_root: dummyUrlRoot,
};
let originalGon;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${dummyProjectId}/secure_files`;
describe('SecureFilesList', () => {
let wrapper;
let mock;
beforeEach(() => {
originalGon = window.gon;
window.gon = { ...dummyGon };
});
afterEach(() => {
wrapper.destroy();
mock.restore();
window.gon = originalGon;
});
const createWrapper = (props = {}) => {
wrapper = mount(SecureFilesList, {
provide: { projectId: dummyProjectId },
...props,
});
};
const findRows = () => wrapper.findAll('tbody tr');
const findRowAt = (i) => findRows().at(i);
const findCell = (i, col) => findRowAt(i).findAll('td').at(col);
const findHeaderAt = (i) => wrapper.findAll('thead th').at(i);
const findPagination = () => wrapper.findAll('ul.pagination');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
describe('when secure files exist in a project', () => {
beforeEach(async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles);
createWrapper();
await waitForPromises();
});
it('displays a table with expected headers', () => {
const headers = ['Filename', 'Permissions', 'Uploaded'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
});
it('displays a table with rows', () => {
expect(findRows()).toHaveLength(secureFiles.length);
const [secureFile] = secureFiles;
expect(findCell(0, 0).text()).toBe(secureFile.name);
expect(findCell(0, 1).text()).toBe(secureFile.permissions);
expect(findCell(0, 2).find(TimeAgoTooltip).props('time')).toBe(secureFile.created_at);
});
});
describe('when no secure files exist in a project', () => {
beforeEach(async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, []);
createWrapper();
await waitForPromises();
});
it('displays a table with expected headers', () => {
const headers = ['Filename', 'Permissions', 'Uploaded'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
});
it('displays a table with a no records message', () => {
expect(findCell(0, 0).text()).toBe('There are no records to show');
});
});
describe('pagination', () => {
it('displays the pagination component with there are more than 20 items', async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles, { 'x-total': 30 });
createWrapper();
await waitForPromises();
expect(findPagination().exists()).toBe(true);
});
it('does not display the pagination component with there are 20 items', async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles, { 'x-total': 20 });
createWrapper();
await waitForPromises();
expect(findPagination().exists()).toBe(false);
});
});
describe('loading state', () => {
it('displays the loading icon while waiting for the backend request', () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles);
createWrapper();
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not display the loading icon after the backend request has completed', async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles);
createWrapper();
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
});
});
});
export const secureFiles = [
{
id: 1,
name: 'myfile.jks',
checksum: '16630b189ab34b2e3504f4758e1054d2e478deda510b2b08cc0ef38d12e80aac',
checksum_algorithm: 'sha256',
permissions: 'read_only',
created_at: '2022-02-22T22:22:22.222Z',
},
{
id: 2,
name: 'myotherfile.jks',
checksum: '16630b189ab34b2e3504f4758e1054d2e478deda510b2b08cc0ef38d12e80aa2',
checksum_algorithm: 'sha256',
permissions: 'execute',
created_at: '2022-02-22T22:22:22.222Z',
},
];
......@@ -154,6 +154,7 @@ RSpec.describe API::Ci::SecureFiles do
Digest::SHA256.hexdigest(fixture_file('ci_secure_files/upload-keystore.jks'))
)
expect(json_response['id']).to eq(secure_file.id)
expect(Time.parse(json_response['created_at'])).to be_like_time(secure_file.created_at)
end
it 'creates a secure file with read_only permissions by default' do
......
......@@ -899,6 +899,12 @@ RSpec.describe 'project routing' do
end
end
describe Projects::Ci::SecureFilesController, 'routing' do
it 'to #show' do
expect(get('/gitlab/gitlabhq/-/ci/secure_files')).to route_to('projects/ci/secure_files#show', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
end
context 'with a non-existent project' do
it 'routes to 404 with get request' do
expect(get: "/gitlab/not_exist").to route_to(
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment