diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index d06cb4f23fb..c1a11c35bab 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -124,6 +124,9 @@ export default { isLoading() { return this.$apollo.queries.issuable.loading || this.loading; }, + initialLoading() { + return this.$apollo.queries.issuable.loading; + }, hasDate() { return this.dateValue !== null; }, @@ -271,10 +274,10 @@ export default { {{ formattedDate }} diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue index b6bfacb2e47..77f8e125dce 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue @@ -17,8 +17,9 @@ export default { type: Object, }, isLoading: { - required: true, + required: false, type: Boolean, + default: false, }, dateType: { type: String, @@ -31,6 +32,7 @@ export default { return this.issuable?.[dateFields[this.dateType].isDateFixed] || false; }, set(fixed) { + if (fixed === this.issuable[dateFields[this.dateType].isDateFixed]) return; this.$emit('set-date', fixed); }, }, diff --git a/app/graphql/resolvers/ci/runner_status_resolver.rb b/app/graphql/resolvers/ci/runner_status_resolver.rb new file mode 100644 index 00000000000..d916a8a13f0 --- /dev/null +++ b/app/graphql/resolvers/ci/runner_status_resolver.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + # NOTE: This class was introduced to allow modifying the meaning of certain values in RunnerStatusEnum + # while preserving backward compatibility. It can be removed in 15.0 once the API has stabilized. + class RunnerStatusResolver < BaseResolver + type Types::Ci::RunnerStatusEnum, null: false + + alias_method :runner, :object + + argument :legacy_mode, + type: GraphQL::Types::String, + default_value: '14.5', + required: false, + description: 'Compatibility mode. A null value turns off compatibility mode.', + deprecated: { reason: 'Will be removed in 15.0. From that release onward, the field will behave as if legacyMode is null', milestone: '14.6' } + + def resolve(legacy_mode:, **args) + runner.status(legacy_mode) + end + end + end +end diff --git a/app/graphql/types/ci/runner_status_enum.rb b/app/graphql/types/ci/runner_status_enum.rb index 8501ce20204..14eae1cdce5 100644 --- a/app/graphql/types/ci/runner_status_enum.rb +++ b/app/graphql/types/ci/runner_status_enum.rb @@ -5,24 +5,33 @@ module Types class RunnerStatusEnum < BaseEnum graphql_name 'CiRunnerStatus' - ::Ci::Runner::AVAILABLE_STATUSES.each do |status| - description = case status - when 'active' - "A runner that is not paused." - when 'online' - "A runner that contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}." - when 'offline' - "A runner that has not contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}." - when 'not_connected' - "A runner that has never contacted this instance." - else - "A runner that is #{status.to_s.tr('_', ' ')}." - end + value 'ACTIVE', + description: 'Runner that is not paused.', + deprecated: { reason: 'Use CiRunnerType.active instead', milestone: '14.6' }, + value: :active - value status.to_s.upcase, - description: description, - value: status.to_sym - end + value 'PAUSED', + description: 'Runner that is paused.', + deprecated: { reason: 'Use CiRunnerType.active instead', milestone: '14.6' }, + value: :paused + + value 'ONLINE', + description: "Runner that contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}.", + value: :online + + value 'OFFLINE', + description: "Runner that has not contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}.", + deprecated: { reason: 'This field will have a slightly different scope starting in 15.0, with STALE being returned after a certain period offline', milestone: '14.6' }, + value: :offline + + value 'STALE', + description: "Runner that has not contacted this instance within the last #{::Ci::Runner::STALE_TIMEOUT.inspect}. Only available if legacyMode is null. Will be a possible return value starting in 15.0", + value: :stale + + value 'NOT_CONNECTED', + description: 'Runner that has never contacted this instance.', + deprecated: { reason: 'This field will have a slightly different scope starting in 15.0, with STALE being returned after a certain period of no contact', milestone: '14.6' }, + value: :not_connected end end end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 9bf98aa7e86..d37cca0927f 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -27,8 +27,11 @@ module Types description: 'Access level of the runner.' field :active, GraphQL::Types::Boolean, null: false, description: 'Indicates the runner is allowed to receive jobs.' - field :status, ::Types::Ci::RunnerStatusEnum, null: false, - description: 'Status of the runner.' + field :status, + Types::Ci::RunnerStatusEnum, + null: false, + description: 'Status of the runner.', + resolver: ::Resolvers::Ci::RunnerStatusResolver field :version, GraphQL::Types::String, null: true, description: 'Version of the runner.' field :short_sha, GraphQL::Types::String, null: true, @@ -50,7 +53,7 @@ module Types field :job_count, GraphQL::Types::Int, null: true, description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)." field :admin_url, GraphQL::Types::String, null: true, - description: 'Admin URL of the runner. Only available for adminstrators.' + description: 'Admin URL of the runner. Only available for administrators.' def job_count # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 09fc1ab9d50..0d514773891 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -61,6 +61,11 @@ module ProfilesHelper def ssh_key_expires_field_description s_('Profiles|Key can still be used after expiration.') end + + # Overridden in EE::ProfilesHelper#ssh_key_expiration_policy_enabled? + def ssh_key_expiration_policy_enabled? + false + end end ProfilesHelper.prepend_mod diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index e86f1b3cf6a..04afbec7765 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -44,7 +44,7 @@ module Ci AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze AVAILABLE_TYPES = runner_types.keys.freeze - AVAILABLE_STATUSES = %w[active paused online offline not_connected].freeze + AVAILABLE_STATUSES = %w[active paused online offline not_connected stale].freeze AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze @@ -287,10 +287,15 @@ module Ci end def stale? + return false unless created_at + [created_at, contacted_at].compact.max < self.class.stale_deadline end - def status + def status(legacy_mode = nil) + return deprecated_rest_status if legacy_mode == '14.5' + + return :stale if stale? return :not_connected unless contacted_at online? ? :online : :offline diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 19c38d7be62..65882491575 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -32,6 +32,7 @@ = render_if_exists 'admin/application_settings/git_two_factor_session_expiry', form: f = render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f = render_if_exists 'admin/application_settings/enforce_pat_expiration', form: f + = render_if_exists 'admin/application_settings/ssh_key_expiration_policy', form: f = render_if_exists 'admin/application_settings/enforce_ssh_key_expiration', form: f .form-group diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 74b48115d0e..2b3109225a8 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -1,3 +1,4 @@ +- max_date = ::Gitlab::CurrentSettings.max_ssh_key_lifetime_from_now.to_date if ssh_key_expiration_policy_enabled? %div = form_for [:profile, @key], html: { class: 'js-requires-input' } do |f| = form_errors(@key) @@ -13,8 +14,8 @@ %p.form-text.text-muted= s_('Profiles|Give your individual key a title. This will be publicly visible.') .col.form-group - = f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold' - = f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, data: { qa_selector: 'key_expiry_date_field' } + = f.label :expires_at, s_('Profiles|Expiration date'), class: 'label-bold' + = f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, max: max_date, data: { qa_selector: 'key_expiry_date_field' } %p.form-text.text-muted{ data: { qa_selector: 'key_expiry_date_field_description' } }= ssh_key_expires_field_description .js-add-ssh-key-validation-warning.hide diff --git a/db/migrate/20211118114228_add_max_ssh_key_lifetime_to_application_settings.rb b/db/migrate/20211118114228_add_max_ssh_key_lifetime_to_application_settings.rb new file mode 100644 index 00000000000..1b0d2104c91 --- /dev/null +++ b/db/migrate/20211118114228_add_max_ssh_key_lifetime_to_application_settings.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddMaxSshKeyLifetimeToApplicationSettings < Gitlab::Database::Migration[1.0] + def change + add_column :application_settings, :max_ssh_key_lifetime, :integer + end +end diff --git a/db/schema_migrations/20211118114228 b/db/schema_migrations/20211118114228 new file mode 100644 index 00000000000..82c7984750d --- /dev/null +++ b/db/schema_migrations/20211118114228 @@ -0,0 +1 @@ +7686fd3e33b25b811aba459aba514cde8e88102277edb3be7e12378cb7e8de85 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 444ff934706..3c3caac5cce 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10477,6 +10477,7 @@ CREATE TABLE application_settings ( sentry_dsn text, sentry_clientside_dsn text, sentry_environment text, + max_ssh_key_lifetime integer, static_objects_external_storage_auth_token_encrypted text, CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)), CONSTRAINT app_settings_dep_proxy_ttl_policies_worker_capacity_positive CHECK ((dependency_proxy_ttl_group_policy_worker_capacity >= 0)), diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 88f08eb48be..92a37a800a7 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -8750,7 +8750,7 @@ Represents the total number of issues and their weights for a particular day. | ---- | ---- | ----------- | | `accessLevel` | [`CiRunnerAccessLevel!`](#cirunneraccesslevel) | Access level of the runner. | | `active` | [`Boolean!`](#boolean) | Indicates the runner is allowed to receive jobs. | -| `adminUrl` | [`String`](#string) | Admin URL of the runner. Only available for adminstrators. | +| `adminUrl` | [`String`](#string) | Admin URL of the runner. Only available for administrators. | | `contactedAt` | [`Time`](#time) | Last contact from the runner. | | `description` | [`String`](#string) | Description of the runner. | | `id` | [`CiRunnerID!`](#cirunnerid) | ID of the runner. | @@ -8765,11 +8765,24 @@ Represents the total number of issues and their weights for a particular day. | `runUntagged` | [`Boolean!`](#boolean) | Indicates the runner is able to run untagged jobs. | | `runnerType` | [`CiRunnerType!`](#cirunnertype) | Type of the runner. | | `shortSha` | [`String`](#string) | First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID. | -| `status` | [`CiRunnerStatus!`](#cirunnerstatus) | Status of the runner. | | `tagList` | [`[String!]`](#string) | Tags associated with the runner. | | `userPermissions` | [`RunnerPermissions!`](#runnerpermissions) | Permissions for the current user on the resource. | | `version` | [`String`](#string) | Version of the runner. | +#### Fields with arguments + +##### `CiRunner.status` + +Status of the runner. + +Returns [`CiRunnerStatus!`](#cirunnerstatus). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `legacyMode` **{warning-solid}** | [`String`](#string) | **Deprecated** in 14.6. Will be removed in 15.0. From that release onward, the field will behave as if legacyMode is null. | + ### `CiStage` #### Fields @@ -15956,11 +15969,12 @@ Values for sorting runners. | Value | Description | | ----- | ----------- | -| `ACTIVE` | A runner that is not paused. | -| `NOT_CONNECTED` | A runner that has never contacted this instance. | -| `OFFLINE` | A runner that has not contacted this instance within the last 2 hours. | -| `ONLINE` | A runner that contacted this instance within the last 2 hours. | -| `PAUSED` | A runner that is paused. | +| `ACTIVE` **{warning-solid}** | **Deprecated** in 14.6. Use CiRunnerType.active instead. | +| `NOT_CONNECTED` **{warning-solid}** | **Deprecated** in 14.6. This field will have a slightly different scope starting in 15.0, with STALE being returned after a certain period of no contact. | +| `OFFLINE` **{warning-solid}** | **Deprecated** in 14.6. This field will have a slightly different scope starting in 15.0, with STALE being returned after a certain period offline. | +| `ONLINE` | Runner that contacted this instance within the last 2 hours. | +| `PAUSED` **{warning-solid}** | **Deprecated** in 14.6. Use CiRunnerType.active instead. | +| `STALE` | Runner that has not contacted this instance within the last 3 months. Only available if legacyMode is null. Will be a possible return value starting in 15.0. | ### `CiRunnerType` diff --git a/doc/api/project_vulnerabilities.md b/doc/api/project_vulnerabilities.md index 7ba359587f6..1267f748633 100644 --- a/doc/api/project_vulnerabilities.md +++ b/doc/api/project_vulnerabilities.md @@ -10,9 +10,11 @@ type: reference, api > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/10242) in GitLab 12.6. WARNING: -This API is in an alpha stage and considered unstable. +This API is in the process of being deprecated and considered unstable. The response payload may be subject to change or breakage -across GitLab releases. +across GitLab releases. Please use the +[GraphQL API](graphql/reference/index.md#queryvulnerabilities) +instead. Every API call to vulnerabilities must be [authenticated](index.md#authentication). diff --git a/doc/api/settings.md b/doc/api/settings.md index b0c9c08e3a5..1a3ea6b1fcf 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -343,6 +343,7 @@ listed in the descriptions of the relevant settings. | `max_import_size` | integer | no | Maximum import size in MB. 0 for unlimited. Default = 0 (unlimited) [Modified](https://gitlab.com/gitlab-org/gitlab/-/issues/251106) from 50MB to 0 in GitLab 13.8. | | `max_pages_size` | integer | no | Maximum size of pages repositories in MB. | | `max_personal_access_token_lifetime` | integer | no | **(ULTIMATE SELF)** Maximum allowable lifetime for personal access tokens in days. | +| `max_ssh_key_lifetime` | integer | no | **(ULTIMATE SELF)** Maximum allowable lifetime for SSH keys in days. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1007) in GitLab 14.6. | | `metrics_method_call_threshold` | integer | no | A method call is only tracked when it takes longer than the given amount of milliseconds. | | `mirror_available` | boolean | no | Allow repository mirroring to configured by project Maintainers. If disabled, only Administrators can configure repository mirroring. | | `mirror_capacity_threshold` | integer | no | **(PREMIUM)** Minimum capacity to be available before scheduling more mirrors preemptively. | diff --git a/doc/user/admin_area/settings/account_and_limit_settings.md b/doc/user/admin_area/settings/account_and_limit_settings.md index afc40995c9c..243ff8ad76b 100644 --- a/doc/user/admin_area/settings/account_and_limit_settings.md +++ b/doc/user/admin_area/settings/account_and_limit_settings.md @@ -192,6 +192,62 @@ To set a limit on how long these sessions are valid: 1. Fill in the **Session duration for Git operations when 2FA is enabled (minutes)** field. 1. Click **Save changes**. +## Limit the lifetime of SSH keys **(ULTIMATE SELF)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1007) in GitLab 14.6 [with a flag](../../../administration/feature_flags.md) named `ff_limit_ssh_key_lifetime`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available, +ask an administrator to [enable the feature flag](../../../administration/feature_flags.md) named `ff_limit_ssh_key_lifetime`. +On GitLab.com, this feature is not available. The feature is not ready for production use. + +Users can optionally specify a lifetime for +[SSH keys](../../../ssh/index.md). +This lifetime is not a requirement, and can be set to any arbitrary number of days. + +SSH keys are user credentials to access GitLab. +However, organizations with security requirements may want to enforce more protection by +requiring the regular rotation of these keys. + +### Set a lifetime + +Only a GitLab administrator can set a lifetime. Leaving it empty means +there are no restrictions. + +To set a lifetime on how long SSH keys are valid: + +1. On the top bar, select **Menu > Admin**. +1. On the left sidebar, select **Settings > General**. +1. Expand the **Account and limit** section. +1. Fill in the **Maximum allowable lifetime for SSH keys (days)** field. +1. Click **Save changes**. + +Once a lifetime for SSH keys is set, GitLab: + +- Requires users to set an expiration date that is no later than the allowed lifetime on new + SSH keys. +- Applies the lifetime restriction to existing SSH keys. Keys with no expiry or a lifetime + greater than the maximum immediately become invalid. + +NOTE: +When a user's SSH key becomes invalid they can delete and re-add the same key again. + +## Allow expired SSH keys to be used **(ULTIMATE SELF)** + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/250480) in GitLab 13.9. +> - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/issues/320970) in GitLab 14.0. + +By default, expired SSH keys **are not usable**. + +To allow the use of expired SSH keys: + +1. On the top bar, select **Menu > Admin**. +1. On the left sidebar, select **Settings > General**. +1. Expand the **Account and limit** section. +1. Uncheck the **Enforce SSH key expiration** checkbox. + +Disabling SSH key expiration immediately enables all expired SSH keys. + ## Limit the lifetime of personal access tokens **(ULTIMATE SELF)** > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/3649) in GitLab 12.6. @@ -225,22 +281,6 @@ Once a lifetime for personal access tokens is set, GitLab: allowed lifetime. Three hours is given to allow administrators to change the allowed lifetime, or remove it, before revocation takes place. -## Allow expired SSH keys to be used **(ULTIMATE SELF)** - -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/250480) in GitLab 13.9. -> - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/issues/320970) in GitLab 14.0. - -By default, expired SSH keys **are not usable**. - -To allow the use of expired SSH keys: - -1. On the top bar, select **Menu > Admin**. -1. On the left sidebar, select **Settings > General**. -1. Expand the **Account and limit** section. -1. Uncheck the **Enforce SSH key expiration** checkbox. - -Disabling SSH key expiration immediately enables all expired SSH keys. - ## Allow expired Personal Access Tokens to be used **(ULTIMATE SELF)** > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/214723) in GitLab 13.1. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9c970d48c3e..11eba8b3392 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -21496,6 +21496,9 @@ msgstr "" msgid "Maximum allowable lifetime for personal access token (days)" msgstr "" +msgid "Maximum allowed lifetime for SSH keys (in days)" +msgstr "" + msgid "Maximum artifacts size" msgstr "" @@ -26705,15 +26708,15 @@ msgstr "" msgid "Profiles|Enter your pronouns to let people know how to refer to you" msgstr "" +msgid "Profiles|Expiration date" +msgstr "" + msgid "Profiles|Expired key is not valid." msgstr "" msgid "Profiles|Expired:" msgstr "" -msgid "Profiles|Expires at" -msgstr "" - msgid "Profiles|Expires:" msgstr "" @@ -26753,15 +26756,18 @@ msgstr "" msgid "Profiles|Key" msgstr "" +msgid "Profiles|Key becomes invalid on this date." +msgstr "" + +msgid "Profiles|Key becomes invalid on this date. Maximum lifetime for SSH keys is %{max_ssh_key_lifetime} days" +msgstr "" + msgid "Profiles|Key can still be used after expiration." msgstr "" msgid "Profiles|Key usable beyond expiration date." msgstr "" -msgid "Profiles|Key will be deleted on this date." -msgstr "" - msgid "Profiles|Last used:" msgstr "" @@ -39221,6 +39227,9 @@ msgstr "" msgid "When a runner is locked, it cannot be assigned to other projects" msgstr "" +msgid "When enabled, SSH keys with no expiry date or an invalid expiration date are no longer accepted. Leave blank for no limit." +msgstr "" + msgid "When enabled, existing personal access tokens may be revoked. Leave blank for no limit." msgstr "" diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js index 64e5ac8586f..570ac1e6ed1 100644 --- a/spec/frontend/google_cloud/components/app_spec.js +++ b/spec/frontend/google_cloud/components/app_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { mapValues } from 'lodash'; import App from '~/google_cloud/components/app.vue'; import Home from '~/google_cloud/components/home.vue'; import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; @@ -8,103 +9,59 @@ import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue' const BASE_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new'; +const SCREEN_COMPONENTS = { + Home, + ServiceAccountsForm, + GcpError, + NoGcpProjects, +}; +const SERVICE_ACCOUNTS_FORM_PROPS = { + gcpProjects: [1, 2, 3], + environments: [4, 5, 6], + cancelPath: '', +}; +const HOME_PROPS = { + serviceAccounts: [{}, {}], + createServiceAccountUrl: '#url-create-service-account', + emptyIllustrationUrl: '#url-empty-illustration', +}; describe('google_cloud App component', () => { let wrapper; const findIncubationBanner = () => wrapper.findComponent(IncubationBanner); - const findGcpError = () => wrapper.findComponent(GcpError); - const findNoGcpProjects = () => wrapper.findComponent(NoGcpProjects); - const findServiceAccountsForm = () => wrapper.findComponent(ServiceAccountsForm); - const findHome = () => wrapper.findComponent(Home); afterEach(() => { wrapper.destroy(); }); - describe('for gcp_error screen', () => { + describe.each` + screen | extraProps | componentName + ${'gcp_error'} | ${{ error: 'mock_gcp_client_error' }} | ${'GcpError'} + ${'no_gcp_projects'} | ${{}} | ${'NoGcpProjects'} + ${'service_accounts_form'} | ${SERVICE_ACCOUNTS_FORM_PROPS} | ${'ServiceAccountsForm'} + ${'home'} | ${HOME_PROPS} | ${'Home'} + `('for screen=$screen', ({ screen, extraProps, componentName }) => { + const component = SCREEN_COMPONENTS[componentName]; + beforeEach(() => { - const propsData = { - screen: 'gcp_error', - error: 'mock_gcp_client_error', - }; - wrapper = shallowMount(App, { propsData }); + wrapper = shallowMount(App, { propsData: { screen, ...extraProps } }); }); - it('renders the gcp_error screen', () => { - expect(findGcpError().exists()).toBe(true); - }); + it(`renders only ${componentName}`, () => { + const existences = mapValues(SCREEN_COMPONENTS, (x) => wrapper.findComponent(x).exists()); - it('should contain incubation banner', () => { - expect(findIncubationBanner().props()).toEqual({ - shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`, - reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`, - featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`, + expect(existences).toEqual({ + ...mapValues(SCREEN_COMPONENTS, () => false), + [componentName]: true, }); }); - }); - describe('for no_gcp_projects screen', () => { - beforeEach(() => { - const propsData = { - screen: 'no_gcp_projects', - }; - wrapper = shallowMount(App, { propsData }); + it(`renders the ${componentName} with props`, () => { + expect(wrapper.findComponent(component).props()).toEqual(extraProps); }); - it('renders the no_gcp_projects screen', () => { - expect(findNoGcpProjects().exists()).toBe(true); - }); - - it('should contain incubation banner', () => { - expect(findIncubationBanner().props()).toEqual({ - shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`, - reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`, - featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`, - }); - }); - }); - - describe('for service_accounts_form screen', () => { - beforeEach(() => { - const propsData = { - screen: 'service_accounts_form', - gcpProjects: [1, 2, 3], - environments: [4, 5, 6], - cancelPath: '', - }; - wrapper = shallowMount(App, { propsData }); - }); - - it('renders the service_accounts_form screen', () => { - expect(findServiceAccountsForm().exists()).toBe(true); - }); - - it('should contain incubation banner', () => { - expect(findIncubationBanner().props()).toEqual({ - shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`, - reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`, - featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`, - }); - }); - }); - - describe('for home screen', () => { - beforeEach(() => { - const propsData = { - screen: 'home', - serviceAccounts: [{}, {}], - createServiceAccountUrl: '#url-create-service-account', - emptyIllustrationUrl: '#url-empty-illustration', - }; - wrapper = shallowMount(App, { propsData }); - }); - - it('renders the home screen', () => { - expect(findHome().exists()).toBe(true); - }); - - it('should contain incubation banner', () => { + it('renders incubation banner', () => { expect(findIncubationBanner().props()).toEqual({ shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`, reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`, diff --git a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js index 619e89beb23..1e2173e2988 100644 --- a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js +++ b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js @@ -145,13 +145,20 @@ describe('Sidebar date Widget', () => { ${false} | ${SidebarInheritDate} | ${'SidebarInheritDate'} | ${false} `( 'when canInherit is $canInherit, $componentName display is $expected', - ({ canInherit, component, expected }) => { + async ({ canInherit, component, expected }) => { createComponent({ canInherit }); + await waitForPromises(); expect(wrapper.find(component).exists()).toBe(expected); }, ); + it('does not render SidebarInheritDate when canInherit is true and date is loading', async () => { + createComponent({ canInherit: true }); + + expect(wrapper.find(SidebarInheritDate).exists()).toBe(false); + }); + it('displays a flash message when query is rejected', async () => { createComponent({ dueDateQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), diff --git a/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js b/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js index 4d38eba8035..fda21e06987 100644 --- a/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js +++ b/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js @@ -10,7 +10,7 @@ describe('SidebarInheritDate', () => { const findFixedRadio = () => wrapper.findAll(GlFormRadio).at(0); const findInheritRadio = () => wrapper.findAll(GlFormRadio).at(1); - const createComponent = () => { + const createComponent = ({ dueDateIsFixed = false } = {}) => { wrapper = shallowMount(SidebarInheritDate, { provide: { canUpdate: true, @@ -18,11 +18,10 @@ describe('SidebarInheritDate', () => { propsData: { issuable: { dueDate: '2021-04-15', - dueDateIsFixed: true, + dueDateIsFixed, dueDateFixed: '2021-04-15', dueDateFromMilestones: '2021-05-15', }, - isLoading: false, dateType: 'dueDate', }, }); @@ -45,6 +44,13 @@ describe('SidebarInheritDate', () => { expect(findInheritRadio().text()).toBe('Inherited:'); }); + it('does not emit set-date if fixed value does not change', () => { + createComponent({ dueDateIsFixed: true }); + findFixedRadio().vm.$emit('input', true); + + expect(wrapper.emitted('set-date')).toBeUndefined(); + }); + it('emits set-date event on click on radio button', () => { findFixedRadio().vm.$emit('input', true); diff --git a/spec/graphql/resolvers/ci/runner_status_resolver_spec.rb b/spec/graphql/resolvers/ci/runner_status_resolver_spec.rb new file mode 100644 index 00000000000..fbef07b72e6 --- /dev/null +++ b/spec/graphql/resolvers/ci/runner_status_resolver_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Ci::RunnerStatusResolver do + include GraphqlHelpers + + describe '#resolve' do + let(:user) { build(:user) } + let(:runner) { build(:ci_runner) } + + subject(:resolve_subject) { resolve(described_class, ctx: { current_user: user }, obj: runner, args: args) } + + context 'with legacy_mode' do + context 'set to 14.5' do + let(:args) do + { legacy_mode: '14.5' } + end + + it 'calls runner.status with specified legacy_mode' do + expect(runner).to receive(:status).with('14.5').once.and_return(:online) + + expect(resolve_subject).to eq(:online) + end + end + + context 'set to nil' do + let(:args) do + { legacy_mode: nil } + end + + it 'calls runner.status with specified legacy_mode' do + expect(runner).to receive(:status).with(nil).once.and_return(:stale) + + expect(resolve_subject).to eq(:stale) + end + end + end + end +end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 9138bb0717b..4ffcca24bf8 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -342,6 +342,7 @@ RSpec.describe Ci::Runner do using RSpec::Parameterized::TableSyntax where(:created_at, :contacted_at, :expected_stale?) do + nil | nil | false 3.months.ago - 1.second | 3.months.ago - 0.001.seconds | true 3.months.ago - 1.second | 3.months.ago + 1.hour | false 3.months.ago - 1.second | nil | true @@ -376,6 +377,8 @@ RSpec.describe Ci::Runner do end def stub_redis_runner_contacted_at(value) + return unless created_at + Gitlab::Redis::Cache.with do |redis| cache_key = runner.send(:cache_attribute_key) expect(redis).to receive(:get).with(cache_key) @@ -419,7 +422,7 @@ RSpec.describe Ci::Runner do it { is_expected.to be_falsey } end - context 'contacted long time ago time' do + context 'contacted long time ago' do before do runner.contacted_at = 1.year.ago end @@ -437,7 +440,7 @@ RSpec.describe Ci::Runner do end context 'with cache value' do - context 'contacted long time ago time' do + context 'contacted long time ago' do before do runner.contacted_at = 1.year.ago stub_redis_runner_contacted_at(1.year.ago.to_s) @@ -699,16 +702,33 @@ RSpec.describe Ci::Runner do end describe '#status' do - let(:runner) { build(:ci_runner, :instance) } + let(:runner) { build(:ci_runner, :instance, created_at: 4.months.ago) } + let(:legacy_mode) { } - subject { runner.status } + subject { runner.status(legacy_mode) } context 'never connected' do before do runner.contacted_at = nil end - it { is_expected.to eq(:not_connected) } + context 'with legacy_mode enabled' do + let(:legacy_mode) { '14.5' } + + it { is_expected.to eq(:not_connected) } + end + + context 'with legacy_mode disabled' do + it { is_expected.to eq(:stale) } + end + + context 'created recently' do + before do + runner.created_at = 1.day.ago + end + + it { is_expected.to eq(:not_connected) } + end end context 'inactive but online' do @@ -717,7 +737,15 @@ RSpec.describe Ci::Runner do runner.active = false end - it { is_expected.to eq(:online) } + context 'with legacy_mode enabled' do + let(:legacy_mode) { '14.5' } + + it { is_expected.to eq(:paused) } + end + + context 'with legacy_mode disabled' do + it { is_expected.to eq(:online) } + end end context 'contacted 1s ago' do @@ -728,13 +756,29 @@ RSpec.describe Ci::Runner do it { is_expected.to eq(:online) } end - context 'contacted long time ago' do + context 'contacted recently' do before do - runner.contacted_at = 1.year.ago + runner.contacted_at = (3.months - 1.hour).ago end it { is_expected.to eq(:offline) } end + + context 'contacted long time ago' do + before do + runner.contacted_at = (3.months + 1.second).ago + end + + context 'with legacy_mode enabled' do + let(:legacy_mode) { '14.5' } + + it { is_expected.to eq(:offline) } + end + + context 'with legacy_mode disabled' do + it { is_expected.to eq(:stale) } + end + end end describe '#deprecated_rest_status' do diff --git a/spec/requests/api/graphql/ci/runner_spec.rb b/spec/requests/api/graphql/ci/runner_spec.rb index ab53ff654e9..66601c0e810 100644 --- a/spec/requests/api/graphql/ci/runner_spec.rb +++ b/spec/requests/api/graphql/ci/runner_spec.rb @@ -63,7 +63,7 @@ RSpec.describe 'Query.runner(id)' do 'revision' => runner.revision, 'locked' => false, 'active' => runner.active, - 'status' => runner.status.to_s.upcase, + 'status' => runner.status('14.5').to_s.upcase, 'maximumTimeout' => runner.maximum_timeout, 'accessLevel' => runner.access_level.to_s.upcase, 'runUntagged' => runner.run_untagged, @@ -221,6 +221,45 @@ RSpec.describe 'Query.runner(id)' do end end + describe 'for runner with status' do + let_it_be(:stale_runner) { create(:ci_runner, description: 'Stale runner 1', created_at: 3.months.ago) } + + let(:query) do + %( + query { + staleRunner: runner(id: "#{stale_runner.to_global_id}") { + status + legacyStatusWithExplicitVersion: status(legacyMode: "14.5") + newStatus: status(legacyMode: null) + } + pausedRunner: runner(id: "#{inactive_instance_runner.to_global_id}") { + status + legacyStatusWithExplicitVersion: status(legacyMode: "14.5") + newStatus: status(legacyMode: null) + } + } + ) + end + + it 'retrieves status fields with expected values' do + post_graphql(query, current_user: user) + + stale_runner_data = graphql_data_at(:stale_runner) + expect(stale_runner_data).to match a_hash_including( + 'status' => 'NOT_CONNECTED', + 'legacyStatusWithExplicitVersion' => 'NOT_CONNECTED', + 'newStatus' => 'STALE' + ) + + paused_runner_data = graphql_data_at(:paused_runner) + expect(paused_runner_data).to match a_hash_including( + 'status' => 'PAUSED', + 'legacyStatusWithExplicitVersion' => 'PAUSED', + 'newStatus' => 'OFFLINE' + ) + end + end + describe 'for multiple runners' do let_it_be(:project1) { create(:project, :test_repo) } let_it_be(:project2) { create(:project, :test_repo) } diff --git a/spec/views/profiles/keys/_form.html.haml_spec.rb b/spec/views/profiles/keys/_form.html.haml_spec.rb index 0f4d7ecc699..d5a605958dc 100644 --- a/spec/views/profiles/keys/_form.html.haml_spec.rb +++ b/spec/views/profiles/keys/_form.html.haml_spec.rb @@ -33,8 +33,8 @@ RSpec.describe 'profiles/keys/_form.html.haml' do end it 'has the expires at field', :aggregate_failures do - expect(rendered).to have_field('Expires at', type: 'date') - expect(page.find_field('Expires at')['min']).to eq(l(1.day.from_now, format: "%Y-%m-%d")) + expect(rendered).to have_field('Expiration date', type: 'date') + expect(page.find_field('Expiration date')['min']).to eq(l(1.day.from_now, format: "%Y-%m-%d")) expect(rendered).to have_text('Key can still be used after expiration.') end