From b8cab3b9fe105d3b61bf7f88b0b1a73414c4b73a Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 1 Nov 2023 11:52:49 -0500 Subject: [PATCH 1/8] fix data reload when adding first key --- web/src/app/admin/AdminAPIKeys.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/app/admin/AdminAPIKeys.tsx b/web/src/app/admin/AdminAPIKeys.tsx index f7d8b7e1f6..97373e8faf 100644 --- a/web/src/app/admin/AdminAPIKeys.tsx +++ b/web/src/app/admin/AdminAPIKeys.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useMemo, useState } from 'react' import makeStyles from '@mui/styles/makeStyles' import { Button, Grid, Typography, Card } from '@mui/material' import { Add } from '@mui/icons-material' @@ -58,7 +58,8 @@ export default function AdminAPIKeys(): JSX.Element { const [deleteDialog, setDeleteDialog] = useState() // Get API Key triggers/actions - const [{ data, fetching, error }] = useQuery({ query }) + const context = useMemo(() => ({ additionalTypenames: ['GQLAPIKey'] }), []) + const [{ data, fetching, error }] = useQuery({ query, context }) if (error) { return From d0d53831d1dcc2971dd41f70e425c68280ca8510 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 1 Nov 2023 11:53:05 -0500 Subject: [PATCH 2/8] fix `Api` -> `API` in title bar --- web/src/app/main/components/ToolbarPageTitle.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/app/main/components/ToolbarPageTitle.tsx b/web/src/app/main/components/ToolbarPageTitle.tsx index 3ad43f5796..71a75fd5bc 100644 --- a/web/src/app/main/components/ToolbarPageTitle.tsx +++ b/web/src/app/main/components/ToolbarPageTitle.tsx @@ -32,6 +32,7 @@ const toTitleCase = (str: string): string => .replace('Limits', 'System Limits') .replace('Admin ', 'Admin: ') .replace(/Config$/, 'Configuration') + .replace('Api', 'API') // todo: not needed once appbar is using same color prop for dark/light modes const getContrastColor = (theme: Theme): string => { From c6bc04bc5d1661afd962d5bedc499018178c406e Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 1 Nov 2023 11:53:14 -0500 Subject: [PATCH 3/8] add name to input field --- web/src/app/admin/admin-api-keys/AdminAPIKeyForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/app/admin/admin-api-keys/AdminAPIKeyForm.tsx b/web/src/app/admin/admin-api-keys/AdminAPIKeyForm.tsx index c81dbcd33d..282b43d0b4 100644 --- a/web/src/app/admin/admin-api-keys/AdminAPIKeyForm.tsx +++ b/web/src/app/admin/admin-api-keys/AdminAPIKeyForm.tsx @@ -121,6 +121,7 @@ export default function AdminAPIKeyForm( fullWidth multiline label='Example Query' + name='query' placeholder='Enter GraphQL query here...' value={exampleQuery} onChange={(e) => setExampleQuery(e.target.value)} From 3830bdd51ea4e1de99cfc22a3e8e1efcca899d2c Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 1 Nov 2023 12:15:11 -0500 Subject: [PATCH 4/8] add api key integration test --- test/integration/gqlapikeys.spec.ts | 152 ++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 test/integration/gqlapikeys.spec.ts diff --git a/test/integration/gqlapikeys.spec.ts b/test/integration/gqlapikeys.spec.ts new file mode 100644 index 0000000000..087cd65dd6 --- /dev/null +++ b/test/integration/gqlapikeys.spec.ts @@ -0,0 +1,152 @@ +import { test, expect } from '@playwright/test' +import { adminSessionFile, baseURLFromFlags } from './lib' +import Chance from 'chance' +const c = new Chance() + +test.describe.configure({ mode: 'parallel' }) +test.use({ storageState: adminSessionFile }) + +// 1. Create a new API key (using query) +// 2. Verify the API key is listed in the table +// 3. Duplicate the API key +// 4. Verify the duplicate API key is listed in the table +// 5. Use the duplicate to delete the original +// 6. Verify the original is no longer listed in the table +// 7. Verify deleting the duplicate using the original fails (key deleted) +// 8. Delete the duplicate via the UI + +const query = ` +query ListAPIKeys { + gqlAPIKeys { + id + name + } +} + +mutation DeleteAPIKey($id: ID!) { + deleteGQLAPIKey(id: $id) +} +` + +test('GQL API keys', async ({ page, request }) => { + const baseName = + 'apikeytest ' + + c.string({ length: 12, casing: 'lower', symbols: false, alpha: true }) + + // add 3 as a suffix so that the duplicate code will increment it to 4, and we can distinguish it from the original. + const originalName = baseName + ' 3' + const duplicateName = baseName + ' 4' + + const descrtiption = c.sentence({ words: 5 }) + + await page.goto(baseURLFromFlags(['gql-api-keys'])) + + // click on Admin, then API Keys + await page.click('text=Admin') + await page.click('text=API Keys') + await page.click('text=Create API Key') + + await page.fill('[name="name"]', originalName) + await page.fill('[name="description"]', descrtiption) + await page.click('[aria-haspopup="listbox"]') + // click the li with the text "Admin" + await page.click('li:text("Admin")') + + await page.click('text=example query') + await page.fill('[name="query"]', query) + + await page.click('text=Submit') + + // get the token from `[aria-label="Copy"]` + const originalToken = await page.textContent('[aria-label="Copy"]') + + await page.click('text=Okay') + + // expect we have a

tag with the name as the text + await expect(page.locator('p', { hasText: originalName })).toBeVisible() + + // click on it to open the drawer + await page.locator('p', { hasText: originalName }).click() + + await page.click('text=Duplicate') + await page.click('text=Submit') + + const duplicateToken = await page.textContent('[aria-label="Copy"]') + + await page.click('text=Okay') + + await expect(page.locator('p', { hasText: duplicateName })).toBeVisible() + + const gqlURL = + baseURLFromFlags(['gql-api-keys']).replace(/\/$/, '') + '/api/graphql' + let resp = await request.post(gqlURL, { + headers: { + Authorization: `Bearer ${duplicateToken}`, + }, + data: { query, operationName: 'ListAPIKeys' }, + }) + + expect(resp.status()).toBe(200) + const data = await resp.json() + + expect(data).toHaveProperty('data') + expect(data.data).toHaveProperty('gqlAPIKeys') + + const originalID = data.data.gqlAPIKeys.find( + (key: any) => key.name === originalName, + ).id + const duplicateID = data.data.gqlAPIKeys.find( + (key: any) => key.name === duplicateName, + ).id + expect(originalID).toBeDefined() + expect(duplicateID).toBeDefined() + + // Delete the original using the duplicate via fetch call + resp = await request.post(gqlURL, { + headers: { + Authorization: `Bearer ${duplicateToken}`, + }, + data: { + query, + operationName: 'DeleteAPIKey', + variables: { + id: originalID, + }, + }, + }) + + expect(resp.status()).toBe(200) + + await page.reload() + + await expect(page.locator('p', { hasText: originalName })).not.toBeVisible() + + // Delete the original using the duplicate via fetch call + resp = await request.post(gqlURL, { + headers: { + Authorization: `Bearer ${originalToken}`, + }, + data: { + query, + operationName: 'DeleteAPIKey', + variables: { + id: duplicateID, + }, + }, + }) + + // expect the delete to fail, since the original was already deleted + expect(resp.status()).toBe(401) + + // Delete the duplicate via the UI menu + // find a div with a

tag with the text duplicateName, then click [aria-label="Other Actions"] + await page + .locator('li', { hasText: duplicateName }) + .locator('[aria-label="Other Actions"]') + .click() + await page.locator('[role=menuitem]', { hasText: 'Delete' }).click() + await page.click('text=Confirm') + + // expect the duplicate to be gone + await expect(page.locator('p', { hasText: duplicateName })).not.toBeVisible() +}) From fbd0c44aa6e83a47fb6cef78d8f750831eb81a56 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 1 Nov 2023 12:16:52 -0500 Subject: [PATCH 5/8] fix ts type --- test/integration/gqlapikeys.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/integration/gqlapikeys.spec.ts b/test/integration/gqlapikeys.spec.ts index 087cd65dd6..39ef6da174 100644 --- a/test/integration/gqlapikeys.spec.ts +++ b/test/integration/gqlapikeys.spec.ts @@ -92,11 +92,16 @@ test('GQL API keys', async ({ page, request }) => { expect(data).toHaveProperty('data') expect(data.data).toHaveProperty('gqlAPIKeys') + type Key = { + id: string + name: string + } + const originalID = data.data.gqlAPIKeys.find( - (key: any) => key.name === originalName, + (key: Key) => key.name === originalName, ).id const duplicateID = data.data.gqlAPIKeys.find( - (key: any) => key.name === duplicateName, + (key: Key) => key.name === duplicateName, ).id expect(originalID).toBeDefined() expect(duplicateID).toBeDefined() From 8fb35b9e6af489a57269332afc95daf9e2b4d588 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 1 Nov 2023 12:20:44 -0500 Subject: [PATCH 6/8] fix comment --- test/integration/gqlapikeys.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/gqlapikeys.spec.ts b/test/integration/gqlapikeys.spec.ts index 39ef6da174..6878ac8259 100644 --- a/test/integration/gqlapikeys.spec.ts +++ b/test/integration/gqlapikeys.spec.ts @@ -126,7 +126,7 @@ test('GQL API keys', async ({ page, request }) => { await expect(page.locator('p', { hasText: originalName })).not.toBeVisible() - // Delete the original using the duplicate via fetch call + // Attempt to delete the duplicate using the original via fetch call resp = await request.post(gqlURL, { headers: { Authorization: `Bearer ${originalToken}`, @@ -140,7 +140,7 @@ test('GQL API keys', async ({ page, request }) => { }, }) - // expect the delete to fail, since the original was already deleted + // expect the delete to fail, since the original was already deleted, and can no longer be used expect(resp.status()).toBe(401) // Delete the duplicate via the UI menu From a5b7cdf3b865d01cf82b7cefb5afb48ea000e4b7 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 1 Nov 2023 17:30:27 -0500 Subject: [PATCH 7/8] fix experimental flag handling in playwright --- test/integration/lib/flags.ts | 29 ++++++++++++++++++++++++++++ test/integration/setup/scan-flags.ts | 21 +++----------------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/test/integration/lib/flags.ts b/test/integration/lib/flags.ts index c3c0465ec5..642e2d9f8f 100644 --- a/test/integration/lib/flags.ts +++ b/test/integration/lib/flags.ts @@ -16,3 +16,32 @@ export function baseURLFromFlags(flags: string[]): string { return srv.url.replace(/\/health$/, '') } + +const validFlagRx = /^[a-z-]+$/ + +// collectFlags will return a list of all unique flag combinations used in the +// provided data. +export function collectFlags(data: string): Array { + const flags = new Set() + + while (true) { + const idx = data.indexOf('baseURLFromFlags(') + if (idx < 0) break + + data = data.slice(idx + 17) + const end = data.indexOf(')') + if (end < 0) break + + const flagSet = JSON.parse(data.slice(0, end).replace(/'/g, '"')) + if (!Array.isArray(flagSet)) continue + + flagSet.sort().forEach((f) => { + if (!validFlagRx.test(f)) throw new Error('invalid flag ' + f) + }) + flags.add(flagSet.sort().join(',')) + + data = data.slice(end) + } + + return Array.from(flags.keys()).sort() +} diff --git a/test/integration/setup/scan-flags.ts b/test/integration/setup/scan-flags.ts index 83e8f60e41..d9bf9ac641 100644 --- a/test/integration/setup/scan-flags.ts +++ b/test/integration/setup/scan-flags.ts @@ -1,8 +1,6 @@ import { globSync } from 'glob' import { readFileSync } from 'fs' - -const callRx = /baseURLFromFlags\(([^)]+)\)/g -const validFlagsRx = /^[a-z,-]+$/ +import { collectFlags } from '../lib' // scanUniqueFlagCombos() is used to generate a list of unique flag combinations // based on calls to baseURLFromFlags in the integration tests. @@ -12,21 +10,8 @@ export function scanUniqueFlagCombos(): string[] { for (const file of files) { const content = readFileSync(file, 'utf8') - const m = callRx.exec(content) - if (!m) continue - - m.slice(1).forEach((match) => { - // The regex will include the entire parameter, including the surrounding - // square brackets, so we can just parse it as JSON after fixing the - // quotes. - const items = JSON.parse(match.replace(/'/g, '"')) as string[] - if (!Array.isArray(items)) throw new Error('not array in ' + file) - - const set = items.sort().join(',') - if (!validFlagsRx.test(set)) throw new Error('invalid flags in ' + file) - - flags.add(set) - }) + const flagSets = collectFlags(content) + for (const flagSet of flagSets) flags.add(flagSet) } return Array.from(flags.keys()).sort() From a8649d53e67da0d499941260cb8195234a8fe596 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 1 Nov 2023 17:40:26 -0500 Subject: [PATCH 8/8] skip gql test on mobile --- test/integration/gqlapikeys.spec.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/integration/gqlapikeys.spec.ts b/test/integration/gqlapikeys.spec.ts index 6878ac8259..b70373c730 100644 --- a/test/integration/gqlapikeys.spec.ts +++ b/test/integration/gqlapikeys.spec.ts @@ -28,7 +28,10 @@ mutation DeleteAPIKey($id: ID!) { } ` -test('GQL API keys', async ({ page, request }) => { +test('GQL API keys', async ({ page, request, isMobile }) => { + // skip this test if we're running on mobile + if (isMobile) return + const baseName = 'apikeytest ' + c.string({ length: 12, casing: 'lower', symbols: false, alpha: true }) @@ -43,7 +46,8 @@ test('GQL API keys', async ({ page, request }) => { // click on Admin, then API Keys await page.click('text=Admin') - await page.click('text=API Keys') + + await page.locator('nav').locator('text=API Keys').click() await page.click('text=Create API Key') await page.fill('[name="name"]', originalName) @@ -66,7 +70,7 @@ test('GQL API keys', async ({ page, request }) => { await expect(page.locator('p', { hasText: originalName })).toBeVisible() // click on it to open the drawer - await page.locator('p', { hasText: originalName }).click() + await page.locator('li', { hasText: originalName }).click() await page.click('text=Duplicate') await page.click('text=Submit') @@ -75,7 +79,7 @@ test('GQL API keys', async ({ page, request }) => { await page.click('text=Okay') - await expect(page.locator('p', { hasText: duplicateName })).toBeVisible() + await expect(page.locator('li', { hasText: duplicateName })).toBeVisible() const gqlURL = baseURLFromFlags(['gql-api-keys']).replace(/\/$/, '') + '/api/graphql' @@ -124,7 +128,7 @@ test('GQL API keys', async ({ page, request }) => { await page.reload() - await expect(page.locator('p', { hasText: originalName })).not.toBeVisible() + await expect(page.locator('li', { hasText: originalName })).not.toBeVisible() // Attempt to delete the duplicate using the original via fetch call resp = await request.post(gqlURL, { @@ -153,5 +157,5 @@ test('GQL API keys', async ({ page, request }) => { await page.click('text=Confirm') // expect the duplicate to be gone - await expect(page.locator('p', { hasText: duplicateName })).not.toBeVisible() + await expect(page.locator('li', { hasText: duplicateName })).not.toBeVisible() })