Merge pull request #11305 from Budibase/feature/offline-license-master

Cherry-pick: Offline Licensing
This commit is contained in:
Rory Powell 2023-07-25 11:38:58 +01:00 committed by GitHub
commit 7236fb8141
35 changed files with 774 additions and 134 deletions

View File

@ -156,6 +156,7 @@ const environment = {
: false,
VERSION: findVersion(),
DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER,
OFFLINE_MODE: process.env.OFFLINE_MODE,
_set(key: any, value: any) {
process.env[key] = value
// @ts-ignore

View File

@ -55,6 +55,18 @@ export class HTTPError extends BudibaseError {
}
}
export class NotFoundError extends HTTPError {
constructor(message: string) {
super(message, 404)
}
}
export class BadRequestError extends HTTPError {
constructor(message: string) {
super(message, 400)
}
}
// LICENSING
export class UsageLimitError extends HTTPError {

View File

@ -264,7 +264,7 @@ const getEventTenantId = async (tenantId: string): Promise<string> => {
}
}
const getUniqueTenantId = async (tenantId: string): Promise<string> => {
export const getUniqueTenantId = async (tenantId: string): Promise<string> => {
// make sure this tenantId always matches the tenantId in context
return context.doInTenant(tenantId, () => {
return withCache(CacheKey.UNIQUE_TENANT_ID, TTL.ONE_DAY, async () => {

View File

@ -13,7 +13,7 @@ import {
} from "@budibase/types"
import _ from "lodash"
export const account = (): Account => {
export const account = (partial: Partial<Account> = {}): Account => {
return {
accountId: uuid(),
tenantId: generator.word(),
@ -29,6 +29,7 @@ export const account = (): Account => {
size: "10+",
profession: "Software Engineer",
quotaUsage: quotas.usage(),
...partial,
}
}

View File

@ -1,4 +1,4 @@
import { structures } from ".."
import { generator } from "./generator"
import { newid } from "../../../../src/docIds/newid"
export function id() {
@ -6,7 +6,7 @@ export function id() {
}
export function rev() {
return `${structures.generator.character({
return `${generator.character({
numeric: true,
})}-${structures.uuid().replace(/-/, "")}`
})}-${generator.guid().replace(/-/, "")}`
}

View File

@ -0,0 +1 @@
export * from "./platform"

View File

@ -0,0 +1 @@
export * as installation from "./installation"

View File

@ -0,0 +1,12 @@
import { generator } from "../../generator"
import { Installation } from "@budibase/types"
import * as db from "../../db"
export function install(): Installation {
return {
_id: "install",
_rev: db.rev(),
installId: generator.guid(),
version: generator.string(),
}
}

View File

@ -2,6 +2,7 @@ export * from "./common"
export * as accounts from "./accounts"
export * as apps from "./apps"
export * as db from "./db"
export * as docs from "./documents"
export * as koa from "./koa"
export * as licenses from "./licenses"
export * as plugins from "./plugins"

View File

@ -3,6 +3,8 @@ import {
Customer,
Feature,
License,
OfflineIdentifier,
OfflineLicense,
PlanModel,
PlanType,
PriceDuration,
@ -11,6 +13,7 @@ import {
Quotas,
Subscription,
} from "@budibase/types"
import { generator } from "./generator"
export function price(): PurchasedPrice {
return {
@ -127,15 +130,15 @@ export function subscription(): Subscription {
}
}
export const license = (
opts: {
quotas?: Quotas
plan?: PurchasedPlan
planType?: PlanType
features?: Feature[]
billing?: Billing
} = {}
): License => {
interface GenerateLicenseOpts {
quotas?: Quotas
plan?: PurchasedPlan
planType?: PlanType
features?: Feature[]
billing?: Billing
}
export const license = (opts: GenerateLicenseOpts = {}): License => {
return {
features: opts.features || [],
quotas: opts.quotas || quotas(),
@ -143,3 +146,22 @@ export const license = (
billing: opts.billing || billing(),
}
}
export function offlineLicense(opts: GenerateLicenseOpts = {}): OfflineLicense {
const base = license(opts)
return {
...base,
expireAt: new Date().toISOString(),
identifier: offlineIdentifier(),
}
}
export function offlineIdentifier(
installId: string = generator.guid(),
tenantId: string = generator.guid()
): OfflineIdentifier {
return {
installId,
tenantId,
}
}

View File

@ -10,6 +10,8 @@
Label,
ButtonGroup,
notifications,
CopyInput,
File,
} from "@budibase/bbui"
import { auth, admin } from "stores/portal"
import { redirect } from "@roxi/routify"
@ -21,15 +23,20 @@
$: license = $auth.user.license
$: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
// LICENSE KEY
$: activateDisabled = !licenseKey || licenseKeyDisabled
let licenseInfo
let licenseKeyDisabled = false
let licenseKeyType = "text"
let licenseKey = ""
let deleteLicenseKeyModal
// OFFLINE
let offlineLicenseIdentifier = ""
let offlineLicense = undefined
const offlineLicenseExtensions = [".txt"]
// Make sure page can't be visited directly in cloud
$: {
if ($admin.cloud) {
@ -37,28 +44,115 @@
}
}
const activate = async () => {
// LICENSE KEY
const getLicenseKey = async () => {
try {
await API.activateLicenseKey({ licenseKey })
await auth.getSelf()
await setLicenseInfo()
notifications.success("Successfully activated")
licenseKey = await API.getLicenseKey()
if (licenseKey) {
licenseKey = "**********************************************"
licenseKeyType = "password"
licenseKeyDisabled = true
activateDisabled = true
}
} catch (e) {
notifications.error(e.message)
console.error(e)
notifications.error("Error retrieving license key")
}
}
const destroy = async () => {
const activateLicenseKey = async () => {
try {
await API.activateLicenseKey({ licenseKey })
await auth.getSelf()
await getLicenseKey()
notifications.success("Successfully activated")
} catch (e) {
console.error(e)
notifications.error("Error activating license key")
}
}
const deleteLicenseKey = async () => {
try {
await API.deleteLicenseKey({ licenseKey })
await auth.getSelf()
await setLicenseInfo()
await getLicenseKey()
// reset the form
licenseKey = ""
licenseKeyDisabled = false
notifications.success("Successfully deleted")
notifications.success("Offline license removed")
} catch (e) {
notifications.error(e.message)
console.error(e)
notifications.error("Error deleting license key")
}
}
// OFFLINE LICENSE
const getOfflineLicense = async () => {
try {
const license = await API.getOfflineLicense()
if (license) {
offlineLicense = {
name: "license",
}
} else {
offlineLicense = undefined
}
} catch (e) {
console.error(e)
notifications.error("Error loading offline license")
}
}
const getOfflineLicenseIdentifier = async () => {
try {
const res = await API.getOfflineLicenseIdentifier()
offlineLicenseIdentifier = res.identifierBase64
} catch (e) {
console.error(e)
notifications.error("Error loading installation identifier")
}
}
async function activateOfflineLicense(offlineLicenseToken) {
try {
await API.activateOfflineLicense({ offlineLicenseToken })
await auth.getSelf()
await getOfflineLicense()
notifications.success("Successfully activated")
} catch (e) {
console.error(e)
notifications.error("Error activating offline license")
}
}
async function deleteOfflineLicense() {
try {
await API.deleteOfflineLicense()
await auth.getSelf()
await getOfflineLicense()
notifications.success("Successfully removed ofline license")
} catch (e) {
console.error(e)
notifications.error("Error upload offline license")
}
}
async function onOfflineLicenseChange(event) {
if (event.detail) {
// prevent file preview jitter by assigning constant
// as soon as possible
offlineLicense = {
name: "license",
}
const reader = new FileReader()
reader.readAsText(event.detail)
reader.onload = () => activateOfflineLicense(reader.result)
} else {
offlineLicense = undefined
await deleteOfflineLicense()
}
}
@ -73,29 +167,19 @@
}
}
// deactivate the license key field if there is a license key set
$: {
if (licenseInfo?.licenseKey) {
licenseKey = "**********************************************"
licenseKeyType = "password"
licenseKeyDisabled = true
activateDisabled = true
}
}
const setLicenseInfo = async () => {
licenseInfo = await API.getLicenseInfo()
}
onMount(async () => {
await setLicenseInfo()
if ($admin.offlineMode) {
await Promise.all([getOfflineLicense(), getOfflineLicenseIdentifier()])
} else {
await getLicenseKey()
}
})
</script>
{#if $auth.isAdmin}
<DeleteLicenseKeyModal
bind:this={deleteLicenseKeyModal}
onConfirm={destroy}
onConfirm={deleteLicenseKey}
/>
<Layout noPadding>
<Layout gap="XS" noPadding>
@ -108,42 +192,82 @@
{:else}
To manage your plan visit your
<Link size="L" href={upgradeUrl}>account</Link>
<div>&nbsp</div>
{/if}
</Body>
</Layout>
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">Activate</Heading>
<Body size="S">Enter your license key below to activate your plan</Body>
</Layout>
<Layout noPadding>
<div class="fields">
<div class="field">
<Label size="L">License key</Label>
<Input
thin
bind:value={licenseKey}
type={licenseKeyType}
disabled={licenseKeyDisabled}
{#if $admin.offlineMode}
<Layout gap="XS" noPadding>
<Heading size="XS">Installation identifier</Heading>
<Body size="S"
>Share this with support@budibase.com to obtain your offline license</Body
>
</Layout>
<Layout noPadding>
<div class="identifier-input">
<CopyInput value={offlineLicenseIdentifier} />
</div>
</Layout>
<Divider />
<Layout gap="XS" noPadding>
<Heading size="XS">License</Heading>
<Body size="S">Upload your license to activate your plan</Body>
</Layout>
<Layout noPadding>
<div>
<File
title="Upload license"
extensions={offlineLicenseExtensions}
value={offlineLicense}
on:change={onOfflineLicenseChange}
allowClear={true}
disabled={!!offlineLicense}
/>
</div>
</div>
<ButtonGroup gap="M">
<Button cta on:click={activate} disabled={activateDisabled}>
Activate
</Button>
{#if licenseInfo?.licenseKey}
<Button warning on:click={() => deleteLicenseKeyModal.show()}>
Delete
</Layout>
{:else}
<Layout gap="XS" noPadding>
<Heading size="XS">Activate</Heading>
<Body size="S">Enter your license key below to activate your plan</Body>
</Layout>
<Layout noPadding>
<div class="fields">
<div class="field">
<Label size="L">License key</Label>
<Input
thin
bind:value={licenseKey}
type={licenseKeyType}
disabled={licenseKeyDisabled}
/>
</div>
</div>
<ButtonGroup gap="M">
<Button cta on:click={activateLicenseKey} disabled={activateDisabled}>
Activate
</Button>
{/if}
</ButtonGroup>
</Layout>
{#if licenseKey}
<Button warning on:click={() => deleteLicenseKeyModal.show()}>
Delete
</Button>
{/if}
</ButtonGroup>
</Layout>
{/if}
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">Plan</Heading>
<Layout noPadding gap="XXS">
<Heading size="XS">Plan</Heading>
<Layout noPadding gap="S">
<Body size="S">You are currently on the {license.plan.type} plan</Body>
<div>
<Body size="S"
>If you purchase or update your plan on the account</Body
>
<Body size="S"
>portal, click the refresh button to sync those changes</Body
>
</div>
<Body size="XS">
{processStringSync("Updated {{ duration time 'millisecond' }} ago", {
time:
@ -169,4 +293,7 @@
grid-gap: var(--spacing-l);
align-items: center;
}
.identifier-input {
width: 300px;
}
</style>

View File

@ -17,6 +17,7 @@ export const DEFAULT_CONFIG = {
adminUser: { checked: false },
sso: { checked: false },
},
offlineMode: false,
}
export function createAdminStore() {

View File

@ -1,30 +1,58 @@
export const buildLicensingEndpoints = API => ({
/**
* Activates a self hosted license key
*/
// LICENSE KEY
activateLicenseKey: async data => {
return API.post({
url: `/api/global/license/activate`,
url: `/api/global/license/key`,
body: data,
})
},
/**
* Delete a self hosted license key
*/
deleteLicenseKey: async () => {
return API.delete({
url: `/api/global/license/info`,
url: `/api/global/license/key`,
})
},
getLicenseKey: async () => {
try {
return await API.get({
url: "/api/global/license/key",
})
} catch (e) {
if (e.status !== 404) {
throw e
}
}
},
/**
* Get the license info - metadata about the license including the
* obfuscated license key.
*/
getLicenseInfo: async () => {
return API.get({
url: "/api/global/license/info",
// OFFLINE LICENSE
activateOfflineLicense: async ({ offlineLicenseToken }) => {
return API.post({
url: "/api/global/license/offline",
body: {
offlineLicenseToken,
},
})
},
deleteOfflineLicense: async () => {
return API.delete({
url: "/api/global/license/offline",
})
},
getOfflineLicense: async () => {
try {
return await API.get({
url: "/api/global/license/offline",
})
} catch (e) {
if (e.status !== 404) {
throw e
}
}
},
getOfflineLicenseIdentifier: async () => {
return await API.get({
url: "/api/global/license/offline/identifier",
})
},
@ -36,7 +64,6 @@ export const buildLicensingEndpoints = API => ({
url: "/api/global/license/refresh",
})
},
/**
* Retrieve the usage information for the tenant
*/

@ -1 +1 @@
Subproject commit 1a5207d91fb9e0835562c708dd9c421973026543
Subproject commit 44fa63cc0f536648ac57ba61b62c60dd2812dbc6

View File

@ -841,7 +841,8 @@
"auto",
"json",
"internal",
"barcodeqr"
"barcodeqr",
"bigint"
],
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
},
@ -1045,7 +1046,8 @@
"auto",
"json",
"internal",
"barcodeqr"
"barcodeqr",
"bigint"
],
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
},
@ -1260,7 +1262,8 @@
"auto",
"json",
"internal",
"barcodeqr"
"barcodeqr",
"bigint"
],
"description": "Defines the type of the column, most explain themselves, a link column is a relationship."
},

View File

@ -768,6 +768,7 @@ components:
- json
- internal
- barcodeqr
- bigint
description: Defines the type of the column, most explain themselves, a link
column is a relationship.
constraints:
@ -931,6 +932,7 @@ components:
- json
- internal
- barcodeqr
- bigint
description: Defines the type of the column, most explain themselves, a link
column is a relationship.
constraints:
@ -1101,6 +1103,7 @@ components:
- json
- internal
- barcodeqr
- bigint
description: Defines the type of the column, most explain themselves, a link
column is a relationship.
constraints:

View File

@ -81,7 +81,6 @@ const environment = {
SELF_HOSTED: process.env.SELF_HOSTED,
HTTP_MB_LIMIT: process.env.HTTP_MB_LIMIT,
FORKED_PROCESS_NAME: process.env.FORKED_PROCESS_NAME || "main",
OFFLINE_MODE: process.env.OFFLINE_MODE,
// old
CLIENT_ID: process.env.CLIENT_ID,
_set(key: string, value: any) {

View File

@ -1,5 +1,6 @@
import { LicenseOverrides, QuotaUsage } from "../../documents"
import { PlanType } from "../../sdk"
import { OfflineLicense, PlanType } from "../../sdk"
import { ISO8601 } from "../../shared"
export interface GetLicenseRequest {
// All fields should be optional to cater for
@ -26,3 +27,13 @@ export interface UpdateLicenseRequest {
planType?: PlanType
overrides?: LicenseOverrides
}
export interface CreateOfflineLicenseRequest {
installationIdentifierBase64: string
expireAt: ISO8601
}
export interface GetOfflineLicenseResponse {
offlineLicenseToken: string
license: OfflineLicense
}

View File

@ -3,3 +3,4 @@ export * from "./auditLogs"
export * from "./events"
export * from "./configs"
export * from "./scim"
export * from "./license"

View File

@ -0,0 +1,25 @@
// LICENSE KEY
export interface ActivateLicenseKeyRequest {
licenseKey: string
}
export interface GetLicenseKeyResponse {
licenseKey: string
}
// OFFLINE LICENSE
export interface ActivateOfflineLicenseTokenRequest {
offlineLicenseToken: string
}
export interface GetOfflineLicenseTokenResponse {
offlineLicenseToken: string
}
// IDENTIFIER
export interface GetOfflineIdentifierResponse {
identifierBase64: string
}

View File

@ -51,6 +51,7 @@ export interface Account extends CreateAccount {
licenseRequestedAt?: number
licenseOverrides?: LicenseOverrides
quotaUsage?: QuotaUsage
offlineLicenseToken?: string
}
export interface PasswordAccount extends Account {

View File

@ -9,6 +9,7 @@ export enum Feature {
BRANDING = "branding",
SCIM = "scim",
SYNC_AUTOMATIONS = "syncAutomations",
OFFLINE = "offline",
}
export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }

View File

@ -1,4 +1,15 @@
import { PurchasedPlan, Quotas, Feature, Billing } from "."
import { ISO8601 } from "../../shared"
export interface OfflineIdentifier {
installId: string
tenantId: string
}
export interface OfflineLicense extends License {
identifier: OfflineIdentifier
expireAt: ISO8601
}
export interface License {
features: Feature[]

View File

@ -1,3 +1,5 @@
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
export type ISO8601 = string

View File

@ -0,0 +1,27 @@
const actual = jest.requireActual("@budibase/pro")
const pro = {
...actual,
licensing: {
keys: {
activateLicenseKey: jest.fn(),
getLicenseKey: jest.fn(),
deleteLicenseKey: jest.fn(),
},
offline: {
activateOfflineLicenseToken: jest.fn(),
getOfflineLicenseToken: jest.fn(),
deleteOfflineLicenseToken: jest.fn(),
getIdentifierBase64: jest.fn(),
},
cache: {
...actual.licensing.cache,
refresh: jest.fn(),
},
},
quotas: {
...actual.quotas,
getQuotaUsage: jest.fn(),
},
}
export = pro

View File

@ -0,0 +1,133 @@
openapi: 3.0.0
info:
title: Worker API Specification
version: 1.0.0
servers:
- url: "http://localhost:10000"
description: localhost
- url: "https://budibaseqa.app"
description: QA
- url: "https://preprod.qa.budibase.net"
description: Preprod
- url: "https://budibase.app"
description: Production
tags:
- name: license
description: License operations
paths:
/api/global/license/key:
post:
tags:
- license
summary: Activate license key
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ActivateLicenseKeyRequest'
responses:
'200':
description: Success
get:
tags:
- license
summary: Get license key
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/GetLicenseKeyResponse'
delete:
tags:
- license
summary: Delete license key
responses:
'204':
description: No content
/api/global/license/offline:
post:
tags:
- license
summary: Activate offline license
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ActivateOfflineLicenseTokenRequest'
responses:
'200':
description: Success
get:
tags:
- license
summary: Get offline license
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/GetOfflineLicenseTokenResponse'
delete:
tags:
- license
summary: Delete offline license
responses:
'204':
description: No content
/api/global/license/offline/identifier:
get:
tags:
- license
summary: Get offline identifier
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/GetOfflineIdentifierResponse'
components:
schemas:
ActivateOfflineLicenseTokenRequest:
type: object
properties:
offlineLicenseToken:
type: string
required:
- offlineLicenseToken
GetOfflineLicenseTokenResponse:
type: object
properties:
offlineLicenseToken:
type: string
required:
- offlineLicenseToken
ActivateLicenseKeyRequest:
type: object
properties:
licenseKey:
type: string
required:
- licenseKey
GetLicenseKeyResponse:
type: object
properties:
licenseKey:
type: string
required:
- licenseKey
GetOfflineIdentifierResponse:
type: object
properties:
identifierBase64:
type: string
required:
- identifierBase64

View File

@ -1,34 +1,83 @@
import { licensing, quotas } from "@budibase/pro"
import {
ActivateLicenseKeyRequest,
ActivateOfflineLicenseTokenRequest,
GetLicenseKeyResponse,
GetOfflineIdentifierResponse,
GetOfflineLicenseTokenResponse,
UserCtx,
} from "@budibase/types"
export const activate = async (ctx: any) => {
// LICENSE KEY
export async function activateLicenseKey(
ctx: UserCtx<ActivateLicenseKeyRequest>
) {
const { licenseKey } = ctx.request.body
if (!licenseKey) {
ctx.throw(400, "licenseKey is required")
}
await licensing.activateLicenseKey(licenseKey)
await licensing.keys.activateLicenseKey(licenseKey)
ctx.status = 200
}
export async function getLicenseKey(ctx: UserCtx<void, GetLicenseKeyResponse>) {
const licenseKey = await licensing.keys.getLicenseKey()
if (licenseKey) {
ctx.body = { licenseKey: "*" }
ctx.status = 200
} else {
ctx.status = 404
}
}
export async function deleteLicenseKey(ctx: UserCtx<void, void>) {
await licensing.keys.deleteLicenseKey()
ctx.status = 204
}
// OFFLINE LICENSE
export async function activateOfflineLicenseToken(
ctx: UserCtx<ActivateOfflineLicenseTokenRequest>
) {
const { offlineLicenseToken } = ctx.request.body
await licensing.offline.activateOfflineLicenseToken(offlineLicenseToken)
ctx.status = 200
}
export async function getOfflineLicenseToken(
ctx: UserCtx<void, GetOfflineLicenseTokenResponse>
) {
const offlineLicenseToken = await licensing.offline.getOfflineLicenseToken()
if (offlineLicenseToken) {
ctx.body = { offlineLicenseToken: "*" }
ctx.status = 200
} else {
ctx.status = 404
}
}
export async function deleteOfflineLicenseToken(ctx: UserCtx<void, void>) {
await licensing.offline.deleteOfflineLicenseToken()
ctx.status = 204
}
export async function getOfflineLicenseIdentifier(
ctx: UserCtx<void, GetOfflineIdentifierResponse>
) {
const identifierBase64 = await licensing.offline.getIdentifierBase64()
ctx.body = { identifierBase64 }
ctx.status = 200
}
// LICENSES
export const refresh = async (ctx: any) => {
await licensing.cache.refresh()
ctx.status = 200
}
export const getInfo = async (ctx: any) => {
const licenseInfo = await licensing.getLicenseInfo()
if (licenseInfo) {
licenseInfo.licenseKey = "*"
ctx.body = licenseInfo
}
ctx.status = 200
}
export const deleteInfo = async (ctx: any) => {
await licensing.deleteLicenseInfo()
ctx.status = 200
}
// USAGE
export const getQuotaUsage = async (ctx: any) => {
ctx.body = await quotas.getQuotaUsage()
ctx.status = 200
}

View File

@ -1,10 +1,11 @@
import { Ctx } from "@budibase/types"
import env from "../../../environment"
import { env as coreEnv } from "@budibase/backend-core"
export const fetch = async (ctx: Ctx) => {
ctx.body = {
multiTenancy: !!env.MULTI_TENANCY,
offlineMode: !!env.OFFLINE_MODE,
offlineMode: !!coreEnv.OFFLINE_MODE,
cloud: !env.SELF_HOSTED,
accountPortalUrl: env.ACCOUNT_PORTAL_URL,
disableAccountPortal: env.DISABLE_ACCOUNT_PORTAL,

View File

@ -1,13 +1,44 @@
import Router from "@koa/router"
import * as controller from "../../controllers/global/license"
import { middleware } from "@budibase/backend-core"
import Joi from "joi"
const activateLicenseKeyValidator = middleware.joiValidator.body(
Joi.object({
licenseKey: Joi.string().required(),
}).required()
)
const activateOfflineLicenseValidator = middleware.joiValidator.body(
Joi.object({
offlineLicenseToken: Joi.string().required(),
}).required()
)
const router: Router = new Router()
router
.post("/api/global/license/activate", controller.activate)
.post("/api/global/license/refresh", controller.refresh)
.get("/api/global/license/info", controller.getInfo)
.delete("/api/global/license/info", controller.deleteInfo)
.get("/api/global/license/usage", controller.getQuotaUsage)
// LICENSE KEY
.post(
"/api/global/license/key",
activateLicenseKeyValidator,
controller.activateLicenseKey
)
.get("/api/global/license/key", controller.getLicenseKey)
.delete("/api/global/license/key", controller.deleteLicenseKey)
// OFFLINE LICENSE
.post(
"/api/global/license/offline",
activateOfflineLicenseValidator,
controller.activateOfflineLicenseToken
)
.get("/api/global/license/offline", controller.getOfflineLicenseToken)
.delete("/api/global/license/offline", controller.deleteOfflineLicenseToken)
.get(
"/api/global/license/offline/identifier",
controller.getOfflineLicenseIdentifier
)
export default router

View File

@ -1,4 +1,6 @@
import { TestConfiguration } from "../../../../tests"
import { TestConfiguration, mocks, structures } from "../../../../tests"
const licensing = mocks.pro.licensing
const quotas = mocks.pro.quotas
describe("/api/global/license", () => {
const config = new TestConfiguration()
@ -12,18 +14,105 @@ describe("/api/global/license", () => {
})
afterEach(() => {
jest.clearAllMocks()
jest.resetAllMocks()
})
describe("POST /api/global/license/activate", () => {
it("activates license", () => {})
describe("POST /api/global/license/refresh", () => {
it("returns 200", async () => {
const res = await config.api.license.refresh()
expect(res.status).toBe(200)
expect(licensing.cache.refresh).toBeCalledTimes(1)
})
})
describe("POST /api/global/license/refresh", () => {})
describe("GET /api/global/license/usage", () => {
it("returns 200 + usage", async () => {
const usage = structures.quotas.usage()
quotas.getQuotaUsage.mockResolvedValue(usage)
const res = await config.api.license.getUsage()
expect(res.status).toBe(200)
expect(res.body).toEqual(usage)
})
})
describe("GET /api/global/license/info", () => {})
describe("POST /api/global/license/key", () => {
it("returns 200", async () => {
const res = await config.api.license.activateLicenseKey({
licenseKey: "licenseKey",
})
expect(res.status).toBe(200)
expect(licensing.keys.activateLicenseKey).toBeCalledWith("licenseKey")
})
})
describe("DELETE /api/global/license/info", () => {})
describe("GET /api/global/license/key", () => {
it("returns 404 when not found", async () => {
const res = await config.api.license.getLicenseKey()
expect(res.status).toBe(404)
})
it("returns 200 + license key", async () => {
licensing.keys.getLicenseKey.mockResolvedValue("licenseKey")
const res = await config.api.license.getLicenseKey()
expect(res.status).toBe(200)
expect(res.body).toEqual({
licenseKey: "*",
})
})
})
describe("GET /api/global/license/usage", () => {})
describe("DELETE /api/global/license/key", () => {
it("returns 204", async () => {
const res = await config.api.license.deleteLicenseKey()
expect(licensing.keys.deleteLicenseKey).toBeCalledTimes(1)
expect(res.status).toBe(204)
})
})
describe("POST /api/global/license/offline", () => {
it("activates offline license", async () => {
const res = await config.api.license.activateOfflineLicense({
offlineLicenseToken: "offlineLicenseToken",
})
expect(licensing.offline.activateOfflineLicenseToken).toBeCalledWith(
"offlineLicenseToken"
)
expect(res.status).toBe(200)
})
})
describe("GET /api/global/license/offline", () => {
it("returns 404 when not found", async () => {
const res = await config.api.license.getOfflineLicense()
expect(res.status).toBe(404)
})
it("returns 200 + offline license token", async () => {
licensing.offline.getOfflineLicenseToken.mockResolvedValue(
"offlineLicenseToken"
)
const res = await config.api.license.getOfflineLicense()
expect(res.status).toBe(200)
expect(res.body).toEqual({
offlineLicenseToken: "*",
})
})
})
describe("DELETE /api/global/license/offline", () => {
it("returns 204", async () => {
const res = await config.api.license.deleteOfflineLicense()
expect(res.status).toBe(204)
expect(licensing.offline.deleteOfflineLicenseToken).toBeCalledTimes(1)
})
})
describe("GET /api/global/license/offline/identifier", () => {
it("returns 200 + identifier base64", async () => {
licensing.offline.getIdentifierBase64.mockResolvedValue("base64")
const res = await config.api.license.getOfflineLicenseIdentifier()
expect(res.status).toBe(200)
expect(res.body).toEqual({
identifierBase64: "base64",
})
})
})
})

View File

@ -61,7 +61,6 @@ const environment = {
CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600,
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY,
OFFLINE_MODE: process.env.OFFLINE_MODE,
/**
* Mock the email service in use - links to ethereal hosted emails are logged instead.
*/

View File

@ -1,8 +1,7 @@
import mocks from "./mocks"
// init the licensing mock
import * as pro from "@budibase/pro"
mocks.licenses.init(pro)
mocks.licenses.init(mocks.pro)
// use unlimited license by default
mocks.licenses.useUnlimited()
@ -238,21 +237,21 @@ class TestConfiguration {
const db = context.getGlobalDB()
const id = dbCore.generateDevInfoID(this.user._id)
const id = dbCore.generateDevInfoID(this.user!._id)
// TODO: dry
this.apiKey = encryption.encrypt(
`${this.tenantId}${dbCore.SEPARATOR}${utils.newid()}`
)
const devInfo = {
_id: id,
userId: this.user._id,
userId: this.user!._id,
apiKey: this.apiKey,
}
await db.put(devInfo)
})
}
async getUser(email: string): Promise<User> {
async getUser(email: string): Promise<User | undefined> {
return context.doInTenant(this.getTenantId(), () => {
return users.getGlobalUserByEmail(email)
})
@ -264,7 +263,7 @@ class TestConfiguration {
}
const response = await this._req(user, null, controllers.users.save)
const body = response as SaveUserResponse
return this.getUser(body.email)
return this.getUser(body.email) as Promise<User>
}
// CONFIGS

View File

@ -1,17 +1,62 @@
import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base"
import {
ActivateLicenseKeyRequest,
ActivateOfflineLicenseTokenRequest,
} from "@budibase/types"
export class LicenseAPI extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
activate = async (licenseKey: string) => {
refresh = async () => {
return this.request
.post("/api/global/license/activate")
.send({ licenseKey: licenseKey })
.post("/api/global/license/refresh")
.set(this.config.defaultHeaders())
}
getUsage = async () => {
return this.request
.get("/api/global/license/usage")
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
}
activateLicenseKey = async (body: ActivateLicenseKeyRequest) => {
return this.request
.post("/api/global/license/key")
.send(body)
.set(this.config.defaultHeaders())
}
getLicenseKey = async () => {
return this.request
.get("/api/global/license/key")
.set(this.config.defaultHeaders())
}
deleteLicenseKey = async () => {
return this.request
.delete("/api/global/license/key")
.set(this.config.defaultHeaders())
}
activateOfflineLicense = async (body: ActivateOfflineLicenseTokenRequest) => {
return this.request
.post("/api/global/license/offline")
.send(body)
.set(this.config.defaultHeaders())
}
getOfflineLicense = async () => {
return this.request
.get("/api/global/license/offline")
.set(this.config.defaultHeaders())
}
deleteOfflineLicense = async () => {
return this.request
.delete("/api/global/license/offline")
.set(this.config.defaultHeaders())
}
getOfflineLicenseIdentifier = async () => {
return this.request
.get("/api/global/license/offline/identifier")
.set(this.config.defaultHeaders())
}
}

View File

@ -1,7 +1,11 @@
import * as email from "./email"
import { mocks } from "@budibase/backend-core/tests"
import * as _pro from "@budibase/pro"
const pro = jest.mocked(_pro, true)
export default {
email,
pro,
...mocks,
}

View File

@ -42,7 +42,7 @@ async function discordResultsNotification(report) {
Accept: "application/json",
},
body: JSON.stringify({
content: `**Nightly Tests Status**: ${OUTCOME}`,
content: `**Tests Status**: ${OUTCOME}`,
embeds: [
{
title: `Budi QA Bot - ${env}`,