Compare commits

...

15 Commits

Author SHA1 Message Date
Raphael Krut-Landau
90b1a96dfc Fix misconfiguration test and extend wait time for adhoc question in hopes this fixes the problem 2025-08-01 15:13:17 -06:00
Raphael Krut-Landau
0e4492e112 wip 2025-08-01 15:12:19 -06:00
Raphael Krut-Landau
2ca7482eba wip: trying to fix failing test 2025-08-01 15:12:19 -06:00
Raphael Krut-Landau
ba579feb61 wip 2025-08-01 15:11:47 -06:00
Raphael Krut-Landau
920a455fdf fix lint issue 2025-08-01 15:11:47 -06:00
Raphael Krut-Landau
f20e2dde17 Remove console.log 2025-08-01 15:11:47 -06:00
Raphael Krut-Landau
8fcf83e405 Problem fixed 2025-08-01 15:11:47 -06:00
Raphael Krut-Landau
6e240af387 wip 2025-08-01 15:11:47 -06:00
Raphael Krut-Landau
96bb6ccf08 Remove api/user deletions 2025-08-01 15:11:47 -06:00
Raphael Krut-Landau
f7da0d9d5b wip 2025-08-01 15:11:47 -06:00
Raphael Krut-Landau
677a7964a8 try clearing cookies before signing 2025-08-01 15:11:46 -06:00
Raphael Krut-Landau
664501701b add some waits to explore 2025-08-01 15:11:46 -06:00
Raphael Krut-Landau
5d5aa426ad Restore vars 2025-08-01 15:11:46 -06:00
Raphael Krut-Landau
d326688fdf Restore questions 2025-08-01 15:11:46 -06:00
Raphael Krut-Landau
a716ffcd76 Test that sandboxing plays well with caching 2025-08-01 15:11:46 -06:00
6 changed files with 334 additions and 119 deletions

View File

@@ -2,11 +2,11 @@ import { USERS } from "e2e/support/cypress_data";
declare global {
namespace Cypress {
interface Chainable {
signIn: (user?: keyof typeof USERS, options?: LoginOptions) => void;
signInAsAdmin: () => void;
signInAsNormalUser: () => void;
signInAsSandboxedUser: () => void;
signInAsImpersonatedUser: () => void;
signIn: (user?: keyof typeof USERS, options?: LoginOptions) => Chainable;
signInAsAdmin: () => Chainable;
signInAsNormalUser: () => Chainable;
signInAsSandboxedUser: () => Chainable;
signInAsImpersonatedUser: () => Chainable;
signOut: () => void;
}
}
@@ -60,8 +60,9 @@ Cypress.Commands.add(
cy.log(`Logging in as ${user}`);
const { email: username, password } = USERS[user];
cy.request("POST", "/api/session", { username, password }).then(
(response) => {
return cy
.request("POST", "/api/session", { username, password })
.then((response) => {
if (setupCache) {
cy.log("saving login token for user", user);
cy.getCookie("metabase.DEVICE").then((deviceCookie) => {
@@ -71,8 +72,7 @@ Cypress.Commands.add(
};
});
}
},
);
});
},
);
@@ -81,15 +81,15 @@ Cypress.Commands.add("signInAsAdmin", () => {
});
Cypress.Commands.add("signInAsNormalUser", () => {
cy.signIn("normal");
return cy.signIn("normal");
});
Cypress.Commands.add("signInAsSandboxedUser", () => {
cy.signIn("sandboxed");
return cy.signIn("sandboxed");
});
Cypress.Commands.add("signInAsImpersonatedUser", () => {
cy.signIn("impersonated");
return cy.signIn("impersonated");
});
Cypress.Commands.add("signOut", () => {

View File

@@ -126,12 +126,22 @@ export function startNewNativeModel(config) {
/**
* Visit any valid query in an ad-hoc manner.
*
* @param {import("./api").QuestionDetails} question
* @param {{callback?: function, mode: (undefined|"notebook")}} config
* @param {object} question
* @param {{
* callback?: function,
* mode?: "notebook",
* waitOptions?: Partial<import("cypress/types/net-stubbing").WaitOptions>
* }} [config]
*/
export function visitQuestionAdhoc(
question,
{ callback, mode, autorun = true, skipWaiting = false } = {},
{
callback,
mode,
autorun = true,
skipWaiting = false,
waitOptions = {},
} = {},
) {
const questionMode = mode === "notebook" ? "/notebook" : "";
@@ -139,12 +149,15 @@ export function visitQuestionAdhoc(
cy.intercept(url).as(alias);
cy.log("Visit ad hoc question");
cy.visit(`/question${questionMode}#` + adhocQuestionHash(question));
runQueryIfNeeded(question, autorun);
if (mode !== "notebook" && !skipWaiting) {
return cy.wait("@" + alias).then((xhr) => callback && callback(xhr));
return cy
.wait("@" + alias, waitOptions)
.then((xhr) => callback && callback(xhr));
}
// Ensure chainability

View File

@@ -1,8 +1,11 @@
import _ from "underscore";
import { SAMPLE_DB_ID, USER_GROUPS } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import type { StructuredQuestionDetails } from "e2e/support/helpers";
import { checkNotNull } from "metabase/lib/types";
import type {
CacheConfig,
CollectionItem,
Dashboard,
FieldValue,
@@ -13,14 +16,16 @@ import type {
StructuredQuery,
User,
} from "metabase-types/api";
import { CacheDurationUnit } from "metabase-types/api";
import type { DashcardQueryResponse, DatasetResponse } from "./types";
type SimpleCollectionItem = Pick<CollectionItem, "id" | "name">;
const { H } = cy;
const { ALL_USERS_GROUP, DATA_GROUP, COLLECTION_GROUP } = USER_GROUPS;
const { PRODUCTS_ID, ORDERS_ID, ORDERS, PRODUCTS } = SAMPLE_DATABASE;
const { ORDERS, ORDERS_ID, PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE;
const { ALL_USERS_GROUP, DATA_GROUP, COLLECTION_GROUP, READONLY_GROUP } =
USER_GROUPS;
const customColumnTypeToFormulaUntyped = {
booleanExpr: '[Category]="Gizmo"',
@@ -87,12 +92,24 @@ const customViews = [questionCustomView, modelCustomView];
const savedQuestion: StructuredQuestionDetails = {
name: "Question showing all products",
query: baseQuery,
query: {
...baseQuery,
fields: [
["field", PRODUCTS.CATEGORY, null],
["field", PRODUCTS.VENDOR, null],
],
},
};
const model: StructuredQuestionDetails = {
name: "Model showing all products",
query: baseQuery,
query: {
...baseQuery,
fields: [
["field", PRODUCTS.CATEGORY, null],
["field", PRODUCTS.VENDOR, null],
],
},
type: "model",
};
@@ -123,6 +140,7 @@ const ordersImplicitlyJoinedToProducts: StructuredQuestionDetails = {
name: "Question with Orders implicitly joined to Products",
query: {
"source-table": ORDERS_ID,
limit: 20,
fields: [
[
"field",
@@ -162,12 +180,20 @@ const questionData: StructuredQuestionDetails[] = [
];
export const adhocQuestionData = {
name: "Adhoc question",
dataset_query: {
database: SAMPLE_DB_ID,
type: "query",
query: {
"source-table": PRODUCTS_ID,
filter: [">", ["field", PRODUCTS.PRICE, null], 50],
fields: [
["field", PRODUCTS.CATEGORY, null],
["field", PRODUCTS.VENDOR, null],
["field", PRODUCTS.PRICE, null],
],
},
limit: 20,
},
};
@@ -184,6 +210,12 @@ function addCustomColumnsToQuestion() {
cy.wait("@updateQuestion");
}
export const preparePermissions = () => {
H.blockUserGroupPermissions(ALL_USERS_GROUP);
H.blockUserGroupPermissions(COLLECTION_GROUP);
H.blockUserGroupPermissions(READONLY_GROUP);
};
/**
* creates all questions and models and puts them in a dashboard
* all of them reside in a single collection
@@ -235,7 +267,13 @@ export const createSandboxingDashboardAndQuestions = () => {
H.createQuestionAndAddToDashboard(
{
name: "Question with custom columns",
query: baseQuery,
query: {
...baseQuery,
fields: [
["field", PRODUCTS.CATEGORY, null],
["field", PRODUCTS.VENDOR, null],
],
},
collection_id: collectionId,
},
dashboard.id,
@@ -261,14 +299,15 @@ export const createSandboxingDashboardAndQuestions = () => {
});
});
// return the collection items
// Provide information about the dashboard and questions that the tests
// can refer to
return cy.request<{ data: CollectionItem[] }>(
`/api/collection/${collectionId}/items`,
);
});
};
type NormalUser = Partial<User> & { password: string };
type NormalUser = Partial<User> & { email: string; password: string };
/** A non-admin user who should only see products that are Gizmos once the
* sandboxing policies are applied */
@@ -295,11 +334,17 @@ export const widgetViewer: NormalUser = {
};
export const signInAs = (user: NormalUser) => {
cy.signOut();
cy.clearCookies();
cy.log(`Sign in as user via an API call: ${user.email}`);
return cy.request("POST", "/api/session", {
username: user.email,
password: user.password,
});
return cy
.request("POST", "/api/session", {
username: user.email,
password: user.password,
})
.then(() => {
return waitForUserToBeLoggedIn(user);
});
};
export const assignAttributeToUser = ({
@@ -410,18 +455,21 @@ export const configureSandboxPolicy = (
H.saveChangesToPermissions();
};
const getQuestionDescription = (
const getQuestionName = (
response: DatasetResponse,
questions: CollectionItem[],
questions: SimpleCollectionItem[],
) => {
// Extract the card ID from the response URL
const cardId = Number(response?.url?.match(/\/card\/(\d+)/)?.[1]);
const questionName = (questions.find((q) => q.id === cardId) as any)?.name as
| string
| undefined;
const query = JSON.stringify(response.body.json_query.query);
const questionDesc = `${questionName} (query: ${query})`;
return { questionDesc, questionName };
let questionName;
if (questions.length === 1) {
questionName = questions[0].name;
} else {
// Extract the card ID from the response URL
const cardId = Number(response?.url?.match(/\/card\/(\d+)/)?.[1]);
questionName = (questions.find((q) => q.id === cardId) as any)?.name as
| string
| undefined;
}
return { questionName };
};
export function rowsShouldContainGizmosAndWidgets({
@@ -429,23 +477,23 @@ export function rowsShouldContainGizmosAndWidgets({
questions,
}: {
responses: DatasetResponse[];
questions: CollectionItem[];
questions: SimpleCollectionItem[];
}) {
expect(responses.length).to.equal(questions.length);
responses.forEach((response) => {
const { questionDesc } = getQuestionDescription(response, questions);
const { questionName } = getQuestionName(response, questions);
expect(
JSON.stringify(response.body),
`No error in ${questionDesc}`,
`No error in ${questionName}`,
).not.to.contain("stacktrace");
expect(
response.body.data.is_sandboxed,
`Results are not sandboxed in ${questionDesc}`,
`Results are not sandboxed in ${questionName}`,
).to.be.false;
const rows = response.body.data.rows;
expect(
rows.some((row) => row.includes("Gizmo")),
`Results include at least one Gizmo in ${questionDesc}`,
`Results include at least one Gizmo in ${questionName}`,
).to.be.true;
expect(
@@ -455,13 +503,18 @@ export function rowsShouldContainGizmosAndWidgets({
row.includes("Gadget") ||
row.includes("Doohickey"),
),
`Results include at least one Widget, Gadget, or Doohickey in ${questionDesc}`,
`Results include at least one Widget, Gadget, or Doohickey in ${questionName}`,
).to.be.true;
});
return cy.wrap(responses);
}
const productCategories = ["Gizmo", "Widget", "Doohickey", "Gadget"] as const;
/** Assert that the rows in the given responses contain only one category,
* productCategory.
*
* We assume that the category is the first value in each row */
export function rowsShouldContainOnlyOneCategory({
responses,
questions,
@@ -476,33 +529,59 @@ export function rowsShouldContainOnlyOneCategory({
);
responses.forEach((response) => {
const { questionDesc } = getQuestionDescription(response, questions);
cy.log(`Results contain only ${productCategory}s in: ${questionDesc}`);
const { questionName } = getQuestionName(response, questions);
cy.log(`Results contain only ${productCategory}s in: ${questionName}`);
expect(
JSON.stringify(response.body),
`No error in ${questionName}`,
).not.to.contain("stacktrace");
expect(
response?.body.data.is_sandboxed,
`Response is sandboxed for: ${questionDesc}`,
`Response is sandboxed for: ${questionName}`,
).to.be.true;
cy.log(`Results contain only ${productCategory}s in: ${questionName}`);
const rows = response.body.data.rows;
expect(
rows.every(
(row) =>
row.includes(productCategory) ||
// With implicit joins, some rows might have a null product
row[0] === null,
),
`Every result should have have a ${productCategory} in: ${questionDesc}`,
).to.be.true;
productCategories
.filter((category) => category !== productCategory)
.forEach((otherCategory) => {
const groupedByCategory = _.groupBy(rows, (row) => row[0] as string);
productCategories.forEach((category) => {
if (category !== productCategory) {
expect(
!rows.some((row) => row.includes(otherCategory)),
`No results should have ${otherCategory}s in: ${questionDesc}`,
).to.be.true;
});
groupedByCategory[category] || [],
`No ${category}s in: ${questionName}`,
).to.have.length(0);
}
});
expect(
groupedByCategory[productCategory],
`There are some ${productCategory}s in: ${questionName}`,
).to.have.length.greaterThan(0);
const categoriesPresent = JSON.stringify(
Object.keys(groupedByCategory).toSorted(),
);
// With implicit joins, some rows might have a null product. That's OK.
const categoriesAreValid =
categoriesPresent === `["${productCategory}"]` ||
categoriesPresent === `["${productCategory}","null"]`;
expect(
categoriesAreValid,
`The categories (${categoriesPresent}) are valid in: ${questionName}`,
).to.be.true;
expect(
response?.body.data.is_sandboxed,
`The response is sandboxed in: ${questionName}`,
).to.be.true;
});
return cy.wrap(responses);
}
export const valuesShouldContainGizmosAndWidgets = (
@@ -525,6 +604,8 @@ export const getDashcardResponses = (
dashboard: Dashboard | null,
questions: SimpleCollectionItem[],
) => {
cy.log("Check dashcard responses");
H.visitDashboard(checkNotNull(dashboard).id);
expect(questions.length).to.be.greaterThan(0);
@@ -540,16 +621,17 @@ export const getDashcardResponses = (
export const getCardResponses = (questions: SimpleCollectionItem[]) => {
expect(questions.length).to.be.greaterThan(0);
cy.log("Check card responses");
return H.cypressWaitAll(
questions.map((question) =>
cy.request<DatasetResponse>("POST", `/api/card/${question.id}/query`),
),
).then((responses) => {
return { responses, questions };
}) as Cypress.Chainable<{
responses: DatasetResponse[];
questions: SimpleCollectionItem[];
}>;
return { responses, questions } as {
responses: DatasetResponse[];
questions: SimpleCollectionItem[];
};
});
};
export const getFieldValuesForProductCategories = () =>
@@ -575,14 +657,24 @@ export const getParameterValuesForProductCategories = () =>
export const assertNoResultsOrValuesAreSandboxed = (
dashboard: Dashboard | null,
questions: SimpleCollectionItem[],
shouldResultsBeCached?: boolean,
) => {
checkNotNull(dashboard);
getDashcardResponses(dashboard, questions).then(
const dashcardResponses = getDashcardResponses(dashboard, questions).then(
rowsShouldContainGizmosAndWidgets,
);
const cardResponses = getCardResponses(questions).then(
rowsShouldContainGizmosAndWidgets,
);
getCardResponses(questions).then(rowsShouldContainGizmosAndWidgets);
H.visitQuestionAdhoc(adhocQuestionData).then(({ response }) =>
if (shouldResultsBeCached) {
dashcardResponses.then(resultsShouldBeCached);
cardResponses.then(resultsShouldBeCached);
}
H.visitQuestionAdhoc(adhocQuestionData, {
waitOptions: { timeout: 20000 },
}).then(({ response }) =>
rowsShouldContainGizmosAndWidgets({
responses: [response],
questions: [adhocQuestionData as unknown as SimpleCollectionItem],
@@ -611,7 +703,9 @@ export const assertAllResultsAndValuesAreSandboxed = (
getCardResponses(questions).then((data) =>
rowsShouldContainOnlyOneCategory({ ...data, productCategory }),
);
H.visitQuestionAdhoc(adhocQuestionData).then(({ response }) =>
H.visitQuestionAdhoc(adhocQuestionData, {
waitOptions: { timeout: 20000 },
}).then(({ response }) =>
rowsShouldContainOnlyOneCategory({
responses: [response],
questions: [adhocQuestionData as unknown as SimpleCollectionItem],
@@ -631,3 +725,78 @@ export const assertResponseFailsClosed = (response) => {
expect(response?.body.data.rows).to.have.length(0);
expect(response?.body.error_type).to.be.oneOf(["driver", "invalid-query"]);
};
export const resultsShouldBeCached = (responses: DatasetResponse[]) => {
responses.forEach((response) => {
expect(response.body.cached, "response should not be cached").not.to.be
.null;
expect(response.body.json_query?.["cache-strategy"]?.type).to.equal(
"duration",
);
});
return cy.wrap(responses);
};
export const cacheUnsandboxedResults = (questions: SimpleCollectionItem[]) => {
const simpleCacheConfiguration: CacheConfig = {
model: "root",
model_id: 0,
strategy: {
type: "duration",
duration: 1,
unit: CacheDurationUnit.Hours,
refresh_automatically: false,
},
};
cy.log(
"We additionally want to ensure that sandboxed users see filtered results even if the unsandboxed results are cached. So let's cache the unsandboxed results",
);
return cy.request("PUT", "/api/cache", simpleCacheConfiguration).then(() => {
cy.log("Populate the caches");
return getCardResponses(questions);
});
};
export const runWithoutCachingThenWithCaching = (
callback: (props: { isCachingEnabled?: boolean }) => void,
{ questions }: { questions: SimpleCollectionItem[] },
) => {
callback({ isCachingEnabled: false });
signInAsAdmin().then(() => {
cacheUnsandboxedResults(questions).then(() => {
callback({ isCachingEnabled: true });
});
});
};
/** This avoids a race condition where requests intended to be made by a normal
* user are instead made by an admin */
export const waitForUserToBeLoggedIn = (user: NormalUser) => {
cy.log("Wait for user to be logged in");
function check(tries: number) {
if (tries === 0) {
return cy.wrap(false);
}
return cy
.request("/api/user/current")
.then((response): Cypress.Chainable<boolean> => {
if (response.body.email === user.email) {
cy.log(`User is logged in: ${user.email}`);
return cy.wrap(true);
} else {
cy.wait(500);
return check(tries - 1);
}
});
}
return check(5).then((success) => {
expect(success, "User is logged in").to.be.true;
});
};
export const signInAsAdmin = () => {
cy.signOut();
cy.clearCookies();
return cy.signInAsAdmin();
};

View File

@@ -29,10 +29,10 @@ describe("admin > permissions > sandboxing > misconfiguration", () => {
cy.log("Create a simple editable products table");
H.queryWritableDB("DROP TABLE IF EXISTS products");
H.queryWritableDB(
"CREATE TABLE IF NOT EXISTS products (id INT PRIMARY KEY, category VARCHAR, name VARCHAR)",
"CREATE TABLE IF NOT EXISTS products (category VARCHAR, id INT PRIMARY KEY, name VARCHAR)",
);
H.queryWritableDB(
"INSERT INTO products (id, name, category) VALUES (1, 'A', 'Gizmo'), (2, 'B', 'Widget')",
"INSERT INTO products (id, name, category) VALUES ('Gizmo', 1, 'A'), ('Widget', 2, 'B')",
);
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: "products" });

View File

@@ -14,19 +14,15 @@ import {
getParameterValuesForProductCategories,
gizmoViewer,
modelCustomView,
preparePermissions,
questionCustomView,
runWithoutCachingThenWithCaching,
signInAs,
widgetViewer,
} from "./helpers/e2e-sandboxing-helpers";
const { H } = cy;
const preparePermissions = () => {
H.blockUserGroupPermissions(USER_GROUPS.ALL_USERS_GROUP);
H.blockUserGroupPermissions(USER_GROUPS.COLLECTION_GROUP);
H.blockUserGroupPermissions(USER_GROUPS.READONLY_GROUP);
};
describe(
"admin > permissions > sandboxing (tested via the admin UI)",
{ tags: "@external" },
@@ -82,18 +78,31 @@ describe(
});
it("shows all data before sandboxing policy is applied", () => {
signInAs(gizmoViewer);
assertNoResultsOrValuesAreSandboxed(dashboard, sandboxableQuestions);
signInAs(widgetViewer);
assertNoResultsOrValuesAreSandboxed(dashboard, sandboxableQuestions);
runWithoutCachingThenWithCaching(
({ isCachingEnabled }) => {
signInAs(gizmoViewer);
assertNoResultsOrValuesAreSandboxed(
dashboard,
sandboxableQuestions,
isCachingEnabled,
);
signInAs(widgetViewer);
assertNoResultsOrValuesAreSandboxed(
dashboard,
sandboxableQuestions,
isCachingEnabled,
);
},
{ questions: sandboxableQuestions },
);
});
describe("we can apply a sandbox policy", () => {
beforeEach(() => {
cy.signInAsAdmin();
cy.signIn("admin", { skipCache: true });
});
it("to a table filtered using a question as a custom view", () => {
it.only("to a table filtered using a question as a custom view", () => {
configureSandboxPolicy({
filterTableBy: "custom_view",
customViewType: "Question" as const,
@@ -102,17 +111,27 @@ describe(
cy.log(
"This sandboxing policy doesn't use user attributes. It makes all users see only the Gizmos.",
);
signInAs(gizmoViewer);
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Gizmo",
);
signInAs(widgetViewer);
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Gizmo",
runWithoutCachingThenWithCaching(
() => {
signInAs(gizmoViewer)
.then(() => {
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Gizmo",
);
})
.then(() => {
signInAs(widgetViewer).then(() => {
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Gizmo", // This user will see Gizmos too
);
});
});
},
{ questions: sandboxableQuestions },
);
});
@@ -125,17 +144,22 @@ describe(
cy.log(
"This sandboxing policy doesn't use user attributes. It makes all users see only the Gizmos.",
);
signInAs(gizmoViewer);
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Gizmo",
);
signInAs(widgetViewer);
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Gizmo",
runWithoutCachingThenWithCaching(
() => {
signInAs(gizmoViewer);
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Gizmo",
);
signInAs(widgetViewer);
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Gizmo", // This user will see Gizmos too
);
},
{ questions: sandboxableQuestions },
);
});
@@ -146,23 +170,32 @@ describe(
filterTableBy: "column",
filterColumn: "Category",
});
signInAs(gizmoViewer);
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Gizmo",
);
signInAs(widgetViewer);
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Widget",
runWithoutCachingThenWithCaching(
() => {
signInAs(gizmoViewer);
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Gizmo",
);
signInAs(widgetViewer);
assertAllResultsAndValuesAreSandboxed(
dashboard,
sandboxableQuestions,
"Widget",
);
},
{ questions: sandboxableQuestions },
);
});
});
// Custom columns currently don't work. These tests ensure that the sandboxing policy fails closed.
describe("we expect an error - and no data to be shown - when applying a sandbox policy...", () => {
before(() => {
H.restore("sandboxing-snapshot" as any);
});
(
[
["Question", "booleanExpr", "true"],

View File

@@ -14,7 +14,7 @@ export const isNumber = (value: unknown): value is number => {
return typeof value === "number";
};
export const checkNotNull = <T>(value: T | null | undefined): T => {
export const checkNotNull = <T>(value: T | null | undefined) => {
if (value != null) {
return value;
} else {