Browse Source

test(cypress): WIP - add tests for api token and user management

Signed-off-by: Pranav C <pranavxc@gmail.com>
pull/4134/head
Pranav C 2 years ago
parent
commit
d10ac2546f
  1. 15
      packages/nc-gui/components/account/SignupSettings.vue
  2. 7
      packages/nc-gui/components/account/Token.vue
  3. 8
      packages/nc-gui/components/account/UserList.vue
  4. 6
      packages/nc-gui/components/account/UsersModal.vue
  5. 1
      packages/nc-gui/lang/en.json
  6. 11
      packages/nc-gui/pages/account/index.vue
  7. 9
      packages/nc-gui/pages/account/index/[page].vue
  8. 68
      packages/nocodb/src/lib/meta/api/orgTokenApis.ts
  9. 2
      packages/nocodb/src/lib/meta/helpers/apiMetrics.ts
  10. 140
      scripts/cypress/integration/common/5c_super_user_role.js

15
packages/nc-gui/components/account/SignupSettings.vue

@ -4,12 +4,11 @@ import { extractSdkResponseErrorMsg, useApi } from '#imports'
const { api } = useApi() const { api } = useApi()
let settings = $ref({ disable_user_signup: false }) let settings = $ref<{ disable_user_signup?: boolean }>({ disable_user_signup: false })
const loadSettings = async () => { const loadSettings = async () => {
try { try {
const response = await api.orgAppSettings.get() const response = await api.orgAppSettings.get()
settings = response settings = response
} catch (e) { } catch (e) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
@ -33,9 +32,15 @@ loadSettings()
<div class="text-xl">Settings</div> <div class="text-xl">Settings</div>
<a-divider class="!my-3" /> <a-divider class="!my-3" />
<a-form-item> <a-form-item>
<a-checkbox v-model:checked="settings.disable_user_signup" name="virtual" @change="saveSettings" <a-checkbox class="nc-checkbox" v-model:checked="settings.disable_user_signup" name="virtual"
>Disable user signup</a-checkbox @change="saveSettings">
> Disable user signup
</a-checkbox>
</a-form-item> </a-form-item>
</div> </div>
</template> </template>
<style scoped>
:deep(.nc-checkbox label) {
@apply flex-row-reverse !flex;
}
</style>

7
packages/nc-gui/components/account/Token.vue

@ -100,11 +100,11 @@ const descriptionInput = ref((el) => {
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2"> <div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<div class="text-xl mt-4">Token Management</div> <div class="text-xl mt-4">Token Management</div>
<a-divider class="!my-3" /> <a-divider class="!my-3" />
<div class="max-w-[900px] mx-auto p-4"> <div class="max-w-[900px] mx-auto p-4" data-cy="nc-token-list">
<div class="py-2 flex gap-4 items-center"> <div class="py-2 flex gap-4 items-center">
<div class="flex-grow"></div> <div class="flex-grow"></div>
<MdiReload class="cursor-pointer" @click="loadTokens" /> <MdiReload class="cursor-pointer" @click="loadTokens" />
<a-button size="small" @click="showNewTokenModal = true"> <a-button data-cy="nc-token-create" size="small" type="primary" @click="showNewTokenModal = true">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<MdiAdd /> <MdiAdd />
Add new token Add new token
@ -242,6 +242,7 @@ const descriptionInput = ref((el) => {
@finish="generateToken" @finish="generateToken"
> >
<a-input <a-input
data-cy="nc-token-modal-description"
:ref="descriptionInput" :ref="descriptionInput"
v-model:value="selectedTokenData.description" v-model:value="selectedTokenData.description"
:placeholder="$t('labels.description')" :placeholder="$t('labels.description')"
@ -249,7 +250,7 @@ const descriptionInput = ref((el) => {
<!-- Generate --> <!-- Generate -->
<div class="flex flex-row justify-center"> <div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit"> <a-button type="primary" html-type="submit" data-cy="nc-token-modal-save">
{{ $t('general.generate') }} {{ $t('general.generate') }}
</a-button> </a-button>
</div> </div>

8
packages/nc-gui/components/account/UserList.vue

@ -119,7 +119,7 @@ const copyPasswordResetUrl = async (user: User) => {
</script> </script>
<template> <template>
<div> <div data-cy='nc-super-user-list'>
<div class="text-xl">User Management</div> <div class="text-xl">User Management</div>
<a-divider class="!my-3" /> <a-divider class="!my-3" />
<div class="max-w-[900px] mx-auto p-4"> <div class="max-w-[900px] mx-auto p-4">
@ -135,7 +135,7 @@ const copyPasswordResetUrl = async (user: User) => {
</a-input-search> </a-input-search>
<div class="flex-grow"></div> <div class="flex-grow"></div>
<MdiReload class="cursor-pointer" @click="loadUsers" /> <MdiReload class="cursor-pointer" @click="loadUsers" />
<a-button size="small" @click="showUserModal = true"> <a-button data-cy="nc-super-user-invite" size="small" type="primary" @click="showUserModal = true">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<MdiAdd /> <MdiAdd />
Invite new user Invite new user
@ -164,7 +164,7 @@ const copyPasswordResetUrl = async (user: User) => {
</a-table-column> </a-table-column>
<!-- Role --> <!-- Role -->
<a-table-column key="roles" :title="$t('objects.role')" data-index="roles"> <a-table-column key="roles" :title="$t('objects.role')" data-index="roles">
<template #default="{ record }"> <template #default="{ record }">
<div> <div>
<div v-if="record.roles.includes('super')" class="font-weight-bold">Super Admin</div> <div v-if="record.roles.includes('super')" class="font-weight-bold">Super Admin</div>
@ -207,7 +207,7 @@ const copyPasswordResetUrl = async (user: User) => {
<a-table-column key="id" :title="$t('labels.actions')" data-index="id"> <a-table-column key="id" :title="$t('labels.actions')" data-index="id">
<template #default="{ text, record }"> <template #default="{ text, record }">
<div v-if="!record.roles.includes('super')" class="flex items-center gap-2"> <div v-if="!record.roles.includes('super')" class="flex items-center gap-2">
<MdiDeleteOutline class="nc-action-btn cursor-pointer" @click="deleteUser(text)" /> <MdiDeleteOutline data-cy="nc-super-user-delete" class="nc-action-btn cursor-pointer" @click="deleteUser(text)" />
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-user-mgmt"> <a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-user-mgmt">
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">

6
packages/nc-gui/components/account/UsersModal.vue

@ -82,7 +82,7 @@ const saveUser = async () => {
emit('reload') emit('reload')
// Successfully updated the user details // Successfully updated the user details
message.success(t('msg.success.userDetailsUpdated')) message.success(t('msg.success.userAdded'))
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e)
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
@ -120,7 +120,7 @@ const emailInput = ref((el) => {
:visible="show" :visible="show"
:closable="false" :closable="false"
width="max(50vw, 44rem)" width="max(50vw, 44rem)"
wrap-class-name="nc-modal-invite-user-and-share-base" wrap-class-name="nc-modal-invite-user"
@cancel="emit('closed')" @cancel="emit('closed')"
> >
<div class="flex flex-col"> <div class="flex flex-col">
@ -129,7 +129,7 @@ const emailInput = ref((el) => {
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')"> <a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')">
<template #icon> <template #icon>
<MaterialSymbolsCloseRounded class="flex mx-auto" /> <MaterialSymbolsCloseRounded data-cy="nc-root-user-invite-modal-close" class="flex mx-auto" />
</template> </template>
</a-button> </a-button>
</div> </div>

1
packages/nc-gui/lang/en.json

@ -692,6 +692,7 @@
"tokenGenerated": "Token generated successfully", "tokenGenerated": "Token generated successfully",
"tokenDeleted": "Token deleted successfully", "tokenDeleted": "Token deleted successfully",
"userAddedToProject": "Successfully added user to project", "userAddedToProject": "Successfully added user to project",
"userAdded": "Successfully added user",
"userDeletedFromProject": "Successfully deleted user from project", "userDeletedFromProject": "Successfully deleted user from project",
"inviteEmailSent": "Invite Email sent successfully", "inviteEmailSent": "Invite Email sent successfully",
"inviteURLCopied": "Invite URL copied to clipboard", "inviteURLCopied": "Invite URL copied to clipboard",

11
packages/nc-gui/pages/account/index.vue

@ -38,17 +38,6 @@ const { isUIAllowed } = useUIPermission()
<div class="select-none">Tokens</div> <div class="select-none">Tokens</div>
</div> </div>
</a-menu-item> </a-menu-item>
<a-menu-item
v-if="isUIAllowed('appLicense')"
key="license"
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)"
@click="navigateTo('/account/license')"
>
<div class="flex items-center space-x-2">
<MdiKeyChainVariant />
<div class="select-none">License</div>
</div>
</a-menu-item>
</a-menu> </a-menu>
</div> </div>
</a-layout-sider> </a-layout-sider>

9
packages/nc-gui/pages/account/index/[page].vue

@ -1,14 +1,5 @@
<script>
export default {
name: 'Index',
}
</script>
<template> <template>
<AccountUserManagement v-if="$route.params.page === 'users'" /> <AccountUserManagement v-if="$route.params.page === 'users'" />
<AccountToken v-else-if="$route.params.page === 'tokens'" /> <AccountToken v-else-if="$route.params.page === 'tokens'" />
<AccountLicense v-else-if="$route.params.page === 'license'" />
<span v-else></span> <span v-else></span>
</template> </template>
<style scoped></style>

68
packages/nocodb/src/lib/meta/api/orgTokenApis.ts

@ -1,54 +1,58 @@
import { Request, Response, Router } from 'express' import { Request, Response, Router } from 'express';
import { OrgUserRoles } from '../../../enums/OrgUserRoles' import { OrgUserRoles } from '../../../enums/OrgUserRoles';
import ApiToken from '../../models/ApiToken' import ApiToken from '../../models/ApiToken';
import { Tele } from '../../utils/Tele' import { Tele } from '../../utils/Tele';
import { metaApiMetrics } from '../helpers/apiMetrics' import { metaApiMetrics } from '../helpers/apiMetrics';
import { NcError } from '../helpers/catchError' import { NcError } from '../helpers/catchError';
import getHandler from '../helpers/getHandler' import getHandler from '../helpers/getHandler';
import ncMetaAclMw from '../helpers/ncMetaAclMw' import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { PagedResponseImpl } from '../helpers/PagedResponse' import { PagedResponseImpl } from '../helpers/PagedResponse';
import { apiTokenListEE } from './ee/orgTokenApis' import { apiTokenListEE } from './ee/orgTokenApis';
async function apiTokenList(req, res) { async function apiTokenList(req, res) {
const fk_user_id = req.user.id const fk_user_id = req.user.id;
let includeUnmappedToken = false let includeUnmappedToken = false;
if (req['user'].roles.includes(OrgUserRoles.SUPER)) { if (req['user'].roles.includes(OrgUserRoles.SUPER)) {
includeUnmappedToken = true includeUnmappedToken = true;
} }
res.json( res.json(
new PagedResponseImpl( new PagedResponseImpl(
await ApiToken.listWithCreatedBy({ ...req.query, fk_user_id, includeUnmappedToken }), await ApiToken.listWithCreatedBy({
...req.query,
fk_user_id,
includeUnmappedToken,
}),
{ {
...req.query, ...req.query,
count: await ApiToken.count({ count: await ApiToken.count({
includeUnmappedToken, includeUnmappedToken,
fk_user_id, fk_user_id,
}), }),
}, }
), )
) );
} }
export async function apiTokenCreate(req: Request, res: Response) { export async function apiTokenCreate(req: Request, res: Response) {
Tele.emit('evt', { evt_type: 'org:apiToken:created' }) Tele.emit('evt', { evt_type: 'org:apiToken:created' });
res.json(await ApiToken.insert({ ...req.body, fk_user_id: req['user'].id })) res.json(await ApiToken.insert({ ...req.body, fk_user_id: req['user'].id }));
} }
export async function apiTokenDelete(req: Request, res: Response) { export async function apiTokenDelete(req: Request, res: Response) {
const fk_user_id = req['user'].id const fk_user_id = req['user'].id;
const apiToken = await ApiToken.getByToken(req.params.apiTokenId) const apiToken = await ApiToken.getByToken(req.params.apiTokenId);
if ( if (
!req['user'].roles.includes(OrgUserRoles.SUPER) && !req['user'].roles.includes(OrgUserRoles.SUPER) &&
apiToken.fk_user_id !== fk_user_id apiToken.fk_user_id !== fk_user_id
) { ) {
NcError.notFound('Token not found') NcError.notFound('Token not found');
} }
Tele.emit('evt', { evt_type: 'org:apiToken:deleted' }) Tele.emit('evt', { evt_type: 'org:apiToken:deleted' });
res.json(await ApiToken.delete(req.params.token)) res.json(await ApiToken.delete(req.params.token));
} }
const router = Router({ mergeParams: true }) const router = Router({ mergeParams: true });
router.get( router.get(
'/api/v1/tokens', '/api/v1/tokens',
@ -56,22 +60,22 @@ router.get(
ncMetaAclMw(getHandler(apiTokenList, apiTokenListEE), 'apiTokenList', { ncMetaAclMw(getHandler(apiTokenList, apiTokenListEE), 'apiTokenList', {
// allowedRoles: [OrgUserRoles.SUPER], // allowedRoles: [OrgUserRoles.SUPER],
blockApiTokenAccess: true, blockApiTokenAccess: true,
}), })
) );
router.post( router.post(
'/api/v1/tokens', '/api/v1/tokens',
metaApiMetrics, metaApiMetrics,
ncMetaAclMw(apiTokenCreate, 'apiTokenCreate', { ncMetaAclMw(apiTokenCreate, 'apiTokenCreate', {
// allowedRoles: [OrgUserRoles.SUPER], // allowedRoles: [OrgUserRoles.SUPER],
blockApiTokenAccess: true, blockApiTokenAccess: true,
}), })
) );
router.delete( router.delete(
'/api/v1/tokens/:token', '/api/v1/tokens/:token',
metaApiMetrics, metaApiMetrics,
ncMetaAclMw(apiTokenDelete, 'apiTokenDelete', { ncMetaAclMw(apiTokenDelete, 'apiTokenDelete', {
// allowedRoles: [OrgUserRoles.SUPER], // allowedRoles: [OrgUserRoles.SUPER],
blockApiTokenAccess: true, blockApiTokenAccess: true,
}), })
) );
export default router export default router;

2
packages/nocodb/src/lib/meta/helpers/apiMetrics.ts

@ -14,7 +14,7 @@ const metrics = async (req: Request, c = 150) => {
}; };
const metaApiMetrics = (req: Request, _res, next) => { const metaApiMetrics = (req: Request, _res, next) => {
metrics(req, 10).then(() => {}); metrics(req, 50).then(() => {});
next(); next();
}; };
export default (req: Request, _res, next) => { export default (req: Request, _res, next) => {

140
scripts/cypress/integration/common/5c_super_user_role.js

@ -1,9 +1,10 @@
import { loginPage } from "../../support/page_objects/navigation"; import { loginPage } from '../../support/page_objects/navigation';
import { roles } from "../../support/page_objects/projectConstants"; import { roles } from '../../support/page_objects/projectConstants';
export const genTest = (apiType, dbType) => { export const genTest = (apiType, dbType) => {
describe(`${apiType.toUpperCase()} api - Super user test`, () => { describe(`${apiType.toUpperCase()} api - Super user test`, () => {
before(() => {}); before(() => {
});
beforeEach(() => { beforeEach(() => {
cy.restoreLocalStorage(); cy.restoreLocalStorage();
@ -13,71 +14,144 @@ export const genTest = (apiType, dbType) => {
cy.saveLocalStorage(); cy.saveLocalStorage();
}); });
after(() => {}); after(() => {
});
it(`Open App store page and check slack app`, () => { it(`Open App store page and check slack app`, () => {
cy.visit("/#/apps").then((win) => { cy.visit('/#/apps').then((win) => {
cy.get(".nc-app-store-title").should("exist"); cy.get('.nc-app-store-title').should('exist');
cy.get(".nc-app-store-card-Slack").should("exist"); cy.get('.nc-app-store-card-Slack').should('exist');
// install slack app // install slack app
cy.get(".nc-app-store-card-Slack .install-btn").invoke( cy.get('.nc-app-store-card-Slack .install-btn').invoke(
"attr", 'attr',
"style", 'style',
"right: 10px" 'right: 10px'
); );
cy.get( cy.get(
".nc-app-store-card-Slack .install-btn .nc-app-store-card-install" '.nc-app-store-card-Slack .install-btn .nc-app-store-card-install'
).click(); ).click();
cy.getActiveModal(".nc-modal-plugin-install") cy.getActiveModal('.nc-modal-plugin-install')
.find('[placeholder="Channel Name"]') .find('[placeholder="Channel Name"]')
.type("Test channel"); .type('Test channel');
cy.getActiveModal(".nc-modal-plugin-install") cy.getActiveModal('.nc-modal-plugin-install')
.find('[placeholder="Webhook URL"]') .find('[placeholder="Webhook URL"]')
.type("http://test.com"); .type('http://test.com');
cy.getActiveModal(".nc-modal-plugin-install") cy.getActiveModal('.nc-modal-plugin-install')
.find('button:contains("Save")') .find('button:contains("Save")')
.click(); .click();
cy.toastWait("Successfully installed"); cy.toastWait('Successfully installed');
cy.get( cy.get(
".nc-app-store-card-Slack .install-btn .nc-app-store-card-install" '.nc-app-store-card-Slack .install-btn .nc-app-store-card-install'
).should("not.exist"); ).should('not.exist');
// update slack app config // update slack app config
cy.get(".nc-app-store-card-Slack .install-btn .nc-app-store-card-edit") cy.get('.nc-app-store-card-Slack .install-btn .nc-app-store-card-edit')
.should("exist") .should('exist')
.click(); .click();
cy.getActiveModal(".nc-modal-plugin-install") cy.getActiveModal('.nc-modal-plugin-install')
.should("exist") .should('exist')
.find('[placeholder="Channel Name"]') .find('[placeholder="Channel Name"]')
.should("have.value", "Test channel") .should('have.value', 'Test channel')
.clear() .clear()
.type("Test channel 2"); .type('Test channel 2');
cy.getActiveModal(".nc-modal-plugin-install") cy.getActiveModal('.nc-modal-plugin-install')
.get('button:contains("Save")') .get('button:contains("Save")')
.click(); .click();
cy.toastWait("Successfully installed"); cy.toastWait('Successfully installed');
// reset slack app // reset slack app
cy.get(".nc-app-store-card-Slack .install-btn .nc-app-store-card-reset") cy.get('.nc-app-store-card-Slack .install-btn .nc-app-store-card-reset')
.should("exist") .should('exist')
.click(); .click();
cy.getActiveModal(".nc-modal-plugin-uninstall") cy.getActiveModal('.nc-modal-plugin-uninstall')
.should("exist") .should('exist')
.find('button:contains("Confirm")') .find('button:contains("Confirm")')
.click(); .click();
cy.toastWait("Plugin uninstalled successfully"); cy.toastWait('Plugin uninstalled successfully');
}); });
}); });
it(`Open super user management page and add/delete user`, () => {
cy.wait(500);
cy.visit('/#/account/users').then((win) => {
cy.get('[data-cy="nc-super-user-list"]').should('exist')
.find('tbody tr').should('have.length', 1);
cy.get('[data-cy=\'nc-super-user-invite\'')
.click();
// additional wait to ensure the modal is fully loaded
cy.getActiveModal('.nc-modal-invite-user').should('exist');
cy.getActiveModal('.nc-modal-invite-user')
.find('input[placeholder="E-mail"]')
.should('exist');
cy.getActiveModal('.nc-modal-invite-user')
.find('input[placeholder="E-mail"]')
.type('test@nocodb.com');
cy.getActiveModal('.nc-modal-invite-user')
.find('.ant-select.nc-user-roles')
.click();
cy.getActiveModal('.nc-modal-invite-user')
.find('button.ant-btn-primary')
.click();
cy.toastWait('Successfully added user');
cy.getActiveModal().find('[data-cy="nc-root-user-invite-modal-close"]').click();
cy.get('[data-cy="nc-super-user-list"]').should('exist')
.find('tbody tr').should('have.length', 2)
.last().find('[data-cy="nc-super-user-delete"]').click();
cy.getActiveModal().find('.ant-modal-confirm-btns .ant-btn-primary').click();
cy.toastWait('User deleted successfully');
cy.get('[data-cy="nc-super-user-list"]').should('exist')
.find('tbody tr').should('have.length', 1);
});
});
it('User management settings', () => {
});
it(`Token management`, () => {
cy.wait(500);
cy.visit('/#/account/tokens').then((win) => {
cy.get('[data-cy="nc-token-list"]').should('exist').find(':contains("No Data")').should('exist');
cy.get('[data-cy="nc-token-create"]').click();
cy.get('[data-cy="nc-token-modal-description"]').type('Descriptqion');
cy.get('[data-cy="nc-token-modal-save"]').click();
cy.toastWait('Token created successfully');
cy.get('[data-cy="nc-token-list"]').should('exist')
.find('tbody tr').should('have.length', 1);
});
});
}); });
}; };

Loading…
Cancel
Save