diff --git a/Gemfile b/Gemfile index 2a018f3e0ee..c974a6f51e5 100644 --- a/Gemfile +++ b/Gemfile @@ -44,7 +44,6 @@ gem 'omniauth-twitter', '~> 1.4' gem 'omniauth_crowd', '~> 2.2.0' gem 'omniauth-authentiq', '~> 0.3.3' gem 'omniauth_openid_connect', '~> 0.3.3' -gem "omniauth-ultraauth", '~> 0.0.2' gem 'omniauth-salesforce', '~> 1.0.5' gem 'rack-oauth2', '~> 1.9.3' gem 'jwt', '~> 2.1.0' diff --git a/Gemfile.lock b/Gemfile.lock index ffff576e8b0..45325603396 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -722,8 +722,6 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - omniauth-ultraauth (0.0.2) - omniauth_openid_connect (~> 0.3.0) omniauth_crowd (2.2.3) activesupport nokogiri (>= 1.4.4) @@ -1317,7 +1315,6 @@ DEPENDENCIES omniauth-saml (~> 1.10) omniauth-shibboleth (~> 1.3.0) omniauth-twitter (~> 1.4) - omniauth-ultraauth (~> 0.0.2) omniauth_crowd (~> 2.2.0) omniauth_openid_connect (~> 0.3.3) org-ruby (~> 0.9.12) diff --git a/app/assets/javascripts/pipelines/components/dag/utils.js b/app/assets/javascripts/pipelines/components/dag/utils.js new file mode 100644 index 00000000000..20d1f785187 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/utils.js @@ -0,0 +1,187 @@ +import { sankey, sankeyLeft } from 'd3-sankey'; +import { uniqWith, isEqual } from 'lodash'; + +/* + The following functions are the main engine in transforming the data as + received from the endpoint into the format the d3 graph expects. + + Input is of the form: + [stages] + stages: {name, groups} + groups: [{ name, size, jobs }] + name is a group name; in the case that the group has one job, it is + also the job name + size is the number of parallel jobs + jobs: [{ name, needs}] + job name is either the same as the group name or group x/y + + Output is of the form: + { nodes: [node], links: [link] } + node: { name, category }, + unused info passed through + link: { source, target, value }, with source & target being node names + and value being a constant + + We create nodes, create links, and then dedupe the links, so that in the case where + job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link + from job 1 to job 2 then another from job 2 to job 4. + + CREATE NODES + stage.name -> node.category + stage.group.name -> node.name (this is the group name if there are parallel jobs) + stage.group.jobs -> node.jobs + stage.group.size -> node.size + + CREATE LINKS + stages.groups.name -> target + stages.groups.needs.each -> source (source is the name of the group, not the parallel job) + 10 -> value (constant) + */ + +export const createNodes = data => { + return data.flatMap(({ groups, name }) => { + return groups.map(group => { + return { ...group, category: name }; + }); + }); +}; + +export const createNodeDict = nodes => { + return nodes.reduce((acc, node) => { + const newNode = { + ...node, + needs: node.jobs.map(job => job.needs || []).flat(), + }; + + if (node.size > 1) { + node.jobs.forEach(job => { + acc[job.name] = newNode; + }); + } + + acc[node.name] = newNode; + return acc; + }, {}); +}; + +export const createNodesStructure = data => { + const nodes = createNodes(data); + const nodeDict = createNodeDict(nodes); + + return { nodes, nodeDict }; +}; + +export const makeLinksFromNodes = (nodes, nodeDict) => { + const constantLinkValue = 10; // all links are the same weight + return nodes + .map(group => { + return group.jobs.map(job => { + if (!job.needs) { + return []; + } + + return job.needs.map(needed => { + return { + source: nodeDict[needed]?.name, + target: group.name, + value: constantLinkValue, + }; + }); + }); + }) + .flat(2); +}; + +export const getAllAncestors = (nodes, nodeDict) => { + const needs = nodes + .map(node => { + return nodeDict[node].needs || ''; + }) + .flat() + .filter(Boolean); + + if (needs.length) { + return [...needs, ...getAllAncestors(needs, nodeDict)]; + } + + return []; +}; + +export const filterByAncestors = (links, nodeDict) => + links.filter(({ target, source }) => { + /* + + for every link, check out it's target + for every target, get the target node's needs + then drop the current link source from that list + + call a function to get all ancestors, recursively + is the current link's source in the list of all parents? + then we drop this link + + */ + const targetNode = target; + const targetNodeNeeds = nodeDict[targetNode].needs; + const targetNodeNeedsMinusSource = targetNodeNeeds.filter(need => need !== source); + + const allAncestors = getAllAncestors(targetNodeNeedsMinusSource, nodeDict); + return !allAncestors.includes(source); + }); + +export const parseData = data => { + const { nodes, nodeDict } = createNodesStructure(data); + const allLinks = makeLinksFromNodes(nodes, nodeDict); + const filteredLinks = filterByAncestors(allLinks, nodeDict); + const links = uniqWith(filteredLinks, isEqual); + + return { nodes, links }; +}; + +/* + createSankey calls the d3 layout to generate the relationships and positioning + values for the nodes and links in the graph. + */ + +export const createSankey = ({ width, height, nodeWidth, nodePadding, paddingForLabels }) => { + const sankeyGenerator = sankey() + .nodeId(({ name }) => name) + .nodeAlign(sankeyLeft) + .nodeWidth(nodeWidth) + .nodePadding(nodePadding) + .extent([ + [paddingForLabels, paddingForLabels], + [width - paddingForLabels, height - paddingForLabels], + ]); + return ({ nodes, links }) => + sankeyGenerator({ + nodes: nodes.map(d => ({ ...d })), + links: links.map(d => ({ ...d })), + }); +}; + +/* + The number of nodes in the most populous generation drives the height of the graph. +*/ + +export const getMaxNodes = nodes => { + const counts = nodes.reduce((acc, { layer }) => { + if (!acc[layer]) { + acc[layer] = 0; + } + + acc[layer] += 1; + + return acc; + }, []); + + return Math.max(...counts); +}; + +/* + Because we cannot know if a node is part of a relationship until after we + generate the links with createSankey, this function is used after the first call + to find nodes that have no relations. +*/ + +export const removeOrphanNodes = sankeyfiedNodes => { + return sankeyfiedNodes.filter(node => node.sourceLinks.length || node.targetLinks.length); +}; diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index d486d734db8..6c443611a60 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -23,8 +23,7 @@ module EnforcesTwoFactorAuthentication def two_factor_authentication_required? Gitlab::CurrentSettings.require_two_factor_authentication? || - current_user.try(:require_two_factor_authentication_from_group?) || - current_user.try(:ultraauth_user?) + current_user.try(:require_two_factor_authentication_from_group?) end def current_user_requires_two_factor? diff --git a/app/models/user.rb b/app/models/user.rb index 1ba5b9cdf71..021b1e60646 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -954,11 +954,11 @@ class User < ApplicationRecord end def allow_password_authentication_for_web? - Gitlab::CurrentSettings.password_authentication_enabled_for_web? && !ldap_user? && !ultraauth_user? + Gitlab::CurrentSettings.password_authentication_enabled_for_web? && !ldap_user? end def allow_password_authentication_for_git? - Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !ldap_user? && !ultraauth_user? + Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !ldap_user? end def can_change_username? @@ -1046,14 +1046,6 @@ class User < ApplicationRecord end end - def ultraauth_user? - if identities.loaded? - identities.find { |identity| Gitlab::Auth::OAuth::Provider.ultraauth_provider?(identity.provider) && !identity.extern_uid.nil? } - else - identities.exists?(["provider = ? AND extern_uid IS NOT NULL", "ultraauth"]) - end - end - def ldap_identity @ldap_identity ||= identities.find_by(["provider LIKE ?", "ldap%"]) end diff --git a/changelogs/unreleased/212848.yml b/changelogs/unreleased/212848.yml new file mode 100644 index 00000000000..c4a33866d62 --- /dev/null +++ b/changelogs/unreleased/212848.yml @@ -0,0 +1,5 @@ +--- +title: Removed UltraAuth integration for OmniAuth +merge_request: 29330 +author: Kartikey Tanna +type: removed diff --git a/db/migrate/20200508021128_remove_ultraauth_provider_from_identities.rb b/db/migrate/20200508021128_remove_ultraauth_provider_from_identities.rb new file mode 100644 index 00000000000..dd3f1dbcc0e --- /dev/null +++ b/db/migrate/20200508021128_remove_ultraauth_provider_from_identities.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class RemoveUltraauthProviderFromIdentities < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :identities, :provider + execute "DELETE FROM identities WHERE provider = 'ultraauth'" + remove_concurrent_index :identities, :provider + end + + def down + end +end diff --git a/db/structure.sql b/db/structure.sql index 052a98d3df3..b183501d86a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -13883,6 +13883,7 @@ COPY "schema_migrations" (version) FROM STDIN; 20200506125731 20200506154421 20200507221434 +20200508021128 20200508050301 20200508091106 20200511080113 diff --git a/doc/administration/auth/README.md b/doc/administration/auth/README.md index f30d6be1775..1c5b00b36ad 100644 --- a/doc/administration/auth/README.md +++ b/doc/administration/auth/README.md @@ -32,4 +32,3 @@ providers: - [Shibboleth](../../integration/shibboleth.md) - [Smartcard](smartcard.md) **(PREMIUM ONLY)** - [Twitter](../../integration/twitter.md) -- [UltraAuth](../../integration/ultra_auth.md) diff --git a/doc/api/feature_flag_specs.md b/doc/api/feature_flag_specs.md index 5669809349b..ff05c4f6fe4 100644 --- a/doc/api/feature_flag_specs.md +++ b/doc/api/feature_flag_specs.md @@ -2,7 +2,10 @@ > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9566) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.5. -The API for creating, updating, reading and deleting [Feature Flag Specs](../user/project/operations/feature_flags.md#define-environment-specs). +CAUTION: **Deprecation** +This API is deprecated and [scheduled for removal in GitLab 14.0](https://gitlab.com/gitlab-org/gitlab/-/issues/213369). + +The API for creating, updating, reading and deleting Feature Flag Specs. Automation engineers benefit from this API by being able to modify Feature Flag Specs without accessing user interface. To manage the [Feature Flag](../user/project/operations/feature_flags.md) resources via public API, please refer to the [Feature Flags API](feature_flags.md) document. diff --git a/doc/api/feature_flags.md b/doc/api/feature_flags.md index e58edd047de..ff9b754ada7 100644 --- a/doc/api/feature_flags.md +++ b/doc/api/feature_flags.md @@ -2,6 +2,9 @@ > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9566) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.5. +NOTE: **Note** +This API is behind a [feature flag](../user/project/operations/feature_flags.md#feature-flag-behavior-change-in-130). If this flag is not enabled in your environment, you can use the [legacy feature flags API](feature_flags_legacy.md). + API for accessing resources of [GitLab Feature Flags](../user/project/operations/feature_flags.md). Users with Developer or higher [permissions](../user/permissions.md) can access Feature Flag API. @@ -35,187 +38,51 @@ Example response: { "name":"merge_train", "description":"This feature is about merge train", + "version": "new_version_flag", "created_at":"2019-11-04T08:13:51.423Z", "updated_at":"2019-11-04T08:13:51.423Z", - "scopes":[ - { - "id":82, - "active":false, - "environment_scope":"*", - "strategies":[ - { - "name":"default", - "parameters":{ - - } - } - ], - "created_at":"2019-11-04T08:13:51.425Z", - "updated_at":"2019-11-04T08:13:51.425Z" - }, - { - "id":83, - "active":true, - "environment_scope":"review/*", - "strategies":[ - { - "name":"default", - "parameters":{ - - } - } - ], - "created_at":"2019-11-04T08:13:51.427Z", - "updated_at":"2019-11-04T08:13:51.427Z" - }, - { - "id":84, - "active":false, - "environment_scope":"production", - "strategies":[ - { - "name":"default", - "parameters":{ - - } - } - ], - "created_at":"2019-11-04T08:13:51.428Z", - "updated_at":"2019-11-04T08:13:51.428Z" - } + "scopes":[], + "strategies": [ + { + "id": 1, + "name": "userWithId", + "parameters": { + "userIds": "user1" + }, + "scopes": [ + { + "id": 1, + "environment_scope": "production" + } + ] + } ] }, { "name":"new_live_trace", "description":"This is a new live trace feature", + "version": "new_version_flag", "created_at":"2019-11-04T08:13:10.507Z", "updated_at":"2019-11-04T08:13:10.507Z", - "scopes":[ - { - "id":79, - "active":false, - "environment_scope":"*", - "strategies":[ - { - "name":"default", - "parameters":{ - - } - } - ], - "created_at":"2019-11-04T08:13:10.516Z", - "updated_at":"2019-11-04T08:13:10.516Z" - }, - { - "id":80, - "active":true, - "environment_scope":"staging", - "strategies":[ - { - "name":"default", - "parameters":{ - - } - } - ], - "created_at":"2019-11-04T08:13:10.525Z", - "updated_at":"2019-11-04T08:13:10.525Z" - }, - { - "id":81, - "active":false, - "environment_scope":"production", - "strategies":[ - { - "name":"default", - "parameters":{ - - } - } - ], - "created_at":"2019-11-04T08:13:10.527Z", - "updated_at":"2019-11-04T08:13:10.527Z" - } + "scopes":[] + "strategies": [ + { + "id": 2, + "name": "default", + "parameters": {}, + "scopes": [ + { + "id": 2, + "environment_scope": "staging" + } + ] + } ] } ] ``` -## New feature flag - -Creates a new feature flag. - -```plaintext -POST /projects/:id/feature_flags -``` - -| Attribute | Type | Required | Description | -| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| -| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | -| `name` | string | yes | The name of the feature flag. | -| `description` | string | no | The description of the feature flag. | -| `scopes` | JSON | no | The [feature flag specs](../user/project/operations/feature_flags.md#define-environment-specs) of the feature flag. | -| `scopes:environment_scope` | string | no | The [environment spec](../ci/environments/index.md#scoping-environments-with-specs). | -| `scopes:active` | boolean | no | Whether the spec is active. | -| `scopes:strategies` | JSON | no | The [strategies](../user/project/operations/feature_flags.md#feature-flag-strategies) of the feature flag spec. | - -```shell -curl https://gitlab.example.com/api/v4/projects/1/feature_flags \ - --header "PRIVATE-TOKEN: " \ - --header "Content-type: application/json" \ - --data @- << EOF -{ - "name": "awesome_feature", - "scopes": [{ "environment_scope": "*", "active": false, "strategies": [{ "name": "default", "parameters": {} }] }, - { "environment_scope": "production", "active": true, "strategies": [{ "name": "userWithId", "parameters": { "userIds": "1,2,3" } }] }] -} -EOF -``` - -Example response: - -```json -{ - "name":"awesome_feature", - "description":null, - "created_at":"2019-11-04T08:32:27.288Z", - "updated_at":"2019-11-04T08:32:27.288Z", - "scopes":[ - { - "id":85, - "active":false, - "environment_scope":"*", - "strategies":[ - { - "name":"default", - "parameters":{ - - } - } - ], - "created_at":"2019-11-04T08:32:29.324Z", - "updated_at":"2019-11-04T08:32:29.324Z" - }, - { - "id":86, - "active":true, - "environment_scope":"production", - "strategies":[ - { - "name":"userWithId", - "parameters":{ - "userIds":"1,2,3" - } - } - ], - "created_at":"2019-11-04T08:32:29.328Z", - "updated_at":"2019-11-04T08:32:29.328Z" - } - ] -} -``` - -## Single feature flag +## Get a single feature flag Gets a single feature flag. @@ -226,71 +93,170 @@ GET /projects/:id/feature_flags/:name | Attribute | Type | Required | Description | | ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | -| `name` | string | yes | The name of the feature flag. | +| `name` | string | yes | The name of the feature flag. | ```shell -curl --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/projects/1/feature_flags/new_live_trace +curl --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/projects/1/feature_flags/awesome_feature ``` Example response: ```json { - "name":"new_live_trace", - "description":"This is a new live trace feature", - "created_at":"2019-11-04T08:13:10.507Z", - "updated_at":"2019-11-04T08:13:10.507Z", - "scopes":[ - { - "id":79, - "active":false, - "environment_scope":"*", - "strategies":[ - { - "name":"default", - "parameters":{ - - } - } - ], - "created_at":"2019-11-04T08:13:10.516Z", - "updated_at":"2019-11-04T08:13:10.516Z" - }, - { - "id":80, - "active":true, - "environment_scope":"staging", - "strategies":[ - { - "name":"default", - "parameters":{ - - } - } - ], - "created_at":"2019-11-04T08:13:10.525Z", - "updated_at":"2019-11-04T08:13:10.525Z" - }, - { - "id":81, - "active":false, - "environment_scope":"production", - "strategies":[ - { - "name":"default", - "parameters":{ - - } - } - ], - "created_at":"2019-11-04T08:13:10.527Z", - "updated_at":"2019-11-04T08:13:10.527Z" - } - ] + "name": "awesome_feature", + "description": null, + "version": "new_version_flag", + "created_at": "2020-05-13T19:56:33.119Z", + "updated_at": "2020-05-13T19:56:33.119Z", + "scopes": [], + "strategies": [ + { + "id": 36, + "name": "default", + "parameters": {}, + "scopes": [ + { + "id": 37, + "environment_scope": "production" + } + ] + } + ] } ``` -## Delete feature flag +## Create a feature flag + +Creates a new feature flag. + +```plaintext +POST /projects/:id/feature_flags +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | +| `version` | string | yes | The version of the feature flag. Must be `new_version_flag`. Omit or set to `legacy_flag` to create a [Legacy Feature Flag](feature_flags_legacy.md). | +| `description` | string | no | The description of the feature flag. | +| `strategies` | JSON | no | The feature flag [strategies](../user/project/operations/feature_flags.md#feature-flag-strategies). | +| `strategies:name` | JSON | no | The strategy name. | +| `strategies:parameters` | JSON | no | The strategy parameters. | +| `strategies:scopes` | JSON | no | The scopes for the strategy. | +| `strategies:scopes:environment_scope` | string | no | The environment spec for the scope. | + +```shell +curl https://gitlab.example.com/api/v4/projects/1/feature_flags \ + --header "PRIVATE-TOKEN: " \ + --header "Content-type: application/json" \ + --data @- << EOF +{ + "name": "awesome_feature", + "version": "new_version_flag", + "strategies": [{ "name": "default", "parameters": {}, "scopes": [{ "environment_scope": "production" }] }] +} +EOF +``` + +Example response: + +```json +{ + "name": "awesome_feature", + "description": null, + "version": "new_version_flag", + "created_at": "2020-05-13T19:56:33.119Z", + "updated_at": "2020-05-13T19:56:33.119Z", + "scopes": [], + "strategies": [ + { + "id": 36, + "name": "default", + "parameters": {}, + "scopes": [ + { + "id": 37, + "environment_scope": "production" + } + ] + } + ] +} +``` + +## Update a feature flag + +Updates a feature flag. + +```plaintext +PUT /projects/:id/feature_flags/:name +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | +| `description` | string | no | The description of the feature flag. | +| `strategies` | JSON | no | The feature flag [strategies](../user/project/operations/feature_flags.md#feature-flag-strategies). | +| `strategies:id` | JSON | no | The feature flag strategy id. | +| `strategies:name` | JSON | no | The strategy name. | +| `strategies:parameters` | JSON | no | The strategy parameters. | +| `strategies:scopes` | JSON | no | The scopes for the strategy. | +| `strategies:scopes:id` | JSON | no | The scopes id. | +| `strategies:scopes:environment_scope` | string | no | The environment spec for the scope. | + +```shell +curl https://gitlab.example.com/api/v4/projects/1/feature_flags/awesome_feature \ + --header "PRIVATE-TOKEN: " \ + --header "Content-type: application/json" \ + --data @- << EOF +{ + "strategies": [{ "name": "gradualRolloutUserId", "parameters": { "groupId": "default", "percentage": "25" }, "scopes": [{ "environment_scope": "staging" }] }] +} +EOF +``` + +Example response: + +```json +{ + "name": "awesome_feature", + "description": null, + "version": "new_version_flag", + "created_at": "2020-05-13T20:10:32.891Z", + "updated_at": "2020-05-13T20:10:32.891Z", + "scopes": [], + "strategies": [ + { + "id": 38, + "name": "gradualRolloutUserId", + "parameters": { + "groupId": "default", + "percentage": "25" + }, + "scopes": [ + { + "id": 40, + "environment_scope": "staging" + } + ] + }, + { + "id": 37, + "name": "default", + "parameters": {}, + "scopes": [ + { + "id": 39, + "environment_scope": "production" + } + ] + } + ] +} +``` + +## Delete a feature flag Deletes a feature flag. diff --git a/doc/api/feature_flags_legacy.md b/doc/api/feature_flags_legacy.md new file mode 100644 index 00000000000..82af43e89f0 --- /dev/null +++ b/doc/api/feature_flags_legacy.md @@ -0,0 +1,311 @@ +# Legacy Feature Flags API **(PREMIUM)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/9566) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.5. + +CAUTION: **Deprecation** +This API is deprecated and [scheduled for removal in GitLab 14.0](https://gitlab.com/gitlab-org/gitlab/-/issues/213369). Use [this API](feature_flags.md) instead. + +API for accessing resources of [GitLab Feature Flags](../user/project/operations/feature_flags.md). + +Users with Developer or higher [permissions](../user/permissions.md) can access Feature Flag API. + +## Feature Flags pagination + +By default, `GET` requests return 20 results at a time because the API results +are [paginated](README.md#pagination). + +## List feature flags for a project + +Gets all feature flags of the requested project. + +```plaintext +GET /projects/:id/feature_flags +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `scope` | string | no | The condition of feature flags, one of: `enabled`, `disabled`. | + +```shell +curl --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/projects/1/feature_flags +``` + +Example response: + +```json +[ + { + "name":"merge_train", + "description":"This feature is about merge train", + "created_at":"2019-11-04T08:13:51.423Z", + "updated_at":"2019-11-04T08:13:51.423Z", + "scopes":[ + { + "id":82, + "active":false, + "environment_scope":"*", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:51.425Z", + "updated_at":"2019-11-04T08:13:51.425Z" + }, + { + "id":83, + "active":true, + "environment_scope":"review/*", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:51.427Z", + "updated_at":"2019-11-04T08:13:51.427Z" + }, + { + "id":84, + "active":false, + "environment_scope":"production", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:51.428Z", + "updated_at":"2019-11-04T08:13:51.428Z" + } + ] + }, + { + "name":"new_live_trace", + "description":"This is a new live trace feature", + "created_at":"2019-11-04T08:13:10.507Z", + "updated_at":"2019-11-04T08:13:10.507Z", + "scopes":[ + { + "id":79, + "active":false, + "environment_scope":"*", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.516Z", + "updated_at":"2019-11-04T08:13:10.516Z" + }, + { + "id":80, + "active":true, + "environment_scope":"staging", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.525Z", + "updated_at":"2019-11-04T08:13:10.525Z" + }, + { + "id":81, + "active":false, + "environment_scope":"production", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.527Z", + "updated_at":"2019-11-04T08:13:10.527Z" + } + ] + } +] +``` + +## New feature flag + +Creates a new feature flag. + +```plaintext +POST /projects/:id/feature_flags +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | +| `description` | string | no | The description of the feature flag. | +| `scopes` | JSON | no | The feature flag specs of the feature flag. | +| `scopes:environment_scope` | string | no | The environment spec. | +| `scopes:active` | boolean | no | Whether the spec is active. | +| `scopes:strategies` | JSON | no | The [strategies](../user/project/operations/feature_flags.md#feature-flag-strategies) of the feature flag spec. | + +```shell +curl https://gitlab.example.com/api/v4/projects/1/feature_flags \ + --header "PRIVATE-TOKEN: " \ + --header "Content-type: application/json" \ + --data @- << EOF +{ + "name": "awesome_feature", + "scopes": [{ "environment_scope": "*", "active": false, "strategies": [{ "name": "default", "parameters": {} }] }, + { "environment_scope": "production", "active": true, "strategies": [{ "name": "userWithId", "parameters": { "userIds": "1,2,3" } }] }] +} +EOF +``` + +Example response: + +```json +{ + "name":"awesome_feature", + "description":null, + "created_at":"2019-11-04T08:32:27.288Z", + "updated_at":"2019-11-04T08:32:27.288Z", + "scopes":[ + { + "id":85, + "active":false, + "environment_scope":"*", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:32:29.324Z", + "updated_at":"2019-11-04T08:32:29.324Z" + }, + { + "id":86, + "active":true, + "environment_scope":"production", + "strategies":[ + { + "name":"userWithId", + "parameters":{ + "userIds":"1,2,3" + } + } + ], + "created_at":"2019-11-04T08:32:29.328Z", + "updated_at":"2019-11-04T08:32:29.328Z" + } + ] +} +``` + +## Single feature flag + +Gets a single feature flag. + +```plaintext +GET /projects/:id/feature_flags/:name +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | + +```shell +curl --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/projects/1/feature_flags/new_live_trace +``` + +Example response: + +```json +{ + "name":"new_live_trace", + "description":"This is a new live trace feature", + "created_at":"2019-11-04T08:13:10.507Z", + "updated_at":"2019-11-04T08:13:10.507Z", + "scopes":[ + { + "id":79, + "active":false, + "environment_scope":"*", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.516Z", + "updated_at":"2019-11-04T08:13:10.516Z" + }, + { + "id":80, + "active":true, + "environment_scope":"staging", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.525Z", + "updated_at":"2019-11-04T08:13:10.525Z" + }, + { + "id":81, + "active":false, + "environment_scope":"production", + "strategies":[ + { + "name":"default", + "parameters":{ + + } + } + ], + "created_at":"2019-11-04T08:13:10.527Z", + "updated_at":"2019-11-04T08:13:10.527Z" + } + ] +} +``` + +## Delete feature flag + +Deletes a feature flag. + +```plaintext +DELETE /projects/:id/feature_flags/:name +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | +| `name` | string | yes | The name of the feature flag. | + +```shell +curl --header "PRIVATE-TOKEN: " --request DELETE https://gitlab.example.com/api/v4/projects/1/feature_flags/awesome_feature +``` diff --git a/doc/integration/img/ultra_auth_credentials.png b/doc/integration/img/ultra_auth_credentials.png deleted file mode 100644 index cff98a4b056..00000000000 Binary files a/doc/integration/img/ultra_auth_credentials.png and /dev/null differ diff --git a/doc/integration/img/ultra_auth_edit_callback_url.png b/doc/integration/img/ultra_auth_edit_callback_url.png deleted file mode 100644 index b7548122c5e..00000000000 Binary files a/doc/integration/img/ultra_auth_edit_callback_url.png and /dev/null differ diff --git a/doc/integration/img/ultra_auth_edit_callback_url_highlighted.png b/doc/integration/img/ultra_auth_edit_callback_url_highlighted.png deleted file mode 100644 index 4abf224756c..00000000000 Binary files a/doc/integration/img/ultra_auth_edit_callback_url_highlighted.png and /dev/null differ diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md index 2afdeccb764..2ace05a8320 100644 --- a/doc/integration/omniauth.md +++ b/doc/integration/omniauth.md @@ -34,7 +34,6 @@ contains some settings that are common for all providers. - [OAuth2Generic](oauth2_generic.md) - [JWT](../administration/auth/jwt.md) - [OpenID Connect](../administration/auth/oidc.md) -- [UltraAuth](ultra_auth.md) - [Salesforce](salesforce.md) - [AWS Cognito](../administration/auth/cognito.md) diff --git a/doc/integration/ultra_auth.md b/doc/integration/ultra_auth.md deleted file mode 100644 index 95f2c0feb3b..00000000000 --- a/doc/integration/ultra_auth.md +++ /dev/null @@ -1,87 +0,0 @@ -# UltraAuth OmniAuth Provider - -You can integrate your GitLab instance with [UltraAuth](https://github.com/ultraauth) to enable users to perform secure biometric authentication to your GitLab instance with your UltraAuth account. Users have to perform the biometric authentication using their mobile device with fingerprint sensor. - -## Create UltraAuth Application - -To enable UltraAuth OmniAuth provider, you must use UltraAuth's credentials for your GitLab instance. -To get the credentials (a pair of Client ID and Client Secret), you must register an application on UltraAuth. - -1. Sign in to [UltraAuth](https://app.ultraauth.com). -1. Navigate to **Create an App** and click on **Ruby on Rails**. -1. Scroll down the page that is displayed to locate the **Client ID** and **Client Secret**. - Keep this page open as you continue configuration. - - ![UltraAuth Credentials: OPENID_CLIENT_ID and OPENID_CLIENT_SECRET](img/ultra_auth_credentials.png) - -1. Click on "Edit Callback URL" link. - - ![Edit UltraAuth Callback URL](img/ultra_auth_edit_callback_url_highlighted.png) - -1. The callback URL will be `http(s):///users/auth/ultraauth/callback` - - ![UltraAuth Callback URL](img/ultra_auth_edit_callback_url.png) - -1. Select **Register application**. -1. On your GitLab server, open the configuration file. - - For Omnibus package: - - ```shell - sudo editor /etc/gitlab/gitlab.rb - ``` - - For installations from source: - - ```shell - cd /home/git/gitlab - sudo -u git -H editor config/gitlab.yml - ``` - -1. See [Initial OmniAuth Configuration](omniauth.md#initial-omniauth-configuration) for initial settings. -1. Add the provider configuration: - - For Omnibus package: - - ```ruby - gitlab_rails['omniauth_providers'] = [ - { - "name" => "ultraauth", - "app_id" => "OPENID_CLIENT_ID", - "app_secret" => "OPENID_CLIENT_SECRET", - "args" => { - "client_options" => { - "redirect_uri" => "https://example.com/users/auth/ultraauth/callback" - } - } - } - ] - ``` - - For installation from source: - - ```yaml - - { name: 'ultraauth', - app_id: 'OPENID_CLIENT_ID', - app_secret: 'OPENID_CLIENT_SECRET', - args: { - client_options: { - redirect_uri: 'https://example.com/users/auth/ultraauth/callback' - } - } - } - ``` - - __Replace `https://example.com/users/auth/ultraauth/callback` with your application's Callback URL.__ - -1. Change `OPENID_CLIENT_ID` to the Client ID from the UltraAuth application page. -1. Change `OPENID_CLIENT_SECRET` to the Client Secret from the UltraAuth application page. -1. Save the configuration file. -1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure) or [restart GitLab](../administration/restart_gitlab.md#installations-from-source) for the changes to take effect if you - installed GitLab via Omnibus or from source respectively. - -On the sign in page, there should now be an UltraAuth icon below the regular sign in form. -Click the icon to begin the authentication process. UltraAuth will ask the user to sign in and authorize the GitLab application. -If everything goes well, the user will be returned to GitLab and will be signed in. - -GitLab requires the email address of each new user. Once the user is logged in using UltraAuth, GitLab will redirect the user to the profile page where they will have to provide the email and verify the email. Password authentication will be disabled for UltraAuth users and two-factor authentication (2FA) will be enforced. diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md index 7bcf52efb1a..e057f03f39a 100644 --- a/doc/user/application_security/dast/index.md +++ b/doc/user/application_security/dast/index.md @@ -456,7 +456,7 @@ DAST can be [configured](#customizing-the-dast-settings) using environment varia | `DAST_FULL_SCAN_DOMAIN_VALIDATION_REQUIRED` | no | Requires [domain validation](#domain-validation) when running DAST full scans. Boolean. `true`, `True`, or `1` are considered as true value, otherwise false. Defaults to `false`. Not supported for API scans. | | `DAST_AUTO_UPDATE_ADDONS` | no | By default the versions of ZAP add-ons are pinned to those provided with the DAST image. Set to `true` to allow ZAP to download the latest versions. | | `DAST_API_HOST_OVERRIDE` | no | Used to override domains defined in API specification files. | -| `DAST_EXCLUDE_RULES` | no | Set to a comma-separated list of Vulnerability Rule IDs to exclude them from scans. Rule IDs are numbers and can be found from the DAST log or on the [ZAP project](https://github.com/zaproxy/zaproxy/blob/master/docs/scanners.md). For example, `HTTP Parameter Override` has a rule ID of `10026`. | +| `DAST_EXCLUDE_RULES` | no | Set to a comma-separated list of Vulnerability Rule IDs to exclude them from the scan report. Currently, excluded rules will get executed but the alerts from them will be suppressed. Rule IDs are numbers and can be found from the DAST log or on the [ZAP project](https://github.com/zaproxy/zaproxy/blob/develop/docs/scanners.md). For example, `HTTP Parameter Override` has a rule ID of `10026`. | | `DAST_REQUEST_HEADERS` | no | Set to a comma-separated list of request header names and values. For example, `Cache-control: no-cache,User-Agent: DAST/1.0` | | `DAST_ZAP_USE_AJAX_SPIDER` | no | Use the AJAX spider in addition to the traditional spider, useful for crawling sites that require JavaScript. Boolean. `true`, `True`, or `1` are considered as true value, otherwise false. Defaults to `false`. | diff --git a/doc/user/project/operations/feature_flags.md b/doc/user/project/operations/feature_flags.md index fb76fb1ac59..2ec2c97c707 100644 --- a/doc/user/project/operations/feature_flags.md +++ b/doc/user/project/operations/feature_flags.md @@ -311,6 +311,6 @@ end You can create, update, read, and delete Feature Flags via API to control them in an automated flow: -- [Feature Flags API](../../../api/feature_flags.md) +- [Legacy Feature Flags API](../../../api/feature_flags_legacy.md) - [Feature Flag Specs API](../../../api/feature_flag_specs.md) - [Feature Flag User Lists API](../../../api/feature_flag_user_lists.md) diff --git a/lib/gitlab/auth/o_auth/provider.rb b/lib/gitlab/auth/o_auth/provider.rb index 6d699d37a8c..1ca59aa827b 100644 --- a/lib/gitlab/auth/o_auth/provider.rb +++ b/lib/gitlab/auth/o_auth/provider.rb @@ -41,10 +41,6 @@ module Gitlab name.to_s.start_with?('ldap') end - def self.ultraauth_provider?(name) - name.to_s.eql?('ultraauth') - end - def self.sync_profile_from_provider?(provider) return true if ldap_provider?(provider) diff --git a/package.json b/package.json index 5710831016b..25a95178d7a 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "core-js": "^3.6.4", "cropper": "^2.3.0", "css-loader": "^2.1.1", + "d3-sankey": "^0.12.3", "d3-scale": "^2.2.2", "d3-selection": "^1.2.0", "dateformat": "^3.0.3", diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index ed2e61d6cf6..ca04e2d0578 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -310,13 +310,6 @@ describe ApplicationController do expect(subject).to be_truthy end - - it 'returns true if user has signed up using omniauth-ultraauth' do - user = create(:omniauth_user, provider: 'ultraauth') - allow(controller).to receive(:current_user).and_return(user) - - expect(subject).to be_truthy - end end describe '#two_factor_grace_period' do diff --git a/spec/frontend/droplab/drop_down_spec.js b/spec/frontend/droplab/drop_down_spec.js new file mode 100644 index 00000000000..d33d6bb70f1 --- /dev/null +++ b/spec/frontend/droplab/drop_down_spec.js @@ -0,0 +1,662 @@ +import DropDown from '~/droplab/drop_down'; +import utils from '~/droplab/utils'; +import { SELECTED_CLASS } from '~/droplab/constants'; + +describe('DropLab DropDown', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + describe('class constructor', () => { + beforeEach(() => { + jest.spyOn(DropDown.prototype, 'getItems').mockImplementation(() => {}); + jest.spyOn(DropDown.prototype, 'initTemplateString').mockImplementation(() => {}); + jest.spyOn(DropDown.prototype, 'addEvents').mockImplementation(() => {}); + + testContext.list = { innerHTML: 'innerHTML' }; + testContext.dropdown = new DropDown(testContext.list); + }); + + it('sets the .hidden property to true', () => { + expect(testContext.dropdown.hidden).toBe(true); + }); + + it('sets the .list property', () => { + expect(testContext.dropdown.list).toBe(testContext.list); + }); + + it('calls .getItems', () => { + expect(DropDown.prototype.getItems).toHaveBeenCalled(); + }); + + it('calls .initTemplateString', () => { + expect(DropDown.prototype.initTemplateString).toHaveBeenCalled(); + }); + + it('calls .addEvents', () => { + expect(DropDown.prototype.addEvents).toHaveBeenCalled(); + }); + + it('sets the .initialState property to the .list.innerHTML', () => { + expect(testContext.dropdown.initialState).toBe(testContext.list.innerHTML); + }); + + describe('if the list argument is a string', () => { + beforeEach(() => { + testContext.element = {}; + testContext.selector = '.selector'; + + jest.spyOn(Document.prototype, 'querySelector').mockReturnValue(testContext.element); + + testContext.dropdown = new DropDown(testContext.selector); + }); + + it('calls .querySelector with the selector string', () => { + expect(Document.prototype.querySelector).toHaveBeenCalledWith(testContext.selector); + }); + + it('sets the .list property element', () => { + expect(testContext.dropdown.list).toBe(testContext.element); + }); + }); + }); + + describe('getItems', () => { + beforeEach(() => { + testContext.list = { querySelectorAll: () => {} }; + testContext.dropdown = { list: testContext.list }; + testContext.nodeList = []; + + jest.spyOn(testContext.list, 'querySelectorAll').mockReturnValue(testContext.nodeList); + + testContext.getItems = DropDown.prototype.getItems.call(testContext.dropdown); + }); + + it('calls .querySelectorAll with a list item query', () => { + expect(testContext.list.querySelectorAll).toHaveBeenCalledWith('li'); + }); + + it('sets the .items property to the returned list items', () => { + expect(testContext.dropdown.items).toEqual(expect.any(Array)); + }); + + it('returns the .items', () => { + expect(testContext.getItems).toEqual(expect.any(Array)); + }); + }); + + describe('initTemplateString', () => { + beforeEach(() => { + testContext.items = [{ outerHTML: '' }, { outerHTML: '' }]; + testContext.dropdown = { items: testContext.items }; + + DropDown.prototype.initTemplateString.call(testContext.dropdown); + }); + + it('should set .templateString to the last items .outerHTML', () => { + expect(testContext.dropdown.templateString).toBe(testContext.items[1].outerHTML); + }); + + it('should not set .templateString to a non-last items .outerHTML', () => { + expect(testContext.dropdown.templateString).not.toBe(testContext.items[0].outerHTML); + }); + + describe('if .items is not set', () => { + beforeEach(() => { + testContext.dropdown = { getItems: () => {} }; + + jest.spyOn(testContext.dropdown, 'getItems').mockReturnValue([]); + + DropDown.prototype.initTemplateString.call(testContext.dropdown); + }); + + it('should call .getItems', () => { + expect(testContext.dropdown.getItems).toHaveBeenCalled(); + }); + }); + + describe('if items array is empty', () => { + beforeEach(() => { + testContext.dropdown = { items: [] }; + + DropDown.prototype.initTemplateString.call(testContext.dropdown); + }); + + it('should set .templateString to an empty string', () => { + expect(testContext.dropdown.templateString).toBe(''); + }); + }); + }); + + describe('clickEvent', () => { + beforeEach(() => { + testContext.classList = { + contains: jest.fn(), + }; + testContext.list = { dispatchEvent: () => {} }; + testContext.dropdown = { + hideOnClick: true, + hide: () => {}, + list: testContext.list, + addSelectedClass: () => {}, + }; + testContext.event = { + preventDefault: () => {}, + target: { + classList: testContext.classList, + closest: () => null, + }, + }; + + testContext.dummyListItem = document.createElement('li'); + jest.spyOn(testContext.event.target, 'closest').mockImplementation(selector => { + if (selector === 'li') { + return testContext.dummyListItem; + } + + return null; + }); + + jest.spyOn(testContext.dropdown, 'hide').mockImplementation(() => {}); + jest.spyOn(testContext.dropdown, 'addSelectedClass').mockImplementation(() => {}); + jest.spyOn(testContext.list, 'dispatchEvent').mockImplementation(() => {}); + jest.spyOn(testContext.event, 'preventDefault').mockImplementation(() => {}); + window.CustomEvent = jest.fn(); + testContext.classList.contains.mockReturnValue(false); + }); + + describe('normal click event', () => { + beforeEach(() => { + DropDown.prototype.clickEvent.call(testContext.dropdown, testContext.event); + }); + it('should call event.target.closest', () => { + expect(testContext.event.target.closest).toHaveBeenCalledWith('.droplab-item-ignore'); + expect(testContext.event.target.closest).toHaveBeenCalledWith('li'); + }); + + it('should call addSelectedClass', () => { + expect(testContext.dropdown.addSelectedClass).toHaveBeenCalledWith( + testContext.dummyListItem, + ); + }); + + it('should call .preventDefault', () => { + expect(testContext.event.preventDefault).toHaveBeenCalled(); + }); + + it('should call .hide', () => { + expect(testContext.dropdown.hide).toHaveBeenCalled(); + }); + + it('should construct CustomEvent', () => { + expect(window.CustomEvent).toHaveBeenCalledWith('click.dl', expect.any(Object)); + }); + + it('should call .dispatchEvent with the customEvent', () => { + expect(testContext.list.dispatchEvent).toHaveBeenCalledWith({}); + }); + }); + + describe('if the target is a UL element', () => { + beforeEach(() => { + testContext.event.target = document.createElement('ul'); + + jest.spyOn(testContext.event.target, 'closest').mockImplementation(() => {}); + }); + + it('should return immediately', () => { + DropDown.prototype.clickEvent.call(testContext.dropdown, testContext.event); + + expect(testContext.event.target.closest).not.toHaveBeenCalled(); + expect(testContext.dropdown.addSelectedClass).not.toHaveBeenCalled(); + }); + }); + + describe('if the target has the droplab-item-ignore class', () => { + beforeEach(() => { + testContext.ignoredButton = document.createElement('button'); + testContext.ignoredButton.classList.add('droplab-item-ignore'); + testContext.event.target = testContext.ignoredButton; + + jest.spyOn(testContext.ignoredButton, 'closest'); + }); + + it('does not select element', () => { + DropDown.prototype.clickEvent.call(testContext.dropdown, testContext.event); + + expect(testContext.ignoredButton.closest.mock.calls.length).toBe(1); + expect(testContext.ignoredButton.closest).toHaveBeenCalledWith('.droplab-item-ignore'); + expect(testContext.dropdown.addSelectedClass).not.toHaveBeenCalled(); + }); + }); + + describe('if no selected element exists', () => { + beforeEach(() => { + testContext.event.preventDefault.mockReset(); + testContext.dummyListItem = null; + }); + + it('should return before .preventDefault is called', () => { + DropDown.prototype.clickEvent.call(testContext.dropdown, testContext.event); + + expect(testContext.event.preventDefault).not.toHaveBeenCalled(); + expect(testContext.dropdown.addSelectedClass).not.toHaveBeenCalled(); + }); + }); + + describe('if hideOnClick is false', () => { + beforeEach(() => { + testContext.dropdown.hideOnClick = false; + testContext.dropdown.hide.mockReset(); + }); + + it('should not call .hide', () => { + DropDown.prototype.clickEvent.call(testContext.dropdown, testContext.event); + + expect(testContext.dropdown.hide).not.toHaveBeenCalled(); + }); + }); + }); + + describe('addSelectedClass', () => { + beforeEach(() => { + testContext.items = Array(4).forEach((item, i) => { + testContext.items[i] = { classList: { add: () => {} } }; + jest.spyOn(testContext.items[i].classList, 'add').mockImplementation(() => {}); + }); + testContext.selected = { classList: { add: () => {} } }; + testContext.dropdown = { removeSelectedClasses: () => {} }; + + jest.spyOn(testContext.dropdown, 'removeSelectedClasses').mockImplementation(() => {}); + jest.spyOn(testContext.selected.classList, 'add').mockImplementation(() => {}); + + DropDown.prototype.addSelectedClass.call(testContext.dropdown, testContext.selected); + }); + + it('should call .removeSelectedClasses', () => { + expect(testContext.dropdown.removeSelectedClasses).toHaveBeenCalled(); + }); + + it('should call .classList.add', () => { + expect(testContext.selected.classList.add).toHaveBeenCalledWith(SELECTED_CLASS); + }); + }); + + describe('removeSelectedClasses', () => { + beforeEach(() => { + testContext.items = [...Array(4)]; + testContext.items.forEach((item, i) => { + testContext.items[i] = { classList: { add: jest.fn(), remove: jest.fn() } }; + }); + testContext.dropdown = { items: testContext.items }; + + DropDown.prototype.removeSelectedClasses.call(testContext.dropdown); + }); + + it('should call .classList.remove for all items', () => { + testContext.items.forEach((_, i) => { + expect(testContext.items[i].classList.remove).toHaveBeenCalledWith(SELECTED_CLASS); + }); + }); + + describe('if .items is not set', () => { + beforeEach(() => { + testContext.dropdown = { getItems: () => {} }; + + jest.spyOn(testContext.dropdown, 'getItems').mockReturnValue([]); + + DropDown.prototype.removeSelectedClasses.call(testContext.dropdown); + }); + + it('should call .getItems', () => { + expect(testContext.dropdown.getItems).toHaveBeenCalled(); + }); + }); + }); + + describe('addEvents', () => { + beforeEach(() => { + testContext.list = { + addEventListener: () => {}, + querySelectorAll: () => [], + }; + testContext.dropdown = { + list: testContext.list, + clickEvent: () => {}, + closeDropdown: () => {}, + eventWrapper: {}, + }; + }); + + it('should call .addEventListener', () => { + jest.spyOn(testContext.list, 'addEventListener').mockImplementation(() => {}); + + DropDown.prototype.addEvents.call(testContext.dropdown); + + expect(testContext.list.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); + expect(testContext.list.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function)); + }); + }); + + describe('setData', () => { + beforeEach(() => { + testContext.dropdown = { render: () => {} }; + testContext.data = ['data']; + + jest.spyOn(testContext.dropdown, 'render').mockImplementation(() => {}); + + DropDown.prototype.setData.call(testContext.dropdown, testContext.data); + }); + + it('should set .data', () => { + expect(testContext.dropdown.data).toBe(testContext.data); + }); + + it('should call .render with the .data', () => { + expect(testContext.dropdown.render).toHaveBeenCalledWith(testContext.data); + }); + }); + + describe('addData', () => { + beforeEach(() => { + testContext.dropdown = { render: () => {}, data: ['data1'] }; + testContext.data = ['data2']; + + jest.spyOn(testContext.dropdown, 'render').mockImplementation(() => {}); + jest.spyOn(Array.prototype, 'concat'); + + DropDown.prototype.addData.call(testContext.dropdown, testContext.data); + }); + + it('should call .concat with data', () => { + expect(Array.prototype.concat).toHaveBeenCalledWith(testContext.data); + }); + + it('should set .data with concatination', () => { + expect(testContext.dropdown.data).toStrictEqual(['data1', 'data2']); + }); + + it('should call .render with the .data', () => { + expect(testContext.dropdown.render).toHaveBeenCalledWith(['data1', 'data2']); + }); + + describe('if .data is undefined', () => { + beforeEach(() => { + testContext.dropdown = { render: () => {}, data: undefined }; + testContext.data = ['data2']; + + jest.spyOn(testContext.dropdown, 'render').mockImplementation(() => {}); + + DropDown.prototype.addData.call(testContext.dropdown, testContext.data); + }); + + it('should set .data with concatination', () => { + expect(testContext.dropdown.data).toStrictEqual(['data2']); + }); + }); + }); + + describe('render', () => { + beforeEach(() => { + testContext.renderableList = {}; + testContext.list = { + querySelector: q => { + if (q === '.filter-dropdown-loading') { + return false; + } + return testContext.renderableList; + }, + dispatchEvent: () => {}, + }; + testContext.dropdown = { renderChildren: () => {}, list: testContext.list }; + testContext.data = [0, 1]; + testContext.customEvent = {}; + + jest.spyOn(testContext.dropdown, 'renderChildren').mockImplementation(data => data); + jest.spyOn(testContext.list, 'dispatchEvent').mockImplementation(() => {}); + jest.spyOn(testContext.data, 'map'); + jest.spyOn(window, 'CustomEvent').mockReturnValue(testContext.customEvent); + + DropDown.prototype.render.call(testContext.dropdown, testContext.data); + }); + + it('should call .map', () => { + expect(testContext.data.map).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should call .renderChildren for each data item', () => { + expect(testContext.dropdown.renderChildren.mock.calls.length).toBe(testContext.data.length); + }); + + it('sets the renderableList .innerHTML', () => { + expect(testContext.renderableList.innerHTML).toBe('01'); + }); + + it('should call render.dl', () => { + expect(window.CustomEvent).toHaveBeenCalledWith('render.dl', expect.any(Object)); + }); + + it('should call dispatchEvent with the customEvent', () => { + expect(testContext.list.dispatchEvent).toHaveBeenCalledWith(testContext.customEvent); + }); + + describe('if no data argument is passed', () => { + beforeEach(() => { + testContext.data.map.mockReset(); + testContext.dropdown.renderChildren.mockReset(); + + DropDown.prototype.render.call(testContext.dropdown, undefined); + }); + + it('should not call .map', () => { + expect(testContext.data.map).not.toHaveBeenCalled(); + }); + + it('should not call .renderChildren', () => { + expect(testContext.dropdown.renderChildren).not.toHaveBeenCalled(); + }); + }); + + describe('if no dynamic list is present', () => { + beforeEach(() => { + testContext.list = { querySelector: () => {}, dispatchEvent: () => {} }; + testContext.dropdown = { renderChildren: () => {}, list: testContext.list }; + testContext.data = [0, 1]; + + jest.spyOn(testContext.dropdown, 'renderChildren').mockImplementation(data => data); + jest.spyOn(testContext.list, 'querySelector').mockImplementation(() => {}); + jest.spyOn(testContext.data, 'map'); + + DropDown.prototype.render.call(testContext.dropdown, testContext.data); + }); + + it('sets the .list .innerHTML', () => { + expect(testContext.list.innerHTML).toBe('01'); + }); + }); + }); + + describe('renderChildren', () => { + beforeEach(() => { + testContext.templateString = 'templateString'; + testContext.dropdown = { templateString: testContext.templateString }; + testContext.data = { droplab_hidden: true }; + testContext.html = 'html'; + testContext.template = { firstChild: { outerHTML: 'outerHTML', style: {} } }; + + jest.spyOn(utils, 'template').mockReturnValue(testContext.html); + jest.spyOn(document, 'createElement').mockReturnValue(testContext.template); + jest.spyOn(DropDown, 'setImagesSrc').mockImplementation(() => {}); + + testContext.renderChildren = DropDown.prototype.renderChildren.call( + testContext.dropdown, + testContext.data, + ); + }); + + it('should call utils.t with .templateString and data', () => { + expect(utils.template).toHaveBeenCalledWith(testContext.templateString, testContext.data); + }); + + it('should call document.createElement', () => { + expect(document.createElement).toHaveBeenCalledWith('div'); + }); + + it('should set the templates .innerHTML to the HTML', () => { + expect(testContext.template.innerHTML).toBe(testContext.html); + }); + + it('should call .setImagesSrc with the template', () => { + expect(DropDown.setImagesSrc).toHaveBeenCalledWith(testContext.template); + }); + + it('should set the template display to none', () => { + expect(testContext.template.firstChild.style.display).toBe('none'); + }); + + it('should return the templates .firstChild.outerHTML', () => { + expect(testContext.renderChildren).toBe(testContext.template.firstChild.outerHTML); + }); + + describe('if droplab_hidden is false', () => { + beforeEach(() => { + testContext.data = { droplab_hidden: false }; + testContext.renderChildren = DropDown.prototype.renderChildren.call( + testContext.dropdown, + testContext.data, + ); + }); + + it('should set the template display to block', () => { + expect(testContext.template.firstChild.style.display).toBe('block'); + }); + }); + }); + + describe('setImagesSrc', () => { + beforeEach(() => { + testContext.template = { querySelectorAll: () => {} }; + + jest.spyOn(testContext.template, 'querySelectorAll').mockReturnValue([]); + + DropDown.setImagesSrc(testContext.template); + }); + + it('should call .querySelectorAll', () => { + expect(testContext.template.querySelectorAll).toHaveBeenCalledWith('img[data-src]'); + }); + }); + + describe('show', () => { + beforeEach(() => { + testContext.list = { style: {} }; + testContext.dropdown = { list: testContext.list, hidden: true }; + + DropDown.prototype.show.call(testContext.dropdown); + }); + + it('it should set .list display to block', () => { + expect(testContext.list.style.display).toBe('block'); + }); + + it('it should set .hidden to false', () => { + expect(testContext.dropdown.hidden).toBe(false); + }); + + describe('if .hidden is false', () => { + beforeEach(() => { + testContext.list = { style: {} }; + testContext.dropdown = { list: testContext.list, hidden: false }; + + testContext.show = DropDown.prototype.show.call(testContext.dropdown); + }); + + it('should return undefined', () => { + expect(testContext.show).toBeUndefined(); + }); + + it('should not set .list display to block', () => { + expect(testContext.list.style.display).not.toBe('block'); + }); + }); + }); + + describe('hide', () => { + beforeEach(() => { + testContext.list = { style: {} }; + testContext.dropdown = { list: testContext.list }; + + DropDown.prototype.hide.call(testContext.dropdown); + }); + + it('it should set .list display to none', () => { + expect(testContext.list.style.display).toBe('none'); + }); + + it('it should set .hidden to true', () => { + expect(testContext.dropdown.hidden).toBe(true); + }); + }); + + describe('toggle', () => { + beforeEach(() => { + testContext.hidden = true; + testContext.dropdown = { hidden: testContext.hidden, show: () => {}, hide: () => {} }; + + jest.spyOn(testContext.dropdown, 'show').mockImplementation(() => {}); + jest.spyOn(testContext.dropdown, 'hide').mockImplementation(() => {}); + + DropDown.prototype.toggle.call(testContext.dropdown); + }); + + it('should call .show', () => { + expect(testContext.dropdown.show).toHaveBeenCalled(); + }); + + describe('if .hidden is false', () => { + beforeEach(() => { + testContext.hidden = false; + testContext.dropdown = { hidden: testContext.hidden, show: () => {}, hide: () => {} }; + + jest.spyOn(testContext.dropdown, 'show').mockImplementation(() => {}); + jest.spyOn(testContext.dropdown, 'hide').mockImplementation(() => {}); + + DropDown.prototype.toggle.call(testContext.dropdown); + }); + + it('should call .hide', () => { + expect(testContext.dropdown.hide).toHaveBeenCalled(); + }); + }); + }); + + describe('destroy', () => { + beforeEach(() => { + testContext.list = { removeEventListener: () => {} }; + testContext.eventWrapper = { clickEvent: 'clickEvent' }; + testContext.dropdown = { + list: testContext.list, + hide: () => {}, + eventWrapper: testContext.eventWrapper, + }; + + jest.spyOn(testContext.list, 'removeEventListener').mockImplementation(() => {}); + jest.spyOn(testContext.dropdown, 'hide').mockImplementation(() => {}); + + DropDown.prototype.destroy.call(testContext.dropdown); + }); + + it('it should call .hide', () => { + expect(testContext.dropdown.hide).toHaveBeenCalled(); + }); + + it('it should call .removeEventListener', () => { + expect(testContext.list.removeEventListener).toHaveBeenCalledWith( + 'click', + testContext.eventWrapper.clickEvent, + ); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/dag/mock_data.js b/spec/frontend/pipelines/components/dag/mock_data.js new file mode 100644 index 00000000000..723cdd3f525 --- /dev/null +++ b/spec/frontend/pipelines/components/dag/mock_data.js @@ -0,0 +1,44 @@ +/* + It is important that the simple base include parallel jobs + as well as non-parallel jobs with spaces in the name to prevent + us relying on spaces as an indicator. +*/ +export default { + stages: [ + { + name: 'test', + groups: [ + { + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2', needs: ['frontend fixtures'] }, { name: 'jest 2/2' }], + }, + { + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], + }, + ], + }, + { + name: 'fixtures', + groups: [ + { + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + ], + }, + { + name: 'un-needed', + groups: [ + { + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, + ], + }, + ], +}; diff --git a/spec/frontend/pipelines/components/dag/utils_spec.js b/spec/frontend/pipelines/components/dag/utils_spec.js new file mode 100644 index 00000000000..41bb91b4800 --- /dev/null +++ b/spec/frontend/pipelines/components/dag/utils_spec.js @@ -0,0 +1,171 @@ +import { + createNodesStructure, + makeLinksFromNodes, + filterByAncestors, + parseData, + createSankey, + removeOrphanNodes, + getMaxNodes, +} from '~/pipelines/components/dag/utils'; + +import mockGraphData from './mock_data'; + +describe('DAG visualization parsing utilities', () => { + const { nodes, nodeDict } = createNodesStructure(mockGraphData.stages); + const unfilteredLinks = makeLinksFromNodes(nodes, nodeDict); + const parsed = parseData(mockGraphData.stages); + + const layoutSettings = { + width: 200, + height: 200, + nodeWidth: 10, + nodePadding: 20, + paddingForLabels: 100, + }; + + const sankeyLayout = createSankey(layoutSettings)(parsed); + + describe('createNodesStructure', () => { + const parallelGroupName = 'jest'; + const parallelJobName = 'jest 1/2'; + const singleJobName = 'frontend fixtures'; + + const { name, jobs, size } = mockGraphData.stages[0].groups[0]; + + it('returns the expected node structure', () => { + expect(nodes[0]).toHaveProperty('category', mockGraphData.stages[0].name); + expect(nodes[0]).toHaveProperty('name', name); + expect(nodes[0]).toHaveProperty('jobs', jobs); + expect(nodes[0]).toHaveProperty('size', size); + }); + + it('adds needs to top level of nodeDict entries', () => { + expect(nodeDict[parallelGroupName]).toHaveProperty('needs'); + expect(nodeDict[parallelJobName]).toHaveProperty('needs'); + expect(nodeDict[singleJobName]).toHaveProperty('needs'); + }); + + it('makes entries in nodeDict for jobs and parallel jobs', () => { + const nodeNames = Object.keys(nodeDict); + + expect(nodeNames.includes(parallelGroupName)).toBe(true); + expect(nodeNames.includes(parallelJobName)).toBe(true); + expect(nodeNames.includes(singleJobName)).toBe(true); + }); + }); + + describe('makeLinksFromNodes', () => { + it('returns the expected link structure', () => { + expect(unfilteredLinks[0]).toHaveProperty('source', 'frontend fixtures'); + expect(unfilteredLinks[0]).toHaveProperty('target', 'jest'); + expect(unfilteredLinks[0]).toHaveProperty('value', 10); + }); + }); + + describe('filterByAncestors', () => { + const allLinks = [ + { source: 'job1', target: 'job4' }, + { source: 'job1', target: 'job2' }, + { source: 'job2', target: 'job4' }, + ]; + + const dedupedLinks = [{ source: 'job1', target: 'job2' }, { source: 'job2', target: 'job4' }]; + + const nodeLookup = { + job1: { + name: 'job1', + }, + job2: { + name: 'job2', + needs: ['job1'], + }, + job4: { + name: 'job4', + needs: ['job1', 'job2'], + category: 'build', + }, + }; + + it('dedupes links', () => { + expect(filterByAncestors(allLinks, nodeLookup)).toMatchObject(dedupedLinks); + }); + }); + + describe('parseData parent function', () => { + it('returns an object containing a list of nodes and links', () => { + // an array of nodes exist and the values are defined + expect(parsed).toHaveProperty('nodes'); + expect(Array.isArray(parsed.nodes)).toBe(true); + expect(parsed.nodes.filter(Boolean)).not.toHaveLength(0); + + // an array of links exist and the values are defined + expect(parsed).toHaveProperty('links'); + expect(Array.isArray(parsed.links)).toBe(true); + expect(parsed.links.filter(Boolean)).not.toHaveLength(0); + }); + }); + + describe('createSankey', () => { + it('returns a nodes data structure with expected d3-added properties', () => { + expect(sankeyLayout.nodes[0]).toHaveProperty('sourceLinks'); + expect(sankeyLayout.nodes[0]).toHaveProperty('targetLinks'); + expect(sankeyLayout.nodes[0]).toHaveProperty('depth'); + expect(sankeyLayout.nodes[0]).toHaveProperty('layer'); + expect(sankeyLayout.nodes[0]).toHaveProperty('x0'); + expect(sankeyLayout.nodes[0]).toHaveProperty('x1'); + expect(sankeyLayout.nodes[0]).toHaveProperty('y0'); + expect(sankeyLayout.nodes[0]).toHaveProperty('y1'); + }); + + it('returns a links data structure with expected d3-added properties', () => { + expect(sankeyLayout.links[0]).toHaveProperty('source'); + expect(sankeyLayout.links[0]).toHaveProperty('target'); + expect(sankeyLayout.links[0]).toHaveProperty('width'); + expect(sankeyLayout.links[0]).toHaveProperty('y0'); + expect(sankeyLayout.links[0]).toHaveProperty('y1'); + }); + + describe('data structure integrity', () => { + const newObject = { name: 'bad-actor' }; + + beforeEach(() => { + sankeyLayout.nodes.unshift(newObject); + }); + + it('sankey does not propagate changes back to the original', () => { + expect(sankeyLayout.nodes[0]).toBe(newObject); + expect(parsed.nodes[0]).not.toBe(newObject); + }); + + afterEach(() => { + sankeyLayout.nodes.shift(); + }); + }); + }); + + describe('removeOrphanNodes', () => { + it('removes sankey nodes that have no needs and are not needed', () => { + const cleanedNodes = removeOrphanNodes(sankeyLayout.nodes); + expect(cleanedNodes).toHaveLength(sankeyLayout.nodes.length - 1); + }); + }); + + describe('getMaxNodes', () => { + it('returns the number of nodes in the most populous generation', () => { + const layerNodes = [ + { layer: 0 }, + { layer: 0 }, + { layer: 1 }, + { layer: 1 }, + { layer: 0 }, + { layer: 3 }, + { layer: 2 }, + { layer: 4 }, + { layer: 1 }, + { layer: 3 }, + { layer: 4 }, + ]; + expect(getMaxNodes(layerNodes)).toBe(3); + }); + }); +}); diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js deleted file mode 100644 index 22346c10547..00000000000 --- a/spec/javascripts/droplab/drop_down_spec.js +++ /dev/null @@ -1,650 +0,0 @@ -import DropDown from '~/droplab/drop_down'; -import utils from '~/droplab/utils'; -import { SELECTED_CLASS } from '~/droplab/constants'; - -describe('DropLab DropDown', function() { - describe('class constructor', function() { - beforeEach(function() { - spyOn(DropDown.prototype, 'getItems'); - spyOn(DropDown.prototype, 'initTemplateString'); - spyOn(DropDown.prototype, 'addEvents'); - - this.list = { innerHTML: 'innerHTML' }; - this.dropdown = new DropDown(this.list); - }); - - it('sets the .hidden property to true', function() { - expect(this.dropdown.hidden).toBe(true); - }); - - it('sets the .list property', function() { - expect(this.dropdown.list).toBe(this.list); - }); - - it('calls .getItems', function() { - expect(DropDown.prototype.getItems).toHaveBeenCalled(); - }); - - it('calls .initTemplateString', function() { - expect(DropDown.prototype.initTemplateString).toHaveBeenCalled(); - }); - - it('calls .addEvents', function() { - expect(DropDown.prototype.addEvents).toHaveBeenCalled(); - }); - - it('sets the .initialState property to the .list.innerHTML', function() { - expect(this.dropdown.initialState).toBe(this.list.innerHTML); - }); - - describe('if the list argument is a string', function() { - beforeEach(function() { - this.element = {}; - this.selector = '.selector'; - - spyOn(Document.prototype, 'querySelector').and.returnValue(this.element); - - this.dropdown = new DropDown(this.selector); - }); - - it('calls .querySelector with the selector string', function() { - expect(Document.prototype.querySelector).toHaveBeenCalledWith(this.selector); - }); - - it('sets the .list property element', function() { - expect(this.dropdown.list).toBe(this.element); - }); - }); - }); - - describe('getItems', function() { - beforeEach(function() { - this.list = { querySelectorAll: () => {} }; - this.dropdown = { list: this.list }; - this.nodeList = []; - - spyOn(this.list, 'querySelectorAll').and.returnValue(this.nodeList); - - this.getItems = DropDown.prototype.getItems.call(this.dropdown); - }); - - it('calls .querySelectorAll with a list item query', function() { - expect(this.list.querySelectorAll).toHaveBeenCalledWith('li'); - }); - - it('sets the .items property to the returned list items', function() { - expect(this.dropdown.items).toEqual(jasmine.any(Array)); - }); - - it('returns the .items', function() { - expect(this.getItems).toEqual(jasmine.any(Array)); - }); - }); - - describe('initTemplateString', function() { - beforeEach(function() { - this.items = [{ outerHTML: '' }, { outerHTML: '' }]; - this.dropdown = { items: this.items }; - - DropDown.prototype.initTemplateString.call(this.dropdown); - }); - - it('should set .templateString to the last items .outerHTML', function() { - expect(this.dropdown.templateString).toBe(this.items[1].outerHTML); - }); - - it('should not set .templateString to a non-last items .outerHTML', function() { - expect(this.dropdown.templateString).not.toBe(this.items[0].outerHTML); - }); - - describe('if .items is not set', function() { - beforeEach(function() { - this.dropdown = { getItems: () => {} }; - - spyOn(this.dropdown, 'getItems').and.returnValue([]); - - DropDown.prototype.initTemplateString.call(this.dropdown); - }); - - it('should call .getItems', function() { - expect(this.dropdown.getItems).toHaveBeenCalled(); - }); - }); - - describe('if items array is empty', function() { - beforeEach(function() { - this.dropdown = { items: [] }; - - DropDown.prototype.initTemplateString.call(this.dropdown); - }); - - it('should set .templateString to an empty string', function() { - expect(this.dropdown.templateString).toBe(''); - }); - }); - }); - - describe('clickEvent', function() { - beforeEach(function() { - this.classList = jasmine.createSpyObj('classList', ['contains']); - this.list = { dispatchEvent: () => {} }; - this.dropdown = { - hideOnClick: true, - hide: () => {}, - list: this.list, - addSelectedClass: () => {}, - }; - this.event = { - preventDefault: () => {}, - target: { - classList: this.classList, - closest: () => null, - }, - }; - this.customEvent = {}; - this.dummyListItem = document.createElement('li'); - spyOn(this.event.target, 'closest').and.callFake(selector => { - if (selector === 'li') { - return this.dummyListItem; - } - - return null; - }); - - spyOn(this.dropdown, 'hide'); - spyOn(this.dropdown, 'addSelectedClass'); - spyOn(this.list, 'dispatchEvent'); - spyOn(this.event, 'preventDefault'); - spyOn(window, 'CustomEvent').and.returnValue(this.customEvent); - this.classList.contains.and.returnValue(false); - }); - - it('should call event.target.closest', function() { - DropDown.prototype.clickEvent.call(this.dropdown, this.event); - - expect(this.event.target.closest).toHaveBeenCalledWith('.droplab-item-ignore'); - expect(this.event.target.closest).toHaveBeenCalledWith('li'); - }); - - it('should call addSelectedClass', function() { - DropDown.prototype.clickEvent.call(this.dropdown, this.event); - - expect(this.dropdown.addSelectedClass).toHaveBeenCalledWith(this.dummyListItem); - }); - - it('should call .preventDefault', function() { - DropDown.prototype.clickEvent.call(this.dropdown, this.event); - - expect(this.event.preventDefault).toHaveBeenCalled(); - }); - - it('should call .hide', function() { - DropDown.prototype.clickEvent.call(this.dropdown, this.event); - - expect(this.dropdown.hide).toHaveBeenCalled(); - }); - - it('should construct CustomEvent', function() { - DropDown.prototype.clickEvent.call(this.dropdown, this.event); - - expect(window.CustomEvent).toHaveBeenCalledWith('click.dl', jasmine.any(Object)); - }); - - it('should call .dispatchEvent with the customEvent', function() { - DropDown.prototype.clickEvent.call(this.dropdown, this.event); - - expect(this.list.dispatchEvent).toHaveBeenCalledWith(this.customEvent); - }); - - describe('if the target is a UL element', function() { - beforeEach(function() { - this.event.target = document.createElement('ul'); - - spyOn(this.event.target, 'closest'); - }); - - it('should return immediately', function() { - DropDown.prototype.clickEvent.call(this.dropdown, this.event); - - expect(this.event.target.closest).not.toHaveBeenCalled(); - expect(this.dropdown.addSelectedClass).not.toHaveBeenCalled(); - }); - }); - - describe('if the target has the droplab-item-ignore class', function() { - beforeEach(function() { - this.ignoredButton = document.createElement('button'); - this.ignoredButton.classList.add('droplab-item-ignore'); - this.event.target = this.ignoredButton; - - spyOn(this.ignoredButton, 'closest').and.callThrough(); - }); - - it('does not select element', function() { - DropDown.prototype.clickEvent.call(this.dropdown, this.event); - - expect(this.ignoredButton.closest.calls.count()).toBe(1); - expect(this.ignoredButton.closest).toHaveBeenCalledWith('.droplab-item-ignore'); - expect(this.dropdown.addSelectedClass).not.toHaveBeenCalled(); - }); - }); - - describe('if no selected element exists', function() { - beforeEach(function() { - this.event.preventDefault.calls.reset(); - this.dummyListItem = null; - }); - - it('should return before .preventDefault is called', function() { - DropDown.prototype.clickEvent.call(this.dropdown, this.event); - - expect(this.event.preventDefault).not.toHaveBeenCalled(); - expect(this.dropdown.addSelectedClass).not.toHaveBeenCalled(); - }); - }); - - describe('if hideOnClick is false', () => { - beforeEach(function() { - this.dropdown.hideOnClick = false; - this.dropdown.hide.calls.reset(); - }); - - it('should not call .hide', function() { - DropDown.prototype.clickEvent.call(this.dropdown, this.event); - - expect(this.dropdown.hide).not.toHaveBeenCalled(); - }); - }); - }); - - describe('addSelectedClass', function() { - beforeEach(function() { - this.items = Array(4).forEach((item, i) => { - this.items[i] = { classList: { add: () => {} } }; - spyOn(this.items[i].classList, 'add'); - }); - this.selected = { classList: { add: () => {} } }; - this.dropdown = { removeSelectedClasses: () => {} }; - - spyOn(this.dropdown, 'removeSelectedClasses'); - spyOn(this.selected.classList, 'add'); - - DropDown.prototype.addSelectedClass.call(this.dropdown, this.selected); - }); - - it('should call .removeSelectedClasses', function() { - expect(this.dropdown.removeSelectedClasses).toHaveBeenCalled(); - }); - - it('should call .classList.add', function() { - expect(this.selected.classList.add).toHaveBeenCalledWith(SELECTED_CLASS); - }); - }); - - describe('removeSelectedClasses', function() { - beforeEach(function() { - this.items = Array(4); - this.items.forEach((item, i) => { - this.items[i] = { classList: { add: () => {} } }; - spyOn(this.items[i].classList, 'add'); - }); - this.dropdown = { items: this.items }; - - DropDown.prototype.removeSelectedClasses.call(this.dropdown); - }); - - it('should call .classList.remove for all items', function() { - this.items.forEach((item, i) => { - expect(this.items[i].classList.add).toHaveBeenCalledWith(SELECTED_CLASS); - }); - }); - - describe('if .items is not set', function() { - beforeEach(function() { - this.dropdown = { getItems: () => {} }; - - spyOn(this.dropdown, 'getItems').and.returnValue([]); - - DropDown.prototype.removeSelectedClasses.call(this.dropdown); - }); - - it('should call .getItems', function() { - expect(this.dropdown.getItems).toHaveBeenCalled(); - }); - }); - }); - - describe('addEvents', function() { - beforeEach(function() { - this.list = { - addEventListener: () => {}, - querySelectorAll: () => [], - }; - this.dropdown = { - list: this.list, - clickEvent: () => {}, - closeDropdown: () => {}, - eventWrapper: {}, - }; - }); - - it('should call .addEventListener', function() { - spyOn(this.list, 'addEventListener'); - - DropDown.prototype.addEvents.call(this.dropdown); - - expect(this.list.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function)); - expect(this.list.addEventListener).toHaveBeenCalledWith('keyup', jasmine.any(Function)); - }); - }); - - describe('setData', function() { - beforeEach(function() { - this.dropdown = { render: () => {} }; - this.data = ['data']; - - spyOn(this.dropdown, 'render'); - - DropDown.prototype.setData.call(this.dropdown, this.data); - }); - - it('should set .data', function() { - expect(this.dropdown.data).toBe(this.data); - }); - - it('should call .render with the .data', function() { - expect(this.dropdown.render).toHaveBeenCalledWith(this.data); - }); - }); - - describe('addData', function() { - beforeEach(function() { - this.dropdown = { render: () => {}, data: ['data1'] }; - this.data = ['data2']; - - spyOn(this.dropdown, 'render'); - spyOn(Array.prototype, 'concat').and.callThrough(); - - DropDown.prototype.addData.call(this.dropdown, this.data); - }); - - it('should call .concat with data', function() { - expect(Array.prototype.concat).toHaveBeenCalledWith(this.data); - }); - - it('should set .data with concatination', function() { - expect(this.dropdown.data).toEqual(['data1', 'data2']); - }); - - it('should call .render with the .data', function() { - expect(this.dropdown.render).toHaveBeenCalledWith(['data1', 'data2']); - }); - - describe('if .data is undefined', function() { - beforeEach(function() { - this.dropdown = { render: () => {}, data: undefined }; - this.data = ['data2']; - - spyOn(this.dropdown, 'render'); - - DropDown.prototype.addData.call(this.dropdown, this.data); - }); - - it('should set .data with concatination', function() { - expect(this.dropdown.data).toEqual(['data2']); - }); - }); - }); - - describe('render', function() { - beforeEach(function() { - this.renderableList = {}; - this.list = { - querySelector: q => { - if (q === '.filter-dropdown-loading') { - return false; - } - return this.renderableList; - }, - dispatchEvent: () => {}, - }; - this.dropdown = { renderChildren: () => {}, list: this.list }; - this.data = [0, 1]; - this.customEvent = {}; - - spyOn(this.dropdown, 'renderChildren').and.callFake(data => data); - spyOn(this.list, 'dispatchEvent'); - spyOn(this.data, 'map').and.callThrough(); - spyOn(window, 'CustomEvent').and.returnValue(this.customEvent); - - DropDown.prototype.render.call(this.dropdown, this.data); - }); - - it('should call .map', function() { - expect(this.data.map).toHaveBeenCalledWith(jasmine.any(Function)); - }); - - it('should call .renderChildren for each data item', function() { - expect(this.dropdown.renderChildren.calls.count()).toBe(this.data.length); - }); - - it('sets the renderableList .innerHTML', function() { - expect(this.renderableList.innerHTML).toBe('01'); - }); - - it('should call render.dl', function() { - expect(window.CustomEvent).toHaveBeenCalledWith('render.dl', jasmine.any(Object)); - }); - - it('should call dispatchEvent with the customEvent', function() { - expect(this.list.dispatchEvent).toHaveBeenCalledWith(this.customEvent); - }); - - describe('if no data argument is passed', function() { - beforeEach(function() { - this.data.map.calls.reset(); - this.dropdown.renderChildren.calls.reset(); - - DropDown.prototype.render.call(this.dropdown, undefined); - }); - - it('should not call .map', function() { - expect(this.data.map).not.toHaveBeenCalled(); - }); - - it('should not call .renderChildren', function() { - expect(this.dropdown.renderChildren).not.toHaveBeenCalled(); - }); - }); - - describe('if no dynamic list is present', function() { - beforeEach(function() { - this.list = { querySelector: () => {}, dispatchEvent: () => {} }; - this.dropdown = { renderChildren: () => {}, list: this.list }; - this.data = [0, 1]; - - spyOn(this.dropdown, 'renderChildren').and.callFake(data => data); - spyOn(this.list, 'querySelector'); - spyOn(this.data, 'map').and.callThrough(); - - DropDown.prototype.render.call(this.dropdown, this.data); - }); - - it('sets the .list .innerHTML', function() { - expect(this.list.innerHTML).toBe('01'); - }); - }); - }); - - describe('renderChildren', function() { - beforeEach(function() { - this.templateString = 'templateString'; - this.dropdown = { templateString: this.templateString }; - this.data = { droplab_hidden: true }; - this.html = 'html'; - this.template = { firstChild: { outerHTML: 'outerHTML', style: {} } }; - - spyOn(utils, 'template').and.returnValue(this.html); - spyOn(document, 'createElement').and.returnValue(this.template); - spyOn(DropDown, 'setImagesSrc'); - - this.renderChildren = DropDown.prototype.renderChildren.call(this.dropdown, this.data); - }); - - it('should call utils.t with .templateString and data', function() { - expect(utils.template).toHaveBeenCalledWith(this.templateString, this.data); - }); - - it('should call document.createElement', function() { - expect(document.createElement).toHaveBeenCalledWith('div'); - }); - - it('should set the templates .innerHTML to the HTML', function() { - expect(this.template.innerHTML).toBe(this.html); - }); - - it('should call .setImagesSrc with the template', function() { - expect(DropDown.setImagesSrc).toHaveBeenCalledWith(this.template); - }); - - it('should set the template display to none', function() { - expect(this.template.firstChild.style.display).toBe('none'); - }); - - it('should return the templates .firstChild.outerHTML', function() { - expect(this.renderChildren).toBe(this.template.firstChild.outerHTML); - }); - - describe('if droplab_hidden is false', function() { - beforeEach(function() { - this.data = { droplab_hidden: false }; - this.renderChildren = DropDown.prototype.renderChildren.call(this.dropdown, this.data); - }); - - it('should set the template display to block', function() { - expect(this.template.firstChild.style.display).toBe('block'); - }); - }); - }); - - describe('setImagesSrc', function() { - beforeEach(function() { - this.template = { querySelectorAll: () => {} }; - - spyOn(this.template, 'querySelectorAll').and.returnValue([]); - - DropDown.setImagesSrc(this.template); - }); - - it('should call .querySelectorAll', function() { - expect(this.template.querySelectorAll).toHaveBeenCalledWith('img[data-src]'); - }); - }); - - describe('show', function() { - beforeEach(function() { - this.list = { style: {} }; - this.dropdown = { list: this.list, hidden: true }; - - DropDown.prototype.show.call(this.dropdown); - }); - - it('it should set .list display to block', function() { - expect(this.list.style.display).toBe('block'); - }); - - it('it should set .hidden to false', function() { - expect(this.dropdown.hidden).toBe(false); - }); - - describe('if .hidden is false', function() { - beforeEach(function() { - this.list = { style: {} }; - this.dropdown = { list: this.list, hidden: false }; - - this.show = DropDown.prototype.show.call(this.dropdown); - }); - - it('should return undefined', function() { - expect(this.show).toEqual(undefined); - }); - - it('should not set .list display to block', function() { - expect(this.list.style.display).not.toEqual('block'); - }); - }); - }); - - describe('hide', function() { - beforeEach(function() { - this.list = { style: {} }; - this.dropdown = { list: this.list }; - - DropDown.prototype.hide.call(this.dropdown); - }); - - it('it should set .list display to none', function() { - expect(this.list.style.display).toBe('none'); - }); - - it('it should set .hidden to true', function() { - expect(this.dropdown.hidden).toBe(true); - }); - }); - - describe('toggle', function() { - beforeEach(function() { - this.hidden = true; - this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} }; - - spyOn(this.dropdown, 'show'); - spyOn(this.dropdown, 'hide'); - - DropDown.prototype.toggle.call(this.dropdown); - }); - - it('should call .show', function() { - expect(this.dropdown.show).toHaveBeenCalled(); - }); - - describe('if .hidden is false', function() { - beforeEach(function() { - this.hidden = false; - this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} }; - - spyOn(this.dropdown, 'show'); - spyOn(this.dropdown, 'hide'); - - DropDown.prototype.toggle.call(this.dropdown); - }); - - it('should call .hide', function() { - expect(this.dropdown.hide).toHaveBeenCalled(); - }); - }); - }); - - describe('destroy', function() { - beforeEach(function() { - this.list = { removeEventListener: () => {} }; - this.eventWrapper = { clickEvent: 'clickEvent' }; - this.dropdown = { list: this.list, hide: () => {}, eventWrapper: this.eventWrapper }; - - spyOn(this.list, 'removeEventListener'); - spyOn(this.dropdown, 'hide'); - - DropDown.prototype.destroy.call(this.dropdown); - }); - - it('it should call .hide', function() { - expect(this.dropdown.hide).toHaveBeenCalled(); - }); - - it('it should call .removeEventListener', function() { - expect(this.list.removeEventListener).toHaveBeenCalledWith( - 'click', - this.eventWrapper.clickEvent, - ); - }); - }); -}); diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a90d7893c79..cea5ac58f60 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2197,26 +2197,6 @@ describe User do end end - describe '#ultraauth_user?' do - it 'is true if provider is ultraauth' do - user = create(:omniauth_user, provider: 'ultraauth') - - expect(user.ultraauth_user?).to be_truthy - end - - it 'is false with othe provider' do - user = create(:omniauth_user, provider: 'not-ultraauth') - - expect(user.ultraauth_user?).to be_falsey - end - - it 'is false if no extern_uid is provided' do - user = create(:omniauth_user, extern_uid: nil) - - expect(user.ldap_user?).to be_falsey - end - end - describe '#full_website_url' do let(:user) { create(:user) } @@ -3492,12 +3472,6 @@ describe User do expect(user.allow_password_authentication_for_web?).to be_falsey end - - it 'returns false for ultraauth user' do - user = create(:omniauth_user, provider: 'ultraauth') - - expect(user.allow_password_authentication_for_web?).to be_falsey - end end describe '#allow_password_authentication_for_git?' do @@ -3520,12 +3494,6 @@ describe User do expect(user.allow_password_authentication_for_git?).to be_falsey end - - it 'returns false for ultraauth user' do - user = create(:omniauth_user, provider: 'ultraauth') - - expect(user.allow_password_authentication_for_git?).to be_falsey - end end describe '#assigned_open_merge_requests_count' do diff --git a/spec/uploaders/uploader_helper_spec.rb b/spec/uploaders/uploader_helper_spec.rb index 753f32a9570..7bc6caf8224 100644 --- a/spec/uploaders/uploader_helper_spec.rb +++ b/spec/uploaders/uploader_helper_spec.rb @@ -14,7 +14,7 @@ describe UploaderHelper do end describe '#extension_match?' do - it 'returns false if file does not exists' do + it 'returns false if file does not exist' do expect(uploader.file).to be_nil expect(uploader.send(:extension_match?, 'jpg')).to eq false end diff --git a/yarn.lock b/yarn.lock index 3d0708dbdd3..d34ee5c4f55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3355,7 +3355,7 @@ cyclist@~0.2.2: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" integrity sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA= -d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0: +d3-array@1, "d3-array@1 - 2", d3-array@^1.1.1, d3-array@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.1.tgz#d1ca33de2f6ac31efadb8e050a021d7e2396d5dc" integrity sha512-CyINJQ0SOUHojDdFDH4JEM0552vCR1utGyLHegJHyYH0JyCpSeTPxi4OBqHMA2jJZq4NH782LtaJWBImqI/HBw== @@ -3489,6 +3489,14 @@ d3-random@1: resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-1.1.0.tgz#6642e506c6fa3a648595d2b2469788a8d12529d3" integrity sha1-ZkLlBsb6OmSFldKyRpeIqNElKdM= +d3-sankey@^0.12.3: + version "0.12.3" + resolved "https://registry.yarnpkg.com/d3-sankey/-/d3-sankey-0.12.3.tgz#b3c268627bd72e5d80336e8de6acbfec9d15d01d" + integrity sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ== + dependencies: + d3-array "1 - 2" + d3-shape "^1.2.0" + d3-scale-chromatic@1: version "1.3.3" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-1.3.3.tgz#dad4366f0edcb288f490128979c3c793583ed3c0" @@ -3514,10 +3522,10 @@ d3-selection@1, d3-selection@^1.1.0, d3-selection@^1.2.0: resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.3.0.tgz#d53772382d3dc4f7507bfb28bcd2d6aed2a0ad6d" integrity sha512-qgpUOg9tl5CirdqESUAu0t9MU/t3O9klYfGfyKsXEmhyxyzLpzpeh08gaxBUTQw1uXIOkr/30Ut2YRjSSxlmHA== -d3-shape@1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.2.0.tgz#45d01538f064bafd05ea3d6d2cb748fd8c41f777" - integrity sha1-RdAVOPBkuv0F6j1tLLdI/YxB93c= +d3-shape@1, d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== dependencies: d3-path "1"