Skip to content
代码片段 群组 项目
未验证 提交 b6975871 编辑于 作者: Jose Ivan Vargas's avatar Jose Ivan Vargas 提交者: GitLab
浏览文件

Add GraphQL queries and specs

This connects the GraphQL queries to display
the authentication logs as well as adds
empty states
上级 198f78d7
No related branches found
No related tags found
无相关合并请求
<script>
import {
GlButton,
GlIcon,
GlKeysetPagination,
GlLink,
GlSprintf,
GlTableLite,
GlTooltipDirective,
} from '@gitlab/ui';
import { createAlert } from '~/alert';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__, __, sprintf, n__ } from '~/locale';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
import getAuthLogsQuery from '../graphql/queries/get_auth_logs.query.graphql';
const ENTRIES_PER_PAGE = 20;
export default {
fields: [
{
key: 'fullPath',
label: __('Project'),
tdClass: 'gl-w-2/3',
},
{
key: 'lastAuthorizedAt',
label: __('Date'),
tdClass: 'gl-w-1/3 gl-text-left !gl-align-middle',
},
],
name: 'CiJobTokensAuthLog',
tokenLogCsvFileName: 'token_log_report.csv',
ciJobTokenHelpPage: helpPagePath('ci/jobs/ci_job_token', {
anchor: 'job-token-authentication-log',
}),
components: {
GlButton,
GlIcon,
GlKeysetPagination,
GlLink,
GlSprintf,
GlTableLite,
CrudComponent,
ProjectAvatar,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
fullPath: {
default: '',
},
csvDownloadPath: {
default: '',
},
},
data() {
return {
authLogs: [],
cursor: {
first: ENTRIES_PER_PAGE,
after: null,
last: null,
before: null,
},
pageInfo: {},
logCount: 0,
};
},
apollo: {
authLogs: {
query: getAuthLogsQuery,
variables() {
return this.queryVariables;
},
update({ project }) {
this.logCount = project.ciJobTokenAuthLogs?.count;
this.pageInfo = project.ciJobTokenAuthLogs?.pageInfo;
return project.ciJobTokenAuthLogs?.nodes || [];
},
error() {
createAlert({
message: s__('CICD|There was a problem fetching authentication logs.'),
});
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.authLogs.loading;
},
showPagination() {
return (this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage) && this.logCount <= 100;
},
queryVariables() {
return {
fullPath: this.fullPath,
...this.cursor,
};
},
logCountTooltip() {
return sprintf(
n__('%{count} event has ocurred', '%{count} events have ocurred', this.logCount),
{
count: this.logCount,
},
);
},
displayLogEventsTable() {
return !this.isLoading && this.logCount > 0 && this.logCount <= 100;
},
},
methods: {
nextPage(item) {
this.cursor = {
first: ENTRIES_PER_PAGE,
after: item,
last: null,
before: null,
};
},
prevPage(item) {
this.cursor = {
first: null,
after: null,
last: ENTRIES_PER_PAGE,
before: item,
};
},
},
};
</script>
<template>
<div>
<crud-component :title="s__('CICD|Authentication log')" class="gl-mt-5">
<template #count>
<span
v-gl-tooltip
:title="logCountTooltip"
class="gl-inline-flex gl-items-center gl-gap-2 gl-text-sm gl-text-subtle"
data-testid="log-count"
>
<gl-icon name="log" />
{{ logCount }}
</span>
</template>
<template #actions>
<gl-button
v-if="!isLoading && logCount > 0"
is-unsafe-link
size="small"
:href="csvDownloadPath"
:title="__('Download CSV')"
:download="$options.tokenLogCsvFileName"
data-testid="auth-log-download-csv-button"
>{{ __('Download CSV') }}</gl-button
>
</template>
<template #description>
<gl-sprintf
:message="
s__(
'CICD|Authentication events from the last 30 days. %{linkStart}Learn more.%{linkEnd}',
)
"
>
<template #link="{ content }">
<gl-link :href="$options.ciJobTokenHelpPage" class="inline-link" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</template>
<gl-table-lite
v-if="displayLogEventsTable"
:items="authLogs"
:fields="$options.fields"
:tbody-tr-attr="{ 'data-testid': 'auth-logs-table-row' }"
class="gl-mb-0"
fixed
>
<template #cell(fullPath)="{ item }">
<div class="gl-inline-flex gl-items-center">
<gl-icon name="project" class="gl-mr-3 gl-shrink-0" />
<project-avatar
:alt="item.originProject.name"
:project-avatar-url="item.originProject.avatarUrl"
:project-id="item.originProject.id"
:project-name="item.originProject.name"
class="gl-mr-3"
:size="24"
/>
<span class="gl-text-gray-900">{{ item.originProject.fullPath }}</span>
</div>
</template>
</gl-table-lite>
<div
v-if="!isLoading && logCount > 100"
class="gl-text-center"
data-testid="auth-logs-too-many-text"
>
{{
s__(
'CICD|There are too many entries to display. Download the CSV file to view the full log.',
)
}}
</div>
<div
v-if="!isLoading && logCount === 0"
class="gl-text-center"
data-testid="auth-logs-no-events"
>
{{ s__('CICD|No authentication events in the last 30 days.') }}
</div>
</crud-component>
<gl-keyset-pagination
v-if="showPagination"
v-bind="pageInfo"
class="gl-mt-3 gl-self-center"
data-testid="auth-log-pagination"
@prev="prevPage"
@next="nextPage"
/>
</div>
</template>
......@@ -3,10 +3,12 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import TokenPermissions from './token_permissions.vue';
import OutboundTokenAccess from './outbound_token_access.vue';
import InboundTokenAccess from './inbound_token_access.vue';
import AuthLog from './auth_log.vue';
export default {
name: 'TokenAccessApp',
components: {
AuthLog,
InboundTokenAccess,
OutboundTokenAccess,
TokenPermissions,
......@@ -18,6 +20,7 @@ export default {
<div>
<token-permissions v-if="glFeatures.allowPushRepositoryForJobToken" />
<inbound-token-access />
<auth-log />
<outbound-token-access />
</div>
</template>
query getAuthLogs($fullPath: ID!, $first: Int, $last: Int, $after: String, $before: String) {
project(fullPath: $fullPath) {
id
ciJobTokenAuthLogs(first: $first, last: $last, after: $after, before: $before) {
count
nodes {
lastAuthorizedAt
originProject {
fullPath
path
avatarUrl
name
id
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
......@@ -19,7 +19,7 @@ export const initTokenAccess = (containerId = 'js-ci-token-access-app') => {
return false;
}
const { fullPath, enforceAllowlist } = containerEl.dataset;
const { fullPath, csvDownloadPath, enforceAllowlist } = containerEl.dataset;
return new Vue({
el: containerEl,
......@@ -28,6 +28,7 @@ export const initTokenAccess = (containerId = 'js-ci-token-access-app') => {
provide: {
enforceAllowlist: JSON.parse(enforceAllowlist),
fullPath,
csvDownloadPath,
},
render(createElement) {
return createElement(TokenAccessApp);
......
#js-ci-token-access-app{ data: { full_path: @project.full_path, enforce_allowlist: Gitlab::CurrentSettings.enforce_ci_inbound_job_token_scope_enabled?.to_s } }
#js-ci-token-access-app{ data: { full_path: @project.full_path, csv_download_path: export_job_token_authorizations_namespace_project_settings_ci_cd_path(@project), enforce_allowlist: Gitlab::CurrentSettings.enforce_ci_inbound_job_token_scope_enabled?.to_s } }
......@@ -289,6 +289,22 @@ To configure the job token scope:
1. Optional. Add existing projects to the token's access scope. The user adding a
project must have the Maintainer role in both projects.
## Job token authentication log
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/467292/) in GitLab 17.6.
You can track which other projects use a CI/CD job token to authenticate with your project
in an authentication log. To check the log:
1. On the left sidebar, select **Search or go to** and find your project.
1. Select **Settings > CI/CD**.
1. Expand **Job token permissions**. The **Authentication log** section displays the
the list of other projects that accessed your project by authenticating with a job token.
1. Optional. Select **Download CSV** to download the full authentication log in CSV format.
The authentication log displays a maximum of 100 authentication events. If the number of events
is more than 100, download the CSV file to view the log.
## Troubleshooting
CI job token failures are usually shown as responses like `404 Not Found` or similar:
......
......@@ -690,6 +690,11 @@ msgid_plural "%{count} contacts"
msgstr[0] ""
msgstr[1] ""
 
msgid "%{count} event has ocurred"
msgid_plural "%{count} events have ocurred"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} files touched"
msgstr ""
 
......@@ -10506,6 +10511,12 @@ msgstr ""
msgid "CICD|Allow Git push requests to the repository"
msgstr ""
 
msgid "CICD|Authentication events from the last 30 days. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
msgid "CICD|Authentication log"
msgstr ""
msgid "CICD|Authorized groups and projects"
msgstr ""
 
......@@ -10569,6 +10580,9 @@ msgstr ""
msgid "CICD|Maintainer"
msgstr ""
 
msgid "CICD|No authentication events in the last 30 days."
msgstr ""
msgid "CICD|Only this project and any groups and projects in the allowlist"
msgstr ""
 
......@@ -10596,6 +10610,12 @@ msgstr ""
msgid "CICD|There are several CI/CD limits in place."
msgstr ""
 
msgid "CICD|There are too many entries to display. Download the CSV file to view the full log."
msgstr ""
msgid "CICD|There was a problem fetching authentication logs."
msgstr ""
msgid "CICD|Unprotected branches will not have access to the cache from protected branches."
msgstr ""
 
......@@ -20113,6 +20133,9 @@ msgstr ""
msgid "Download (%{size})"
msgstr ""
 
msgid "Download CSV"
msgstr ""
msgid "Download PDF"
msgstr ""
 
import { GlTableLite } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import AuthLog from '~/token_access/components/auth_log.vue';
import getAuthLogsQuery from '~/token_access/graphql/queries/get_auth_logs.query.graphql';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import { mockAuthLogsResponse } from './mock_data';
const projectPath = 'root/my-repo';
const csvDownloadPath = '/root/my-repo/-/settings/ci_cd/export_job_token_authorizations';
const message = 'An error occurred';
const error = new Error(message);
Vue.use(VueApollo);
jest.mock('~/alert');
describe('TokenAccess component', () => {
let wrapper;
const getAuthLogsQueryResponseHandler = jest.fn().mockResolvedValue(mockAuthLogsResponse());
const getAuthLogsQueryEmptyResponseHandler = jest.fn().mockResolvedValue();
const failureHandler = jest.fn().mockRejectedValue(error);
const createMockApolloProvider = (requestHandlers) => {
return createMockApollo(requestHandlers);
};
const mockToastShow = jest.fn();
const findGlTable = () => wrapper.findComponent(GlTableLite);
const findAllTableRows = () => wrapper.findAllByTestId('auth-logs-table-row');
const findCrudComponentBody = () => wrapper.findByTestId('crud-body');
const findDownloadButton = () => wrapper.findByTestId('auth-log-download-csv-button');
const findPagination = () => wrapper.findByTestId('auth-log-pagination');
const createComponent = (requestHandlers, mountFn = shallowMountExtended) => {
wrapper = mountFn(AuthLog, {
provide: {
fullPath: projectPath,
csvDownloadPath,
},
apolloProvider: createMockApolloProvider(requestHandlers),
mocks: {
$toast: {
show: mockToastShow,
},
},
stubs: { CrudComponent },
});
};
describe('queries', () => {
it('fetches the authentication events correctly', async () => {
const expectedVariables = {
fullPath: projectPath,
after: null,
before: null,
first: 20,
last: null,
};
createComponent([[getAuthLogsQuery, getAuthLogsQueryResponseHandler]], mountExtended);
await waitForPromises();
expect(getAuthLogsQueryResponseHandler).toHaveBeenCalledWith(expectedVariables);
});
it('handles fetch scope error correctly', async () => {
createComponent([[getAuthLogsQuery, failureHandler]]);
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith({
message: 'There was a problem fetching authentication logs.',
});
});
});
describe('Authentication log', () => {
it('displays an empty state when no data is available', async () => {
createComponent([[getAuthLogsQuery, getAuthLogsQueryEmptyResponseHandler]], mountExtended);
await waitForPromises();
expect(findCrudComponentBody().text()).toContain(
'No authentication events in the last 30 days.',
);
});
it('displays a table when data is available', async () => {
createComponent([[getAuthLogsQuery, getAuthLogsQueryResponseHandler]], mountExtended);
await waitForPromises();
expect(findGlTable().exists()).toBe(true);
expect(findAllTableRows()).toHaveLength(
mockAuthLogsResponse().data.project.ciJobTokenAuthLogs.nodes.length,
);
});
it('displays pagination controls', async () => {
const getAuthLogsQueryResponseHandlerWithPagination = jest
.fn()
.mockResolvedValue(mockAuthLogsResponse(true));
createComponent(
[[getAuthLogsQuery, getAuthLogsQueryResponseHandlerWithPagination]],
mountExtended,
);
await waitForPromises();
expect(findPagination().exists()).toBe(true);
});
it('displays a download button when there is at least one event available', async () => {
createComponent([[getAuthLogsQuery, getAuthLogsQueryResponseHandler]], mountExtended);
await waitForPromises();
expect(findDownloadButton().exists()).toBe(true);
expect(findDownloadButton().props()).toEqual(
expect.objectContaining({
isUnsafeLink: true,
category: 'primary',
variant: 'default',
size: 'small',
}),
);
});
});
});
......@@ -324,3 +324,35 @@ export const mockPermissionsMutationResponse = ({
},
},
});
export const mockAuthLogsResponse = (hasNextPage = false) => ({
data: {
project: {
id: 'gid://gitlab/Project/26',
__typename: 'Project',
ciJobTokenAuthLogs: {
__typename: 'CiJobTokenAuthLogConnection',
count: 1,
nodes: [
{
__typename: 'CiJobTokenAuthLog',
lastAuthorizedAt: '2024-10-25',
originProject: {
__typename: 'Project',
fullPath: 'root/project-that-triggers-external-pipeline',
path: 'project-that-triggers-external-pipeline',
avatarUrl: null,
name: 'project-that-triggers-external-pipeline',
id: 'gid://gitlab/Project/26',
},
},
],
pageInfo: {
__typename: 'PageInfo',
endCursor: 'eyJpZCI6IjEifQ',
hasNextPage,
},
},
},
},
});
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册