mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-04 00:56:01 +00:00
Merge remote-tracking branch 'origin/main' into feat/data-ui-fillable-visible
This commit is contained in:
@@ -6,13 +6,16 @@ describe("Api", async () => {
|
||||
it("should construct without options", () => {
|
||||
const api = new Api();
|
||||
expect(api.baseUrl).toBe("http://localhost");
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
|
||||
// verified is true, because no token, user, headers or request given
|
||||
// therefore nothing to check, auth state is verified
|
||||
expect(api.isAuthVerified()).toBe(true);
|
||||
});
|
||||
|
||||
it("should ignore force verify if no claims given", () => {
|
||||
const api = new Api({ verified: true });
|
||||
expect(api.baseUrl).toBe("http://localhost");
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
expect(api.isAuthVerified()).toBe(true);
|
||||
});
|
||||
|
||||
it("should construct from request (token)", async () => {
|
||||
|
||||
@@ -201,7 +201,10 @@ describe("mcp auth", async () => {
|
||||
},
|
||||
return_config: true,
|
||||
});
|
||||
expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]);
|
||||
expect(addGuestRole.config.guest.permissions.map((p) => p.permission)).toEqual([
|
||||
"read",
|
||||
"write",
|
||||
]);
|
||||
|
||||
// update role
|
||||
await tool(server, "config_auth_roles_update", {
|
||||
@@ -210,13 +213,15 @@ describe("mcp auth", async () => {
|
||||
permissions: ["read"],
|
||||
},
|
||||
});
|
||||
expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]);
|
||||
expect(app.toJSON().auth.roles?.guest?.permissions?.map((p) => p.permission)).toEqual([
|
||||
"read",
|
||||
]);
|
||||
|
||||
// get role
|
||||
const getGuestRole = await tool(server, "config_auth_roles_get", {
|
||||
key: "guest",
|
||||
});
|
||||
expect(getGuestRole.value.permissions).toEqual(["read"]);
|
||||
expect(getGuestRole.value.permissions.map((p) => p.permission)).toEqual(["read"]);
|
||||
|
||||
// remove role
|
||||
await tool(server, "config_auth_roles_remove", {
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
import { Authenticator } from "auth/authenticate/Authenticator";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
describe("Authenticator", async () => {});
|
||||
describe("Authenticator", async () => {
|
||||
test("should return auth cookie headers", async () => {
|
||||
const auth = new Authenticator({}, null as any, {
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
fields: [],
|
||||
},
|
||||
cookie: {
|
||||
sameSite: "strict",
|
||||
},
|
||||
});
|
||||
const headers = await auth.getAuthCookieHeader("token");
|
||||
const cookie = headers.get("Set-Cookie");
|
||||
expect(cookie).toStartWith("auth=");
|
||||
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
||||
|
||||
// now expect it to be removed
|
||||
const headers2 = await auth.removeAuthCookieHeader(headers);
|
||||
const cookie2 = headers2.get("Set-Cookie");
|
||||
expect(cookie2).toStartWith("auth=; Max-Age=0; Path=/; Expires=");
|
||||
expect(cookie2).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
||||
});
|
||||
|
||||
test("should return auth cookie string", async () => {
|
||||
const auth = new Authenticator({}, null as any, {
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
fields: [],
|
||||
},
|
||||
cookie: {
|
||||
sameSite: "strict",
|
||||
},
|
||||
});
|
||||
const cookie = await auth.unsafeGetAuthCookie("token");
|
||||
expect(cookie).toStartWith("auth=");
|
||||
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Guard } from "../../../src/auth/authorize/Guard";
|
||||
import { Guard, type GuardConfig } from "auth/authorize/Guard";
|
||||
import { Permission } from "auth/authorize/Permission";
|
||||
import { Role, type RoleSchema } from "auth/authorize/Role";
|
||||
import { objectTransform, s } from "bknd/utils";
|
||||
|
||||
function createGuard(
|
||||
permissionNames: string[],
|
||||
roles?: Record<string, Omit<RoleSchema, "name">>,
|
||||
config?: GuardConfig,
|
||||
) {
|
||||
const _roles = roles
|
||||
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
|
||||
return Role.create(name, { permissions, is_default, implicit_allow });
|
||||
})
|
||||
: {};
|
||||
const _permissions = permissionNames.map((name) => new Permission(name));
|
||||
return new Guard(_permissions, Object.values(_roles), config);
|
||||
}
|
||||
|
||||
describe("authorize", () => {
|
||||
const read = new Permission("read", {
|
||||
filterable: true,
|
||||
});
|
||||
const write = new Permission("write");
|
||||
|
||||
test("basic", async () => {
|
||||
const guard = Guard.create(
|
||||
const guard = createGuard(
|
||||
["read", "write"],
|
||||
{
|
||||
admin: {
|
||||
@@ -16,14 +38,14 @@ describe("authorize", () => {
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
expect(guard.granted(read, user)).toBeUndefined();
|
||||
expect(guard.granted(write, user)).toBeUndefined();
|
||||
|
||||
expect(() => guard.granted("something")).toThrow();
|
||||
expect(() => guard.granted(new Permission("something"), {})).toThrow();
|
||||
});
|
||||
|
||||
test("with default", async () => {
|
||||
const guard = Guard.create(
|
||||
const guard = createGuard(
|
||||
["read", "write"],
|
||||
{
|
||||
admin: {
|
||||
@@ -37,26 +59,26 @@ describe("authorize", () => {
|
||||
{ enabled: true },
|
||||
);
|
||||
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(false);
|
||||
expect(guard.granted(read, {})).toBeUndefined();
|
||||
expect(() => guard.granted(write, {})).toThrow();
|
||||
|
||||
const user = {
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
expect(guard.granted(read, user)).toBeUndefined();
|
||||
expect(guard.granted(write, user)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("guard implicit allow", async () => {
|
||||
const guard = Guard.create([], {}, { enabled: false });
|
||||
const guard = createGuard([], {}, { enabled: false });
|
||||
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(true);
|
||||
expect(guard.granted(read, {})).toBeUndefined();
|
||||
expect(guard.granted(write, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
test("role implicit allow", async () => {
|
||||
const guard = Guard.create(["read", "write"], {
|
||||
const guard = createGuard(["read", "write"], {
|
||||
admin: {
|
||||
implicit_allow: true,
|
||||
},
|
||||
@@ -66,12 +88,12 @@ describe("authorize", () => {
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
expect(guard.granted(read, user)).toBeUndefined();
|
||||
expect(guard.granted(write, user)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("guard with guest role implicit allow", async () => {
|
||||
const guard = Guard.create(["read", "write"], {
|
||||
const guard = createGuard(["read", "write"], {
|
||||
guest: {
|
||||
implicit_allow: true,
|
||||
is_default: true,
|
||||
@@ -79,7 +101,143 @@ describe("authorize", () => {
|
||||
});
|
||||
|
||||
expect(guard.getUserRole()?.name).toBe("guest");
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(true);
|
||||
expect(guard.granted(read, {})).toBeUndefined();
|
||||
expect(guard.granted(write, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("cases", () => {
|
||||
test("guest none, member deny if user.enabled is false", () => {
|
||||
const guard = createGuard(
|
||||
["read"],
|
||||
{
|
||||
guest: {
|
||||
is_default: true,
|
||||
},
|
||||
member: {
|
||||
permissions: [
|
||||
{
|
||||
permission: "read",
|
||||
policies: [
|
||||
{
|
||||
condition: {},
|
||||
effect: "filter",
|
||||
filter: {
|
||||
type: "member",
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: {
|
||||
"user.enabled": false,
|
||||
},
|
||||
effect: "deny",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ enabled: true },
|
||||
);
|
||||
|
||||
expect(() => guard.granted(read, { role: "guest" })).toThrow();
|
||||
|
||||
// member is allowed, because default role permission effect is allow
|
||||
// and no deny policy is met
|
||||
expect(guard.granted(read, { role: "member" })).toBeUndefined();
|
||||
|
||||
// member is allowed, because deny policy is not met
|
||||
expect(guard.granted(read, { role: "member", enabled: true })).toBeUndefined();
|
||||
|
||||
// member is denied, because deny policy is met
|
||||
expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow();
|
||||
|
||||
// get the filter for member role
|
||||
expect(guard.filters(read, { role: "member" }).filter).toEqual({
|
||||
type: "member",
|
||||
});
|
||||
|
||||
// get filter for guest
|
||||
expect(guard.filters(read, {}).filter).toBeUndefined();
|
||||
});
|
||||
|
||||
test("guest should only read posts that are public", () => {
|
||||
const read = new Permission(
|
||||
"read",
|
||||
{
|
||||
// make this permission filterable
|
||||
// without this, `filter` policies have no effect
|
||||
filterable: true,
|
||||
},
|
||||
// expect the context to match this schema
|
||||
// otherwise exit with 500 to ensure proper policy checking
|
||||
s.object({
|
||||
entity: s.string(),
|
||||
}),
|
||||
);
|
||||
const guard = createGuard(
|
||||
["read"],
|
||||
{
|
||||
guest: {
|
||||
// this permission is applied if no (or invalid) role is provided
|
||||
is_default: true,
|
||||
permissions: [
|
||||
{
|
||||
permission: "read",
|
||||
// effect deny means only having this permission, doesn't guarantee access
|
||||
effect: "deny",
|
||||
policies: [
|
||||
{
|
||||
// only if this condition is met
|
||||
condition: {
|
||||
entity: {
|
||||
$in: ["posts"],
|
||||
},
|
||||
},
|
||||
// the effect is allow
|
||||
effect: "allow",
|
||||
},
|
||||
{
|
||||
condition: {
|
||||
entity: "posts",
|
||||
},
|
||||
effect: "filter",
|
||||
filter: {
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// members should be allowed to read all
|
||||
member: {
|
||||
permissions: [
|
||||
{
|
||||
permission: "read",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ enabled: true },
|
||||
);
|
||||
|
||||
// guest can only read posts
|
||||
expect(guard.granted(read, {}, { entity: "posts" })).toBeUndefined();
|
||||
expect(() => guard.granted(read, {}, { entity: "users" })).toThrow();
|
||||
|
||||
// and guests can only read public posts
|
||||
expect(guard.filters(read, {}, { entity: "posts" }).filter).toEqual({
|
||||
public: true,
|
||||
});
|
||||
|
||||
// member can read posts and users
|
||||
expect(guard.granted(read, { role: "member" }, { entity: "posts" })).toBeUndefined();
|
||||
expect(guard.granted(read, { role: "member" }, { entity: "users" })).toBeUndefined();
|
||||
|
||||
// member should not have a filter
|
||||
expect(
|
||||
guard.filters(read, { role: "member" }, { entity: "posts" }).filter,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { createApp } from "core/test/utils";
|
||||
import type { CreateAppConfig } from "App";
|
||||
import * as proto from "data/prototype";
|
||||
import { mergeObject } from "core/utils/objects";
|
||||
import type { App, DB } from "bknd";
|
||||
import type { CreateUserPayload } from "auth/AppAuth";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
|
||||
const app = createApp({
|
||||
config: mergeObject(
|
||||
{
|
||||
data: proto
|
||||
.em(
|
||||
{
|
||||
users: proto.systemEntity("users", {}),
|
||||
posts: proto.entity("posts", {
|
||||
title: proto.text(),
|
||||
content: proto.text(),
|
||||
}),
|
||||
comments: proto.entity("comments", {
|
||||
content: proto.text(),
|
||||
}),
|
||||
},
|
||||
({ relation }, { users, posts, comments }) => {
|
||||
relation(posts).manyToOne(users);
|
||||
relation(comments).manyToOne(posts);
|
||||
},
|
||||
)
|
||||
.toJSON(),
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
config,
|
||||
),
|
||||
});
|
||||
await app.build();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function createUsers(app: App, users: CreateUserPayload[]) {
|
||||
return Promise.all(
|
||||
users.map(async (user) => {
|
||||
return await app.createUser(user);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFixtures(app: App, fixtures: Record<string, any[]> = {}) {
|
||||
const results = {} as any;
|
||||
for (const [entity, data] of Object.entries(fixtures)) {
|
||||
results[entity] = await app.em
|
||||
.mutator(entity as any)
|
||||
.insertMany(data)
|
||||
.then((result) => result.data);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
describe("data permissions", async () => {
|
||||
const app = await makeApp({
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
guard: {
|
||||
enabled: true,
|
||||
},
|
||||
roles: {
|
||||
guest: {
|
||||
is_default: true,
|
||||
permissions: [
|
||||
{
|
||||
permission: "system.access.api",
|
||||
},
|
||||
{
|
||||
permission: "data.entity.read",
|
||||
policies: [
|
||||
{
|
||||
condition: {
|
||||
entity: "posts",
|
||||
},
|
||||
effect: "filter",
|
||||
filter: {
|
||||
users_id: { $isnull: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
permission: "data.entity.create",
|
||||
policies: [
|
||||
{
|
||||
condition: {
|
||||
entity: "posts",
|
||||
},
|
||||
effect: "filter",
|
||||
filter: {
|
||||
users_id: { $isnull: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
permission: "data.entity.update",
|
||||
policies: [
|
||||
{
|
||||
condition: {
|
||||
entity: "posts",
|
||||
},
|
||||
effect: "filter",
|
||||
filter: {
|
||||
users_id: { $isnull: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
permission: "data.entity.delete",
|
||||
policies: [
|
||||
{
|
||||
condition: { entity: "posts" },
|
||||
},
|
||||
{
|
||||
condition: { entity: "posts" },
|
||||
effect: "filter",
|
||||
filter: {
|
||||
users_id: { $isnull: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const users = [
|
||||
{ email: "foo@example.com", password: "password" },
|
||||
{ email: "bar@example.com", password: "password" },
|
||||
];
|
||||
const fixtures = {
|
||||
posts: [
|
||||
{ content: "post 1", users_id: 1 },
|
||||
{ content: "post 2", users_id: 2 },
|
||||
{ content: "post 3", users_id: null },
|
||||
],
|
||||
comments: [
|
||||
{ content: "comment 1", posts_id: 1 },
|
||||
{ content: "comment 2", posts_id: 2 },
|
||||
{ content: "comment 3", posts_id: 3 },
|
||||
],
|
||||
};
|
||||
await createUsers(app, users);
|
||||
const results = await loadFixtures(app, fixtures);
|
||||
|
||||
describe("http", async () => {
|
||||
it("read many", async () => {
|
||||
// many only includes posts with users_id is null
|
||||
const res = await app.server.request("/api/data/entity/posts");
|
||||
const data = await res.json().then((r: any) => r.data);
|
||||
expect(data).toEqual([results.posts[2]]);
|
||||
|
||||
// same with /query
|
||||
{
|
||||
const res = await app.server.request("/api/data/entity/posts/query", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json().then((r: any) => r.data);
|
||||
expect(data).toEqual([results.posts[2]]);
|
||||
}
|
||||
});
|
||||
|
||||
it("read one", async () => {
|
||||
// one only includes posts with users_id is null
|
||||
{
|
||||
const res = await app.server.request("/api/data/entity/posts/1");
|
||||
const data = await res.json().then((r: any) => r.data);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data).toBeUndefined();
|
||||
}
|
||||
|
||||
// read one by allowed id
|
||||
{
|
||||
const res = await app.server.request("/api/data/entity/posts/3");
|
||||
const data = await res.json().then((r: any) => r.data);
|
||||
expect(res.status).toBe(200);
|
||||
expect(data).toEqual(results.posts[2]);
|
||||
}
|
||||
});
|
||||
|
||||
it("read many by reference", async () => {
|
||||
const res = await app.server.request("/api/data/entity/posts/1/comments");
|
||||
const data = await res.json().then((r: any) => r.data);
|
||||
expect(res.status).toBe(200);
|
||||
expect(data).toEqual(results.comments.filter((c: any) => c.posts_id === 1));
|
||||
});
|
||||
|
||||
it("mutation create one", async () => {
|
||||
// not allowed
|
||||
{
|
||||
const res = await app.server.request("/api/data/entity/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: "post 4" }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
// allowed
|
||||
{
|
||||
const res = await app.server.request("/api/data/entity/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: "post 4", users_id: null }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
it("mutation update one", async () => {
|
||||
// update one: not allowed
|
||||
const res = await app.server.request("/api/data/entity/posts/1", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ content: "post 4" }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
{
|
||||
// update one: allowed
|
||||
const res = await app.server.request("/api/data/entity/posts/3", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ content: "post 3 (updated)" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json().then((r: any) => r.data.content)).toBe("post 3 (updated)");
|
||||
}
|
||||
});
|
||||
|
||||
it("mutation update many", async () => {
|
||||
// update many: not allowed
|
||||
const res = await app.server.request("/api/data/entity/posts", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update: { content: "post 4" },
|
||||
where: { users_id: { $isnull: 0 } },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200); // because filtered
|
||||
const _data = await res.json().then((r: any) => r.data.map((p: any) => p.users_id));
|
||||
expect(_data.every((u: any) => u === null)).toBe(true);
|
||||
|
||||
// verify
|
||||
const data = await app.em
|
||||
.repo("posts")
|
||||
.findMany({ select: ["content", "users_id"] })
|
||||
.then((r) => r.data);
|
||||
|
||||
// expect non null users_id to not have content "post 4"
|
||||
expect(
|
||||
data.filter((p: any) => p.users_id !== null).every((p: any) => p.content !== "post 4"),
|
||||
).toBe(true);
|
||||
// expect null users_id to have content "post 4"
|
||||
expect(
|
||||
data.filter((p: any) => p.users_id === null).every((p: any) => p.content === "post 4"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const count = async () => {
|
||||
const {
|
||||
data: { count: _count },
|
||||
} = await app.em.repo("posts").count();
|
||||
return _count;
|
||||
};
|
||||
it("mutation delete one", async () => {
|
||||
const initial = await count();
|
||||
|
||||
// delete one: not allowed
|
||||
const res = await app.server.request("/api/data/entity/posts/1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(await count()).toBe(initial);
|
||||
|
||||
{
|
||||
// delete one: allowed
|
||||
const res = await app.server.request("/api/data/entity/posts/3", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await count()).toBe(initial - 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("mutation delete many", async () => {
|
||||
// delete many: not allowed
|
||||
const res = await app.server.request("/api/data/entity/posts", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
where: {},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// only deleted posts with users_id is null
|
||||
const remaining = await app.em
|
||||
.repo("posts")
|
||||
.findMany()
|
||||
.then((r) => r.data);
|
||||
expect(remaining.every((p: any) => p.users_id !== null)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { SystemController } from "modules/server/SystemController";
|
||||
import { createApp } from "core/test/utils";
|
||||
import type { CreateAppConfig } from "App";
|
||||
import { getPermissionRoutes } from "auth/middlewares/permission.middleware";
|
||||
|
||||
async function makeApp(config: Partial<CreateAppConfig> = {}) {
|
||||
const app = createApp(config);
|
||||
await app.build();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe.skip("SystemController", () => {
|
||||
it("...", async () => {
|
||||
const app = await makeApp();
|
||||
const controller = new SystemController(app);
|
||||
const hono = controller.getController();
|
||||
console.log(getPermissionRoutes(hono));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,543 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { s } from "bknd/utils";
|
||||
import { Permission } from "auth/authorize/Permission";
|
||||
import { Policy } from "auth/authorize/Policy";
|
||||
import { Hono } from "hono";
|
||||
import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware";
|
||||
import { auth } from "auth/middlewares/auth.middleware";
|
||||
import { Guard, mergeFilters, type GuardConfig } from "auth/authorize/Guard";
|
||||
import { Role, RolePermission } from "auth/authorize/Role";
|
||||
import { Exception } from "bknd";
|
||||
import { convert } from "core/object/query/object-query";
|
||||
|
||||
describe("Permission", () => {
|
||||
it("works with minimal schema", () => {
|
||||
expect(() => new Permission("test")).not.toThrow();
|
||||
});
|
||||
|
||||
it("parses context", () => {
|
||||
const p = new Permission(
|
||||
"test3",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
a: s.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
expect(() => p.parseContext({ a: [] })).toThrow();
|
||||
expect(p.parseContext({ a: "test" })).toEqual({ a: "test" });
|
||||
// @ts-expect-error
|
||||
expect(p.parseContext({ a: 1 })).toEqual({ a: "1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Policy", () => {
|
||||
it("works with minimal schema", () => {
|
||||
expect(() => new Policy().toJSON()).not.toThrow();
|
||||
});
|
||||
|
||||
it("checks condition", () => {
|
||||
const p = new Policy({
|
||||
condition: {
|
||||
a: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(p.meetsCondition({ a: 1 })).toBe(true);
|
||||
expect(p.meetsCondition({ a: 2 })).toBe(false);
|
||||
expect(p.meetsCondition({ a: 1, b: 1 })).toBe(true);
|
||||
expect(p.meetsCondition({})).toBe(false);
|
||||
|
||||
const p2 = new Policy({
|
||||
condition: {
|
||||
a: { $gt: 1 },
|
||||
$or: {
|
||||
b: { $lt: 2 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(p2.meetsCondition({ a: 2 })).toBe(true);
|
||||
expect(p2.meetsCondition({ a: 1 })).toBe(false);
|
||||
expect(p2.meetsCondition({ a: 1, b: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("filters", () => {
|
||||
const p = new Policy({
|
||||
filter: {
|
||||
age: { $gt: 18 },
|
||||
},
|
||||
});
|
||||
const subjects = [{ age: 19 }, { age: 17 }, { age: 12 }];
|
||||
|
||||
expect(p.getFiltered(subjects)).toEqual([{ age: 19 }]);
|
||||
|
||||
expect(p.meetsFilter({ age: 19 })).toBe(true);
|
||||
expect(p.meetsFilter({ age: 17 })).toBe(false);
|
||||
expect(p.meetsFilter({ age: 12 })).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces placeholders", () => {
|
||||
const p = new Policy({
|
||||
condition: {
|
||||
a: "@auth.username",
|
||||
},
|
||||
filter: {
|
||||
a: "@auth.username",
|
||||
},
|
||||
});
|
||||
const vars = { auth: { username: "test" } };
|
||||
|
||||
expect(p.meetsCondition({ a: "test" }, vars)).toBe(true);
|
||||
expect(p.meetsCondition({ a: "test2" }, vars)).toBe(false);
|
||||
expect(p.meetsCondition({ a: "test2" })).toBe(false);
|
||||
expect(p.meetsFilter({ a: "test" }, vars)).toBe(true);
|
||||
expect(p.meetsFilter({ a: "test2" }, vars)).toBe(false);
|
||||
expect(p.meetsFilter({ a: "test2" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Guard", () => {
|
||||
it("collects filters", () => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
a: s.number(),
|
||||
}),
|
||||
);
|
||||
const r = new Role("test", [
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: { a: { $eq: 1 } },
|
||||
filter: { foo: "bar" },
|
||||
effect: "filter",
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
const guard = new Guard([p], [r], {
|
||||
enabled: true,
|
||||
});
|
||||
expect(guard.filters(p, { role: r.name }, { a: 1 }).filter).toEqual({ foo: "bar" });
|
||||
expect(guard.filters(p, { role: r.name }, { a: 2 }).filter).toBeUndefined();
|
||||
// if no user context given, filter cannot be applied
|
||||
expect(guard.filters(p, {}, { a: 1 }).filter).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collects filters for default role", () => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
a: s.number(),
|
||||
}),
|
||||
);
|
||||
const r = new Role(
|
||||
"test",
|
||||
[
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: { a: { $eq: 1 } },
|
||||
filter: { foo: "bar" },
|
||||
effect: "filter",
|
||||
}),
|
||||
]),
|
||||
],
|
||||
true,
|
||||
);
|
||||
const guard = new Guard([p], [r], {
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
guard.filters(
|
||||
p,
|
||||
{
|
||||
role: r.name,
|
||||
},
|
||||
{ a: 1 },
|
||||
).filter,
|
||||
).toEqual({ foo: "bar" });
|
||||
expect(
|
||||
guard.filters(
|
||||
p,
|
||||
{
|
||||
role: r.name,
|
||||
},
|
||||
{ a: 2 },
|
||||
).filter,
|
||||
).toBeUndefined();
|
||||
// if no user context given, the default role is applied
|
||||
// hence it can be found
|
||||
expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("merges filters correctly", () => {
|
||||
expect(mergeFilters({ foo: "bar" }, { baz: "qux" })).toEqual({
|
||||
foo: { $eq: "bar" },
|
||||
baz: { $eq: "qux" },
|
||||
});
|
||||
expect(mergeFilters({ foo: "bar" }, { baz: { $eq: "qux" } })).toEqual({
|
||||
foo: { $eq: "bar" },
|
||||
baz: { $eq: "qux" },
|
||||
});
|
||||
expect(mergeFilters({ foo: "bar" }, { foo: "baz" })).toEqual({ foo: { $eq: "baz" } });
|
||||
|
||||
expect(mergeFilters({ foo: "bar" }, { foo: { $lt: 1 } })).toEqual({
|
||||
foo: { $eq: "bar", $lt: 1 },
|
||||
});
|
||||
|
||||
// overwrite base $or with priority
|
||||
expect(mergeFilters({ $or: { foo: "one" } }, { foo: "bar" })).toEqual({
|
||||
$or: {
|
||||
foo: {
|
||||
$eq: "bar",
|
||||
},
|
||||
},
|
||||
foo: {
|
||||
$eq: "bar",
|
||||
},
|
||||
});
|
||||
|
||||
// ignore base $or if priority has different key
|
||||
expect(mergeFilters({ $or: { other: "one" } }, { foo: "bar" })).toEqual({
|
||||
$or: {
|
||||
other: {
|
||||
$eq: "one",
|
||||
},
|
||||
},
|
||||
foo: {
|
||||
$eq: "bar",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission middleware", () => {
|
||||
const makeApp = (
|
||||
permissions: Permission<any, any, any, any>[],
|
||||
roles: Role[] = [],
|
||||
config: Partial<GuardConfig> = {},
|
||||
) => {
|
||||
const app = {
|
||||
module: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
ctx: () => ({
|
||||
guard: new Guard(permissions, roles, {
|
||||
enabled: true,
|
||||
...config,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
return new Hono()
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("app", app);
|
||||
await next();
|
||||
})
|
||||
.use(auth())
|
||||
.onError((err, c) => {
|
||||
if (err instanceof Exception) {
|
||||
return c.json(err.toJSON(), err.code as any);
|
||||
}
|
||||
return c.json({ error: err.message }, "code" in err ? (err.code as any) : 500);
|
||||
});
|
||||
};
|
||||
|
||||
it("allows if guard is disabled", async () => {
|
||||
const p = new Permission("test");
|
||||
const hono = makeApp([p], [], { enabled: false }).get("/test", permission(p, {}), async (c) =>
|
||||
c.text("test"),
|
||||
);
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("test");
|
||||
});
|
||||
|
||||
it("denies if guard is enabled", async () => {
|
||||
const p = new Permission("test");
|
||||
const hono = makeApp([p]).get("/test", permission(p, {}), async (c) => c.text("test"));
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows if user has (plain) role", async () => {
|
||||
const p = new Permission("test");
|
||||
const r = Role.create("test", { permissions: [p.name] });
|
||||
const hono = makeApp([p], [r])
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
|
||||
await next();
|
||||
})
|
||||
.get("/test", permission(p, {}), async (c) => c.text("test"));
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows if user has role with policy", async () => {
|
||||
const p = new Permission("test");
|
||||
const r = new Role("test", [
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $gte: 1 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
const hono = makeApp([p], [r], {
|
||||
context: {
|
||||
a: 1,
|
||||
},
|
||||
})
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
|
||||
await next();
|
||||
})
|
||||
.get("/test", permission(p, {}), async (c) => c.text("test"));
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("denies if user with role doesn't meet condition", async () => {
|
||||
const p = new Permission("test");
|
||||
const r = new Role("test", [
|
||||
new RolePermission(
|
||||
p,
|
||||
[
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $lt: 1 },
|
||||
},
|
||||
// default effect is allow
|
||||
}),
|
||||
],
|
||||
// change default effect to deny if no condition is met
|
||||
"deny",
|
||||
),
|
||||
]);
|
||||
const hono = makeApp([p], [r], {
|
||||
context: {
|
||||
a: 1,
|
||||
},
|
||||
})
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
|
||||
await next();
|
||||
})
|
||||
.get("/test", permission(p, {}), async (c) => c.text("test"));
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows if user with role doesn't meet condition (from middleware)", async () => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{},
|
||||
s.object({
|
||||
a: s.number(),
|
||||
}),
|
||||
);
|
||||
const r = new Role("test", [
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $eq: 1 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
const hono = makeApp([p], [r])
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
|
||||
await next();
|
||||
})
|
||||
.get(
|
||||
"/test",
|
||||
permission(p, {
|
||||
context: (c) => ({
|
||||
a: 1,
|
||||
}),
|
||||
}),
|
||||
async (c) => c.text("test"),
|
||||
);
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("throws if permission context is invalid", async () => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{},
|
||||
s.object({
|
||||
a: s.number({ minimum: 2 }),
|
||||
}),
|
||||
);
|
||||
const r = new Role("test", [
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $eq: 1 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
const hono = makeApp([p], [r])
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
|
||||
await next();
|
||||
})
|
||||
.get(
|
||||
"/test",
|
||||
permission(p, {
|
||||
context: (c) => ({
|
||||
a: 1,
|
||||
}),
|
||||
}),
|
||||
async (c) => c.text("test"),
|
||||
);
|
||||
|
||||
const res = await hono.request("/test");
|
||||
// expecting 500 because bknd should have handled it correctly
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("checks context on routes with permissions", async () => {
|
||||
const make = (user: any) => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{},
|
||||
s.object({
|
||||
a: s.number(),
|
||||
}),
|
||||
);
|
||||
const r = new Role("test", [
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $eq: 1 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
return makeApp([p], [r])
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user });
|
||||
await next();
|
||||
})
|
||||
.get(
|
||||
"/valid",
|
||||
permission(p, {
|
||||
context: (c) => ({
|
||||
a: 1,
|
||||
}),
|
||||
}),
|
||||
async (c) => c.text("test"),
|
||||
)
|
||||
.get(
|
||||
"/invalid",
|
||||
permission(p, {
|
||||
// @ts-expect-error
|
||||
context: (c) => ({
|
||||
b: "1",
|
||||
}),
|
||||
}),
|
||||
async (c) => c.text("test"),
|
||||
)
|
||||
.get(
|
||||
"/invalid2",
|
||||
permission(p, {
|
||||
// @ts-expect-error
|
||||
context: (c) => ({}),
|
||||
}),
|
||||
async (c) => c.text("test"),
|
||||
)
|
||||
.get(
|
||||
"/invalid3",
|
||||
// @ts-expect-error
|
||||
permission(p),
|
||||
async (c) => c.text("test"),
|
||||
);
|
||||
};
|
||||
|
||||
const hono = make({ id: 0, role: "test" });
|
||||
const valid = await hono.request("/valid");
|
||||
expect(valid.status).toBe(200);
|
||||
const invalid = await hono.request("/invalid");
|
||||
expect(invalid.status).toBe(500);
|
||||
const invalid2 = await hono.request("/invalid2");
|
||||
expect(invalid2.status).toBe(500);
|
||||
const invalid3 = await hono.request("/invalid3");
|
||||
expect(invalid3.status).toBe(500);
|
||||
|
||||
{
|
||||
const hono = make(null);
|
||||
const valid = await hono.request("/valid");
|
||||
expect(valid.status).toBe(403);
|
||||
const invalid = await hono.request("/invalid");
|
||||
expect(invalid.status).toBe(500);
|
||||
const invalid2 = await hono.request("/invalid2");
|
||||
expect(invalid2.status).toBe(500);
|
||||
const invalid3 = await hono.request("/invalid3");
|
||||
expect(invalid3.status).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Role", () => {
|
||||
it("serializes and deserializes", () => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
a: s.number({ minimum: 2 }),
|
||||
}),
|
||||
);
|
||||
const r = new Role(
|
||||
"test",
|
||||
[
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $eq: 1 },
|
||||
},
|
||||
effect: "deny",
|
||||
filter: {
|
||||
b: { $lt: 1 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
true,
|
||||
);
|
||||
const json = JSON.parse(JSON.stringify(r.toJSON()));
|
||||
const r2 = Role.create(p.name, json);
|
||||
expect(r2.toJSON()).toEqual(r.toJSON());
|
||||
});
|
||||
});
|
||||
@@ -66,4 +66,14 @@ describe("object-query", () => {
|
||||
expect(result).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("paths", () => {
|
||||
const result = validate({ "user.age": { $lt: 18 } }, { user: { age: 17 } });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("empty filters", () => {
|
||||
const result = validate({}, { user: { age: 17 } });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
makeValidator,
|
||||
exp,
|
||||
Expression,
|
||||
isPrimitive,
|
||||
type Primitive,
|
||||
} from "../../../src/core/object/query/query";
|
||||
|
||||
describe("query", () => {
|
||||
test("isPrimitive", () => {
|
||||
expect(isPrimitive(1)).toBe(true);
|
||||
expect(isPrimitive("1")).toBe(true);
|
||||
expect(isPrimitive(true)).toBe(true);
|
||||
expect(isPrimitive(false)).toBe(true);
|
||||
|
||||
// not primitives
|
||||
expect(isPrimitive(null)).toBe(false);
|
||||
expect(isPrimitive(undefined)).toBe(false);
|
||||
expect(isPrimitive([])).toBe(false);
|
||||
expect(isPrimitive({})).toBe(false);
|
||||
expect(isPrimitive(Symbol("test"))).toBe(false);
|
||||
expect(isPrimitive(new Date())).toBe(false);
|
||||
expect(isPrimitive(new Error())).toBe(false);
|
||||
expect(isPrimitive(new Set())).toBe(false);
|
||||
expect(isPrimitive(new Map())).toBe(false);
|
||||
});
|
||||
|
||||
test("strict expression creation", () => {
|
||||
// @ts-expect-error
|
||||
expect(() => exp()).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("")).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("invalid")).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("$eq")).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("$eq", 1)).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("$eq", () => null)).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("$eq", () => null, 1)).toThrow();
|
||||
expect(
|
||||
exp(
|
||||
"$eq",
|
||||
() => true,
|
||||
() => null,
|
||||
),
|
||||
).toBeInstanceOf(Expression);
|
||||
});
|
||||
|
||||
test("$eq is required", () => {
|
||||
expect(() => makeValidator([])).toThrow();
|
||||
expect(() =>
|
||||
makeValidator([
|
||||
exp(
|
||||
"$valid",
|
||||
() => true,
|
||||
() => null,
|
||||
),
|
||||
]),
|
||||
).toThrow();
|
||||
expect(
|
||||
makeValidator([
|
||||
exp(
|
||||
"$eq",
|
||||
() => true,
|
||||
() => null,
|
||||
),
|
||||
]),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("validates filter structure", () => {
|
||||
const validator = makeValidator([
|
||||
exp(
|
||||
"$eq",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(e, a) => e === a,
|
||||
),
|
||||
exp(
|
||||
"$like",
|
||||
(v: string) => typeof v === "string",
|
||||
(e, a) => e === a,
|
||||
),
|
||||
]);
|
||||
|
||||
// @ts-expect-error intentionally typed as union of given expression keys
|
||||
expect(validator.expressionKeys).toEqual(["$eq", "$like"]);
|
||||
|
||||
// @ts-expect-error "$and" is not allowed
|
||||
expect(() => validator.convert({ $and: {} })).toThrow();
|
||||
|
||||
// @ts-expect-error "$or" must be an object
|
||||
expect(() => validator.convert({ $or: [] })).toThrow();
|
||||
|
||||
// @ts-expect-error "invalid" is not a valid expression key
|
||||
expect(() => validator.convert({ foo: { invalid: "bar" } })).toThrow();
|
||||
|
||||
// @ts-expect-error "invalid" is not a valid expression key
|
||||
expect(() => validator.convert({ foo: { $invalid: "bar" } })).toThrow();
|
||||
|
||||
// @ts-expect-error "null" is not a valid value
|
||||
expect(() => validator.convert({ foo: null })).toThrow();
|
||||
|
||||
// @ts-expect-error only primitives are allowed for $eq
|
||||
expect(() => validator.convert({ foo: { $eq: [] } })).toThrow();
|
||||
|
||||
// @ts-expect-error only strings are allowed for $like
|
||||
expect(() => validator.convert({ foo: { $like: 1 } })).toThrow();
|
||||
|
||||
// undefined values are ignored
|
||||
expect(validator.convert({ foo: undefined })).toEqual({});
|
||||
|
||||
expect(validator.convert({ foo: "bar" })).toEqual({ foo: { $eq: "bar" } });
|
||||
expect(validator.convert({ foo: { $eq: "bar" } })).toEqual({ foo: { $eq: "bar" } });
|
||||
expect(validator.convert({ foo: { $like: "bar" } })).toEqual({ foo: { $like: "bar" } });
|
||||
});
|
||||
});
|
||||
@@ -194,6 +194,182 @@ describe("Core Utils", async () => {
|
||||
expect(result).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("recursivelyReplacePlaceholders", () => {
|
||||
// test basic replacement with simple pattern
|
||||
const obj1 = { a: "Hello, {$name}!", b: { c: "Hello, {$name}!" } };
|
||||
const variables1 = { name: "John" };
|
||||
const result1 = utils.recursivelyReplacePlaceholders(obj1, /\{\$(\w+)\}/g, variables1);
|
||||
expect(result1).toEqual({ a: "Hello, John!", b: { c: "Hello, John!" } });
|
||||
|
||||
// test the specific example from the user request
|
||||
const obj2 = { some: "value", here: "@auth.user" };
|
||||
const variables2 = { auth: { user: "what" } };
|
||||
const result2 = utils.recursivelyReplacePlaceholders(obj2, /^@([a-z\.]+)$/, variables2);
|
||||
expect(result2).toEqual({ some: "value", here: "what" });
|
||||
|
||||
// test with arrays
|
||||
const obj3 = { items: ["@config.name", "static", "@config.version"] };
|
||||
const variables3 = { config: { name: "MyApp", version: "1.0.0" } };
|
||||
const result3 = utils.recursivelyReplacePlaceholders(obj3, /^@([a-z\.]+)$/, variables3);
|
||||
expect(result3).toEqual({ items: ["MyApp", "static", "1.0.0"] });
|
||||
|
||||
// test with nested objects and deep paths
|
||||
const obj4 = {
|
||||
user: "@auth.user.name",
|
||||
settings: {
|
||||
theme: "@ui.theme",
|
||||
nested: {
|
||||
value: "@deep.nested.value",
|
||||
},
|
||||
},
|
||||
};
|
||||
const variables4 = {
|
||||
auth: { user: { name: "Alice" } },
|
||||
ui: { theme: "dark" },
|
||||
deep: { nested: { value: "found" } },
|
||||
};
|
||||
const result4 = utils.recursivelyReplacePlaceholders(obj4, /^@([a-z\.]+)$/, variables4);
|
||||
expect(result4).toEqual({
|
||||
user: "Alice",
|
||||
settings: {
|
||||
theme: "dark",
|
||||
nested: {
|
||||
value: "found",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// test with missing paths (should return original match)
|
||||
const obj5 = { value: "@missing.path" };
|
||||
const variables5 = { existing: "value" };
|
||||
const result5 = utils.recursivelyReplacePlaceholders(obj5, /^@([a-z\.]+)$/, variables5);
|
||||
expect(result5).toEqual({ value: "@missing.path" });
|
||||
|
||||
// test with non-matching strings (should remain unchanged)
|
||||
const obj6 = { value: "normal string", other: "not@matching" };
|
||||
const variables6 = { some: "value" };
|
||||
const result6 = utils.recursivelyReplacePlaceholders(obj6, /^@([a-z\.]+)$/, variables6);
|
||||
expect(result6).toEqual({ value: "normal string", other: "not@matching" });
|
||||
|
||||
// test with primitive values (should handle gracefully)
|
||||
expect(
|
||||
utils.recursivelyReplacePlaceholders("@test.value", /^@([a-z\.]+)$/, {
|
||||
test: { value: "replaced" },
|
||||
}),
|
||||
).toBe("replaced");
|
||||
expect(utils.recursivelyReplacePlaceholders(123, /^@([a-z\.]+)$/, {})).toBe(123);
|
||||
expect(utils.recursivelyReplacePlaceholders(null, /^@([a-z\.]+)$/, {})).toBe(null);
|
||||
|
||||
// test type preservation for full string matches
|
||||
const variables7 = { test: { value: 123, flag: true, data: null, arr: [1, 2, 3] } };
|
||||
const result7 = utils.recursivelyReplacePlaceholders(
|
||||
{
|
||||
number: "@test.value",
|
||||
boolean: "@test.flag",
|
||||
nullValue: "@test.data",
|
||||
array: "@test.arr",
|
||||
},
|
||||
/^@([a-z\.]+)$/,
|
||||
variables7,
|
||||
null,
|
||||
);
|
||||
expect(result7).toEqual({
|
||||
number: 123,
|
||||
boolean: true,
|
||||
nullValue: null,
|
||||
array: [1, 2, 3],
|
||||
});
|
||||
|
||||
// test partial string replacement (should convert to string)
|
||||
const result8 = utils.recursivelyReplacePlaceholders(
|
||||
{ message: "The value is @test.value!" },
|
||||
/@([a-z\.]+)/g,
|
||||
variables7,
|
||||
);
|
||||
expect(result8).toEqual({ message: "The value is 123!" });
|
||||
|
||||
// test with fallback parameter
|
||||
const obj9 = { user: "@user.id", config: "@config.theme" };
|
||||
const variables9 = {}; // empty context
|
||||
const result9 = utils.recursivelyReplacePlaceholders(
|
||||
obj9,
|
||||
/^@([a-z\.]+)$/,
|
||||
variables9,
|
||||
null,
|
||||
);
|
||||
expect(result9).toEqual({ user: null, config: null });
|
||||
|
||||
// test with fallback for partial matches
|
||||
const obj10 = { message: "Hello @user.name, welcome!" };
|
||||
const variables10 = {}; // empty context
|
||||
const result10 = utils.recursivelyReplacePlaceholders(
|
||||
obj10,
|
||||
/@([a-z\.]+)/g,
|
||||
variables10,
|
||||
"Guest",
|
||||
);
|
||||
expect(result10).toEqual({ message: "Hello Guest, welcome!" });
|
||||
|
||||
// test with different fallback types
|
||||
const obj11 = {
|
||||
stringFallback: "@missing.string",
|
||||
numberFallback: "@missing.number",
|
||||
booleanFallback: "@missing.boolean",
|
||||
objectFallback: "@missing.object",
|
||||
};
|
||||
const variables11 = {};
|
||||
const result11 = utils.recursivelyReplacePlaceholders(
|
||||
obj11,
|
||||
/^@([a-z\.]+)$/,
|
||||
variables11,
|
||||
"default",
|
||||
);
|
||||
expect(result11).toEqual({
|
||||
stringFallback: "default",
|
||||
numberFallback: "default",
|
||||
booleanFallback: "default",
|
||||
objectFallback: "default",
|
||||
});
|
||||
|
||||
// test fallback with arrays
|
||||
const obj12 = { items: ["@item1", "@item2", "static"] };
|
||||
const variables12 = { item1: "found" }; // item2 is missing
|
||||
const result12 = utils.recursivelyReplacePlaceholders(
|
||||
obj12,
|
||||
/^@([a-zA-Z0-9\.]+)$/,
|
||||
variables12,
|
||||
"missing",
|
||||
);
|
||||
expect(result12).toEqual({ items: ["found", "missing", "static"] });
|
||||
|
||||
// test fallback with nested objects
|
||||
const obj13 = {
|
||||
user: "@user.id",
|
||||
settings: {
|
||||
theme: "@theme.name",
|
||||
nested: {
|
||||
value: "@deep.value",
|
||||
},
|
||||
},
|
||||
};
|
||||
const variables13 = {}; // empty context
|
||||
const result13 = utils.recursivelyReplacePlaceholders(
|
||||
obj13,
|
||||
/^@([a-z\.]+)$/,
|
||||
variables13,
|
||||
null,
|
||||
);
|
||||
expect(result13).toEqual({
|
||||
user: null,
|
||||
settings: {
|
||||
theme: null,
|
||||
nested: {
|
||||
value: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("file", async () => {
|
||||
@@ -264,6 +440,35 @@ describe("Core Utils", async () => {
|
||||
height: 512,
|
||||
});
|
||||
});
|
||||
|
||||
test("isFileAccepted", () => {
|
||||
const file = new File([""], "file.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
expect(utils.isFileAccepted(file, "text/plain")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, "text/plain,text/html")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, "text/html")).toBe(false);
|
||||
|
||||
{
|
||||
const file = new File([""], "file.jpg", {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
expect(utils.isFileAccepted(file, "image/jpeg")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, "image/jpeg,image/png")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, "image/png")).toBe(false);
|
||||
expect(utils.isFileAccepted(file, "image/*")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, ".jpg")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, ".jpg,.png")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, ".png")).toBe(false);
|
||||
}
|
||||
|
||||
{
|
||||
const file = new File([""], "file.png");
|
||||
expect(utils.isFileAccepted(file, undefined as any)).toBe(true);
|
||||
}
|
||||
|
||||
expect(() => utils.isFileAccepted(null as any, "text/plain")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dates", () => {
|
||||
|
||||
@@ -30,9 +30,9 @@ describe("some tests", async () => {
|
||||
const query = await em.repository(users).findId(1);
|
||||
|
||||
expect(query.sql).toBe(
|
||||
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
|
||||
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? order by "users"."id" asc limit ? offset ?',
|
||||
);
|
||||
expect(query.parameters).toEqual([1, 1]);
|
||||
expect(query.parameters).toEqual([1, 1, 0]);
|
||||
expect(query.data).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, describe, expect, spyOn, test } from "bun:test";
|
||||
import { randomString } from "core/utils";
|
||||
import { Entity, EntityManager } from "data/entities";
|
||||
import { TextField, EntityIndex } from "data/fields";
|
||||
@@ -268,4 +268,39 @@ describe("SchemaManager tests", async () => {
|
||||
const diffAfter = await em.schema().getDiff();
|
||||
expect(diffAfter.length).toBe(0);
|
||||
});
|
||||
|
||||
test("returns statements", async () => {
|
||||
const amount = 5;
|
||||
const entities = new Array(amount)
|
||||
.fill(0)
|
||||
.map(() => new Entity(randomString(16), [new TextField("text")]));
|
||||
const em = new EntityManager(entities, dummyConnection);
|
||||
const statements = await em.schema().sync({ force: true });
|
||||
expect(statements.length).toBe(amount);
|
||||
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("batches statements", async () => {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const entities = new Array(20)
|
||||
.fill(0)
|
||||
.map(() => new Entity(randomString(16), [new TextField("text")]));
|
||||
const em = new EntityManager(entities, dummyConnection);
|
||||
const spy = spyOn(em.connection, "executeQueries");
|
||||
const statements = await em.schema().sync();
|
||||
expect(statements.length).toBe(entities.length);
|
||||
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
|
||||
true,
|
||||
);
|
||||
await em.schema().sync({ force: true });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const tables = await em.connection.kysely
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "table")
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(tables.length).toBe(entities.length + 1); /* 1+ for sqlite_sequence */
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("[data] JsonField", async () => {
|
||||
const field = new JsonField("test");
|
||||
fieldTestSuite(bunTestRunner, JsonField, {
|
||||
defaultValue: { a: 1 },
|
||||
sampleValues: ["string", { test: 1 }, 1],
|
||||
//sampleValues: ["string", { test: 1 }, 1],
|
||||
schemaType: "text",
|
||||
});
|
||||
|
||||
@@ -33,9 +33,9 @@ describe("[data] JsonField", async () => {
|
||||
});
|
||||
|
||||
test("getValue", async () => {
|
||||
expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}');
|
||||
expect(field.getValue("string", "form")).toBe('"string"');
|
||||
expect(field.getValue(1, "form")).toBe("1");
|
||||
expect(field.getValue({ test: 1 }, "form")).toEqual({ test: 1 });
|
||||
expect(field.getValue("string", "form")).toBe("string");
|
||||
expect(field.getValue(1, "form")).toBe(1);
|
||||
|
||||
expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 });
|
||||
expect(field.getValue('"string"', "submit")).toBe("string");
|
||||
@@ -43,6 +43,5 @@ describe("[data] JsonField", async () => {
|
||||
|
||||
expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}');
|
||||
expect(field.getValue("string", "table")).toBe('"string"');
|
||||
expect(field.getValue(1, "form")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { App, createApp, type AuthResponse } from "../../src";
|
||||
import { auth } from "../../src/auth/middlewares";
|
||||
import { auth } from "../../src/modules/middlewares";
|
||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { getDummyConnection } from "../helper";
|
||||
|
||||
Reference in New Issue
Block a user