diff --git a/devenv/dev-dashboards/feature-templating/testdata-test-variable-output.json b/devenv/dev-dashboards/feature-templating/testdata-test-variable-output.json new file mode 100644 index 00000000000..ed8abf12fd0 --- /dev/null +++ b/devenv/dev-dashboards/feature-templating/testdata-test-variable-output.json @@ -0,0 +1,76 @@ +{ + "__inputs": [], + "__elements": [], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "8.4.0-pre" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "mode": "markdown", + "content": "VariableUnderTest: $VariableUnderTest" + }, + "pluginVersion": "8.4.0-pre", + "title": "Panel Title", + "type": "text" + } + ], + "schemaVersion": 35, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Test variable output", + "uid": "kVi2Gex7z", + "version": 2, + "weekStart": "" +} diff --git a/docs/sources/administration/database-encryption-enterprise.md b/docs/sources/administration/database-encryption-enterprise.md deleted file mode 100644 index d0974ab581c..00000000000 --- a/docs/sources/administration/database-encryption-enterprise.md +++ /dev/null @@ -1,22 +0,0 @@ -+++ -title = "Database encryption (Enterprise)" -description = "Grafana Enterprise database encryption" -keywords = ["grafana", "enterprise", "database", "encryption", "documentation"] -aliases = [""] -weight = 440 -+++ - -# Grafana Enterprise database encryption - -If you are using Grafana Enterprise, you can change Grafana’s cryptographic mode of operation from AES-CFB to AES-GCM, and integrate with a key management system (KMS) provider. - -## Changing your encryption mode to AES-GCM - -Grafana encrypts secrets using Advanced Encryption Standard in Cipher -FeedBack mode (AES-CFB). You might prefer to use AES in Galois/Counter -Mode (AES-GCM) instead, to meet your company’s security requirements or -in order to maintain consistency with other services. - -To change your encryption mode, update the `algorithm` value in the -`[security.encryption]` section of your Grafana configuration file. -For details, refer to Enterprise configuration. diff --git a/docs/sources/administration/database-encryption.md b/docs/sources/administration/database-encryption.md index e441aa1bb63..ceb343d512f 100644 --- a/docs/sources/administration/database-encryption.md +++ b/docs/sources/administration/database-encryption.md @@ -1,7 +1,7 @@ +++ title = "Database encryption" description = "Grafana database encryption" -keywords = ["grafana", "database", "encryption", "documentation"] +keywords = ["grafana", "database", "encryption", "envelope encryption", "documentation"] aliases = [""] weight = 450 +++ @@ -12,6 +12,24 @@ Grafana’s database contains secrets, which are used to query data sources, sen Grafana encrypts these secrets before they are written to the database, by using a symmetric-key encryption algorithm called Advanced Encryption Standard (AES), and using a [secret key]({{< relref "../administration/configuration/#secret_key" >}}) that you can change when you configure a new Grafana instance. -You can choose to use [envelope encryption]({{< relref "./envelope-encryption.md" >}}), which complements a [KMS integration]({{< relref "../enterprise/kms-integration/_index.md" >}}) in Grafana Enterprise by adding a layer of indirection to the encryption process. +You can choose to use [envelope encryption](#envelope-encryption), which adds a layer of indirection to the encryption process. -In Grafana Enterprise, you can also choose to [encrypt secrets in AES-GCM mode]({{< relref "../administration/database-encryption-enterprise.md" >}}) instead of AES-CFB. +> **Note:** In Grafana Enterprise, you can also choose to [encrypt secrets in AES-GCM mode]({{< relref "../enterprise/enterprise-encryption/#changing-your-encryption-mode-to-aes-gcm" >}}) instead of AES-CFB. + +# Envelope encryption + +In Grafana, you can choose to use envelope encryption. Instead of +encrypting all secrets with a single key, Grafana uses a set of keys +called data encryption keys (DEKs) to encrypt them. These data +encryption keys are themselves encrypted with a single key encryption +key (KEK). + +To turn on envelope encryption, add the term `envelopeEncryption` to the list of feature toggles in your [Grafana configuration]({{< relref "../administration/configuration/#feature_toggles" >}}). + +> **Note:** Avoid turning off envelope encryption once you have turned it on, and back up your database before turning it on for the first time. If you turn envelope encryption on, create new secrets or update your existing secrets (for example, by creating a new data source or alert notification channel), and then turn envelope encryption off, then those data sources, alert notification channels, and other resources using envelope encryption will stop working and you will experience errors. This is because the secrets encrypted with envelope encryption cannot be decrypted or used by Grafana when envelope encryption is turned off. + +# KMS integration + +With KMS integrations, you can choose to encrypt secrets stored in the Grafana database using a key from a KMS, which is a secure central storage location that is designed to help you to create and manage cryptographic keys and control their use across many services. + +> **Note:** KMS integration is available in Grafana Enterprise. For more information, refer to [Enterprise Encryption]({{< relref "../enterprise/enterprise-encryption/_index.md" >}}) in Grafana Enterprise. diff --git a/docs/sources/administration/envelope-encryption.md b/docs/sources/administration/envelope-encryption.md deleted file mode 100644 index db8887b51bb..00000000000 --- a/docs/sources/administration/envelope-encryption.md +++ /dev/null @@ -1,21 +0,0 @@ -+++ -title = "Envelope encryption" -description = "Envelope encryption" -keywords = ["grafana", "envelope encryption", "documentation"] -aliases = [""] -weight = 430 -+++ - -# Envelope encryption - -In Grafana, you can choose to use envelope encryption. Instead of -encrypting all secrets with a single key, Grafana uses a set of keys -called data encryption keys (DEKs) to encrypt them. These data -encryption keys are themselves encrypted with a single key encryption -key (KEK). - -To turn on envelope encryption, add the term `envelopeEncryption` to the list of feature toggles in your [Grafana configuration]({{< relref "../administration/configuration/#feature_toggles" >}}). - -> **Note:** Avoid turning off envelope encryption once you have turned it on, and back up your database before turning it on for the first time. If you turn envelope encryption on, create new secrets or update your existing secrets (for example, by creating a new data source or alert notification channel), and then turn envelope encryption off, then those data sources, alert notification channels, and other resources using envelope encryption will stop working and you will experience errors. This is because the secrets encrypted with envelope encryption cannot be decrypted or used by Grafana when envelope encryption is turned off. - -Refer to [Database encryption]({{< relref "../administration/database-encryption.md" >}}) to learn more about how Grafana encrypts secrets in the database. diff --git a/docs/sources/enterprise/enterprise-encryption/_index.md b/docs/sources/enterprise/enterprise-encryption/_index.md new file mode 100644 index 00000000000..ca4a52640a3 --- /dev/null +++ b/docs/sources/enterprise/enterprise-encryption/_index.md @@ -0,0 +1,35 @@ ++++ +title = "Enterprise database encryption" +description = "Grafana Enterprise database encryption" +keywords = ["grafana", "enterprise", "database", "encryption", "documentation"] +aliases = [""] +weight = 130 ++++ + +# Grafana Enterprise database encryption + +If you are using Grafana Enterprise, you can integrate with a key management system (KMS) provider, and change Grafana’s cryptographic mode of operation from AES-CFB to AES-GCM. + +## Encrypting your database with a key from a Key Management System (KMS) + +You can choose to encrypt secrets stored in the Grafana database using a key from a KMS, which is a secure central storage location that is designed to help you to create and manage cryptographic keys and control their use across many services. When you integrate with a KMS, Grafana does not directly store your encryption key. Instead, Grafana stores KMS credentials and the identifier of the key, which Grafana uses to encrypt the database. + +Grafana integrates with the following key management systems: + +- [AWS KMS]({{< relref "/using-aws-kms-to-encrypt-database-secrets.md" >}}) +- [Azure Key Vault]({{< relref "/using-azure-key-vault-to-encrypt-database-secrets.md" >}}) +- [Google Cloud KMS]({{< relref "/using-google-cloud-kms-to-encrypt-database-secrets.md" >}}) +- [Hashicorp Key Vault]({{< relref "/using-hashicorp-key-vault-to-encrypt-database-secrets.md" >}}) + +Refer to [Database encryption]({{< relref "../../administration/database-encryption.md" >}}) to learn more about how Grafana encrypts secrets in the database. + +## Changing your encryption mode to AES-GCM + +Grafana encrypts secrets using Advanced Encryption Standard in Cipher +FeedBack mode (AES-CFB). You might prefer to use AES in Galois/Counter +Mode (AES-GCM) instead, to meet your company’s security requirements or +in order to maintain consistency with other services. + +To change your encryption mode, update the `algorithm` value in the +`[security.encryption]` section of your Grafana configuration file. +For details, refer to [Enterprise configuration]({{< relref "../enterprise-configuration.md#securityencryption" >}}). diff --git a/docs/sources/enterprise/kms-integration/using-aws-kms-to-encrypt-database-secrets.md b/docs/sources/enterprise/enterprise-encryption/using-aws-kms-to-encrypt-database-secrets.md similarity index 100% rename from docs/sources/enterprise/kms-integration/using-aws-kms-to-encrypt-database-secrets.md rename to docs/sources/enterprise/enterprise-encryption/using-aws-kms-to-encrypt-database-secrets.md diff --git a/docs/sources/enterprise/kms-integration/using-azure-key-vault-to-encrypt-database-secrets.md b/docs/sources/enterprise/enterprise-encryption/using-azure-key-vault-to-encrypt-database-secrets.md similarity index 100% rename from docs/sources/enterprise/kms-integration/using-azure-key-vault-to-encrypt-database-secrets.md rename to docs/sources/enterprise/enterprise-encryption/using-azure-key-vault-to-encrypt-database-secrets.md diff --git a/docs/sources/enterprise/kms-integration/using-google-cloud-kms-to-encrypt-database-secrets.md b/docs/sources/enterprise/enterprise-encryption/using-google-cloud-kms-to-encrypt-database-secrets.md similarity index 100% rename from docs/sources/enterprise/kms-integration/using-google-cloud-kms-to-encrypt-database-secrets.md rename to docs/sources/enterprise/enterprise-encryption/using-google-cloud-kms-to-encrypt-database-secrets.md diff --git a/docs/sources/enterprise/kms-integration/using-hashicorp-key-vault-to-encrypt-database-secrets.md b/docs/sources/enterprise/enterprise-encryption/using-hashicorp-key-vault-to-encrypt-database-secrets.md similarity index 100% rename from docs/sources/enterprise/kms-integration/using-hashicorp-key-vault-to-encrypt-database-secrets.md rename to docs/sources/enterprise/enterprise-encryption/using-hashicorp-key-vault-to-encrypt-database-secrets.md diff --git a/docs/sources/enterprise/kms-integration/_index.md b/docs/sources/enterprise/kms-integration/_index.md deleted file mode 100644 index 3ee28fa22a8..00000000000 --- a/docs/sources/enterprise/kms-integration/_index.md +++ /dev/null @@ -1,19 +0,0 @@ -+++ -title = "KMS integration" -description = "" -keywords = ["grafana", "kms", "key management system integration"] -weight = 1200 -+++ - -# Key management systems (KMSs) - -You can choose to encrypt secrets stored in the Grafana database using a key from a KMS, which is a secure central storage location that is designed to help you to create and manage cryptographic keys and control their use across many services. When you integrate with a KMS, Grafana does not directly store your encryption key. Instead, Grafana stores KMS credentials and the identifier of the key, which Grafana uses to encrypt the database. - -Grafana integrates with the following key management systems: - -- [AWS KMS]({{< relref "/using-aws-kms-to-encrypt-database-secrets.md" >}}) -- [Azure Key Vault]({{< relref "/using-azure-key-vault-to-encrypt-database-secrets.md" >}}) -- [Google Cloud KMS]({{< relref "/using-google-cloud-kms-to-encrypt-database-secrets.md" >}}) -- [Hashicorp Key Vault]({{< relref "/using-hashicorp-key-vault-to-encrypt-database-secrets.md" >}}) - -Refer to [Database encryption]({{< relref "../../administration/database-encryption.md" >}}) to learn more about how Grafana encrypts secrets in the database. diff --git a/docs/sources/http_api/library_element.md b/docs/sources/http_api/library_element.md index 1d0cb086297..3b500f3837a 100644 --- a/docs/sources/http_api/library_element.md +++ b/docs/sources/http_api/library_element.md @@ -269,8 +269,8 @@ Creates a new library element. JSON Body schema: -- **folderId** – ID of the folder where the library element is stored. -- **name** – Name of the library element. +- **folderId** – Optional, the ID of the folder where the library element is stored. +- **name** – Optional, the name of the library element. - **model** – The JSON model for the library element. - **kind** – Kind of element to create, Use `1` for library panels or `2` for library variables. - **uid** – Optional, the [unique identifier](/http_api/library_element/#identifier-id-vs-unique-identifier-uid). diff --git a/docs/sources/http_api/snapshot.md b/docs/sources/http_api/snapshot.md index 5b88002f713..3db642fc013 100644 --- a/docs/sources/http_api/snapshot.md +++ b/docs/sources/http_api/snapshot.md @@ -71,7 +71,7 @@ JSON Body schema: "deleteUrl":"myurl/api/snapshots-delete/XXXXXXX", "key":"YYYYYYY", "url":"myurl/dashboard/snapshot/YYYYYYY", - "id": 1, + "id": 1 } ``` diff --git a/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md b/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md index 5fdade2cdd2..3563b22e542 100644 --- a/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md +++ b/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md @@ -148,31 +148,44 @@ Reduce takes one or more time series returned from a query or an expression and - **Function -** The reduction function to use - **Input -** The variable (refID (such as `A`)) to resample +- **Mode -** Allows control behavior of reduction function when a series contains non-numerical values (null, NaN, +\-Inf) #### Reduction Functions -> **Note:** In the future we plan to add options to control empty, NaN, and null behavior for reduction functions. - ##### Count Count returns the number of points in each series. ##### Mean -Mean returns the total of all values in each series divided by the number of points in that series. If any values in the series are null or nan, or if the series is empty, NaN is returned. +Mean returns the total of all values in each series divided by the number of points in that series. In `strict` mode if any values in the series are null or nan, or if the series is empty, NaN is returned. ##### Min and Max -Min and Max return the smallest or largest value in the series respectively. If any values in the series are null or nan, or if the series is empty, NaN is returned. +Min and Max return the smallest or largest value in the series respectively. In `strict` mode if any values in the series are null or nan, or if the series is empty, NaN is returned. ##### Sum -Sum returns the total of all values in the series. If series is of zero length, the sum will be 0. If there are any NaN or Null values in the series, NaN is returned. +Sum returns the total of all values in the series. If series is of zero length, the sum will be 0. In `strict` mode if there are any NaN or Null values in the series, NaN is returned. #### Last Last returns the last number in the series. If the series has no values then returns NaN. +#### Reduction Modes + +##### Strict + +In Strict mode the input series is processed as is. If any values in the series are non-numeric (null, NaN or +\-Inf), NaN is returned. + +##### Drop Non-Numeric + +In this mode all non-numeric values (null, NaN or +\-Inf) in the input series are filtered out before executing the reduction function. + +##### Replace Non-Numeric + +In this mode all non-numeric values are replaced by a pre-defined value. + ### Resample Resample changes the time stamps in each time series to have a consistent time interval. The main use case is so you can resample time series that do not share the same timestamps so math can be performed between them. This can be done by resample each of the two series, and then in a Math operation referencing the resampled variables. diff --git a/e2e/dashboards-suite/new-constant-variable.spec.ts b/e2e/dashboards-suite/new-constant-variable.spec.ts new file mode 100644 index 00000000000..a173a5cf6fb --- /dev/null +++ b/e2e/dashboards-suite/new-constant-variable.spec.ts @@ -0,0 +1,31 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; + +describe('Variables - Constant', () => { + it('can add a new text box variable', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + + // Create a new "Constant" variable + e2e.components.CallToActionCard.buttonV2('Add variable').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect().type('Constant{enter}'); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().clear().type('VariableUnderTest').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type('Variable under test').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.constantOptionsQueryInput().type('pesto').blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().eq(0).should('have.text', 'pesto'); + + // Navigate back to the homepage and change the selected variable value + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + e2e.components.RefreshPicker.runButtonV2().click(); + + // Assert it was rendered + e2e().get('.markdown-html').should('include.text', 'VariableUnderTest: pesto'); + + // Assert the variable is not visible in the dashboard nav + e2e.pages.Dashboard.SubMenu.submenuItemLabels('Variable under test').should('not.exist'); + }); +}); diff --git a/e2e/dashboards-suite/new-custom-variable.spec.ts b/e2e/dashboards-suite/new-custom-variable.spec.ts new file mode 100644 index 00000000000..28aaca032b9 --- /dev/null +++ b/e2e/dashboards-suite/new-custom-variable.spec.ts @@ -0,0 +1,60 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; + +function fillInCustomVariable(name: string, label: string, value: string) { + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect().type('Custom{enter}'); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().clear().type(name).blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type(label).blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput().type(value).blur(); +} + +function assertPreviewValues(expectedValues: string[]) { + for (const expected of expectedValues) { + const index = expectedValues.indexOf(expected); + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().eq(index).should('have.text', expected); + } +} + +describe('Variables - Custom', () => { + it('can add a custom template variable', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + + // Create a new "Custom" variable + e2e.components.CallToActionCard.buttonV2('Add variable').click(); + fillInCustomVariable('VariableUnderTest', 'Variable under test', 'one,two,three'); + assertPreviewValues(['one', 'two', 'three']); + + // Navigate back to the homepage and change the selected variable value + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('one').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('two').click(); + + // Assert it was rendered + e2e().get('.markdown-html').should('include.text', 'VariableUnderTest: two'); + }); + + it('can add a custom template variable with labels', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + + // Create a new "Custom" variable + e2e.components.CallToActionCard.buttonV2('Add variable').click(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect().type('Custom{enter}'); + + // Set it's name, label, and content + fillInCustomVariable('VariableUnderTest', 'Variable under test', 'One : 1,Two : 2, Three : 3'); + assertPreviewValues(['One', 'Two', 'Three']); + + // Navigate back to the homepage and change the selected variable value + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('One').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('Two').click(); + + // Assert it was rendered + e2e().get('.markdown-html').should('include.text', 'VariableUnderTest: 2'); + }); +}); diff --git a/e2e/dashboards-suite/new-query-variable.spec.ts b/e2e/dashboards-suite/new-query-variable.spec.ts index 4efd16bd1bf..206e014a15b 100644 --- a/e2e/dashboards-suite/new-query-variable.spec.ts +++ b/e2e/dashboards-suite/new-query-variable.spec.ts @@ -2,7 +2,7 @@ import { e2e } from '@grafana/e2e'; const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; -describe('Variables - Add variable', () => { +describe('Variables - Query - Add variable', () => { it('query variable should be default and default fields should be correct', () => { e2e.flows.login('admin', 'admin'); e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); diff --git a/e2e/dashboards-suite/new-text-box-variable.spec.ts b/e2e/dashboards-suite/new-text-box-variable.spec.ts new file mode 100644 index 00000000000..bb19b03e4bc --- /dev/null +++ b/e2e/dashboards-suite/new-text-box-variable.spec.ts @@ -0,0 +1,28 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; + +describe('Variables - Text box', () => { + it('can add a new text box variable', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=templating` }); + + // Create a new "Custom" variable + e2e.components.CallToActionCard.buttonV2('Add variable').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect().type('Text box{enter}'); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().clear().type('VariableUnderTest').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type('Variable under test').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInput().type('cat-dog').blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().eq(0).should('have.text', 'cat-dog'); + + // Navigate back to the homepage and change the selected variable value + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + e2e().get('#VariableUnderTest').clear().type('dog-cat').blur(); + + // Assert it was rendered + e2e().get('.markdown-html').should('include.text', 'VariableUnderTest: dog-cat'); + }); +}); diff --git a/package.json b/package.json index aa220327e0b..e421573e495 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "postinstall": "husky install" }, "grafana": { - "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v8-3/", + "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v8-4/", "releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/" }, "lint-staged": { diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index bb4e9784e34..36c27d1b845 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -67,6 +67,12 @@ export interface FieldConfig { min?: number | null; max?: number | null; + // Interval indicates the expected regular step between values in the series. + // When an interval exists, consumers can identify "missing" values when the expected value is not present. + // The grafana timeseries visualization will render disconnected values when missing values are found it the time field. + // The interval uses the same units as the values. For time.Time, this is defined in milliseconds. + interval?: number | null; + // Convert input values into a display string mappings?: ValueMapping[]; diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 842d47e33c7..ae1ca74046a 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -135,6 +135,9 @@ export const Pages = { TextBoxVariable: { textBoxOptionsQueryInput: 'Variable editor Form TextBox Query field', }, + CustomVariable: { + customValueInput: 'data-testid custom-variable-input', + }, }, }, }, diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx index 89d2300b033..afc2decad36 100644 --- a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx @@ -32,6 +32,10 @@ export const CollapsableSection: FC = ({ const styles = useStyles2(collapsableSectionStyles); const tooltip = `Click to ${open ? 'collapse' : 'expand'}`; const onClick = (e: React.MouseEvent) => { + if (e.target instanceof HTMLElement && e.target.tagName === 'A') { + return; + } + e.preventDefault(); e.stopPropagation(); diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts new file mode 100644 index 00000000000..c74fb32dbf5 --- /dev/null +++ b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts @@ -0,0 +1,215 @@ +import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data'; +import { applyNullInsertThreshold } from './nullInsertThreshold'; + +function randInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +function genFrame() { + let fieldCount = 10; + let valueCount = 3000; + let step = 1000; + let skipProb = 0.5; + let skipSteps = [1, 5]; // min, max + + let allValues = Array(fieldCount); + + allValues[0] = Array(valueCount); + + for (let i = 0, curStep = Date.now(); i < valueCount; i++) { + curStep = allValues[0][i] = curStep + step * (Math.random() < skipProb ? randInt(skipSteps[0], skipSteps[1]) : 1); + } + + for (let fi = 1; fi < fieldCount; fi++) { + let values = Array(valueCount); + + for (let i = 0; i < valueCount; i++) { + values[i] = Math.random() * 100; + } + + allValues[fi] = values; + } + + return { + length: valueCount, + fields: allValues.map((values, i) => { + return { + name: 'A-' + i, + type: i === 0 ? FieldType.time : FieldType.number, + config: { + interval: i === 0 ? step : null, + }, + values: new ArrayVector(values), + }; + }), + }; +} + +describe('nullInsertThreshold Transformer', () => { + test('should insert nulls at +threshold between adjacent > threshold: 1', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, config: { custom: { insertNulls: 1 } }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 1 } }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold(df); + + expect(result.fields[0].values.toArray()).toStrictEqual([1, 2, 3, 4, 10]); + expect(result.fields[1].values.toArray()).toStrictEqual([4, null, 6, null, 8]); + expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']); + }); + + test('should insert nulls at +threshold between adjacent > threshold: 2', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [5, 7, 11] }, + { name: 'One', type: FieldType.number, config: { custom: { insertNulls: 2 } }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 2 } }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold(df); + + expect(result.fields[0].values.toArray()).toStrictEqual([5, 7, 9, 11]); + expect(result.fields[1].values.toArray()).toStrictEqual([4, 6, null, 8]); + expect(result.fields[2].values.toArray()).toStrictEqual(['a', 'b', null, 'c']); + }); + + test('should insert nulls at +interval between adjacent > interval: 1', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold(df); + + expect(result.fields[0].values.toArray()).toStrictEqual([1, 2, 3, 4, 10]); + expect(result.fields[1].values.toArray()).toStrictEqual([4, null, 6, null, 8]); + expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']); + }); + + // TODO: make this work + test.skip('should insert nulls at +threshold (when defined) instead of +interval', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 2 }, values: [5, 7, 11] }, + { name: 'One', type: FieldType.number, config: { custom: { insertNulls: 1 } }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 1 } }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold(df); + + expect(result.fields[0].values.toArray()).toStrictEqual([5, 6, 7, 8, 11]); + expect(result.fields[1].values.toArray()).toStrictEqual([4, null, 6, null, 8]); + expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']); + }); + + test('should insert nulls at midpoints between adjacent > interval: 2', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 2 }, values: [5, 7, 11] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold(df); + + expect(result.fields[0].values.toArray()).toStrictEqual([5, 7, 9, 11]); + expect(result.fields[1].values.toArray()).toStrictEqual([4, 6, null, 8]); + expect(result.fields[2].values.toArray()).toStrictEqual(['a', 'b', null, 'c']); + }); + + test('should noop on fewer than two values', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1] }, + { name: 'Value', type: FieldType.number, values: [1] }, + ], + }); + + const result = applyNullInsertThreshold(df); + + expect(result).toBe(df); + }); + + test('should noop on invalid threshold', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2, 4] }, + { name: 'Value', type: FieldType.number, config: { custom: { insertNulls: -1 } }, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold(df); + + expect(result).toBe(df); + }); + + test('should noop on invalid interval', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: -1 }, values: [1, 2, 4] }, + { name: 'Value', type: FieldType.number, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold(df); + + expect(result).toBe(df); + }); + + test('should noop when no missing steps', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 2, 3] }, + { name: 'Value', type: FieldType.number, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold(df); + + expect(result).toBe(df); + }); + + test('should noop when refFieldName not found', () => { + const df = new MutableDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 2, 5] }, + { name: 'Value', type: FieldType.number, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold(df, 'Time2'); + + expect(result).toBe(df); + }); + + test('perf stress test should be <= 10ms', () => { + // 10 fields x 3,000 values with 50% skip (output = 10 fields x 6,000 values) + let bigFrameA = genFrame(); + + // eslint-disable-next-line no-console + console.time('insertValues-10x3k'); + applyNullInsertThreshold(bigFrameA); + // eslint-disable-next-line no-console + console.timeEnd('insertValues-10x3k'); + }); +}); diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts new file mode 100644 index 00000000000..4e22d0af2b1 --- /dev/null +++ b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts @@ -0,0 +1,115 @@ +import { ArrayVector, DataFrame, FieldType } from '@grafana/data'; + +type InsertMode = (prev: number, next: number, threshold: number) => number; + +const INSERT_MODES = { + threshold: (prev: number, next: number, threshold: number) => prev + threshold, + midpoint: (prev: number, next: number, threshold: number) => (prev + next) / 2, + // previous time + 1ms to prevent StateTimeline from forward-interpolating prior state + plusone: (prev: number, next: number, threshold: number) => prev + 1, +}; + +export function applyNullInsertThreshold( + frame: DataFrame, + refFieldName?: string | null, + insertMode: InsertMode = INSERT_MODES.threshold +): DataFrame { + if (frame.length < 2) { + return frame; + } + + const refField = frame.fields.find((field) => { + // note: getFieldDisplayName() would require full DF[] + return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time; + }); + + if (refField == null) { + return frame; + } + + const thresholds = frame.fields.map((field) => field.config.custom?.insertNulls ?? refField.config.interval ?? null); + + const uniqueThresholds = new Set(thresholds); + + uniqueThresholds.delete(null as any); + + if (uniqueThresholds.size === 0) { + return frame; + } + + if (uniqueThresholds.size === 1) { + const threshold = uniqueThresholds.values().next().value; + + if (threshold <= 0) { + return frame; + } + + const refValues = refField.values.toArray(); + + const frameValues = frame.fields.map((field) => field.values.toArray()); + + const filledFieldValues = nullInsertThreshold(refValues, frameValues, threshold, insertMode); + + if (filledFieldValues === frameValues) { + return frame; + } + + return { + ...frame, + length: filledFieldValues[0].length, + fields: frame.fields.map((field, i) => ({ + ...field, + values: new ArrayVector(filledFieldValues[i]), + })), + }; + } + + // TODO: unique threshold-per-field (via overrides) is unimplemented + // should be done by processing each (refField + thresholdA-field1 + thresholdA-field2...) + // as a separate nullInsertThreshold() dataset, then re-join into single dataset via join() + return frame; +} + +function nullInsertThreshold(refValues: number[], frameValues: any[][], threshold: number, getInsertValue: InsertMode) { + const len = refValues.length; + let prevValue: number = refValues[0]; + const refValuesNew: number[] = [prevValue]; + + for (let i = 1; i < len; i++) { + const curValue = refValues[i]; + + if (curValue - prevValue > threshold) { + refValuesNew.push(getInsertValue(prevValue, curValue, threshold)); + } + + refValuesNew.push(curValue); + + prevValue = curValue; + } + + const filledLen = refValuesNew.length; + + if (filledLen === len) { + return frameValues; + } + + const filledFieldValues: any[][] = []; + + for (let fieldValues of frameValues) { + let filledValues; + + if (fieldValues !== refValues) { + filledValues = Array(filledLen); + + for (let i = 0, j = 0; i < filledLen; i++) { + filledValues[i] = refValues[j] === refValuesNew[i] ? fieldValues[j++] : null; + } + } else { + filledValues = refValuesNew; + } + + filledFieldValues.push(filledValues); + } + + return filledFieldValues; +} diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts index fe733543086..427408064a0 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.ts @@ -1,6 +1,7 @@ import { XYFieldMatchers } from './types'; import { ArrayVector, DataFrame, FieldConfig, FieldType, outerJoinDataFrames } from '@grafana/data'; import { nullToUndefThreshold } from './nullToUndefThreshold'; +import { applyNullInsertThreshold } from './nullInsertThreshold'; import { AxisPlacement, GraphFieldConfig, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema'; import { FIXED_UNIT } from './GraphNG'; @@ -32,7 +33,7 @@ function applySpanNullsThresholds(frame: DataFrame) { export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers) { let alignedFrame = outerJoinDataFrames({ - frames: frames, + frames: frames.map((frame) => applyNullInsertThreshold(frame)), joinBy: dimFields.x, keep: dimFields.y, keepOriginIndices: true, diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index d9ec460f3b3..9f57db07152 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -1,5 +1,6 @@ import { DataFrame, FieldConfig, FieldSparkline, IndexVector } from '@grafana/data'; import { GraphFieldConfig } from '@grafana/schema'; +import { applyNullInsertThreshold } from '../GraphNG/nullInsertThreshold'; /** @internal * Given a sparkline config returns a DataFrame ready to be turned into Plot data set @@ -11,7 +12,7 @@ export function preparePlotFrame(sparkline: FieldSparkline, config?: FieldConfig ...config, }; - return { + return applyNullInsertThreshold({ refId: 'sparkline', fields: [ sparkline.x ?? IndexVector.newField(length), @@ -21,5 +22,5 @@ export function preparePlotFrame(sparkline: FieldSparkline, config?: FieldConfig }, ], length, - }; + }); } diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index 631a7abe6b2..fef1af41415 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -112,7 +111,7 @@ func (hs *HTTPServer) AdminUpdateUserPermissions(c *models.ReqContext) response. return response.Error(http.StatusBadRequest, "id is invalid", err) } - err = updateUserPermissions(hs.SQLStore, userID, form.IsGrafanaAdmin) + err = hs.SQLStore.UpdateUserPermissions(userID, form.IsGrafanaAdmin) if err != nil { if errors.Is(err, models.ErrLastGrafanaAdmin) { return response.Error(400, models.ErrLastGrafanaAdmin.Error(), nil) @@ -230,10 +229,3 @@ func (hs *HTTPServer) AdminRevokeUserAuthToken(c *models.ReqContext) response.Re } return hs.revokeUserAuthTokenInternal(c, userID, cmd) } - -// updateUserPermissions updates the user's permissions. -// -// Stubbable by tests. -var updateUserPermissions = func(sqlStore *sqlstore.SQLStore, userID int64, isAdmin bool) error { - return sqlStore.UpdateUserPermissions(userID, isAdmin) -} diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index cbe4d2d6536..e79320ee2d0 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,25 +35,18 @@ func TestAdminAPIEndpoint(t *testing.T) { updateCmd := dtos.AdminUpdateUserPermissionsForm{ IsGrafanaAdmin: false, } - + mock := mockstore.SQLStoreMock{ + ExpectedError: models.ErrLastGrafanaAdmin, + } putAdminScenario(t, "When calling PUT on", "/api/admin/users/1/permissions", "/api/admin/users/:id/permissions", role, updateCmd, func(sc *scenarioContext) { - // TODO: Use a fake SQLStore when it's represented by an interface - origUpdateUserPermissions := updateUserPermissions - t.Cleanup(func() { - updateUserPermissions = origUpdateUserPermissions - }) - - updateUserPermissions = func(sqlStore *sqlstore.SQLStore, userID int64, isAdmin bool) error { - return models.ErrLastGrafanaAdmin - } - sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() assert.Equal(t, 400, sc.resp.Code) - }) + }, mock) }) t.Run("When a server admin attempts to logout himself from all devices", func(t *testing.T) { + mock := mockstore.NewSQLStoreMock() adminLogoutUserScenario(t, "Should not be allowed when calling POST on", "/api/admin/users/1/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) { bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { @@ -62,54 +56,41 @@ func TestAdminAPIEndpoint(t *testing.T) { sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 400, sc.resp.Code) - }) + }, mock) }) t.Run("When a server admin attempts to logout a non-existing user from all devices", func(t *testing.T) { + mock := mockstore.SQLStoreMock{ + ExpectedError: models.ErrUserNotFound, + } adminLogoutUserScenario(t, "Should return not found when calling POST on", "/api/admin/users/200/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) { - userID := int64(0) - - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - userID = cmd.Id - return models.ErrUserNotFound - }) sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 404, sc.resp.Code) - assert.Equal(t, int64(200), userID) - }) + }, mock) }) t.Run("When a server admin attempts to revoke an auth token for a non-existing user", func(t *testing.T) { cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} - + mock := mockstore.SQLStoreMock{ + ExpectedError: models.ErrUserNotFound, + } adminRevokeUserAuthTokenScenario(t, "Should return not found when calling POST on", "/api/admin/users/200/revoke-auth-token", "/api/admin/users/:id/revoke-auth-token", cmd, func(sc *scenarioContext) { - var userID int64 - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - userID = cmd.Id - return models.ErrUserNotFound - }) - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 404, sc.resp.Code) - assert.Equal(t, int64(200), userID) - }) + }, mock) }) t.Run("When a server admin gets auth tokens for a non-existing user", func(t *testing.T) { + mock := mockstore.SQLStoreMock{ + ExpectedError: models.ErrUserNotFound, + } adminGetUserAuthTokensScenario(t, "Should return not found when calling GET on", "/api/admin/users/200/auth-tokens", "/api/admin/users/:id/auth-tokens", func(sc *scenarioContext) { - var userID int64 - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - userID = cmd.Id - return models.ErrUserNotFound - }) - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() assert.Equal(t, 404, sc.resp.Code) - assert.Equal(t, int64(200), userID) - }) + }, mock) }) t.Run("When a server admin attempts to enable/disable a nonexistent user", func(t *testing.T) { @@ -305,12 +286,13 @@ func TestAdminAPIEndpoint(t *testing.T) { } func putAdminScenario(t *testing.T, desc string, url string, routePattern string, role models.RoleType, - cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) { + cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) hs := &HTTPServer{ - Cfg: setting.NewCfg(), + Cfg: setting.NewCfg(), + SQLStore: sqlStore, } sc := setupScenarioContext(t, url) @@ -330,13 +312,14 @@ func putAdminScenario(t *testing.T, desc string, url string, routePattern string }) } -func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc) { +func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) hs := HTTPServer{ Bus: bus.GetBus(), AuthTokenService: auth.NewFakeUserAuthTokenService(), + SQLStore: sqlStore, } sc := setupScenarioContext(t, url) @@ -357,7 +340,7 @@ func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern }) } -func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, fn scenarioFunc) { +func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) @@ -366,6 +349,7 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou hs := HTTPServer{ Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, + SQLStore: sqlStore, } sc := setupScenarioContext(t, url) @@ -386,7 +370,7 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou }) } -func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc) { +func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) @@ -395,6 +379,7 @@ func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, route hs := HTTPServer{ Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, + SQLStore: sqlStore, } sc := setupScenarioContext(t, url) diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go index d36f927ee2b..1a7caf1e65d 100644 --- a/pkg/api/alerting_test.go +++ b/pkg/api/alerting_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/search" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -81,6 +82,7 @@ func TestAlertingAPIEndpoint(t *testing.T) { }) }) + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/alerts?dashboardId=1", "/api/alerts", models.ROLE_EDITOR, func(sc *scenarioContext) { setUp() @@ -102,7 +104,7 @@ func TestAlertingAPIEndpoint(t *testing.T) { require.Nil(t, searchQuery) assert.NotNil(t, getAlertsQuery) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/alerts?dashboardId=1&dashboardId=2&folderId=3&dashboardTag=abc&dashboardQuery=dbQuery&limit=5&query=alertQuery", @@ -140,7 +142,7 @@ func TestAlertingAPIEndpoint(t *testing.T) { assert.Equal(t, int64(2), getAlertsQuery.DashboardIDs[1]) assert.Equal(t, int64(5), getAlertsQuery.Limit) assert.Equal(t, "alertQuery", getAlertsQuery.Query) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/alert-notifications/1", "/alert-notifications/:notificationId", models.ROLE_ADMIN, func(sc *scenarioContext) { @@ -149,7 +151,7 @@ func TestAlertingAPIEndpoint(t *testing.T) { sc.handlerFunc = GetAlertNotificationByID sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() assert.Equal(t, 404, sc.resp.Code) - }) + }, mock) } func callPauseAlert(sc *scenarioContext) { diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index e380b703d23..f51b3349d55 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/stretchr/testify/assert" ) @@ -55,6 +56,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }) + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { fakeAnnoRepo = &fakeAnnotationsRepo{} @@ -62,7 +64,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) }) }) @@ -84,7 +86,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) }) - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { fakeAnnoRepo = &fakeAnnotationsRepo{} @@ -92,7 +94,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) - }) + }, mock) }) }) }) @@ -165,7 +167,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() assert.Equal(t, 403, sc.resp.Code) }) - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { setUp() @@ -174,7 +176,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) }) }) @@ -198,7 +200,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) }) - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { setUp() @@ -207,7 +209,7 @@ func TestAnnotationsAPIEndpoint(t *testing.T) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) - }) + }, mock) }) }) diff --git a/pkg/api/api.go b/pkg/api/api.go index ea6c3859c08..e1aff6d4591 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -154,7 +154,7 @@ func (hs *HTTPServer) registerRoutes() { userRoute.Delete("/stars/dashboard/:id", routing.Wrap(hs.UnstarDashboard)) userRoute.Put("/password", routing.Wrap(hs.ChangeUserPassword)) - userRoute.Get("/quotas", routing.Wrap(GetUserQuotas)) + userRoute.Get("/quotas", routing.Wrap(hs.GetUserQuotas)) userRoute.Put("/helpflags/:id", routing.Wrap(hs.SetHelpFlag)) // For dev purpose userRoute.Get("/helpflags/clear", routing.Wrap(hs.ClearHelpFlags)) @@ -264,7 +264,7 @@ func (hs *HTTPServer) registerRoutes() { // Preferences apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) { - prefRoute.Post("/set-home-dash", routing.Wrap(SetHomeDashboard)) + prefRoute.Post("/set-home-dash", routing.Wrap(hs.SetHomeDashboard)) }) // Data sources @@ -493,8 +493,8 @@ func (hs *HTTPServer) registerRoutes() { adminUserRoute.Delete("/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersDelete, userIDScope)), routing.Wrap(AdminDeleteUser)) adminUserRoute.Post("/:id/disable", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersDisable, userIDScope)), routing.Wrap(hs.AdminDisableUser)) adminUserRoute.Post("/:id/enable", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersEnable, userIDScope)), routing.Wrap(AdminEnableUser)) - adminUserRoute.Get("/:id/quotas", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersQuotasList, userIDScope)), routing.Wrap(GetUserQuotas)) - adminUserRoute.Put("/:id/quotas/:target", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersQuotasUpdate, userIDScope)), routing.Wrap(UpdateUserQuota)) + adminUserRoute.Get("/:id/quotas", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersQuotasList, userIDScope)), routing.Wrap(hs.GetUserQuotas)) + adminUserRoute.Put("/:id/quotas/:target", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersQuotasUpdate, userIDScope)), routing.Wrap(hs.UpdateUserQuota)) adminUserRoute.Post("/:id/logout", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersLogout, userIDScope)), routing.Wrap(hs.AdminLogoutUser)) adminUserRoute.Get("/:id/auth-tokens", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersAuthTokenList, userIDScope)), routing.Wrap(hs.AdminGetUserAuthTokens)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index ed2e956e096..4b337918132 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -40,15 +40,16 @@ import ( "github.com/stretchr/testify/require" ) -func loggedInUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc) { - loggedInUserScenarioWithRole(t, desc, "GET", url, routePattern, models.ROLE_EDITOR, fn) +func loggedInUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, sqlStore sqlstore.Store) { + loggedInUserScenarioWithRole(t, desc, "GET", url, routePattern, models.ROLE_EDITOR, fn, sqlStore) } -func loggedInUserScenarioWithRole(t *testing.T, desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) { +func loggedInUserScenarioWithRole(t *testing.T, desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) sc := setupScenarioContext(t, url) + sc.sqlStore = sqlStore sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { sc.context = c sc.context.UserId = testUserID @@ -156,6 +157,7 @@ type scenarioContext struct { req *http.Request url string userAuthTokenService *auth.FakeUserAuthTokenService + sqlStore sqlstore.Store } func (sc *scenarioContext) exec() { diff --git a/pkg/api/dashboard_permission_test.go b/pkg/api/dashboard_permission_test.go index a79733baba1..d33958df6ce 100644 --- a/pkg/api/dashboard_permission_test.go +++ b/pkg/api/dashboard_permission_test.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/dashboards" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" ) @@ -30,13 +31,13 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { return models.ErrDashboardNotFound }) } - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) { setUp() callGetDashboardPermissions(sc, hs) assert.Equal(t, 404, sc.resp.Code) - }) + }, mock) cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ @@ -73,13 +74,13 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { return nil }) } - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) { setUp() callGetDashboardPermissions(sc, hs) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ @@ -125,6 +126,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { return nil }) } + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", models.ROLE_ADMIN, func(sc *scenarioContext) { @@ -139,7 +141,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { assert.Len(t, resp, 5) assert.Equal(t, int64(2), resp[0].UserId) assert.Equal(t, models.PERMISSION_VIEW, resp[0].Permission) - }) + }, mock) cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ @@ -341,7 +343,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { return nil }) } - + mock := mockstore.NewSQLStoreMock() var resp []*models.DashboardAclInfoDTO loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", models.ROLE_ADMIN, func(sc *scenarioContext) { @@ -357,7 +359,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { assert.Equal(t, models.PERMISSION_EDIT, resp[0].Permission) assert.Equal(t, int64(4), resp[1].UserId) assert.Equal(t, models.PERMISSION_ADMIN, resp[1].Permission) - }) + }, mock) cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index ae83a137cf1..67f2f4cb922 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -69,6 +70,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { } t.Run("When user has editor role and is not in the ACL", func(t *testing.T) { + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "Should not be able to delete snapshot when calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) { mockSnapshotResult := setUpSnapshotTest(t) @@ -84,7 +86,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) require.Nil(t, externalRequest) - }) + }, mock) }) t.Run("When user is anonymous", func(t *testing.T) { @@ -120,7 +122,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { {Role: &viewerRole, Permission: models.PERMISSION_VIEW}, {Role: &editorRole, Permission: models.PERMISSION_EDIT}, } - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "Should be able to delete a snapshot when calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) { mockSnapshotResult := setUpSnapshotTest(t) @@ -143,11 +145,12 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { assert.Equal(t, 1, respJSON.Get("id").MustInt()) assert.Equal(t, ts.URL, fmt.Sprintf("http://%s", externalRequest.Host)) assert.Equal(t, "/", externalRequest.URL.EscapedPath()) - }) + }, mock) }) t.Run("When user is editor and creator of the snapshot", func(t *testing.T) { aclMockResp = []*models.DashboardAclInfoDTO{} + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "Should be able to delete a snapshot when calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) { mockSnapshotResult := setUpSnapshotTest(t) @@ -164,11 +167,12 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted")) assert.Equal(t, 1, respJSON.Get("id").MustInt()) - }) + }, mock) }) t.Run("When deleting an external snapshot", func(t *testing.T) { aclMockResp = []*models.DashboardAclInfoDTO{} + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "Should gracefully delete local snapshot when remote snapshot has already been removed when calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) { @@ -192,7 +196,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted")) assert.Equal(t, 1, respJSON.Get("id").MustInt()) - }) + }, mock) loggedInUserScenarioWithRole(t, "Should fail to delete local snapshot when an unexpected 500 error occurs when calling DELETE on", "DELETE", @@ -213,7 +217,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { require.NoError(t, writeErr) assert.Equal(t, 500, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "Should fail to delete local snapshot when an unexpected remote error occurs when calling DELETE on", @@ -230,7 +234,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() assert.Equal(t, 500, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "Should be able to read a snapshot's unencrypted data when calling GET on", "GET", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) { @@ -247,6 +251,6 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { id := dashboard.Get("id") assert.Equal(t, int64(100), id.MustInt64()) - }) + }, mock) }) } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index c44ef24a034..5738c5300fd 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -26,6 +26,7 @@ import ( "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/assert" @@ -159,7 +160,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { t.Run("When user is an Org Viewer", func(t *testing.T) { role := models.ROLE_VIEWER - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUp() @@ -171,7 +172,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { assert.False(t, dash.Meta.CanEdit) assert.False(t, dash.Meta.CanSave) assert.False(t, dash.Meta.CanAdmin) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { @@ -185,7 +186,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -193,7 +194,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { callGetDashboardVersion(sc) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { @@ -201,12 +202,12 @@ func TestDashboardAPIEndpoint(t *testing.T) { callGetDashboardVersions(sc) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) }) t.Run("When user is an Org Editor", func(t *testing.T) { role := models.ROLE_EDITOR - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUp() @@ -217,7 +218,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { assert.True(t, dash.Meta.CanEdit) assert.True(t, dash.Meta.CanSave) assert.False(t, dash.Meta.CanAdmin) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { @@ -227,10 +228,11 @@ func TestDashboardAPIEndpoint(t *testing.T) { Cfg: setting.NewCfg(), LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, + SQLStore: mock, }) assert.Equal(t, 200, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -238,7 +240,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { callGetDashboardVersion(sc) assert.Equal(t, 200, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { @@ -246,7 +248,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { callGetDashboardVersions(sc) assert.Equal(t, 200, sc.resp.Code) - }) + }, mock) }) }) @@ -256,6 +258,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { Live: newTestLive(t), LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, + SQLStore: mockstore.NewSQLStoreMock(), } setUp := func() *testState { @@ -315,7 +318,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { t.Run("When user is an Org Viewer and has no permissions for this dashboard", func(t *testing.T) { role := models.ROLE_VIEWER - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUp() @@ -324,7 +327,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { @@ -333,7 +336,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { callDeleteDashboardByUID(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -341,7 +344,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { callGetDashboardVersion(sc) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { @@ -349,12 +352,12 @@ func TestDashboardAPIEndpoint(t *testing.T) { callGetDashboardVersions(sc) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) }) t.Run("When user is an Org Editor and has no permissions for this dashboard", func(t *testing.T) { role := models.ROLE_EDITOR - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUp() @@ -364,7 +367,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { @@ -373,7 +376,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { callDeleteDashboardByUID(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { @@ -381,7 +384,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { callGetDashboardVersion(sc) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { @@ -389,7 +392,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { callGetDashboardVersions(sc) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) }) t.Run("When user is an Org Viewer but has an edit permission", func(t *testing.T) { @@ -407,7 +410,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { }) return state } - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() @@ -418,29 +421,28 @@ func TestDashboardAPIEndpoint(t *testing.T) { assert.True(t, dash.Meta.CanEdit) assert.True(t, dash.Meta.CanSave) assert.False(t, dash.Meta.CanAdmin) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() - callDeleteDashboardByUID(sc, hs) assert.Equal(t, 200, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { setUpInner() callGetDashboardVersion(sc) assert.Equal(t, 200, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { setUpInner() callGetDashboardVersions(sc) assert.Equal(t, 200, sc.resp.Code) - }) + }, mock) }) t.Run("When user is an Org Viewer and viewers can edit", func(t *testing.T) { @@ -466,7 +468,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { return state } - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() @@ -477,7 +479,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { assert.True(t, dash.Meta.CanEdit) assert.False(t, dash.Meta.CanSave) assert.False(t, dash.Meta.CanAdmin) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() @@ -485,7 +487,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { callDeleteDashboardByUID(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) - }) + }, mock) }) t.Run("When user is an Org Viewer but has an admin permission", func(t *testing.T) { @@ -503,7 +505,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { }) return state } - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() @@ -513,7 +515,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { assert.True(t, dash.Meta.CanEdit) assert.True(t, dash.Meta.CanSave) assert.True(t, dash.Meta.CanAdmin) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() @@ -521,21 +523,21 @@ func TestDashboardAPIEndpoint(t *testing.T) { callDeleteDashboardByUID(sc, hs) assert.Equal(t, 200, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { setUpInner() callGetDashboardVersion(sc) assert.Equal(t, 200, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { setUpInner() callGetDashboardVersions(sc) assert.Equal(t, 200, sc.resp.Code) - }) + }, mock) }) t.Run("When user is an Org Editor but has a view permission", func(t *testing.T) { @@ -553,7 +555,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { }) return state } - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() @@ -561,7 +563,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) assert.False(t, dash.Meta.CanEdit) assert.False(t, dash.Meta.CanSave) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() @@ -569,21 +571,21 @@ func TestDashboardAPIEndpoint(t *testing.T) { callDeleteDashboardByUID(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) { setUpInner() callGetDashboardVersion(sc) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/2/versions", "/api/dashboards/id/:dashboardId/versions", role, func(sc *scenarioContext) { setUpInner() callGetDashboardVersions(sc) assert.Equal(t, 403, sc.resp.Code) - }) + }, mock) }) }) @@ -964,7 +966,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { return nil }) } - + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/db/abcdefghi", "/api/dashboards/db/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) { setUp() @@ -972,12 +974,13 @@ func TestDashboardAPIEndpoint(t *testing.T) { Cfg: setting.NewCfg(), LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, + SQLStore: mock, }) assert.Equal(t, 400, sc.resp.Code) result := sc.ToJSON() assert.Equal(t, models.ErrDashboardCannotDeleteProvisionedDashboard.Error(), result.Get("error").MustString()) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) { setUp() @@ -990,8 +993,9 @@ func TestDashboardAPIEndpoint(t *testing.T) { dash := getDashboardShouldReturn200WithConfig(sc, mock) assert.Equal(t, filepath.Join("test", "dashboard1.json"), dash.Meta.ProvisionedExternalId) - }) + }, mock) + mockSQLStore := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When allowUiUpdates is true and calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) { setUp() @@ -1008,6 +1012,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { ProvisioningService: mock, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, + SQLStore: mockSQLStore, } callGetDashboard(sc, hs) @@ -1018,7 +1023,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { require.NoError(t, err) assert.Equal(t, false, dash.Meta.Provisioned) - }) + }, mock) }) } @@ -1036,6 +1041,7 @@ func getDashboardShouldReturn200WithConfig(sc *scenarioContext, provisioningServ LibraryPanelService: &libraryPanelsService, LibraryElementService: &libraryElementsService, ProvisioningService: provisioningService, + SQLStore: sc.sqlStore, } callGetDashboard(sc, hs) diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index ac43d1cd016..4e95153c6ec 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,6 +28,7 @@ const ( ) func TestDataSourcesProxy_userLoggedIn(t *testing.T) { + mock := mockstore.NewSQLStoreMock() loggedInUserScenario(t, "When calling GET on", "/api/datasources/", "/api/datasources/", func(sc *scenarioContext) { // Stubs the database query bus.AddHandler("test", func(ctx context.Context, query *models.GetDataSourcesQuery) error { @@ -45,6 +47,7 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) { Bus: bus.GetBus(), Cfg: setting.NewCfg(), pluginStore: &fakePluginStore{}, + SQLStore: mock, } sc.handlerFunc = hs.GetDataSources sc.fakeReq("GET", "/api/datasources").exec() @@ -57,7 +60,7 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) { assert.Equal(t, "BBB", respJSON[1]["name"]) assert.Equal(t, "mmm", respJSON[2]["name"]) assert.Equal(t, "ZZZ", respJSON[3]["name"]) - }) + }, mock) loggedInUserScenario(t, "Should be able to save a data source when calling DELETE on non-existing", "/api/datasources/name/12345", "/api/datasources/name/:name", func(sc *scenarioContext) { @@ -70,7 +73,7 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) { sc.handlerFunc = hs.DeleteDataSourceByName sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() assert.Equal(t, 404, sc.resp.Code) - }) + }, mock) } // Adding data sources with invalid URLs should lead to an error. diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go index 410ae187862..a784b76e6b5 100644 --- a/pkg/api/folder_permission_test.go +++ b/pkg/api/folder_permission_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" ) @@ -34,11 +35,11 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { dashboards.NewFolderService = origNewFolderService }) mockFolderService(mock) - + mockSQLStore := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) { callGetFolderPermissions(sc, hs) assert.Equal(t, 404, sc.resp.Code) - }) + }, mockSQLStore) cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ @@ -77,11 +78,11 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { } mockFolderService(mock) - + mockSQLStore := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) { callGetFolderPermissions(sc, hs) assert.Equal(t, 403, sc.resp.Code) - }) + }, mockSQLStore) cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ @@ -130,7 +131,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { } mockFolderService(mock) - + mockSQLStore := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_ADMIN, func(sc *scenarioContext) { callGetFolderPermissions(sc, hs) assert.Equal(t, 200, sc.resp.Code) @@ -142,7 +143,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { assert.Len(t, resp, 5) assert.Equal(t, int64(2), resp[0].UserId) assert.Equal(t, models.PERMISSION_VIEW, resp[0].Permission) - }) + }, mockSQLStore) cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ @@ -325,6 +326,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { mockFolderService(mock) var resp []*models.DashboardAclInfoDTO + mockSQLStore := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_ADMIN, func(sc *scenarioContext) { callGetFolderPermissions(sc, hs) assert.Equal(t, 200, sc.resp.Code) @@ -337,7 +339,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { assert.Equal(t, models.PERMISSION_EDIT, resp[0].Permission) assert.Equal(t, int64(4), resp[1].UserId) assert.Equal(t, models.PERMISSION_ADMIN, resp[1].Permission) - }) + }, mockSQLStore) cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 5a33efa7706..3c84c2bf73f 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -107,7 +107,7 @@ type HTTPServer struct { LivePushGateway *pushhttp.Gateway ThumbService thumbs.Service ContextHandler *contexthandler.ContextHandler - SQLStore *sqlstore.SQLStore + SQLStore sqlstore.Store AlertEngine *alerting.AlertEngine LoadSchemaService *schemaloader.SchemaLoaderService AlertNG *ngalert.AlertNG @@ -432,6 +432,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { m := hs.web m.Use(middleware.RequestTracing(hs.tracer)) + m.Use(middleware.RequestMetrics(hs.Features)) m.Use(middleware.Logger(hs.Cfg)) diff --git a/pkg/api/index.go b/pkg/api/index.go index 2358fa61821..25565347c10 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -144,9 +144,8 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) } func enableServiceAccount(hs *HTTPServer, c *models.ReqContext) bool { - return c.OrgRole == models.ROLE_ADMIN && - hs.Features.IsEnabled(featuremgmt.FlagServiceAccounts) && - hs.serviceAccountsService.Migrated(c.Req.Context(), c.OrgId) + return (c.OrgRole == models.ROLE_ADMIN || (hs.Cfg.EditorsCanAdmin && c.OrgRole == models.ROLE_EDITOR)) && + hs.Features.IsEnabled(featuremgmt.FlagServiceAccounts) } func enableTeams(hs *HTTPServer, c *models.ReqContext) bool { diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 009e00d18a4..ee5dedb51c7 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -41,7 +42,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) sqlStore.Cfg = settings hs.SQLStore = sqlStore - + mock := mockstore.NewSQLStoreMock() loggedInUserScenario(t, "When calling GET on", "api/org/users", "api/org/users", func(sc *scenarioContext) { setUpGetOrgUsersDB(t, sqlStore) @@ -54,7 +55,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { err := json.Unmarshal(sc.resp.Body.Bytes(), &resp) require.NoError(t, err) assert.Len(t, resp, 3) - }) + }, mock) loggedInUserScenario(t, "When calling GET on", "api/org/users/search", "api/org/users/search", func(sc *scenarioContext) { setUpGetOrgUsersDB(t, sqlStore) @@ -72,7 +73,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { assert.Equal(t, int64(3), resp.TotalCount) assert.Equal(t, 1000, resp.PerPage) assert.Equal(t, 1, resp.Page) - }) + }, mock) loggedInUserScenario(t, "When calling GET with page and limit query parameters on", "api/org/users/search", "api/org/users/search", func(sc *scenarioContext) { setUpGetOrgUsersDB(t, sqlStore) @@ -90,7 +91,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { assert.Equal(t, int64(3), resp.TotalCount) assert.Equal(t, 2, resp.PerPage) assert.Equal(t, 2, resp.Page) - }) + }, mock) t.Run("Given there are two hidden users", func(t *testing.T) { settings.HiddenUsers = map[string]struct{}{ @@ -113,7 +114,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { assert.Len(t, resp, 2) assert.Equal(t, testUserLogin, resp[0].Login) assert.Equal(t, "user2", resp[1].Login) - }) + }, mock) loggedInUserScenarioWithRole(t, "When calling GET as an admin on", "GET", "api/org/users/lookup", "api/org/users/lookup", models.ROLE_ADMIN, func(sc *scenarioContext) { @@ -130,7 +131,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { assert.Len(t, resp, 2) assert.Equal(t, testUserLogin, resp[0].Login) assert.Equal(t, "user2", resp[1].Login) - }) + }, mock) }) } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 431edfd16c5..f0293470579 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -18,7 +18,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" @@ -143,7 +142,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Respon } query := models.GetPluginSettingByIdQuery{PluginId: pluginID, OrgId: c.OrgId} - if err := bus.Dispatch(c.Req.Context(), &query); err != nil { + if err := hs.SQLStore.GetPluginSettingById(c.Req.Context(), &query); err != nil { if !errors.Is(err, models.ErrPluginSettingNotFound) { return response.Error(500, "Failed to get login settings", nil) } @@ -175,7 +174,7 @@ func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext) response.Respons cmd.OrgId = c.OrgId cmd.PluginId = pluginID - if err := bus.Dispatch(c.Req.Context(), &cmd); err != nil { + if err := hs.SQLStore.UpdatePluginSetting(c.Req.Context(), &cmd); err != nil { return response.Error(500, "Failed to update plugin setting", err) } diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index c7da91a0fdf..5a071b5d9b3 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/web" ) @@ -18,7 +17,7 @@ const ( ) // POST /api/preferences/set-home-dash -func SetHomeDashboard(c *models.ReqContext) response.Response { +func (hs *HTTPServer) SetHomeDashboard(c *models.ReqContext) response.Response { cmd := models.SavePreferencesCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -26,7 +25,7 @@ func SetHomeDashboard(c *models.ReqContext) response.Response { cmd.UserId = c.UserId cmd.OrgId = c.OrgId - if err := bus.Dispatch(c.Req.Context(), &cmd); err != nil { + if err := hs.SQLStore.SavePreferences(c.Req.Context(), &cmd); err != nil { return response.Error(500, "Failed to set home dashboard", err) } diff --git a/pkg/api/quota.go b/pkg/api/quota.go index fee44d6ed46..cdd8c9762d1 100644 --- a/pkg/api/quota.go +++ b/pkg/api/quota.go @@ -5,7 +5,6 @@ import ( "strconv" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" @@ -61,7 +60,7 @@ func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response { return response.Success("Organization quota updated") } -func GetUserQuotas(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response { if !setting.Quota.Enabled { return response.Error(404, "Quotas not enabled", nil) } @@ -73,14 +72,14 @@ func GetUserQuotas(c *models.ReqContext) response.Response { query := models.GetUserQuotasQuery{UserId: id} - if err := bus.Dispatch(c.Req.Context(), &query); err != nil { + if err := hs.SQLStore.GetUserQuotas(c.Req.Context(), &query); err != nil { return response.Error(500, "Failed to get org quotas", err) } return response.JSON(200, query.Result) } -func UpdateUserQuota(c *models.ReqContext) response.Response { +func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response { cmd := models.UpdateUserQuotaCmd{} var err error if err := web.Bind(c.Req, &cmd); err != nil { @@ -99,7 +98,7 @@ func UpdateUserQuota(c *models.ReqContext) response.Response { return response.Error(404, "Invalid quota target", nil) } - if err := bus.Dispatch(c.Req.Context(), &cmd); err != nil { + if err := hs.SQLStore.UpdateUserQuota(c.Req.Context(), &cmd); err != nil { return response.Error(500, "Failed to update org quotas", err) } return response.Success("Organization quota updated") diff --git a/pkg/api/roles.go b/pkg/api/roles.go index 943649a41d3..e8f3fc9fe53 100644 --- a/pkg/api/roles.go +++ b/pkg/api/roles.go @@ -231,7 +231,7 @@ func (hs *HTTPServer) declareFixedRoles() error { DisplayName: "Team writer", Description: "Create, read, write, or delete a team as well as controlling team memberships.", Group: "Teams", - Version: 1, + Version: 2, Permissions: []accesscontrol.Permission{ {Action: accesscontrol.ActionTeamsCreate}, {Action: accesscontrol.ActionTeamsDelete, Scope: accesscontrol.ScopeTeamsAll}, diff --git a/pkg/api/routing/route_register.go b/pkg/api/routing/route_register.go index 4c2a59eee25..f9751972951 100644 --- a/pkg/api/routing/route_register.go +++ b/pkg/api/routing/route_register.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/web" ) @@ -52,8 +51,8 @@ type RouteRegister interface { type RegisterNamedMiddleware func(name string) web.Handler -func ProvideRegister(features featuremgmt.FeatureToggles) *RouteRegisterImpl { - return NewRouteRegister(middleware.ProvideRouteOperationName, middleware.RequestMetrics(features)) +func ProvideRegister() *RouteRegisterImpl { + return NewRouteRegister(middleware.ProvideRouteOperationName) } // NewRouteRegister creates a new RouteRegister with all middlewares sent as params diff --git a/pkg/api/team.go b/pkg/api/team.go index c695db38b4c..5db290459b2 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -25,7 +24,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response { return response.Error(403, "Not allowed to create team.", nil) } - team, err := createTeam(hs.SQLStore, cmd.Name, cmd.Email, c.OrgId) + team, err := hs.SQLStore.CreateTeam(cmd.Name, cmd.Email, c.OrgId) if err != nil { if errors.Is(err, models.ErrTeamNameTaken) { return response.Error(409, "Team name taken", err) @@ -45,7 +44,6 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response { c.Logger.Warn("Could not add creator to team because is not a real user") } } - return response.JSON(200, &util.DynMap{ "teamId": team.Id, "message": "Team created", @@ -211,10 +209,3 @@ func (hs *HTTPServer) UpdateTeamPreferences(c *models.ReqContext) response.Respo return hs.updatePreferencesFor(c.Req.Context(), orgId, 0, teamId, &dtoCmd) } - -// createTeam creates a team. -// -// Stubbable by tests. -var createTeam = func(sqlStore *sqlstore.SQLStore, name, email string, orgID int64) (models.Team, error) { - return sqlStore.CreateTeam(name, email, orgID) -} diff --git a/pkg/api/team_members_test.go b/pkg/api/team_members_test.go index 37dd49b424f..8dba3bc7335 100644 --- a/pkg/api/team_members_test.go +++ b/pkg/api/team_members_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/teamguardian/database" "github.com/grafana/grafana/pkg/services/teamguardian/manager" "github.com/grafana/grafana/pkg/setting" @@ -47,6 +48,7 @@ func TestTeamMembersAPIEndpoint_userLoggedIn(t *testing.T) { License: &licensing.OSSLicensingService{}, SQLStore: sqlStore, } + mock := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "api/teams/1/members", "api/teams/:teamId/members", models.ROLE_ADMIN, func(sc *scenarioContext) { @@ -61,7 +63,7 @@ func TestTeamMembersAPIEndpoint_userLoggedIn(t *testing.T) { err := json.Unmarshal(sc.resp.Body.Bytes(), &resp) require.NoError(t, err) assert.Len(t, resp, 3) - }) + }, mock) t.Run("Given there is two hidden users", func(t *testing.T) { settings.HiddenUsers = map[string]struct{}{ @@ -86,7 +88,7 @@ func TestTeamMembersAPIEndpoint_userLoggedIn(t *testing.T) { assert.Equal(t, "loginuser0", resp[0].Login) assert.Equal(t, "loginuser1", resp[1].Login) assert.Equal(t, "loginuser2", resp[2].Login) - }) + }, mock) }) } diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index 0e2a0a8c620..5f6803edc2e 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/assert" @@ -34,7 +35,7 @@ func TestTeamAPIEndpoint(t *testing.T) { t.Run("Given two teams", func(t *testing.T) { hs := setupSimpleHTTPServer(nil) hs.SQLStore = sqlstore.InitTestDB(t) - + mock := mockstore.SQLStoreMock{} loggedInUserScenario(t, "When calling GET on", "/api/teams/search", "/api/teams/search", func(sc *scenarioContext) { _, err := hs.SQLStore.CreateTeam("team1", "", 1) require.NoError(t, err) @@ -50,7 +51,7 @@ func TestTeamAPIEndpoint(t *testing.T) { assert.EqualValues(t, 2, resp.TotalCount) assert.Equal(t, 2, len(resp.Teams)) - }) + }, mock) loggedInUserScenario(t, "When calling GET on", "/api/teams/search", "/api/teams/search", func(sc *scenarioContext) { _, err := hs.SQLStore.CreateTeam("team1", "", 1) @@ -67,29 +68,15 @@ func TestTeamAPIEndpoint(t *testing.T) { assert.EqualValues(t, 2, resp.TotalCount) assert.Equal(t, 0, len(resp.Teams)) - }) + }, mock) }) t.Run("When creating team with API key", func(t *testing.T) { hs := setupSimpleHTTPServer(nil) hs.Cfg.EditorsCanAdmin = true - + hs.SQLStore = mockstore.NewSQLStoreMock() teamName := "team foo" - // TODO: Use a fake SQLStore when it's represented by an interface - orgCreateTeam := createTeam - orgAddTeamMember := addOrUpdateTeamMember - t.Cleanup(func() { - createTeam = orgCreateTeam - addOrUpdateTeamMember = orgAddTeamMember - }) - - createTeamCalled := 0 - createTeam = func(sqlStore *sqlstore.SQLStore, name, email string, orgID int64) (models.Team, error) { - createTeamCalled++ - return models.Team{Name: teamName, Id: 42}, nil - } - addTeamMemberCalled := 0 addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService *resourcepermissions.Service, userID, orgID, teamID int64, permission string) error { @@ -109,9 +96,9 @@ func TestTeamAPIEndpoint(t *testing.T) { } c.OrgRole = models.ROLE_EDITOR c.Req.Body = mockRequestBody(models.CreateTeamCommand{Name: teamName}) - hs.CreateTeam(c) - assert.Equal(t, createTeamCalled, 1) - assert.Equal(t, addTeamMemberCalled, 0) + r := hs.CreateTeam(c) + + assert.Equal(t, 200, r.Status()) assert.True(t, stub.warnCalled) assert.Equal(t, stub.warnMessage, "Could not add creator to team because is not a real user") }) @@ -125,10 +112,8 @@ func TestTeamAPIEndpoint(t *testing.T) { } c.OrgRole = models.ROLE_EDITOR c.Req.Body = mockRequestBody(models.CreateTeamCommand{Name: teamName}) - createTeamCalled, addTeamMemberCalled = 0, 0 - hs.CreateTeam(c) - assert.Equal(t, createTeamCalled, 1) - assert.Equal(t, addTeamMemberCalled, 1) + r := hs.CreateTeam(c) + assert.Equal(t, 200, r.Status()) assert.False(t, stub.warnCalled) }) }) diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index e9aa77d46c3..727c08ccbad 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "golang.org/x/oauth2" @@ -41,7 +42,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { }, TotalCount: 2, } - + mock := mockstore.NewSQLStoreMock() loggedInUserScenario(t, "When calling GET on", "api/users/1", "api/users/:id", func(sc *scenarioContext) { fakeNow := time.Date(2019, 2, 11, 17, 30, 40, 0, time.UTC) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) @@ -100,7 +101,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { resp.UpdatedAt = fakeNow resp.AvatarUrl = avatarUrl require.EqualValues(t, expected, resp) - }) + }, mock) loggedInUserScenario(t, "When calling GET on", "/api/users/lookup", "/api/users/lookup", func(sc *scenarioContext) { fakeNow := time.Date(2019, 2, 11, 17, 30, 40, 0, time.UTC) @@ -141,7 +142,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { require.Equal(t, "admin", resp.Login) require.Equal(t, "admin@test.com", resp.Email) require.True(t, resp.IsGrafanaAdmin) - }) + }, mock) loggedInUserScenario(t, "When calling GET on", "/api/users", "/api/users", func(sc *scenarioContext) { var sentLimit int @@ -165,7 +166,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) require.NoError(t, err) assert.Equal(t, 2, len(respJSON.MustArray())) - }) + }, mock) loggedInUserScenario(t, "When calling GET with page and limit querystring parameters on", "/api/users", "/api/users", func(sc *scenarioContext) { var sentLimit int @@ -185,7 +186,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { assert.Equal(t, 10, sentLimit) assert.Equal(t, 2, sendPage) - }) + }, mock) loggedInUserScenario(t, "When calling GET on", "/api/users/search", "/api/users/search", func(sc *scenarioContext) { var sentLimit int @@ -211,7 +212,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { assert.Equal(t, 2, respJSON.Get("totalCount").MustInt()) assert.Equal(t, 2, len(respJSON.Get("users").MustArray())) - }) + }, mock) loggedInUserScenario(t, "When calling GET with page and perpage querystring parameters on", "/api/users/search", "/api/users/search", func(sc *scenarioContext) { var sentLimit int @@ -231,5 +232,5 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { assert.Equal(t, 10, sentLimit) assert.Equal(t, 2, sendPage) - }) + }, mock) } diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index 5eb8c153b83..53b040bd531 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -32,7 +31,7 @@ func (hs *HTTPServer) RevokeUserAuthToken(c *models.ReqContext) response.Respons func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, userID int64) response.Response { userQuery := models.GetUserByIdQuery{Id: userID} - if err := bus.Dispatch(ctx, &userQuery); err != nil { + if err := hs.SQLStore.GetUserById(ctx, &userQuery); err != nil { if errors.Is(err, models.ErrUserNotFound) { return response.Error(404, "User not found", err) } @@ -52,7 +51,7 @@ func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, user func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) response.Response { userQuery := models.GetUserByIdQuery{Id: userID} - if err := bus.Dispatch(c.Req.Context(), &userQuery); err != nil { + if err := hs.SQLStore.GetUserById(c.Req.Context(), &userQuery); err != nil { if errors.Is(err, models.ErrUserNotFound) { return response.Error(404, "User not found", err) } @@ -118,8 +117,7 @@ func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) response.Response { userQuery := models.GetUserByIdQuery{Id: userID} - - if err := bus.Dispatch(c.Req.Context(), &userQuery); err != nil { + if err := hs.SQLStore.GetUserById(c.Req.Context(), &userQuery); err != nil { if errors.Is(err, models.ErrUserNotFound) { return response.Error(404, "User not found", err) } diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go index 1d9ebeb0b70..68c523c4777 100644 --- a/pkg/api/user_token_test.go +++ b/pkg/api/user_token_test.go @@ -11,87 +11,73 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/stretchr/testify/assert" ) func TestUserTokenAPIEndpoint(t *testing.T) { + mock := mockstore.NewSQLStoreMock() t.Run("When current user attempts to revoke an auth token for a non-existing user", func(t *testing.T) { cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} - + mock.ExpectedError = models.ErrUserNotFound revokeUserAuthTokenScenario(t, "Should return not found when calling POST on", "/api/user/revoke-auth-token", "/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) { - var userID int64 - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - userID = cmd.Id - return models.ErrUserNotFound - }) - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 404, sc.resp.Code) - assert.Equal(t, int64(200), userID) - }) + }, mock) }) t.Run("When current user gets auth tokens for a non-existing user", func(t *testing.T) { + mock := mockstore.SQLStoreMock{ + ExpectedUser: &models.User{Id: 200}, + ExpectedError: models.ErrUserNotFound, + } getUserAuthTokensScenario(t, "Should return not found when calling GET on", "/api/user/auth-tokens", "/api/user/auth-tokens", 200, func(sc *scenarioContext) { - var userID int64 - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - userID = cmd.Id - return models.ErrUserNotFound - }) - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() assert.Equal(t, 404, sc.resp.Code) - assert.Equal(t, int64(200), userID) - }) + }, mock) }) t.Run("When logging out an existing user from all devices", func(t *testing.T) { + mock := mockstore.SQLStoreMock{ + ExpectedUser: &models.User{Id: 200}, + } logoutUserFromAllDevicesInternalScenario(t, "Should be successful", 1, func(sc *scenarioContext) { - const userID int64 = 200 - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - cmd.Result = &models.User{Id: userID} - return nil - }) - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) - }) + }, mock) }) t.Run("When logout a non-existing user from all devices", func(t *testing.T) { logoutUserFromAllDevicesInternalScenario(t, "Should return not found", testUserID, func(sc *scenarioContext) { - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - return models.ErrUserNotFound - }) + mock.ExpectedError = models.ErrUserNotFound sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 404, sc.resp.Code) - }) + }, mock) }) t.Run("When revoke an auth token for a user", func(t *testing.T) { cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} token := &models.UserToken{Id: 1} + mock := mockstore.SQLStoreMock{ + ExpectedUser: &models.User{Id: 200}, + } revokeUserAuthTokenInternalScenario(t, "Should be successful", cmd, 200, token, func(sc *scenarioContext) { - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - cmd.Result = &models.User{Id: 200} - return nil - }) - sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { return &models.UserToken{Id: 2}, nil } sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) - }) + }, mock) }) t.Run("When revoke the active auth token used by himself", func(t *testing.T) { cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} token := &models.UserToken{Id: 2} - + mock := mockstore.NewSQLStoreMock() revokeUserAuthTokenInternalScenario(t, "Should not be successful", cmd, testUserID, token, func(sc *scenarioContext) { bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { cmd.Result = &models.User{Id: testUserID} @@ -103,18 +89,13 @@ func TestUserTokenAPIEndpoint(t *testing.T) { } sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 400, sc.resp.Code) - }) + }, mock) }) t.Run("When gets auth tokens for a user", func(t *testing.T) { currentToken := &models.UserToken{Id: 1} - + mock := mockstore.NewSQLStoreMock() getUserAuthTokensInternalScenario(t, "Should be successful", currentToken, func(sc *scenarioContext) { - bus.AddHandler("test", func(ctx context.Context, cmd *models.GetUserByIdQuery) error { - cmd.Result = &models.User{Id: testUserID} - return nil - }) - tokens := []*models.UserToken{ { Id: 1, @@ -165,12 +146,12 @@ func TestUserTokenAPIEndpoint(t *testing.T) { assert.Equal(t, "11.0", resultTwo.Get("browserVersion").MustString()) assert.Equal(t, "iOS", resultTwo.Get("os").MustString()) assert.Equal(t, "11.0", resultTwo.Get("osVersion").MustString()) - }) + }, mock) }) } func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, - userId int64, fn scenarioFunc) { + userId int64, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) @@ -179,6 +160,7 @@ func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePat hs := HTTPServer{ Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, + SQLStore: sqlStore, } sc := setupScenarioContext(t, url) @@ -199,7 +181,7 @@ func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePat }) } -func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, userId int64, fn scenarioFunc) { +func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, userId int64, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) @@ -208,6 +190,7 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte hs := HTTPServer{ Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, + SQLStore: sqlStore, } sc := setupScenarioContext(t, url) @@ -227,13 +210,14 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte }) } -func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId int64, fn scenarioFunc) { +func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId int64, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(desc, func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) hs := HTTPServer{ Bus: bus.GetBus(), AuthTokenService: auth.NewFakeUserAuthTokenService(), + SQLStore: sqlStore, } sc := setupScenarioContext(t, "/") @@ -253,7 +237,7 @@ func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId } func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.RevokeAuthTokenCmd, userId int64, - token *models.UserToken, fn scenarioFunc) { + token *models.UserToken, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(desc, func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) @@ -262,6 +246,7 @@ func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.R hs := HTTPServer{ Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, + SQLStore: sqlStore, } sc := setupScenarioContext(t, "/") @@ -275,14 +260,12 @@ func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.R return hs.revokeUserAuthTokenInternal(c, userId, cmd) }) - sc.m.Post("/", sc.defaultHandler) - fn(sc) }) } -func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models.UserToken, fn scenarioFunc) { +func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models.UserToken, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(desc, func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) @@ -291,6 +274,7 @@ func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models. hs := HTTPServer{ Bus: bus.GetBus(), AuthTokenService: fakeAuthTokenService, + SQLStore: sqlStore, } sc := setupScenarioContext(t, "/") diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index 9ae1f6e7f52..55f381ebec3 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -183,6 +183,16 @@ var adminCommands = []*cli.Command{ Usage: "Re-encrypts secrets by decrypting and re-encrypting them with the currently configured encryption. Returns ok unless there is an error. Safe to execute multiple times.", Action: runRunnerCommand(secretsmigrations.ReEncryptSecrets), }, + { + Name: "rollback", + Usage: "Rolls back secrets to legacy encryption. Returns ok unless there is an error. Safe to execute multiple times.", + Action: runRunnerCommand(secretsmigrations.RollBackSecrets), + }, + { + Name: "re-encrypt-data-keys", + Usage: "Rotates persisted data encryption keys. Returns ok unless there is an error. Safe to execute multiple times.", + Action: runRunnerCommand(secretsmigrations.ReEncryptDEKS), + }, }, }, } diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_deks.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_deks.go new file mode 100644 index 00000000000..0d97599c491 --- /dev/null +++ b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_deks.go @@ -0,0 +1,19 @@ +package secretsmigrations + +import ( + "context" + + "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" + "github.com/grafana/grafana/pkg/cmd/grafana-cli/runner" + "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +func ReEncryptDEKS(_ utils.CommandLine, runner runner.Runner) error { + if !runner.Features.IsEnabled(featuremgmt.FlagEnvelopeEncryption) { + logger.Warn("Envelope encryption is not enabled, quitting...") + return nil + } + + return runner.SecretsService.ReEncryptDataKeys(context.Background()) +} diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go index f175d4464d9..067a5d10d36 100644 --- a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go +++ b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go @@ -17,12 +17,6 @@ import ( "xorm.io/xorm" ) -type simpleSecret struct { - tableName string - columnName string - isBase64Encoded bool -} - func (s simpleSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) error { var rows []struct { Id int @@ -76,10 +70,6 @@ func (s simpleSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.S return nil } -type jsonSecret struct { - tableName string -} - func (s jsonSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) error { var rows []struct { Id int @@ -119,8 +109,6 @@ func (s jsonSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Ses return nil } -type alertingSecret struct{} - func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) error { var results []struct { Id int diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/rollback_secrets.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/rollback_secrets.go new file mode 100644 index 00000000000..619cbfb8f6e --- /dev/null +++ b/pkg/cmd/grafana-cli/commands/secretsmigrations/rollback_secrets.go @@ -0,0 +1,220 @@ +package secretsmigrations + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + + "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" + "github.com/grafana/grafana/pkg/cmd/grafana-cli/runner" + "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" + "github.com/grafana/grafana/pkg/services/encryption" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/services/sqlstore" + "xorm.io/xorm" +) + +func (s simpleSecret) rollback( + secretsSrv *manager.SecretsService, + encryptionSrv encryption.Internal, + sess *xorm.Session, + secretKey string, +) error { + var rows []struct { + Id int + Secret string + } + + if err := sess.Table(s.tableName).Select(fmt.Sprintf("id, %s as secret", s.columnName)).Find(&rows); err != nil { + return err + } + + for _, row := range rows { + if len(row.Secret) == 0 { + continue + } + + var ( + err error + decoded = []byte(row.Secret) + ) + + if s.isBase64Encoded { + decoded, err = base64.StdEncoding.DecodeString(row.Secret) + if err != nil { + return err + } + } + + decrypted, err := secretsSrv.Decrypt(context.Background(), decoded) + if err != nil { + return err + } + + encrypted, err := encryptionSrv.Encrypt(context.Background(), decrypted, secretKey) + if err != nil { + return err + } + + encoded := string(encrypted) + if s.isBase64Encoded { + encoded = base64.StdEncoding.EncodeToString(encrypted) + } + + updateSQL := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", s.tableName, s.columnName) + if _, err := sess.Exec(updateSQL, encoded, row.Id); err != nil { + return err + } + } + + logger.Infof("Column %s from %s have been rolled back successfully\n", s.columnName, s.tableName) + + return nil +} + +func (s jsonSecret) rollback( + secretsSrv *manager.SecretsService, + encryptionSrv encryption.Internal, + sess *xorm.Session, + secretKey string, +) error { + var rows []struct { + Id int + SecureJsonData map[string][]byte + } + + if err := sess.Table(s.tableName).Cols("id", "secure_json_data").Find(&rows); err != nil { + return err + } + + for _, row := range rows { + if len(row.SecureJsonData) == 0 { + continue + } + + decrypted, err := secretsSrv.DecryptJsonData(context.Background(), row.SecureJsonData) + if err != nil { + return err + } + + var toUpdate struct { + SecureJsonData map[string][]byte + } + + toUpdate.SecureJsonData, err = encryptionSrv.EncryptJsonData(context.Background(), decrypted, secretKey) + if err != nil { + return err + } + + if _, err := sess.Table(s.tableName).Where("id = ?", row.Id).Update(toUpdate); err != nil { + return err + } + } + + logger.Infof("Secure json data from %s have been rolled back successfully\n", s.tableName) + + return nil +} + +func (s alertingSecret) rollback( + secretsSrv *manager.SecretsService, + encryptionSrv encryption.Internal, + sess *xorm.Session, + secretKey string, +) error { + var results []struct { + Id int + AlertmanagerConfiguration string + } + + selectSQL := "SELECT id, alertmanager_configuration FROM alert_configuration" + if err := sess.SQL(selectSQL).Find(&results); err != nil { + return err + } + + for _, result := range results { + result := result + postableUserConfig, err := notifier.Load([]byte(result.AlertmanagerConfiguration)) + if err != nil { + return err + } + + for _, receiver := range postableUserConfig.AlertmanagerConfig.Receivers { + for _, gmr := range receiver.GrafanaManagedReceivers { + for k, v := range gmr.SecureSettings { + decoded, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return err + } + + decrypted, err := secretsSrv.Decrypt(context.Background(), decoded) + if err != nil { + return err + } + + reencrypted, err := encryptionSrv.Encrypt(context.Background(), decrypted, secretKey) + if err != nil { + return err + } + + gmr.SecureSettings[k] = base64.StdEncoding.EncodeToString(reencrypted) + } + } + } + + marshalled, err := json.Marshal(postableUserConfig) + if err != nil { + return err + } + + result.AlertmanagerConfiguration = string(marshalled) + if _, err := sess.Table("alert_configuration").Where("id = ?", result.Id).Update(&result); err != nil { + return err + } + } + + logger.Info("Alerting secrets have rolled re-encrypted successfully\n") + + return nil +} + +func RollBackSecrets(_ utils.CommandLine, runner runner.Runner) error { + if !runner.Features.IsEnabled(featuremgmt.FlagEnvelopeEncryption) { + logger.Warn("Envelope encryption is not enabled, quitting...") + return nil + } + + toMigrate := []interface { + rollback(*manager.SecretsService, encryption.Internal, *xorm.Session, string) error + }{ + simpleSecret{tableName: "dashboard_snapshot", columnName: "dashboard_encrypted", isBase64Encoded: false}, + simpleSecret{tableName: "user_auth", columnName: "o_auth_access_token", isBase64Encoded: true}, + simpleSecret{tableName: "user_auth", columnName: "o_auth_refresh_token", isBase64Encoded: true}, + simpleSecret{tableName: "user_auth", columnName: "o_auth_token_type", isBase64Encoded: true}, + jsonSecret{tableName: "data_source"}, + jsonSecret{tableName: "plugin_setting"}, + alertingSecret{}, + } + + return runner.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + for _, m := range toMigrate { + if err := m.rollback( + runner.SecretsService, + runner.EncryptionService, + sess.Session, + runner.Cfg.SecretKey); err != nil { + return err + } + } + + if _, err := sess.Exec("DELETE FROM data_keys"); err != nil { + logger.Warn("Error while cleaning up data keys table...", "err", err) + } + + return nil + }) +} diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/types.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/types.go new file mode 100644 index 00000000000..b272983f26d --- /dev/null +++ b/pkg/cmd/grafana-cli/commands/secretsmigrations/types.go @@ -0,0 +1,13 @@ +package secretsmigrations + +type simpleSecret struct { + tableName string + columnName string + isBase64Encoded bool +} + +type jsonSecret struct { + tableName string +} + +type alertingSecret struct{} diff --git a/pkg/expr/classic/reduce_test.go b/pkg/expr/classic/reduce_test.go index c30d33966d0..50871dd61cd 100644 --- a/pkg/expr/classic/reduce_test.go +++ b/pkg/expr/classic/reduce_test.go @@ -402,10 +402,7 @@ func TestPercentDiffAbsReducer(t *testing.T) { func valBasedSeries(vals ...*float64) mathexp.Series { newSeries := mathexp.NewSeries("", nil, len(vals)) for idx, f := range vals { - err := newSeries.SetPoint(idx, time.Unix(int64(idx), 0), f) - if err != nil { - panic(err) - } + newSeries.SetPoint(idx, time.Unix(int64(idx), 0), f) } return newSeries } @@ -413,10 +410,7 @@ func valBasedSeries(vals ...*float64) mathexp.Series { func valBasedSeriesWithLabels(l data.Labels, vals ...*float64) mathexp.Series { newSeries := mathexp.NewSeries("", l, len(vals)) for idx, f := range vals { - err := newSeries.SetPoint(idx, time.Unix(int64(idx), 0), f) - if err != nil { - panic(err) - } + newSeries.SetPoint(idx, time.Unix(int64(idx), 0), f) } return newSeries } diff --git a/pkg/expr/commands.go b/pkg/expr/commands.go index f31b9164f67..f238e4ef9f5 100644 --- a/pkg/expr/commands.go +++ b/pkg/expr/commands.go @@ -7,6 +7,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" + "github.com/grafana/grafana/pkg/expr/mathexp" ) @@ -69,19 +70,25 @@ func (gm *MathCommand) Execute(ctx context.Context, vars mathexp.Vars) (mathexp. // ReduceCommand is an expression command for reduction of a timeseries such as a min, mean, or max. type ReduceCommand struct { - Reducer string - VarToReduce string - refID string + Reducer string + VarToReduce string + refID string + seriesMapper mathexp.ReduceMapper } // NewReduceCommand creates a new ReduceCMD. -func NewReduceCommand(refID, reducer, varToReduce string) *ReduceCommand { - // TODO: validate reducer here, before execution - return &ReduceCommand{ - Reducer: reducer, - VarToReduce: varToReduce, - refID: refID, +func NewReduceCommand(refID, reducer, varToReduce string, mapper mathexp.ReduceMapper) (*ReduceCommand, error) { + _, err := mathexp.GetReduceFunc(reducer) + if err != nil { + return nil, err } + + return &ReduceCommand{ + Reducer: reducer, + VarToReduce: varToReduce, + refID: refID, + seriesMapper: mapper, + }, nil } // UnmarshalReduceCommand creates a MathCMD from Grafana's frontend query. @@ -105,7 +112,36 @@ func UnmarshalReduceCommand(rn *rawNode) (*ReduceCommand, error) { return nil, fmt.Errorf("expected reducer to be a string, got %T for refId %v", rawReducer, rn.RefID) } - return NewReduceCommand(rn.RefID, redFunc, varToReduce), nil + var mapper mathexp.ReduceMapper = nil + settings, ok := rn.Query["settings"] + if ok { + switch s := settings.(type) { + case map[string]interface{}: + mode, ok := s["mode"] + if ok && mode != "" { + switch mode { + case "dropNN": + mapper = mathexp.DropNonNumber{} + case "replaceNN": + valueStr, ok := s["replaceWithValue"] + if !ok { + return nil, fmt.Errorf("expected settings.replaceWithValue to be specified when mode is 'replaceNN' for refId %v", rn.RefID) + } + switch value := valueStr.(type) { + case float64: + mapper = mathexp.ReplaceNonNumberWithValue{Value: value} + default: + return nil, fmt.Errorf("expected settings.replaceWithValue to be a number, got %T for refId %v", value, rn.RefID) + } + default: + return nil, fmt.Errorf("reducer mode %s is not supported for refId %v. Supported only: [dropNN,replaceNN]", mode, rn.RefID) + } + } + default: + return nil, fmt.Errorf("expected settings to be an object, got %T for refId %v", s, rn.RefID) + } + } + return NewReduceCommand(rn.RefID, redFunc, varToReduce, mapper) } // NeedsVars returns the variable names (refIds) that are dependencies @@ -123,7 +159,7 @@ func (gr *ReduceCommand) Execute(ctx context.Context, vars mathexp.Vars) (mathex if !ok { return newRes, fmt.Errorf("can only reduce type series, got type %v", val.Type()) } - num, err := series.Reduce(gr.refID, gr.Reducer) + num, err := series.Reduce(gr.refID, gr.Reducer, gr.seriesMapper) if err != nil { return newRes, err } diff --git a/pkg/expr/commands_test.go b/pkg/expr/commands_test.go new file mode 100644 index 00000000000..d467dd76a4b --- /dev/null +++ b/pkg/expr/commands_test.go @@ -0,0 +1,91 @@ +package expr + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/expr/mathexp" +) + +func Test_UnmarshalReduceCommand_Settings(t *testing.T) { + var tests = []struct { + name string + querySettings string + isError bool + expectedMapper mathexp.ReduceMapper + }{ + { + name: "no mapper function when settings is not specified", + querySettings: ``, + expectedMapper: nil, + }, + { + name: "no mapper function when mode is not specified", + querySettings: `, "settings" : { }`, + expectedMapper: nil, + }, + { + name: "error when settings is not object", + querySettings: `, "settings" : "drop-nan"`, + isError: true, + }, + { + name: "no mapper function when mode is empty", + querySettings: `, "settings" : { "mode": "" }`, + expectedMapper: nil, + }, + { + name: "error when mode is not known", + querySettings: `, "settings" : { "mode": "test" }`, + isError: true, + }, + { + name: "filterNonNumber function when mode is 'dropNN'", + querySettings: `, "settings" : { "mode": "dropNN" }`, + expectedMapper: mathexp.DropNonNumber{}, + }, + { + name: "replaceNanWithValue function when mode is 'dropNN'", + querySettings: `, "settings" : { "mode": "replaceNN" , "replaceWithValue": -12 }`, + expectedMapper: mathexp.ReplaceNonNumberWithValue{Value: -12}, + }, + { + name: "error if mode is 'replaceNN' but field replaceWithValue is not specified", + querySettings: `, "settings" : { "mode": "replaceNN" }`, + isError: true, + }, + { + name: "error if mode is 'replaceNN' but field replaceWithValue is not a number", + querySettings: `, "settings" : { "mode": "replaceNN", "replaceWithValue" : "-12" }`, + isError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + q := fmt.Sprintf(`{ "expression" : "$A", "reducer": "sum"%s }`, test.querySettings) + var qmap = make(map[string]interface{}) + require.NoError(t, json.Unmarshal([]byte(q), &qmap)) + + cmd, err := UnmarshalReduceCommand(&rawNode{ + RefID: "A", + Query: qmap, + QueryType: "", + TimeRange: TimeRange{}, + DataSource: nil, + }) + + if test.isError { + require.Error(t, err) + return + } + + require.NotNil(t, cmd) + + require.Equal(t, test.expectedMapper, cmd.seriesMapper) + }) + } +} diff --git a/pkg/expr/mathexp/exp.go b/pkg/expr/mathexp/exp.go index 43b9d11689e..afb00c3a74f 100644 --- a/pkg/expr/mathexp/exp.go +++ b/pkg/expr/mathexp/exp.go @@ -133,18 +133,14 @@ func (e *State) unarySeries(s Series, op string) (Series, error) { for i := 0; i < s.Len(); i++ { t, f := s.GetPoint(i) if f == nil { - if err := newSeries.SetPoint(i, t, nil); err != nil { - return newSeries, err - } + newSeries.SetPoint(i, t, nil) continue } newF, err := unaryOp(op, *f) if err != nil { return newSeries, err } - if err := newSeries.SetPoint(i, t, &newF); err != nil { - return newSeries, err - } + newSeries.SetPoint(i, t, &newF) } return newSeries, nil } @@ -437,9 +433,7 @@ func (e *State) biSeriesNumber(labels data.Labels, op string, s Series, scalarVa nF := math.NaN() t, f := s.GetPoint(i) if f == nil || scalarVal == nil { - if err := newSeries.SetPoint(i, t, nil); err != nil { - return newSeries, err - } + newSeries.SetPoint(i, t, nil) continue } if seriesFirst { @@ -450,9 +444,7 @@ func (e *State) biSeriesNumber(labels data.Labels, op string, s Series, scalarVa if err != nil { return newSeries, err } - if err := newSeries.SetPoint(i, t, &nF); err != nil { - return newSeries, err - } + newSeries.SetPoint(i, t, &nF) } return newSeries, nil } @@ -475,18 +467,14 @@ func (e *State) biSeriesSeries(labels data.Labels, op string, aSeries, bSeries S continue } if aF == nil || bF == nil { - if err := newSeries.AppendPoint(aIdx, aTime, nil); err != nil { - return newSeries, err - } + newSeries.AppendPoint(aTime, nil) continue } nF, err := binaryOp(op, *aF, *bF) if err != nil { return newSeries, err } - if err := newSeries.AppendPoint(aIdx, aTime, &nF); err != nil { - return newSeries, err - } + newSeries.AppendPoint(aTime, &nF) } return newSeries, nil } diff --git a/pkg/expr/mathexp/exp_scalar_no_test.go b/pkg/expr/mathexp/exp_scalar_no_test.go index 33b79e0ccb5..fa8a015e42a 100644 --- a/pkg/expr/mathexp/exp_scalar_no_test.go +++ b/pkg/expr/mathexp/exp_scalar_no_test.go @@ -116,28 +116,24 @@ func TestNumberExpr(t *testing.T) { results: Results{[]Value{makeNumber("", nil, float64Pointer(-2.0))}}, }, { - name: "binary: Scalar Op Number (Number will nil val) - currently Panics", + name: "binary: Scalar Op Number (Number will nil val) returns nil", expr: "1 + $A", + newErrIs: assert.NoError, + execErrIs: assert.NoError, + resultIs: assert.Equal, vars: Vars{"A": Results{[]Value{makeNumber("", nil, nil)}}}, - willPanic: true, + results: Results{[]Value{makeNumber("", nil, nil)}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - testBlock := func() { - e, err := New(tt.expr) - tt.newErrIs(t, err) - if e != nil { - res, err := e.Execute("", tt.vars) - tt.execErrIs(t, err) - tt.resultIs(t, tt.results, res) - } - } - if tt.willPanic { - assert.Panics(t, testBlock) - } else { - assert.NotPanics(t, testBlock) + e, err := New(tt.expr) + tt.newErrIs(t, err) + if e != nil { + res, err := e.Execute("", tt.vars) + tt.execErrIs(t, err) + tt.resultIs(t, tt.results, res) } }) } diff --git a/pkg/expr/mathexp/exp_test.go b/pkg/expr/mathexp/exp_test.go index e368545e5c1..85936c2ca74 100644 --- a/pkg/expr/mathexp/exp_test.go +++ b/pkg/expr/mathexp/exp_test.go @@ -16,10 +16,7 @@ type tp struct { func makeSeries(name string, labels data.Labels, points ...tp) Series { newSeries := NewSeries(name, labels, len(points)) for idx, p := range points { - err := newSeries.SetPoint(idx, p.t, p.f) - if err != nil { - panic(err) - } + newSeries.SetPoint(idx, p.t, p.f) } return newSeries } diff --git a/pkg/expr/mathexp/funcs.go b/pkg/expr/mathexp/funcs.go index 681d3d9afbd..a299e3462e5 100644 --- a/pkg/expr/mathexp/funcs.go +++ b/pkg/expr/mathexp/funcs.go @@ -226,9 +226,7 @@ func perFloat(e *State, val Value, floatF func(x float64) float64) (Value, error if f != nil { nF = floatF(*f) } - if err := newSeries.SetPoint(i, t, &nF); err != nil { - return newSeries, err - } + newSeries.SetPoint(i, t, &nF) } newVal = newSeries default: @@ -257,9 +255,7 @@ func perNullableFloat(e *State, val Value, floatF func(x *float64) *float64) (Va newSeries := NewSeries(e.RefID, resSeries.GetLabels(), resSeries.Len()) for i := 0; i < resSeries.Len(); i++ { t, f := resSeries.GetPoint(i) - if err := newSeries.SetPoint(i, t, floatF(f)); err != nil { - return newSeries, err - } + newSeries.SetPoint(i, t, floatF(f)) } newVal = newSeries default: diff --git a/pkg/expr/mathexp/reduce.go b/pkg/expr/mathexp/reduce.go index be8c7816c36..7bd55d72e30 100644 --- a/pkg/expr/mathexp/reduce.go +++ b/pkg/expr/mathexp/reduce.go @@ -3,10 +3,13 @@ package mathexp import ( "fmt" "math" + "strings" "github.com/grafana/grafana-plugin-sdk-go/data" ) +type ReducerFunc = func(fv *Float64Field) *float64 + func Sum(fv *Float64Field) *float64 { var sum float64 for i := 0; i < fv.Len(); i++ { @@ -75,38 +78,114 @@ func Last(fv *Float64Field) *float64 { f = math.NaN() return &f } - v := fv.GetValue(fv.Len() - 1) - f = *v - return &f + return fv.GetValue(fv.Len() - 1) +} + +func GetReduceFunc(rFunc string) (ReducerFunc, error) { + switch strings.ToLower(rFunc) { + case "sum": + return Sum, nil + case "mean": + return Avg, nil + case "min": + return Min, nil + case "max": + return Max, nil + case "count": + return Count, nil + case "last": + return Last, nil + default: + return nil, fmt.Errorf("reduction %v not implemented", rFunc) + } } // Reduce turns the Series into a Number based on the given reduction function -func (s Series) Reduce(refID, rFunc string) (Number, error) { +// if ReduceMapper is defined it applies it to the provided series and performs reduction of the resulting series. +// Otherwise, the reduction operation is done against the original series. +func (s Series) Reduce(refID, rFunc string, mapper ReduceMapper) (Number, error) { var l data.Labels if s.GetLabels() != nil { l = s.GetLabels().Copy() } number := NewNumber(refID, l) var f *float64 - fVec := s.Frame.Fields[seriesTypeValIdx] + series := s + if mapper != nil { + series = mapSeries(s, mapper) + } + fVec := series.Frame.Fields[seriesTypeValIdx] floatField := Float64Field(*fVec) - switch rFunc { - case "sum": - f = Sum(&floatField) - case "mean": - f = Avg(&floatField) - case "min": - f = Min(&floatField) - case "max": - f = Max(&floatField) - case "count": - f = Count(&floatField) - case "last": - f = Last(&floatField) - default: - return number, fmt.Errorf("reduction %v not implemented", rFunc) + reduceFunc, err := GetReduceFunc(rFunc) + if err != nil { + return number, err + } + f = reduceFunc(&floatField) + if f != nil && mapper != nil { + f = mapper.MapOutput(f) } number.SetValue(f) - return number, nil } + +type ReduceMapper interface { + MapInput(s *float64) *float64 + MapOutput(v *float64) *float64 +} + +// mapSeries creates a series where all points are mapped using the provided map function ReduceMapper.MapInput +func mapSeries(s Series, mapper ReduceMapper) Series { + newSeries := NewSeries(s.Frame.RefID, s.GetLabels(), 0) + for i := 0; i < s.Len(); i++ { + f := s.GetValue(i) + f = mapper.MapInput(f) + if f == nil { + continue + } + newFloat := *f + newSeries.AppendPoint(s.GetTime(i), &newFloat) + } + return newSeries +} + +type DropNonNumber struct { +} + +// MapInput returns nil if the input parameter is nil or point to either a NaN or a Inf +func (d DropNonNumber) MapInput(s *float64) *float64 { + if s == nil || math.IsNaN(*s) || math.IsInf(*s, 0) { + return nil + } + return s +} + +// MapOutput returns nil if the input parameter is nil or point to either a NaN or a Inf +func (d DropNonNumber) MapOutput(s *float64) *float64 { + if s != nil && math.IsNaN(*s) { + return nil + } + return s +} + +type ReplaceNonNumberWithValue struct { + Value float64 +} + +// MapInput returns a pointer to ReplaceNonNumberWithValue.Value if input parameter is nil or points to either a NaN or an Inf. +// Otherwise, returns the input pointer as is. +func (r ReplaceNonNumberWithValue) MapInput(v *float64) *float64 { + if v == nil || math.IsNaN(*v) || math.IsInf(*v, 0) { + return &r.Value + } else { + return v + } +} + +// MapOutput returns a pointer to ReplaceNonNumberWithValue.Value if input parameter is nil or points to either a NaN or an Inf. +// Otherwise, returns the input pointer as is. +func (r ReplaceNonNumberWithValue) MapOutput(s *float64) *float64 { + if s != nil && math.IsNaN(*s) { + return &r.Value + } + return s +} diff --git a/pkg/expr/mathexp/reduce_test.go b/pkg/expr/mathexp/reduce_test.go index 2bd13780018..e4576e555d7 100644 --- a/pkg/expr/mathexp/reduce_test.go +++ b/pkg/expr/mathexp/reduce_test.go @@ -2,6 +2,7 @@ package mathexp import ( "math" + "math/rand" "testing" "time" @@ -227,6 +228,19 @@ func TestSeriesReduce(t *testing.T) { }, }, }, + { + name: "last null series", + red: "last", + varToReduce: "A", + vars: seriesWithNil, + errIs: require.NoError, + resultsIs: require.Equal, + results: Results{ + []Value{ + makeNumber("", nil, nil), + }, + }, + }, } for _, tt := range tests { @@ -234,7 +248,7 @@ func TestSeriesReduce(t *testing.T) { results := Results{} seriesSet := tt.vars[tt.varToReduce] for _, series := range seriesSet.Values { - ns, err := series.Value().(*Series).Reduce("", tt.red) + ns, err := series.Value().(*Series).Reduce("", tt.red, nil) tt.errIs(t, err) if err != nil { return @@ -251,3 +265,252 @@ func TestSeriesReduce(t *testing.T) { }) } } + +var seriesNonNumbers = Vars{ + "A": Results{ + []Value{ + makeSeries("temp", nil, + tp{time.Unix(5, 0), NaN}, + tp{time.Unix(10, 0), float64Pointer(math.Inf(-1))}, + tp{time.Unix(15, 0), float64Pointer(math.Inf(1))}, + tp{time.Unix(15, 0), nil}), + }, + }, +} + +func TestSeriesReduceDropNN(t *testing.T) { + var tests = []struct { + name string + red string + vars Vars + varToReduce string + results Results + }{ + { + name: "dropNN: sum series", + red: "sum", + varToReduce: "A", + vars: aSeries, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(3)), + }, + }, + }, + { + name: "dropNN: sum series with a nil value", + red: "sum", + varToReduce: "A", + vars: seriesWithNil, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(2)), + }, + }, + }, + { + name: "dropNN: sum empty series", + red: "sum", + varToReduce: "A", + vars: seriesEmpty, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(0)), + }, + }, + }, + { + name: "dropNN: mean series with a nil value and real value", + red: "mean", + varToReduce: "A", + vars: seriesWithNil, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(2)), + }, + }, + }, + { + name: "DropNN: mean empty series", + red: "mean", + varToReduce: "A", + vars: seriesEmpty, + results: Results{ + []Value{ + makeNumber("", nil, nil), + }, + }, + }, + { + name: "DropNN: mean series that becomes empty after filtering non-number", + red: "mean", + varToReduce: "A", + vars: seriesNonNumbers, + results: Results{ + []Value{ + makeNumber("", nil, nil), + }, + }, + }, + { + name: "DropNN: count empty series", + red: "count", + varToReduce: "A", + vars: seriesEmpty, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(0)), + }, + }, + }, + { + name: "DropNN: count series with nil and value should only count real numbers", + red: "count", + varToReduce: "A", + vars: seriesWithNil, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(1)), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := Results{} + seriesSet := tt.vars[tt.varToReduce] + for _, series := range seriesSet.Values { + ns, err := series.Value().(*Series).Reduce("", tt.red, DropNonNumber{}) + require.NoError(t, err) + results.Values = append(results.Values, ns) + } + opt := cmp.Comparer(func(x, y float64) bool { + return (math.IsNaN(x) && math.IsNaN(y)) || x == y + }) + options := append([]cmp.Option{opt}, data.FrameTestCompareOptions()...) + if diff := cmp.Diff(tt.results, results, options...); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestSeriesReduceReplaceNN(t *testing.T) { + replaceWith := rand.Float64() + var tests = []struct { + name string + red string + vars Vars + varToReduce string + results Results + }{ + { + name: "replaceNN: sum series", + red: "sum", + varToReduce: "A", + vars: aSeries, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(3)), + }, + }, + }, + { + name: "replaceNN: sum series with a nil value", + red: "sum", + varToReduce: "A", + vars: seriesWithNil, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(replaceWith+2)), + }, + }, + }, + { + name: "replaceNN: sum empty series", + red: "sum", + varToReduce: "A", + vars: seriesEmpty, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(0)), + }, + }, + }, + { + name: "replaceNN: mean series with a nil value and real value", + red: "mean", + varToReduce: "A", + vars: seriesWithNil, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer((2+replaceWith)/2e0)), + }, + }, + }, + { + name: "replaceNN: mean empty series", + red: "mean", + varToReduce: "A", + vars: seriesEmpty, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(replaceWith)), + }, + }, + }, + { + name: "replaceNN: mean series that becomes empty after filtering non-number", + red: "mean", + varToReduce: "A", + vars: seriesNonNumbers, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(replaceWith)), + }, + }, + }, + { + name: "replaceNN: count empty series", + red: "count", + varToReduce: "A", + vars: seriesEmpty, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(0)), + }, + }, + }, + { + name: "replaceNN: count series with nil and value should only count real numbers", + red: "count", + varToReduce: "A", + vars: seriesWithNil, + results: Results{ + []Value{ + makeNumber("", nil, float64Pointer(2)), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := Results{} + seriesSet := tt.vars[tt.varToReduce] + for _, series := range seriesSet.Values { + ns, err := series.Value().(*Series).Reduce("", tt.red, ReplaceNonNumberWithValue{Value: replaceWith}) + require.NoError(t, err) + results.Values = append(results.Values, ns) + } + opt := cmp.Comparer(func(x, y float64) bool { + return (math.IsNaN(x) && math.IsNaN(y)) || x == y + }) + options := append([]cmp.Option{opt}, data.FrameTestCompareOptions()...) + if diff := cmp.Diff(tt.results, results, options...); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/expr/mathexp/resample.go b/pkg/expr/mathexp/resample.go index e568adc5fbd..13cd4b0914a 100644 --- a/pkg/expr/mathexp/resample.go +++ b/pkg/expr/mathexp/resample.go @@ -72,9 +72,7 @@ func (s Series) Resample(refID string, interval time.Duration, downsampler strin } value = tmp } - if err := resampled.SetPoint(idx, t, value); err != nil { - return resampled, err - } + resampled.SetPoint(idx, t, value) t = t.Add(interval) idx++ } diff --git a/pkg/expr/mathexp/type_series.go b/pkg/expr/mathexp/type_series.go index 8bb44439487..6748116c5fc 100644 --- a/pkg/expr/mathexp/type_series.go +++ b/pkg/expr/mathexp/type_series.go @@ -166,17 +166,15 @@ func (s Series) GetPoint(pointIdx int) (time.Time, *float64) { } // SetPoint sets the time and value on the corresponding vectors at the specified index. -func (s Series) SetPoint(pointIdx int, t time.Time, f *float64) (err error) { +func (s Series) SetPoint(pointIdx int, t time.Time, f *float64) { s.Frame.Fields[seriesTypeTimeIdx].Set(pointIdx, t) s.Frame.Fields[seriesTypeValIdx].Set(pointIdx, f) - return } // AppendPoint appends a point (time/value). -func (s Series) AppendPoint(pointIdx int, t time.Time, f *float64) (err error) { +func (s Series) AppendPoint(t time.Time, f *float64) { s.Frame.Fields[seriesTypeTimeIdx].Append(t) s.Frame.Fields[seriesTypeValIdx].Append(f) - return } // Len returns the length of the series. @@ -214,8 +212,8 @@ func (ss SortSeriesByTime) Len() int { return Series(ss).Len() } func (ss SortSeriesByTime) Swap(i, j int) { iTimeVal, iFVal := Series(ss).GetPoint(i) jTimeVal, jFVal := Series(ss).GetPoint(j) - _ = Series(ss).SetPoint(j, iTimeVal, iFVal) - _ = Series(ss).SetPoint(i, jTimeVal, jFVal) + Series(ss).SetPoint(j, iTimeVal, iFVal) + Series(ss).SetPoint(i, jTimeVal, jFVal) } func (ss SortSeriesByTime) Less(i, j int) bool { diff --git a/pkg/middleware/request_metrics.go b/pkg/middleware/request_metrics.go index 6429f95f318..d4923f233a6 100644 --- a/pkg/middleware/request_metrics.go +++ b/pkg/middleware/request_metrics.go @@ -45,50 +45,60 @@ func init() { } // RequestMetrics is a middleware handler that instruments the request. -func RequestMetrics(features featuremgmt.FeatureToggles) func(handler string) web.Handler { - return func(handler string) web.Handler { - return func(res http.ResponseWriter, req *http.Request, c *web.Context) { - rw := res.(web.ResponseWriter) - now := time.Now() - httpRequestsInFlight.Inc() - defer httpRequestsInFlight.Dec() +func RequestMetrics(features featuremgmt.FeatureToggles) web.Handler { + return func(res http.ResponseWriter, req *http.Request, c *web.Context) { + if strings.HasPrefix(c.Req.URL.Path, "/public/") || c.Req.URL.Path == "robots.txt" || c.Req.URL.Path == "/metrics" { c.Next() + return + } - status := rw.Status() + rw := res.(web.ResponseWriter) + now := time.Now() + httpRequestsInFlight.Inc() + defer httpRequestsInFlight.Dec() + c.Map(c.Req) + c.Next() - code := sanitizeCode(status) - method := sanitizeMethod(req.Method) + handler := "unknown" - // enable histogram and disable summaries + counters for http requests. - if features.IsEnabled(featuremgmt.FlagDisableHttpRequestHistogram) { - duration := time.Since(now).Nanoseconds() / int64(time.Millisecond) - metrics.MHttpRequestTotal.WithLabelValues(handler, code, method).Inc() - metrics.MHttpRequestSummary.WithLabelValues(handler, code, method).Observe(float64(duration)) - } else { - // avoiding the sanitize functions for in the new instrumentation - // since they dont make much sense. We should remove them later. - histogram := httpRequestDurationHistogram. - WithLabelValues(handler, strconv.Itoa(rw.Status()), req.Method) - if traceID, ok := cw.ExtractSampledTraceID(c.Req.Context()); ok { - // Need to type-convert the Observer to an - // ExemplarObserver. This will always work for a - // HistogramVec. - histogram.(prometheus.ExemplarObserver).ObserveWithExemplar( - time.Since(now).Seconds(), prometheus.Labels{"traceID": traceID}, - ) - return - } - histogram.Observe(time.Since(now).Seconds()) + if routeOperation, exists := RouteOperationNameFromContext(c.Req.Context()); exists { + handler = routeOperation + } + + status := rw.Status() + + code := sanitizeCode(status) + method := sanitizeMethod(req.Method) + + // enable histogram and disable summaries + counters for http requests. + if features.IsEnabled(featuremgmt.FlagDisableHttpRequestHistogram) { + duration := time.Since(now).Nanoseconds() / int64(time.Millisecond) + metrics.MHttpRequestTotal.WithLabelValues(handler, code, method).Inc() + metrics.MHttpRequestSummary.WithLabelValues(handler, code, method).Observe(float64(duration)) + } else { + // avoiding the sanitize functions for in the new instrumentation + // since they dont make much sense. We should remove them later. + histogram := httpRequestDurationHistogram. + WithLabelValues(handler, code, req.Method) + if traceID, ok := cw.ExtractSampledTraceID(c.Req.Context()); ok { + // Need to type-convert the Observer to an + // ExemplarObserver. This will always work for a + // HistogramVec. + histogram.(prometheus.ExemplarObserver).ObserveWithExemplar( + time.Since(now).Seconds(), prometheus.Labels{"traceID": traceID}, + ) + return } + histogram.Observe(time.Since(now).Seconds()) + } - switch { - case strings.HasPrefix(req.RequestURI, "/api/datasources/proxy"): - countProxyRequests(status) - case strings.HasPrefix(req.RequestURI, "/api/"): - countApiRequests(status) - default: - countPageRequests(status) - } + switch { + case strings.HasPrefix(req.RequestURI, "/api/datasources/proxy"): + countProxyRequests(status) + case strings.HasPrefix(req.RequestURI, "/api/"): + countApiRequests(status) + default: + countPageRequests(status) } } } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index cf171ac8874..a98268e2146 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -65,6 +65,7 @@ import ( serviceaccountsmanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/teamguardian" teamguardianDatabase "github.com/grafana/grafana/pkg/services/teamguardian/database" teamguardianManager "github.com/grafana/grafana/pkg/services/teamguardian/manager" @@ -205,6 +206,7 @@ var wireSet = wire.NewSet( wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), + wire.Bind(new(sqlstore.Store), new(*sqlstore.SQLStore)), ) var wireTestSet = wire.NewSet( @@ -217,6 +219,8 @@ var wireTestSet = wire.NewSet( wire.Bind(new(notifications.Service), new(*notifications.NotificationServiceMock)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationServiceMock)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationServiceMock)), + mockstore.NewSQLStoreMock, + wire.Bind(new(sqlstore.Store), new(*mockstore.SQLStoreMock)), ) func Initialize(cla setting.CommandLineArgs, opts Options, apiOpts api.ServerOptions) (*Server, error) { diff --git a/pkg/services/accesscontrol/middleware/middleware.go b/pkg/services/accesscontrol/middleware/middleware.go index ca5427b2c3b..333cae520af 100644 --- a/pkg/services/accesscontrol/middleware/middleware.go +++ b/pkg/services/accesscontrol/middleware/middleware.go @@ -91,7 +91,7 @@ func buildScopeParams(c *models.ReqContext) accesscontrol.ScopeParams { type OrgIDGetter func(c *models.ReqContext) (int64, error) -func AuthorizeInOrgMiddleware(ac accesscontrol.AccessControl, db *sqlstore.SQLStore) func(web.Handler, OrgIDGetter, accesscontrol.Evaluator) web.Handler { +func AuthorizeInOrgMiddleware(ac accesscontrol.AccessControl, db sqlstore.Store) func(web.Handler, OrgIDGetter, accesscontrol.Evaluator) web.Handler { return func(fallback web.Handler, getTargetOrg OrgIDGetter, evaluator accesscontrol.Evaluator) web.Handler { if ac.IsDisabled() { return fallback diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 651ed0f3009..3763a03f094 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -13,19 +13,16 @@ var ( Description: "encrypt secrets", State: FeatureStateBeta, }, - { Name: "httpclientprovider_azure_auth", Description: "use http client for azure auth", State: FeatureStateBeta, }, { - Name: "service-accounts", - Description: "support service accounts", - State: FeatureStateBeta, - RequiresLicense: true, + Name: "service-accounts", + Description: "support service accounts", + State: FeatureStateBeta, }, - { Name: "database_metrics", Description: "Add prometheus metrics for database tables", diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go index 36d8dc1d5b3..e8344434860 100644 --- a/pkg/services/ngalert/api/api_prometheus.go +++ b/pkg/services/ngalert/api/api_prometheus.go @@ -9,9 +9,9 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/services/ngalert/eval" apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -40,8 +40,8 @@ func (srv PrometheusSrv) RouteGetAlertStatuses(c *models.ReqContext) response.Re for _, alertState := range srv.manager.GetAll(c.OrgId) { startsAt := alertState.StartsAt valString := "" - if len(alertState.Results) > 0 && alertState.State == eval.Alerting { - valString = alertState.Results[0].EvaluationString + if alertState.State == eval.Alerting { + valString = alertState.LastEvaluationString } alertResponse.Data.Alerts = append(alertResponse.Data.Alerts, &apimodels.Alert{ Labels: map[string]string(alertState.Labels), @@ -173,8 +173,8 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res for _, alertState := range srv.manager.GetStatesForRuleUID(c.OrgId, rule.UID) { activeAt := alertState.StartsAt valString := "" - if len(alertState.Results) > 0 && alertState.State == eval.Alerting { - valString = alertState.Results[0].EvaluationString + if alertState.State == eval.Alerting { + valString = alertState.LastEvaluationString } alert := &apimodels.Alert{ Labels: map[string]string(alertState.Labels), diff --git a/pkg/services/ngalert/notifier/channels/discord.go b/pkg/services/ngalert/notifier/channels/discord.go index 22cb9bd23b3..3937a1cfe4e 100644 --- a/pkg/services/ngalert/notifier/channels/discord.go +++ b/pkg/services/ngalert/notifier/channels/discord.go @@ -103,6 +103,7 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, u := tmpl(d.WebhookURL) if tmplErr != nil { d.log.Warn("failed to template Discord message", "err", tmplErr.Error()) + return false, tmplErr } body, err := json.Marshal(bodyJSON) diff --git a/pkg/services/ngalert/notifier/channels/discord_test.go b/pkg/services/ngalert/notifier/channels/discord_test.go index 3a7efd27c22..51a89589e64 100644 --- a/pkg/services/ngalert/notifier/channels/discord_test.go +++ b/pkg/services/ngalert/notifier/channels/discord_test.go @@ -3,6 +3,7 @@ package channels import ( "context" "encoding/json" + "errors" "net/url" "testing" @@ -99,6 +100,14 @@ func TestDiscordNotifier(t *testing.T) { settings: `{}`, expInitError: `failed to validate receiver "discord_testing" of type "discord": could not find webhook url property in settings`, }, + { + name: "Invalid template returns error", + settings: `{ + "url": "http://localhost", + "message": "{{ template \"invalid.template\" }}" + }`, + expMsgError: errors.New("template: :1:12: executing \"\" at <{{template \"invalid.template\"}}>: template \"invalid.template\" not defined"), + }, { name: "Default config with one alert, use default discord username", settings: `{ diff --git a/pkg/services/ngalert/schedule/compat.go b/pkg/services/ngalert/schedule/compat.go index 17dc9057f6a..fda9b1da948 100644 --- a/pkg/services/ngalert/schedule/compat.go +++ b/pkg/services/ngalert/schedule/compat.go @@ -35,8 +35,8 @@ func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.Post nL := alertState.Labels.Copy() nA := data.Labels(alertState.Annotations).Copy() - if len(alertState.Results) > 0 { - nA["__value_string__"] = alertState.Results[0].EvaluationString + if alertState.LastEvaluationString != "" { + nA["__value_string__"] = alertState.LastEvaluationString } var urlStr string diff --git a/pkg/services/ngalert/schedule/compat_test.go b/pkg/services/ngalert/schedule/compat_test.go index 704fbaff730..6c67cc04b23 100644 --- a/pkg/services/ngalert/schedule/compat_test.go +++ b/pkg/services/ngalert/schedule/compat_test.go @@ -100,11 +100,7 @@ func Test_stateToPostableAlert(t *testing.T) { alertState := randomState(tc.state) alertState.Annotations = randomMapOfStrings() expectedValueString := util.GenerateShortUID() - alertState.Results = []state.Evaluation{ - { - EvaluationString: expectedValueString, - }, - } + alertState.LastEvaluationString = expectedValueString result := stateToPostableAlert(alertState, appURL) diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 5a3d2e03cb3..a1db3b23ef2 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -96,16 +96,16 @@ func (st *Manager) Warm() { st.log.Error("error getting cacheId for entry", "msg", err.Error()) } stateForEntry := &State{ - AlertRuleUID: entry.RuleUID, - OrgID: entry.RuleOrgID, - CacheId: cacheId, - Labels: lbs, - State: translateInstanceState(entry.CurrentState), - Results: []Evaluation{}, - StartsAt: entry.CurrentStateSince, - EndsAt: entry.CurrentStateEnd, - LastEvaluationTime: entry.LastEvalTime, - Annotations: ruleForEntry.Annotations, + AlertRuleUID: entry.RuleUID, + OrgID: entry.RuleOrgID, + CacheId: cacheId, + Labels: lbs, + State: translateInstanceState(entry.CurrentState), + LastEvaluationString: "", + StartsAt: entry.CurrentStateSince, + EndsAt: entry.CurrentStateEnd, + LastEvaluationTime: entry.LastEvalTime, + Annotations: ruleForEntry.Annotations, } states = append(states, stateForEntry) } @@ -158,11 +158,11 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu currentState.LastEvaluationTime = result.EvaluatedAt currentState.EvaluationDuration = result.EvaluationDuration currentState.Results = append(currentState.Results, Evaluation{ - EvaluationTime: result.EvaluatedAt, - EvaluationState: result.State, - EvaluationString: result.EvaluationString, - Values: NewEvaluationValues(result.Values), + EvaluationTime: result.EvaluatedAt, + EvaluationState: result.State, + Values: NewEvaluationValues(result.Values), }) + currentState.LastEvaluationString = result.EvaluationString currentState.TrimResults(alertRule) oldState := currentState.State diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 9149bc31bb6..21ec276a9d8 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -437,16 +437,6 @@ func TestProcessEvalResults(t *testing.T) { }, State: eval.Pending, Results: []state.Evaluation{ - { - EvaluationTime: evaluationTime.Add(10 * time.Second), - EvaluationState: eval.Alerting, - Values: make(map[string]*float64), - }, - { - EvaluationTime: evaluationTime.Add(20 * time.Second), - EvaluationState: eval.NoData, - Values: make(map[string]*float64), - }, { EvaluationTime: evaluationTime.Add(30 * time.Second), EvaluationState: eval.Alerting, @@ -528,16 +518,6 @@ func TestProcessEvalResults(t *testing.T) { }, State: eval.NoData, Results: []state.Evaluation{ - { - EvaluationTime: evaluationTime, - EvaluationState: eval.Alerting, - Values: make(map[string]*float64), - }, - { - EvaluationTime: evaluationTime.Add(10 * time.Second), - EvaluationState: eval.Alerting, - Values: make(map[string]*float64), - }, { EvaluationTime: evaluationTime.Add(20 * time.Second), EvaluationState: eval.Alerting, @@ -1336,11 +1316,6 @@ func TestProcessEvalResults(t *testing.T) { }, State: eval.Alerting, Results: []state.Evaluation{ - { - EvaluationTime: evaluationTime, - EvaluationState: eval.Normal, - Values: make(map[string]*float64), - }, { EvaluationTime: evaluationTime.Add(30 * time.Second), EvaluationState: eval.Alerting, @@ -1428,11 +1403,6 @@ func TestProcessEvalResults(t *testing.T) { }, State: eval.NoData, Results: []state.Evaluation{ - { - EvaluationTime: evaluationTime, - EvaluationState: eval.Normal, - Values: make(map[string]*float64), - }, { EvaluationTime: evaluationTime.Add(30 * time.Second), EvaluationState: eval.Alerting, diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 02f1f41440c..2947f789761 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -12,26 +12,26 @@ import ( ) type State struct { - AlertRuleUID string - OrgID int64 - CacheId string - State eval.State - Resolved bool - Results []Evaluation - StartsAt time.Time - EndsAt time.Time - LastEvaluationTime time.Time - EvaluationDuration time.Duration - LastSentAt time.Time - Annotations map[string]string - Labels data.Labels - Error error + AlertRuleUID string + OrgID int64 + CacheId string + State eval.State + Resolved bool + Results []Evaluation + LastEvaluationString string + StartsAt time.Time + EndsAt time.Time + LastEvaluationTime time.Time + EvaluationDuration time.Duration + LastSentAt time.Time + Annotations map[string]string + Labels data.Labels + Error error } type Evaluation struct { - EvaluationTime time.Time - EvaluationState eval.State - EvaluationString string + EvaluationTime time.Time + EvaluationState eval.State // Values contains the RefID and value of reduce and math expressions. // It does not contain values for classic conditions as the values // in classic conditions do not have a RefID. @@ -152,7 +152,7 @@ func (a *State) Equals(b *State) bool { } func (a *State) TrimResults(alertRule *ngModels.AlertRule) { - numBuckets := 2 * (int64(alertRule.For.Seconds()) / alertRule.IntervalSeconds) + numBuckets := int64(alertRule.For.Seconds()) / alertRule.IntervalSeconds if numBuckets == 0 { numBuckets = 10 // keep at least 10 evaluations in the event For is set to 0 } diff --git a/pkg/services/secrets/database/database.go b/pkg/services/secrets/database/database.go index b9e0ac81863..19a5e509e93 100644 --- a/pkg/services/secrets/database/database.go +++ b/pkg/services/secrets/database/database.go @@ -87,3 +87,42 @@ func (ss *SecretsStoreImpl) DeleteDataKey(ctx context.Context, name string) erro return err }) } + +func (ss *SecretsStoreImpl) ReEncryptDataKeys( + ctx context.Context, + providers map[secrets.ProviderID]secrets.Provider, + currProvider secrets.ProviderID, +) error { + return ss.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + keys := make([]*secrets.DataKey, 0) + if err := sess.Table(dataKeysTable).Find(&keys); err != nil { + return err + } + + for _, k := range keys { + provider, ok := providers[k.Provider] + if !ok { + return fmt.Errorf("could not find encryption provider '%s'", k.Provider) + } + + decrypted, err := provider.Decrypt(ctx, k.EncryptedData) + if err != nil { + return err + } + + // Updating current data key by re-encrypting it with current provider. + // Accessing the current provider within providers map should be safe. + k.Provider = currProvider + k.EncryptedData, err = providers[currProvider].Encrypt(ctx, decrypted) + if err != nil { + return err + } + + if _, err := sess.Table(dataKeysTable).Where("name = ?", k.Name).Update(k); err != nil { + return err + } + } + + return nil + }) +} diff --git a/pkg/services/secrets/fakes/fake_service.go b/pkg/services/secrets/fakes/fake_service.go index b743c3af7ec..9a9b2c1c35a 100644 --- a/pkg/services/secrets/fakes/fake_service.go +++ b/pkg/services/secrets/fakes/fake_service.go @@ -40,6 +40,10 @@ func (f FakeSecretsService) GetDecryptedValue(_ context.Context, sjd map[string] return fallback } +func (f FakeSecretsService) ReEncryptDataKeys(_ context.Context) error { + return nil +} + func (f FakeSecretsService) CurrentProviderID() string { return "fakeProvider" } diff --git a/pkg/services/secrets/fakes/fake_store.go b/pkg/services/secrets/fakes/fake_store.go index 6ac990f6d1f..53a89956bcd 100644 --- a/pkg/services/secrets/fakes/fake_store.go +++ b/pkg/services/secrets/fakes/fake_store.go @@ -45,3 +45,7 @@ func (f FakeSecretsStore) DeleteDataKey(_ context.Context, name string) error { delete(f.store, name) return nil } + +func (f FakeSecretsStore) ReEncryptDataKeys(_ context.Context, _ map[secrets.ProviderID]secrets.Provider, _ secrets.ProviderID) error { + return nil +} diff --git a/pkg/services/secrets/manager/manager.go b/pkg/services/secrets/manager/manager.go index 14cf179537c..b31f0c7f73b 100644 --- a/pkg/services/secrets/manager/manager.go +++ b/pkg/services/secrets/manager/manager.go @@ -353,6 +353,17 @@ func (s *SecretsService) GetProviders() map[secrets.ProviderID]secrets.Provider return s.providers } +func (s *SecretsService) ReEncryptDataKeys(ctx context.Context) error { + err := s.store.ReEncryptDataKeys(ctx, s.providers, s.currentProviderID) + if err != nil { + return nil + } + + // Invalidate cache + s.dataKeyCache = make(map[string]dataKeyCacheItem) + return err +} + // These variables are used to test the code // responsible for periodically cleaning up // data encryption keys cache. diff --git a/pkg/services/secrets/manager/manager_test.go b/pkg/services/secrets/manager/manager_test.go index 7080c008dcf..ada2608c712 100644 --- a/pkg/services/secrets/manager/manager_test.go +++ b/pkg/services/secrets/manager/manager_test.go @@ -319,3 +319,42 @@ func TestSecretsService_Run(t *testing.T) { assert.True(t, svc.dataKeyCache[dataKeyID].expiry.After(time.Now().Add(dekTTL))) }) } + +func TestSecretsService_ReEncryptDataKeys(t *testing.T) { + ctx := context.Background() + sql := sqlstore.InitTestDB(t) + store := database.ProvideSecretsStore(sql) + svc := SetupTestService(t, store) + + // Encrypt to generate data encryption key + withoutScope := secrets.WithoutScope() + ciphertext, err := svc.Encrypt(ctx, []byte("grafana"), withoutScope) + require.NoError(t, err) + + t.Run("existing key should be re-encrypted", func(t *testing.T) { + prevDataKeys, err := store.GetAllDataKeys(ctx) + require.NoError(t, err) + require.Len(t, prevDataKeys, 1) + + err = svc.ReEncryptDataKeys(ctx) + require.NoError(t, err) + + reEncryptedDataKeys, err := store.GetAllDataKeys(ctx) + require.NoError(t, err) + require.Len(t, reEncryptedDataKeys, 1) + + assert.NotEqual(t, prevDataKeys[0].EncryptedData, reEncryptedDataKeys[0].EncryptedData) + }) + + t.Run("data keys cache should be invalidated", func(t *testing.T) { + // Decrypt to ensure data key is cached + _, err := svc.Decrypt(ctx, ciphertext) + require.NoError(t, err) + require.NotEmpty(t, svc.dataKeyCache) + + err = svc.ReEncryptDataKeys(ctx) + require.NoError(t, err) + + assert.Empty(t, svc.dataKeyCache) + }) +} diff --git a/pkg/services/secrets/secrets.go b/pkg/services/secrets/secrets.go index d1d3ee3cf68..f4b2fa7cc8f 100644 --- a/pkg/services/secrets/secrets.go +++ b/pkg/services/secrets/secrets.go @@ -24,6 +24,8 @@ type Service interface { DecryptJsonData(ctx context.Context, sjd map[string][]byte) (map[string]string, error) GetDecryptedValue(ctx context.Context, sjd map[string][]byte, key, fallback string) string + + ReEncryptDataKeys(ctx context.Context) error } // Store defines methods to interact with secrets storage @@ -33,6 +35,7 @@ type Store interface { CreateDataKey(ctx context.Context, dataKey DataKey) error CreateDataKeyWithDBSession(ctx context.Context, dataKey DataKey, sess *xorm.Session) error DeleteDataKey(ctx context.Context, name string) error + ReEncryptDataKeys(ctx context.Context, providers map[ProviderID]Provider, currProvider ProviderID) error } // Provider is a key encryption key provider for envelope encryption diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 985ad0f5637..c9891da1d75 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -47,7 +47,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints( } auth := acmiddleware.Middleware(api.accesscontrol) - api.RouterRegister.Group("/api/org/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) { + api.RouterRegister.Group("/api/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) { serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts)) serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.RetrieveServiceAccount)) serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount)) diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index c02c7a815a2..4749974901a 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -24,7 +24,7 @@ import ( ) var ( - serviceaccountIDPath = "/api/org/serviceaccounts/%v" + serviceaccountIDPath = "/api/serviceaccounts/%v" ) // test the accesscontrol endpoints diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index a48576097cf..747a3ee3c2e 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -61,9 +61,3 @@ func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgI } return sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID) } - -func (sa *ServiceAccountsService) Migrated(ctx context.Context, orgID int64) bool { - // TODO: implement migration logic - // change this to return true for development of service accounts page - return false -} diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go index a5718ff9043..c1452e7d416 100644 --- a/pkg/services/serviceaccounts/serviceaccounts.go +++ b/pkg/services/serviceaccounts/serviceaccounts.go @@ -9,7 +9,6 @@ import ( type Service interface { CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*models.User, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error - Migrated(ctx context.Context, orgID int64) bool } type Store interface { diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 7783611925d..b666447fc5b 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -9,7 +9,7 @@ import ( ) type SQLStoreMock struct { - SQLStore *sqlstore.SQLStore + ExpectedUser *models.User ExpectedError error } @@ -98,6 +98,7 @@ func (m SQLStoreMock) CreateUser(ctx context.Context, cmd models.CreateUserComma } func (m SQLStoreMock) GetUserById(ctx context.Context, query *models.GetUserByIdQuery) error { + query.Result = m.ExpectedUser return m.ExpectedError } @@ -150,7 +151,7 @@ func (m SQLStoreMock) DeleteUser(ctx context.Context, cmd *models.DeleteUserComm } func (m SQLStoreMock) UpdateUserPermissions(userID int64, isAdmin bool) error { - return nil // TODO: Implement + return m.ExpectedError } func (m SQLStoreMock) SetUserHelpFlag(ctx context.Context, cmd *models.SetUserHelpFlagCommand) error { @@ -158,7 +159,11 @@ func (m SQLStoreMock) SetUserHelpFlag(ctx context.Context, cmd *models.SetUserHe } func (m SQLStoreMock) CreateTeam(name string, email string, orgID int64) (models.Team, error) { - return models.Team{}, nil // TODO: Implement + return models.Team{ + Name: name, + Email: email, + OrgId: orgID, + }, nil } func (m SQLStoreMock) UpdateTeam(ctx context.Context, cmd *models.UpdateTeamCommand) error { diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 0e09553c1ef..b38c2f9e4ea 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -30,7 +30,7 @@ func (ss *SQLStore) addUserQueryAndCommandHandlers() { bus.AddHandler("sql", ss.GetUserProfile) bus.AddHandler("sql", SearchUsers) bus.AddHandler("sql", ss.GetUserOrgList) - bus.AddHandler("sql", DisableUser) + bus.AddHandler("sql", ss.DisableUser) bus.AddHandler("sql", ss.BatchDisableUsers) bus.AddHandler("sql", ss.DeleteUser) bus.AddHandler("sql", ss.SetUserHelpFlag) @@ -320,7 +320,7 @@ func (ss *SQLStore) CreateUser(ctx context.Context, cmd models.CreateUserCommand return user, err } -func (ss *SQLStore) GetUserById(ctx context.Context, query *models.GetUserByIdQuery) error { +func (ss SQLStore) GetUserById(ctx context.Context, query *models.GetUserByIdQuery) error { return withDbSession(ctx, x, func(sess *DBSession) error { user := new(models.User) has, err := sess.ID(query.Id).Get(user) @@ -721,7 +721,7 @@ func SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error { return err } -func DisableUser(ctx context.Context, cmd *models.DisableUserCommand) error { +func (ss *SQLStore) DisableUser(ctx context.Context, cmd *models.DisableUserCommand) error { user := models.User{} sess := x.Table("user") diff --git a/public/app/features/dashboard/components/ShareModal/ShareEmbed.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareEmbed.test.tsx new file mode 100644 index 00000000000..c54132fc6a8 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareEmbed.test.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { ShareEmbed } from './ShareEmbed'; +import { render, screen } from '@testing-library/react'; +import config from 'app/core/config'; +import { DashboardModel, PanelModel } from '../../state'; + +jest.mock('app/features/dashboard/services/TimeSrv', () => ({ + getTimeSrv: () => ({ + timeRange: () => { + return { from: new Date(1000), to: new Date(2000) }; + }, + }), +})); + +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + sidemenu: true, + user: {}, + isSignedIn: false, + isGrafanaAdmin: false, + isEditor: false, + hasEditPermissionFolders: false, + }, +})); + +function mockLocationHref(href: string) { + const location = window.location; + + let search = ''; + const searchPos = href.indexOf('?'); + if (searchPos >= 0) { + search = href.substring(searchPos); + } + + // @ts-ignore + delete window.location; + (window as any).location = { + ...location, + href, + origin: new URL(href).origin, + search, + }; +} + +describe('ShareEmbed', () => { + let originalBootData: any; + + beforeAll(() => { + originalBootData = config.bootData; + config.appUrl = 'http://dashboards.grafana.com/'; + + config.bootData = { + user: { + orgId: 1, + }, + }; + }); + + afterAll(() => { + config.bootData = originalBootData; + }); + + it('generates the correct embed url for a dashboard', () => { + const mockDashboard = new DashboardModel({ + uid: 'mockDashboardUid', + }); + const mockPanel = new PanelModel({ + id: 'mockPanelId', + }); + mockLocationHref(`http://dashboards.grafana.com/d/${mockDashboard.uid}?orgId=1`); + render(); + + const embedUrl = screen.getByTestId('share-embed-html'); + expect(embedUrl).toBeInTheDocument(); + expect(embedUrl).toHaveTextContent( + `http://dashboards.grafana.com/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}` + ); + }); + + it('generates the correct embed url for a dashboard set to the homepage in the grafana config', () => { + mockLocationHref('http://dashboards.grafana.com/?orgId=1'); + const mockDashboard = new DashboardModel({ + uid: 'mockDashboardUid', + }); + const mockPanel = new PanelModel({ + id: 'mockPanelId', + }); + render(); + + const embedUrl = screen.getByTestId('share-embed-html'); + expect(embedUrl).toBeInTheDocument(); + expect(embedUrl).toHaveTextContent( + `http://dashboards.grafana.com/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}` + ); + }); + + it('generates the correct embed url for a snapshot', () => { + const mockSlug = 'mockSlug'; + mockLocationHref(`http://dashboards.grafana.com/dashboard/snapshot/${mockSlug}?orgId=1`); + const mockDashboard = new DashboardModel({ + uid: 'mockDashboardUid', + }); + const mockPanel = new PanelModel({ + id: 'mockPanelId', + }); + render(); + + const embedUrl = screen.getByTestId('share-embed-html'); + expect(embedUrl).toBeInTheDocument(); + expect(embedUrl).toHaveTextContent( + `http://dashboards.grafana.com/dashboard-solo/snapshot/${mockSlug}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}` + ); + }); + + it('generates the correct embed url for a scripted dashboard', () => { + const mockSlug = 'scripted.js'; + mockLocationHref(`http://dashboards.grafana.com/dashboard/script/${mockSlug}?orgId=1`); + const mockDashboard = new DashboardModel({ + uid: 'mockDashboardUid', + }); + const mockPanel = new PanelModel({ + id: 'mockPanelId', + }); + render(); + + const embedUrl = screen.getByTestId('share-embed-html'); + expect(embedUrl).toBeInTheDocument(); + expect(embedUrl).toHaveTextContent( + `http://dashboards.grafana.com/dashboard-solo/script/${mockSlug}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}` + ); + }); +}); diff --git a/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx b/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx index 92100d76a56..f1c97a727a8 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx @@ -34,10 +34,10 @@ export class ShareEmbed extends PureComponent { } buildIframeHtml = () => { - const { panel } = this.props; + const { panel, dashboard } = this.props; const { useCurrentTimeRange, selectedTheme } = this.state; - const iframeHtml = buildIframeHtml(useCurrentTimeRange, selectedTheme, panel); + const iframeHtml = buildIframeHtml(useCurrentTimeRange, dashboard.uid, selectedTheme, panel); this.setState({ iframeHtml }); }; @@ -92,6 +92,7 @@ export class ShareEmbed extends PureComponent { the user viewing that page need to be signed into Grafana for the graph to load." >