feat(launchpad): authenticate with dashboard (#17649)

* wip: add auth

* add example making authenticated request to runs

* simplify template

* remove cosnle

* revert some files

* Add test

* fix buld

* support logging out

* test: fix ConfigFile

Co-authored-by: ElevateBart <ledouxb@gmail.com>
This commit is contained in:
Lachlan Miller
2021-08-11 14:13:08 +10:00
committed by GitHub
parent da827512df
commit bcefdaba6d
21 changed files with 379 additions and 80 deletions

View File

@@ -18,6 +18,9 @@ type App {
"""All known projects for the app"""
projects: [Project!]!
"""Cypress Cloud"""
user: User
}
"""
@@ -46,9 +49,15 @@ type Mutation {
"""Create a Cypress config file for a new project"""
appCreateConfigFile(code: String!, configFilename: String!): App
"""Auth with Cypress Cloud"""
authenticate: App
"""Initializes the plugins for the current active project"""
initializePlugins: Project
"""Log out of Cypress Cloud"""
logout: App
"""Installs the dependencies for the component testing step"""
wizardInstallDependencies: Wizard
@@ -102,6 +111,12 @@ type Project {
type Query {
app: App!
"""Get runs for a given projectId on Cypress Cloud"""
runs(projectId: String!): App
"""Namespace for user and authentication"""
user: User
"""Metadata about the wizard, null if we arent showing the wizard"""
wizard: Wizard
}
@@ -123,6 +138,13 @@ type TestingTypeInfo {
title: String
}
"""Namespace for information related to authentication with Cypress Cloud"""
type User {
authToken: String
email: String
name: String
}
"""
The Wizard is a container for any state associated with initial onboarding to Cypress
"""

View File

@@ -51,4 +51,7 @@ export abstract class BaseActions {
}
abstract createProjectBase(input: NxsMutationArgs<'addProject'>['input']): ProjectBaseContract | Promise<ProjectBaseContract>
abstract authenticate (): Promise<void>
abstract logout (): Promise<void>
abstract getRuns ({ projectId }: { projectId: string }): Promise<void>
}

View File

@@ -1,5 +1,5 @@
import type { BaseActions } from '../actions/BaseActions'
import { App, Wizard } from '../entities'
import { App, User, Wizard } from '../entities'
import type { Project } from '../entities/Project'
/**
@@ -12,6 +12,7 @@ import type { Project } from '../entities/Project'
export abstract class BaseContext {
abstract readonly actions: BaseActions
abstract projects: Project[]
abstract user?: User = undefined
wizard = new Wizard()
app = new App(this)

View File

@@ -1,6 +1,7 @@
import { nxs, NxsResult } from 'nexus-decorators'
import type { BaseContext } from '../context/BaseContext'
import { Project } from './Project'
import { User } from './User'
@nxs.objectType({
description: 'Namespace for information related to the app',
@@ -28,4 +29,11 @@ export class App {
get activeProject (): NxsResult<'App', 'activeProject'> {
return this.projects.find((p) => p.isCurrent) ?? null
}
@nxs.field.type(() => User, {
description: 'Cypress Cloud',
})
get user (): NxsResult<'App', 'user'> {
return this.ctx.user ?? null
}
}

View File

@@ -107,6 +107,31 @@ export const mutation = mutationType({
},
})
t.field('authenticate', {
type: 'App',
description: 'Auth with Cypress Cloud',
async resolve (_root, args, ctx) {
// already authenticated this session - just return
if (ctx.user) {
return ctx.app
}
await ctx.actions.authenticate()
return ctx.app
},
})
t.field('logout', {
type: 'App',
description: 'Log out of Cypress Cloud',
async resolve (_root, args, ctx) {
await ctx.actions.logout()
return ctx.app
},
})
t.field('initializePlugins', {
type: 'Project',
description: 'Initializes the plugins for the current active project',

View File

@@ -1,6 +1,7 @@
import { nxs, NxsQueryResult, NxsResult } from 'nexus-decorators'
import { nxs, NxsArgs, NxsQueryResult, NxsResult } from 'nexus-decorators'
import type { NexusGenTypes } from '../gen/nxs.gen'
import { App } from './App'
import { User } from './User'
import { Wizard } from './Wizard'
@nxs.objectType({
@@ -18,4 +19,23 @@ export class Query {
wizard (args: unknown, ctx: NexusGenTypes['context']): NxsResult<'App', 'wizard'> {
return ctx.wizard
}
@nxs.field.type(() => User, {
description: 'Namespace for user and authentication',
})
user (args: unknown, ctx: NexusGenTypes['context']): NxsResult<'App', 'user'> {
return ctx.user ?? null
}
@nxs.field.type(() => App, {
description: 'Get runs for a given projectId on Cypress Cloud',
args (t) {
t.nonNull.string('projectId')
},
})
async runs (args: NxsArgs<'Query', 'runs'>, ctx: NexusGenTypes['context']): Promise<NxsResult<'App', 'runs'>> {
await ctx.actions.getRuns({ projectId: args.projectId })
return ctx.app
}
}

View File

@@ -0,0 +1,29 @@
import { nxs, NxsResult } from 'nexus-decorators'
export interface AuthenticatedUser {
name: string
email: string
authToken: string
}
@nxs.objectType({
description: 'Namespace for information related to authentication with Cypress Cloud',
})
export class User {
constructor (private user: AuthenticatedUser) {}
@nxs.field.string()
get name (): NxsResult<'User', 'name'> {
return this.user?.name ?? null
}
@nxs.field.string()
get email (): NxsResult<'User', 'email'> {
return this.user?.email ?? null
}
@nxs.field.string()
get authToken (): NxsResult<'User', 'authToken'> {
return this.user?.authToken ?? null
}
}

View File

@@ -6,6 +6,8 @@ export * from './Project'
export * from './Query'
export * from './User'
export * from './TestingTypeInfo'
export * from './Wizard'

View File

@@ -9,6 +9,7 @@ import type { BaseContext } from "./../context/BaseContext"
import type { App } from "./../entities/App"
import type { Project } from "./../entities/Project"
import type { Query } from "./../entities/Query"
import type { User } from "./../entities/User"
import type { TestingTypeInfo } from "./../entities/TestingTypeInfo"
import type { Wizard } from "./../entities/Wizard"
import type { WizardBundler } from "./../entities/WizardBundler"
@@ -79,6 +80,7 @@ export interface NexusGenObjects {
Project: Project;
Query: Query;
TestingTypeInfo: TestingTypeInfo;
User: User;
Wizard: Wizard;
WizardBundler: WizardBundler;
WizardFrontendFramework: WizardFrontendFramework;
@@ -100,11 +102,14 @@ export interface NexusGenFieldTypes {
activeProject: NexusGenRootTypes['Project'] | null; // Project
isFirstOpen: boolean; // Boolean!
projects: NexusGenRootTypes['Project'][]; // [Project!]!
user: NexusGenRootTypes['User'] | null; // User
}
Mutation: { // field return type
addProject: NexusGenRootTypes['Project']; // Project!
appCreateConfigFile: NexusGenRootTypes['App'] | null; // App
authenticate: NexusGenRootTypes['App'] | null; // App
initializePlugins: NexusGenRootTypes['Project'] | null; // Project
logout: NexusGenRootTypes['App'] | null; // App
wizardInstallDependencies: NexusGenRootTypes['Wizard'] | null; // Wizard
wizardNavigate: NexusGenRootTypes['Wizard'] | null; // Wizard
wizardNavigateForward: NexusGenRootTypes['Wizard'] | null; // Wizard
@@ -125,6 +130,8 @@ export interface NexusGenFieldTypes {
}
Query: { // field return type
app: NexusGenRootTypes['App']; // App!
runs: NexusGenRootTypes['App'] | null; // App
user: NexusGenRootTypes['User'] | null; // User
wizard: NexusGenRootTypes['Wizard'] | null; // Wizard
}
TestingTypeInfo: { // field return type
@@ -132,6 +139,11 @@ export interface NexusGenFieldTypes {
id: NexusGenEnums['TestingTypeEnum']; // TestingTypeEnum!
title: string | null; // String
}
User: { // field return type
authToken: string | null; // String
email: string | null; // String
name: string | null; // String
}
Wizard: { // field return type
allBundlers: NexusGenRootTypes['WizardBundler'][]; // [WizardBundler!]!
bundler: NexusGenRootTypes['WizardBundler'] | null; // WizardBundler
@@ -170,11 +182,14 @@ export interface NexusGenFieldTypeNames {
activeProject: 'Project'
isFirstOpen: 'Boolean'
projects: 'Project'
user: 'User'
}
Mutation: { // field return type name
addProject: 'Project'
appCreateConfigFile: 'App'
authenticate: 'App'
initializePlugins: 'Project'
logout: 'App'
wizardInstallDependencies: 'Wizard'
wizardNavigate: 'Wizard'
wizardNavigateForward: 'Wizard'
@@ -195,6 +210,8 @@ export interface NexusGenFieldTypeNames {
}
Query: { // field return type name
app: 'App'
runs: 'App'
user: 'User'
wizard: 'Wizard'
}
TestingTypeInfo: { // field return type name
@@ -202,6 +219,11 @@ export interface NexusGenFieldTypeNames {
id: 'TestingTypeEnum'
title: 'String'
}
User: { // field return type name
authToken: 'String'
email: 'String'
name: 'String'
}
Wizard: { // field return type name
allBundlers: 'WizardBundler'
bundler: 'WizardBundler'
@@ -260,6 +282,11 @@ export interface NexusGenArgTypes {
type: NexusGenEnums['TestingTypeEnum']; // TestingTypeEnum!
}
}
Query: {
runs: { // args
projectId: string; // String!
}
}
Wizard: {
sampleCode: { // args
lang: NexusGenEnums['WizardCodeLanguage']; // WizardCodeLanguage!

View File

@@ -7,6 +7,7 @@ export interface TestSourceTypeLookup {
Project: NexusGenObjects['Project'],
Query: NexusGenObjects['Query'],
TestingTypeInfo: NexusGenObjects['TestingTypeInfo'],
User: NexusGenObjects['User'],
Wizard: NexusGenObjects['Wizard'],
WizardBundler: NexusGenObjects['WizardBundler'],
WizardFrontendFramework: NexusGenObjects['WizardFrontendFramework'],
@@ -25,6 +26,7 @@ export const testUnionType = unionType({
'Project',
'Query',
'TestingTypeInfo',
'User',
'Wizard',
'WizardBundler',
'WizardFrontendFramework',

View File

@@ -1,6 +1 @@
const path = require('path')
module.exports = {
spec: 'test/unit/**/*.spec.{js,ts,tsx,jsx}',
require: path.resolve(__dirname, 'spec_helper.js'),
}
module.exports = {}

View File

@@ -0,0 +1,33 @@
import { expect } from 'chai'
import { initGraphql, makeRequest, TestContext } from './utils'
describe('App', () => {
describe('authenticate', () => {
it('assigns a new user', async () => {
const context = new TestContext()
const { endpoint } = await initGraphql(context)
const result = await makeRequest(endpoint, `
mutation Authenticate {
authenticate {
user {
email
name
authToken
}
}
}
`)
expect(result).to.eql({
authenticate: {
user: {
email: 'test@cypress.io',
name: 'cypress test',
authToken: 'test-auth-token',
},
},
})
})
})
})

View File

@@ -1,70 +1,7 @@
import type { NxsMutationArgs } from 'nexus-decorators'
import snapshot from 'snap-shot-it'
import { expect } from 'chai'
import axios from 'axios'
import { BaseActions, BaseContext, Project, Wizard } from '../../src'
import { startGraphQLServer, closeGraphQLServer, setServerContext } from '../../src/server'
class TestActions extends BaseActions {
installDependencies () {}
createConfigFile () {}
createProjectBase (input: NxsMutationArgs<'addProject'>['input']) {
return new Project({
isCurrent: true,
projectRoot: '/foo/bar',
projectBase: {
isOpen: true,
initializePlugins: () => Promise.resolve(),
},
})
}
}
interface TestContextInjectionOptions {
wizard?: Wizard
}
class TestContext extends BaseContext {
projects: Project[] = []
readonly actions: BaseActions
constructor ({ wizard }: TestContextInjectionOptions = {}) {
super()
this.actions = new TestActions(this)
if (wizard) {
this.wizard = wizard
}
}
}
/**
* Creates a new GraphQL server to query during integration tests.
* Also performsn any clean up from previous tests.
* Optionally you may provide a context to orchestrate testing
* specific scenarios or states.
*/
const initGraphql = async (ctx: BaseContext) => {
await closeGraphQLServer()
if (ctx) {
setServerContext(ctx)
}
return startGraphQLServer({ port: 51515 })
}
const makeRequest = async (endpoint: string, query: string) => {
const res = await axios.post(endpoint,
JSON.stringify({
query,
}),
{
headers: {
'Content-Type': 'application/json',
},
})
return res.data.data
}
import { Wizard } from '../../src'
import { initGraphql, makeRequest, TestContext } from './utils'
describe('Wizard', () => {
describe('sampleCode', () => {

View File

@@ -0,0 +1,82 @@
import type { NxsMutationArgs } from 'nexus-decorators'
import axios from 'axios'
import { BaseActions, BaseContext, Project, User, Wizard } from '../../src'
import { startGraphQLServer, closeGraphQLServer, setServerContext } from '../../src/server'
class TestActions extends BaseActions {
async authenticate () {
this.ctx.user = new User({
authToken: 'test-auth-token',
email: 'test@cypress.io',
name: 'cypress test',
})
}
async getRuns ({ projectId }: { projectId: string }) {}
async logout () {
this.ctx.user = undefined
}
installDependencies () {}
createConfigFile () {}
createProjectBase (input: NxsMutationArgs<'addProject'>['input']) {
return new Project({
isCurrent: true,
projectRoot: '/foo/bar',
projectBase: {
isOpen: true,
initializePlugins: () => Promise.resolve(),
},
})
}
}
interface TestContextInjectionOptions {
wizard?: Wizard
}
export class TestContext extends BaseContext {
projects: Project[] = []
readonly actions: BaseActions
user: undefined
constructor ({ wizard }: TestContextInjectionOptions = {}) {
super()
this.actions = new TestActions(this)
if (wizard) {
this.wizard = wizard
}
}
}
/**
* Creates a new GraphQL server to query during integration tests.
* Also performsn any clean up from previous tests.
* Optionally you may provide a context to orchestrate testing
* specific scenarios or states.
*/
export const initGraphql = async (ctx: BaseContext) => {
await closeGraphQLServer()
if (ctx) {
setServerContext(ctx)
}
return startGraphQLServer({ port: 51515 })
}
export const makeRequest = async (endpoint: string, query: string) => {
const res = await axios.post(endpoint,
JSON.stringify({
query,
}),
{
headers: {
'Content-Type': 'application/json',
},
})
return res.data.data
}

View File

@@ -0,0 +1,70 @@
<template>
<Button @click="handleAuth">Click to Authenticate</Button>
<div v-if="error">An error occurred while authenticating: {{ error }}</div>
<div v-else-if="data?.user?.email">
<p>
Congrats {{ data?.user?.email }}, you authenticated with Cypress Cloud.
</p>
<Button @click="handleLogout">Log out</Button>
</div>
<div v-else>
Nothing here yet
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { gql } from "@urql/core"
import { useMutation } from "@urql/vue"
import { AuthenticateDocument, UserFragment, LogoutDocument } from '../generated/graphql'
import Button from '../components/button/Button.vue'
gql`
fragment User on App {
user {
email
authToken
}
}
`
gql`
mutation authenticate {
authenticate {
...User
}
}
`
gql`
mutation Logout {
logout {
...User
}
}
`
const authenticate = useMutation(AuthenticateDocument)
const logout = useMutation(LogoutDocument)
const error = ref<string>()
const handleAuth = async () => {
const result = await authenticate.executeMutation({})
error.value = result.error?.message ?? undefined
}
const handleLogout = async () => {
// clear this for good measure
error.value = undefined
await logout.executeMutation({})
}
const props = defineProps<{
gql?: UserFragment | null
}>()
const data = computed(() => props.gql)
</script>

View File

@@ -1,5 +1,6 @@
<template>
<template v-if="!loading && wizard">
<Auth :gql="app" />
<h1 class="text-3xl mt-12 text-center">{{ wizard.title }}</h1>
<p class="text-center text-gray-400 my-2 mx-10" v-html="wizard.description" />
<div class="mx-5">
@@ -25,6 +26,7 @@
<script lang="ts">
import { defineComponent, watch, computed } from "vue";
import Auth from './Auth.vue'
import TestingType from "./TestingType.vue";
import EnvironmentSetup from "./EnvironmentSetup.vue";
import InstallDependencies from "./InstallDependencies.vue";
@@ -39,6 +41,7 @@ query Wizard {
app {
isFirstOpen
...ProjectRoot
...User
}
wizard {
step
@@ -58,6 +61,7 @@ export default defineComponent({
TestingType,
EnvironmentSetup,
InstallDependencies,
Auth,
ConfigFile,
OpenBrowser,
},

View File

@@ -1,7 +1,16 @@
import type { NxsMutationArgs } from 'nexus-decorators'
import { ProjectBase } from '../project-base'
import type { ServerContext } from './ServerContext'
import { BaseActions } from '@packages/graphql'
import { AuthenticatedUser, BaseActions, User } from '@packages/graphql'
// @ts-ignore
import user from '@packages/server/lib/user'
// @ts-ignore
import auth from '@packages/server/lib/gui/auth'
// @ts-ignore
import api from '@packages/server/lib/api'
/**
*
@@ -22,4 +31,22 @@ export class ServerActions extends BaseActions {
options: {},
})
}
async authenticate () {
const user: AuthenticatedUser = await auth.start(() => {}, 'launchpad')
this.ctx.user = new User(user)
}
async logout () {
await user.logOut()
this.ctx.user = undefined
}
async getRuns ({ projectId }: { projectId: string }) {
const runs = await api.getProjectRuns(projectId, this.ctx.user?.authToken)
/* eslint-disable-next-line no-console */
console.log({ runs })
}
}

View File

@@ -1,8 +1,23 @@
import { ServerActions } from './ServerActions'
import { Project, BaseContext } from '@packages/graphql'
import { Project, BaseContext, User, AuthenticatedUser } from '@packages/graphql'
// @ts-ignore
import user from '@packages/server/lib/user'
export class ServerContext extends BaseContext {
readonly actions = new ServerActions(this)
user?: User
constructor () {
super()
user.get().then((cachedUser: AuthenticatedUser) => {
// cache returns empty object if user is undefined
this.user = Object.keys(cachedUser).length > 0
? new User(cachedUser)
: undefined
})
}
projects: Project[] = []
}

View File

@@ -4,6 +4,7 @@ import { ServerActions } from '../ServerActions'
export class ServerContext extends BaseContext {
readonly actions = new ServerActions(this)
user: undefined
projects: Project[] = []
}

View File

@@ -1,4 +0,0 @@
export interface ProjectBaseContract {
isOpen: boolean
initializePlugins(): Promise<unknown>
}

View File

@@ -40,7 +40,7 @@ const nullifyUnserializableValues = (obj) => {
return null
}
return val
return undefined
})
}