Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

apikey: Add integration test for GQL API keys #3400

Merged
merged 9 commits into from
Nov 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions test/integration/gqlapikeys.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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, 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 })

// 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.locator('nav').locator('text=API Keys').click()
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 <p> 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('li', { 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('li', { 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')

type Key = {
id: string
name: string
}

const originalID = data.data.gqlAPIKeys.find(
(key: Key) => key.name === originalName,
).id
const duplicateID = data.data.gqlAPIKeys.find(
(key: Key) => 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('li', { hasText: originalName })).not.toBeVisible()

// Attempt to delete the duplicate using the original 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, and can no longer be used
expect(resp.status()).toBe(401)

// Delete the duplicate via the UI menu
// find a div with a <p> 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('li', { hasText: duplicateName })).not.toBeVisible()
})
29 changes: 29 additions & 0 deletions test/integration/lib/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const flags = new Set<string>()

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()
}
21 changes: 3 additions & 18 deletions test/integration/setup/scan-flags.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions web/src/app/admin/AdminAPIKeys.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useMemo, useState } from 'react'
import { Button, Card, Grid, Typography } from '@mui/material'
import { Add } from '@mui/icons-material'
import makeStyles from '@mui/styles/makeStyles'
Expand Down Expand Up @@ -60,7 +60,8 @@ export default function AdminAPIKeys(): JSX.Element {
const [deleteDialog, setDeleteDialog] = useState<string | undefined>()

// 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 <GenericError error={error.message} />
Expand Down
1 change: 1 addition & 0 deletions web/src/app/admin/admin-api-keys/AdminAPIKeyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
1 change: 1 addition & 0 deletions web/src/app/main/components/ToolbarPageTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down