Merge branch 'main' into cp/216-fix-users-link

This commit is contained in:
cameronapak
2025-12-30 06:55:20 -06:00
402 changed files with 20979 additions and 3585 deletions

View File

@@ -1,12 +1,15 @@
import { afterEach, describe, test, expect } from "bun:test";
import { afterEach, describe, test, expect, beforeAll, afterAll } from "bun:test";
import { App, createApp } from "core/test/utils";
import { getDummyConnection } from "./helper";
import { Hono } from "hono";
import * as proto from "../src/data/prototype";
import { pick } from "lodash-es";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
afterEach(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("App tests", async () => {
test("boots and pongs", async () => {
@@ -19,7 +22,7 @@ describe("App tests", async () => {
test("plugins", async () => {
const called: string[] = [];
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
},

Binary file not shown.

View File

@@ -0,0 +1 @@
hello

View File

@@ -1,4 +1,4 @@
import { expect, describe, it, beforeAll, afterAll } from "bun:test";
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
@@ -9,60 +9,49 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("adapter", () => {
it("makes config", () => {
expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({});
expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).toEqual(
{},
);
it("makes config", async () => {
expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
expect(
omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
).toEqual({});
// merges everything returned from `app` with the config
expect(
omitKeys(
adapter.makeConfig(
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
await adapter.makeConfig(
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
),
["connection"],
),
).toEqual({
initialConfig: { server: { cors: { origin: "test" } } },
config: { server: { cors: { origin: "test" } } },
});
});
/* it.only("...", async () => {
const app = await adapter.createAdapterApp();
}); */
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
const first = await adapter.createAdapterApp(
it("allows all properties in app function", async () => {
const called = mock(() => null);
const config = await adapter.makeConfig(
{
initialConfig: { server: { cors: { origin: "random" } } },
app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: {
mode: "db",
},
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
},
undefined,
{ id },
{ foo: "bar" },
);
const second = await adapter.createAdapterApp();
const third = await adapter.createAdapterApp(undefined, undefined, { id });
await first.build();
await second.build();
await third.build();
expect(first.toJSON().server.cors.origin).toEqual("random");
expect(first).toBe(third);
expect(first).not.toBe(second);
expect(second).not.toBe(third);
expect(second.toJSON().server.cors.origin).toEqual("*");
// recreate the first one
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
await first2.build();
expect(first2).not.toBe(first);
expect(first2).not.toBe(third);
expect(first2).not.toBe(second);
expect(first2.toJSON().server.cors.origin).toEqual("*");
expect(config.connection).toEqual({ url: "test" });
expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
expect(config.options).toEqual({ mode: "db" });
await config.onBuilt?.(null as any);
expect(called).toHaveBeenCalled();
});
adapterTestSuite(bunTestRunner, {

View File

@@ -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 () => {
@@ -42,7 +45,6 @@ describe("Api", async () => {
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
console.log(params);
expect(params.token).toBe(token);
expect(params.token_transport).toBe("cookie");
expect(params.host).toBe("http://example.com");

View File

@@ -1,9 +1,23 @@
import { describe, expect, mock, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src";
import { App, createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype";
import * as proto from "data/prototype";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("App", () => {
test("use db mode by default", async () => {
const app = createApp();
await app.build();
expect(app.mode).toBe("db");
expect(app.isReadOnly()).toBe(false);
expect(app.modules instanceof DbModuleManager).toBe(true);
});
test("seed includes ctx and app", async () => {
const called = mock(() => null);
await createApp({
@@ -20,6 +34,7 @@ describe("App", () => {
"guard",
"flags",
"logger",
"mcp",
"helper",
]);
},
@@ -28,7 +43,7 @@ describe("App", () => {
expect(called).toHaveBeenCalled();
const app = createApp({
initialConfig: {
config: {
data: proto
.em({
todos: proto.entity("todos", {
@@ -135,4 +150,21 @@ describe("App", () => {
// expect async listeners to be executed sync after request
expect(called).toHaveBeenCalled();
});
test("getMcpClient", async () => {
const app = createApp({
config: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
const client = app.getMcpClient();
const res = await client.listTools();
expect(res).toBeDefined();
expect(res?.tools.length).toBeGreaterThan(0);
});
});

View File

@@ -13,6 +13,11 @@ describe("AppServer", () => {
allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
},
mcp: {
enabled: false,
path: "/api/system/mcp",
logLevel: "warning",
},
});
}
@@ -31,6 +36,11 @@ describe("AppServer", () => {
allow_methods: ["GET", "POST"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
},
mcp: {
enabled: false,
path: "/api/system/mcp",
logLevel: "warning",
},
});
}
});

View File

@@ -0,0 +1,127 @@
import { describe, expect, mock, test } from "bun:test";
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
import { getDummyConnection } from "../../__test__/helper";
import { ModuleManager } from "modules/ModuleManager";
import { em, entity, text } from "data/prototype";
async function createApp(config: CreateAppConfig = {}) {
const app = internalCreateApp({
connection: getDummyConnection().dummyConnection,
...config,
options: {
...config.options,
mode: "code",
},
});
await app.build();
return app;
}
describe("code-only", () => {
test("should create app with correct manager", async () => {
const app = await createApp();
await app.build();
expect(app.version()).toBeDefined();
expect(app.modules).toBeInstanceOf(ModuleManager);
});
test("should not perform database syncs", async () => {
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
});
expect(app.em.entities.map((e) => e.name)).toEqual(["test"]);
expect(
await app.em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute(),
).toEqual([]);
// only perform when explicitly forced
await app.em.schema().sync({ force: true });
expect(
await app.em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute()
.then((r) => r.map((r) => r.name)),
).toEqual(["test", "sqlite_sequence"]);
});
test("should not perform seeding", async () => {
const called = mock(() => null);
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
options: {
seed: async (ctx) => {
called();
await ctx.em.mutator("test").insertOne({ name: "test" });
},
},
});
await app.em.schema().sync({ force: true });
expect(called).not.toHaveBeenCalled();
expect(
await app.em
.repo("test")
.findMany({})
.then((r) => r.data),
).toEqual([]);
});
test("should sync and perform seeding", async () => {
const called = mock(() => null);
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
options: {
seed: async (ctx) => {
called();
await ctx.em.mutator("test").insertOne({ name: "test" });
},
},
});
await app.em.schema().sync({ force: true });
await app.options?.seed?.({
...app.modules.ctx(),
app: app,
});
expect(called).toHaveBeenCalled();
expect(
await app.em
.repo("test")
.findMany({})
.then((r) => r.data),
).toEqual([{ id: 1, name: "test" }]);
});
test("should not allow to modify config", async () => {
const app = await createApp();
// biome-ignore lint/suspicious/noPrototypeBuiltins: <explanation>
expect(app.modules.hasOwnProperty("mutateConfigSafe")).toBe(false);
expect(() => {
app.modules.configs().auth.enabled = true;
}).toThrow();
});
});

View File

@@ -0,0 +1,232 @@
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import type { McpServer } from "bknd/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [x] auth_me
* - [x] auth_strategies
* - [x] auth_user_create
* - [x] auth_user_token
* - [x] auth_user_password_change
* - [x] auth_user_password_test
* - [x] config_auth_get
* - [x] config_auth_update
* - [x] config_auth_strategies_get
* - [x] config_auth_strategies_add
* - [x] config_auth_strategies_update
* - [x] config_auth_strategies_remove
* - [x] config_auth_roles_get
* - [x] config_auth_roles_add
* - [x] config_auth_roles_update
* - [x] config_auth_roles_remove
*/
describe("mcp auth", async () => {
let app: App;
let server: McpServer;
beforeEach(async () => {
app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "secret",
},
},
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
console.dir(message, { depth: null });
});
});
const tool = createMcpToolCaller();
test("auth_*", async () => {
const me = await tool(server, "auth_me", {});
expect(me.user).toBeNull();
// strategies
const strategies = await tool(server, "auth_strategies", {});
expect(Object.keys(strategies.strategies).length).toEqual(1);
expect(strategies.strategies.password.enabled).toBe(true);
// create user
const user = await tool(
server,
"auth_user_create",
{
email: "test@test.com",
password: "12345678",
},
new Headers(),
);
expect(user.email).toBe("test@test.com");
// create token
const token = await tool(
server,
"auth_user_token",
{
email: "test@test.com",
},
new Headers(),
);
expect(token.token).toBeDefined();
expect(token.user.email).toBe("test@test.com");
// me
const me2 = await tool(
server,
"auth_me",
{},
new Request("http://localhost", {
headers: new Headers({
Authorization: `Bearer ${token.token}`,
}),
}),
);
expect(me2.user.email).toBe("test@test.com");
// change password
const changePassword = await tool(
server,
"auth_user_password_change",
{
email: "test@test.com",
password: "87654321",
},
new Headers(),
);
expect(changePassword.changed).toBe(true);
// test password
const testPassword = await tool(
server,
"auth_user_password_test",
{
email: "test@test.com",
password: "87654321",
},
new Headers(),
);
expect(testPassword.valid).toBe(true);
});
test("config_auth_{get,update}", async () => {
expect(await tool(server, "config_auth_get", {})).toEqual({
path: "",
secrets: false,
partial: false,
value: app.toJSON().auth,
});
// update
await tool(server, "config_auth_update", {
value: {
allow_register: false,
},
});
expect(app.toJSON().auth.allow_register).toBe(false);
});
test("config_auth_strategies_{get,add,update,remove}", async () => {
const strategies = await tool(server, "config_auth_strategies_get", {
key: "password",
});
expect(strategies).toEqual({
secrets: false,
module: "auth",
key: "password",
value: {
enabled: true,
type: "password",
},
});
// add google oauth
const addGoogleOauth = await tool(server, "config_auth_strategies_add", {
key: "google",
value: {
type: "oauth",
enabled: true,
config: {
name: "google",
type: "oidc",
client: {
client_id: "client_id",
client_secret: "client_secret",
},
},
},
return_config: true,
});
expect(addGoogleOauth.config.google.enabled).toBe(true);
expect(app.toJSON().auth.strategies.google?.enabled).toBe(true);
// update (disable) google oauth
await tool(server, "config_auth_strategies_update", {
key: "google",
value: {
enabled: false,
},
});
expect(app.toJSON().auth.strategies.google?.enabled).toBe(false);
// remove google oauth
await tool(server, "config_auth_strategies_remove", {
key: "google",
});
expect(app.toJSON().auth.strategies.google).toBeUndefined();
});
test("config_auth_roles_{get,add,update,remove}", async () => {
// add role
const addGuestRole = await tool(server, "config_auth_roles_add", {
key: "guest",
value: {
permissions: ["read", "write"],
},
return_config: true,
});
expect(addGuestRole.config.guest.permissions.map((p) => p.permission)).toEqual([
"read",
"write",
]);
// update role
await tool(server, "config_auth_roles_update", {
key: "guest",
value: {
permissions: ["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.map((p) => p.permission)).toEqual(["read"]);
// remove role
await tool(server, "config_auth_roles_remove", {
key: "guest",
});
expect(app.toJSON().auth.roles?.guest).toBeUndefined();
});
});

View File

@@ -0,0 +1,44 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import { registries } from "index";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("mcp", () => {
it("should have tools", async () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
config: {
auth: {
enabled: true,
},
media: {
enabled: true,
adapter: {
type: "local",
config: {
path: "./",
},
},
},
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
// expect mcp to not be loaded yet
expect(app.mcp).toBeNull();
// after first request, mcp should be loaded
await app.getMcpClient().listTools();
expect(app.mcp?.tools.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,347 @@
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import { getSystemMcp } from "modules/mcp/system-mcp";
import { pickKeys, type McpServer } from "bknd/utils";
import { entity, text } from "bknd";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [ ] data_sync
* - [x] data_entity_fn_count
* - [x] data_entity_fn_exists
* - [x] data_entity_read_one
* - [x] data_entity_read_many
* - [x] data_entity_insert
* - [x] data_entity_update_many
* - [x] data_entity_update_one
* - [x] data_entity_delete_one
* - [x] data_entity_delete_many
* - [x] data_entity_info
* - [ ] config_data_get
* - [ ] config_data_update
* - [x] config_data_entities_get
* - [x] config_data_entities_add
* - [x] config_data_entities_update
* - [x] config_data_entities_remove
* - [x] config_data_relations_add
* - [x] config_data_relations_get
* - [x] config_data_relations_update
* - [x] config_data_relations_remove
* - [x] config_data_indices_get
* - [x] config_data_indices_add
* - [x] config_data_indices_update
* - [x] config_data_indices_remove
*/
describe("mcp data", async () => {
let app: App;
let server: McpServer;
beforeEach(async () => {
const time = performance.now();
app = createApp({
config: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
console.dir(message, { depth: null });
});
});
const tool = createMcpToolCaller();
test("config_data_entities_{add,get,update,remove}", async () => {
const result = await tool(server, "config_data_entities_add", {
key: "test",
return_config: true,
value: {},
});
expect(result.success).toBe(true);
expect(result.module).toBe("data");
expect(result.config.test?.type).toEqual("regular");
const entities = Object.keys(app.toJSON().data.entities ?? {});
expect(entities).toContain("test");
{
// get
const result = await tool(server, "config_data_entities_get", {
key: "test",
});
expect(result.module).toBe("data");
expect(result.key).toBe("test");
expect(result.value.type).toEqual("regular");
}
{
// update
const result = await tool(server, "config_data_entities_update", {
key: "test",
return_config: true,
value: {
config: {
name: "Test",
},
},
});
expect(result.success).toBe(true);
expect(result.module).toBe("data");
expect(result.config.test.config?.name).toEqual("Test");
expect(app.toJSON().data.entities?.test?.config?.name).toEqual("Test");
}
{
// remove
const result = await tool(server, "config_data_entities_remove", {
key: "test",
});
expect(result.success).toBe(true);
expect(result.module).toBe("data");
expect(app.toJSON().data.entities?.test).toBeUndefined();
}
});
test("config_data_relations_{add,get,update,remove}", async () => {
// create posts and comments
await tool(server, "config_data_entities_add", {
key: "posts",
value: {},
});
await tool(server, "config_data_entities_add", {
key: "comments",
value: {},
});
expect(Object.keys(app.toJSON().data.entities ?? {})).toEqual(["posts", "comments"]);
// create relation
await tool(server, "config_data_relations_add", {
key: "", // doesn't matter
value: {
type: "n:1",
source: "comments",
target: "posts",
},
});
const config = app.toJSON().data;
expect(
pickKeys((config.relations?.n1_comments_posts as any) ?? {}, ["type", "source", "target"]),
).toEqual({
type: "n:1",
source: "comments",
target: "posts",
});
expect(config.entities?.comments?.fields?.posts_id?.type).toBe("relation");
{
// info
const postsInfo = await tool(server, "data_entity_info", {
entity: "posts",
});
expect(postsInfo.fields).toEqual(["id"]);
expect(postsInfo.relations.all.length).toBe(1);
const commentsInfo = await tool(server, "data_entity_info", {
entity: "comments",
});
expect(commentsInfo.fields).toEqual(["id", "posts_id"]);
expect(commentsInfo.relations.all.length).toBe(1);
}
// update
await tool(server, "config_data_relations_update", {
key: "n1_comments_posts",
value: {
config: {
with_limit: 10,
},
},
});
expect((app.toJSON().data.relations?.n1_comments_posts?.config as any)?.with_limit).toBe(10);
// delete
await tool(server, "config_data_relations_remove", {
key: "n1_comments_posts",
});
expect(app.toJSON().data.relations?.n1_comments_posts).toBeUndefined();
});
test("config_data_indices_update", async () => {
expect(server.tools.map((t) => t.name).includes("config_data_indices_update")).toBe(false);
});
test("config_data_indices_{add,get,remove}", async () => {
// create posts and comments
await tool(server, "config_data_entities_add", {
key: "posts",
value: entity("posts", {
title: text(),
content: text(),
}).toJSON(),
});
// add index on title
await tool(server, "config_data_indices_add", {
key: "", // auto generated
value: {
entity: "posts",
fields: ["title"],
},
});
expect(app.toJSON().data.indices?.idx_posts_title).toEqual({
entity: "posts",
fields: ["title"],
unique: false,
});
// delete
await tool(server, "config_data_indices_remove", {
key: "idx_posts_title",
});
expect(app.toJSON().data.indices?.idx_posts_title).toBeUndefined();
});
test("data_entity_*", async () => {
// create posts and comments
await tool(server, "config_data_entities_add", {
key: "posts",
value: entity("posts", {
title: text(),
content: text(),
}).toJSON(),
});
await tool(server, "config_data_entities_add", {
key: "comments",
value: entity("comments", {
content: text(),
}).toJSON(),
});
// insert a few posts
for (let i = 0; i < 10; i++) {
await tool(server, "data_entity_insert", {
entity: "posts",
json: {
title: `Post ${i}`,
},
});
}
// insert a few comments
for (let i = 0; i < 5; i++) {
await tool(server, "data_entity_insert", {
entity: "comments",
json: {
content: `Comment ${i}`,
},
});
}
const result = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 5,
});
expect(result.data.length).toBe(5);
expect(result.meta.items).toBe(5);
expect(result.meta.total).toBe(10);
expect(result.data[0].title).toBe("Post 0");
{
// count
const result = await tool(server, "data_entity_fn_count", {
entity: "posts",
});
expect(result.count).toBe(10);
}
{
// exists
const res = await tool(server, "data_entity_fn_exists", {
entity: "posts",
json: {
id: result.data[0].id,
},
});
expect(res.exists).toBe(true);
const res2 = await tool(server, "data_entity_fn_exists", {
entity: "posts",
json: {
id: "123",
},
});
expect(res2.exists).toBe(false);
}
// update
await tool(server, "data_entity_update_one", {
entity: "posts",
id: result.data[0].id,
json: {
title: "Post 0 updated",
},
});
const result2 = await tool(server, "data_entity_read_one", {
entity: "posts",
id: result.data[0].id,
});
expect(result2.data.title).toBe("Post 0 updated");
// delete the second post
await tool(server, "data_entity_delete_one", {
entity: "posts",
id: result.data[1].id,
});
const result3 = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 2,
});
expect(result3.data.map((p) => p.id)).toEqual([1, 3]);
// update many
await tool(server, "data_entity_update_many", {
entity: "posts",
update: {
title: "Post updated",
},
where: {
title: { $isnull: 0 },
},
});
const result4 = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 10,
});
expect(result4.data.length).toBe(9);
expect(result4.data.map((p) => p.title)).toEqual(
Array.from({ length: 9 }, () => "Post updated"),
);
// delete many
await tool(server, "data_entity_delete_many", {
entity: "posts",
json: {
title: { $isnull: 0 },
},
});
const result5 = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 10,
});
expect(result5.data.length).toBe(0);
expect(result5.meta.items).toBe(0);
expect(result5.meta.total).toBe(0);
});
});

View File

@@ -0,0 +1,119 @@
import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import { getSystemMcp } from "modules/mcp/system-mcp";
import { registries } from "index";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import type { McpServer } from "bknd/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [x] config_media_get
* - [x] config_media_update
* - [x] config_media_adapter_get
* - [x] config_media_adapter_update
*/
describe("mcp media", async () => {
let app: App;
let server: McpServer;
beforeEach(async () => {
registries.media.register("local", StorageLocalAdapter);
app = createApp({
config: {
media: {
enabled: true,
adapter: {
type: "local",
config: {
path: "./",
},
},
},
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
console.dir(message, { depth: null });
});
});
const tool = createMcpToolCaller();
test("config_media_{get,update}", async () => {
const result = await tool(server, "config_media_get", {});
expect(result).toEqual({
path: "",
secrets: false,
partial: false,
value: app.toJSON().media,
});
// partial
expect((await tool(server, "config_media_get", { path: "adapter" })).value).toEqual({
type: "local",
config: {
path: "./",
},
});
// update
await tool(server, "config_media_update", {
value: {
storage: {
body_max_size: 1024 * 1024 * 10,
},
},
return_config: true,
});
expect(app.toJSON().media.storage.body_max_size).toBe(1024 * 1024 * 10);
});
test("config_media_adapter_{get,update}", async () => {
const result = await tool(server, "config_media_adapter_get", {});
expect(result).toEqual({
secrets: false,
value: app.toJSON().media.adapter,
});
// update
await tool(server, "config_media_adapter_update", {
value: {
type: "local",
config: {
path: "./subdir",
},
},
});
const adapter = app.toJSON().media.adapter as any;
expect(adapter.config.path).toBe("./subdir");
expect(adapter.type).toBe("local");
// set to s3
{
await tool(server, "config_media_adapter_update", {
value: {
type: "s3",
config: {
access_key: "123",
secret_access_key: "456",
url: "https://example.com/what",
},
},
});
const adapter = app.toJSON(true).media.adapter as any;
expect(adapter.type).toBe("s3");
expect(adapter.config.url).toBe("https://example.com/what");
}
});
});

View File

@@ -0,0 +1,77 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
/**
* - [x] config_server_get
* - [x] config_server_update
*/
describe("mcp system", async () => {
let app: App;
let server: McpServer;
beforeAll(async () => {
app = createApp({
config: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
});
const tool = createMcpToolCaller();
test("config_server_get", async () => {
const result = await tool(server, "config_server_get", {});
expect(JSON.parse(JSON.stringify(result))).toEqual({
path: "",
secrets: false,
partial: false,
value: JSON.parse(JSON.stringify(app.toJSON().server)),
});
});
test("config_server_get2", async () => {
const result = await tool(server, "config_server_get", {});
expect(JSON.parse(JSON.stringify(result))).toEqual({
path: "",
secrets: false,
partial: false,
value: JSON.parse(JSON.stringify(app.toJSON().server)),
});
});
test("config_server_update", async () => {
const original = JSON.parse(JSON.stringify(app.toJSON().server));
const result = await tool(server, "config_server_update", {
value: {
cors: {
origin: "http://localhost",
},
},
return_config: true,
});
expect(JSON.parse(JSON.stringify(result))).toEqual({
success: true,
module: "server",
config: {
...original,
cors: {
...original.cors,
origin: "http://localhost",
},
},
});
expect(app.toJSON().server.cors.origin).toBe("http://localhost");
});
});

View File

@@ -0,0 +1,57 @@
import { AppEvents } from "App";
import { describe, test, expect, beforeAll, mock } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils";
/**
* - [x] system_config
* - [x] system_build
* - [x] system_ping
* - [x] system_info
*/
describe("mcp system", async () => {
let app: App;
let server: McpServer;
beforeAll(async () => {
app = createApp({
config: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
});
const tool = createMcpToolCaller();
test("system_ping", async () => {
const result = await tool(server, "system_ping", {});
expect(result).toEqual({ pong: true });
});
test("system_info", async () => {
const result = await tool(server, "system_info", {});
expect(Object.keys(result).length).toBeGreaterThan(0);
expect(Object.keys(result)).toContainValues(["version", "runtime", "connection"]);
});
test("system_build", async () => {
const called = mock(() => null);
app.emgr.onEvent(AppEvents.AppBuiltEvent, () => void called(), { once: true });
const result = await tool(server, "system_build", {});
expect(called).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
});
test("system_config", async () => {
const result = await tool(server, "system_config", {});
expect(result).toEqual(app.toJSON());
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, mock, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import { syncConfig } from "plugins/dev/sync-config.plugin";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("syncConfig", () => {
it("should only sync if enabled", async () => {
const called = mock(() => null);
const app = createApp();
await app.build();
await syncConfig({
write: () => {
called();
},
enabled: false,
includeFirstBoot: false,
})(app).onBuilt?.();
expect(called).not.toHaveBeenCalled();
await syncConfig({
write: () => {
called();
},
enabled: false,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).not.toHaveBeenCalled();
await syncConfig({
write: () => {
called();
},
enabled: true,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).toHaveBeenCalledTimes(1);
});
it("should respect secrets", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
});
await app.build();
await syncConfig({
write: async (config) => {
called();
expect(config.auth.jwt.secret).toBe("test");
},
enabled: true,
includeSecrets: true,
includeFirstBoot: true,
})(app).onBuilt?.();
await syncConfig({
write: async (config) => {
called();
// it's an important test, because the `jwt` part is omitted if secrets=false in general app.toJSON()
// but it's required to get the app running
expect(config.auth.jwt.secret).toBe("");
},
enabled: true,
includeSecrets: false,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { registries } from "../../src";
import { createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("repros", async () => {
/**
@@ -88,7 +92,7 @@ describe("repros", async () => {
fns.relation(schema.product_likes).manyToOne(schema.users);
},
);
const app = createApp({ initialConfig: { data: schema.toJSON() } });
const app = createApp({ config: { data: schema.toJSON() } });
await app.build();
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;

View File

@@ -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");
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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));
});
});

View File

@@ -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());
});
});

View File

@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);

View File

@@ -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);
});
});

View File

@@ -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" } });
});
});

View File

@@ -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 () => {
@@ -248,7 +424,7 @@ describe("Core Utils", async () => {
expect(utils.getContentName(request)).toBe(name);
});
test.only("detectImageDimensions", async () => {
test("detectImageDimensions", async () => {
// wrong
// @ts-expect-error
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
@@ -264,15 +440,44 @@ 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", () => {
test.only("formats local time", () => {
test("formats local time", () => {
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
console.log(utils.datetimeStringUTC(new Date()));
/*console.log(utils.datetimeStringUTC(new Date()));
console.log(utils.datetimeStringUTC());
console.log(new Date());
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone);
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); */
});
});
});

View File

@@ -5,7 +5,8 @@ import { parse } from "core/utils/schema";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
import { Entity, EntityManager, type EntityData } from "data/entities";
@@ -13,7 +14,7 @@ import { TextField } from "data/fields";
import { ManyToOneRelation } from "data/relations";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog(["log", "warn"]));
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
const dataConfig = parse(dataConfigSchema, {});

View File

@@ -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();
});

View File

@@ -47,8 +47,4 @@ describe("[data] Entity", async () => {
entity.addField(field);
expect(entity.getField("new_field")).toBe(field);
});
test.only("types", async () => {
console.log(entity.toTypes());
});
});

View File

@@ -1,12 +1,15 @@
import { afterAll, describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities";
import { ManyToOneRelation } from "data/relations";
import { TextField } from "data/fields";
import { JoinBuilder } from "data/entities/query/JoinBuilder";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] JoinBuilder", async () => {
test("missing relation", async () => {

View File

@@ -9,13 +9,14 @@ import {
} from "data/relations";
import { NumberField, TextField } from "data/fields";
import * as proto from "data/prototype";
import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper";
import { getDummyConnection } from "../../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { MutatorEvents } from "data/events";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog(["log", "warn"]));
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] Mutator (base)", async () => {

View File

@@ -1,4 +1,4 @@
import { afterAll, describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { Kysely, Transaction } from "kysely";
import { TextField } from "data/fields";
import { em as $em, entity as $entity, text as $text } from "data/prototype";
@@ -6,11 +6,13 @@ import { Entity, EntityManager } from "data/entities";
import { ManyToOneRelation } from "data/relations";
import { RepositoryEvents } from "data/events";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
type E = Kysely<any> | Transaction<any>;
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
async function sleep(ms: number) {
return new Promise((resolve) => {

View File

@@ -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 */
});
});

View File

@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities";
import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations";
import { TextField } from "data/fields";
@@ -6,6 +6,10 @@ import * as proto from "data/prototype";
import { WithBuilder } from "data/entities/query/WithBuilder";
import { schemaToEm } from "../../helper";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
const { dummyConnection } = getDummyConnection();

View File

@@ -23,11 +23,4 @@ describe("FieldIndex", async () => {
expect(index.name).toEqual("idx_test_name");
expect(index.unique).toEqual(false);
});
test("it fails on non-unique", async () => {
const field = new TestField("name", { required: false });
expect(() => new EntityIndex(entity, [field], true)).toThrowError();
expect(() => new EntityIndex(entity, [field])).toBeDefined();
});
});

View File

@@ -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");
});
});

View File

@@ -4,8 +4,10 @@ import {
type BaseRelationConfig,
EntityRelation,
EntityRelationAnchor,
ManyToManyRelation,
RelationTypes,
} from "data/relations";
import * as proto from "data/prototype";
class TestEntityRelation extends EntityRelation {
constructor(config?: BaseRelationConfig) {
@@ -75,4 +77,15 @@ describe("[data] EntityRelation", async () => {
const relation2 = new TestEntityRelation({ required: true });
expect(relation2.required).toBe(true);
});
it("correctly produces the relation name", async () => {
const relation = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"));
expect(relation.getName()).not.toContain(",");
expect(relation.getName()).toBe("mn_apps_organizations");
const relation2 = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"), {
connectionTableMappedName: "appOrganizations",
});
expect(relation2.getName()).toBe("mn_apps_organizations_appOrganizations");
});
});

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from "bun:test";
import * as sDirect from "jsonv-ts";
import { s as sFromBknd } from "bknd/utils";
describe("jsonv-ts resolution", () => {
it("should resolve to a single instance", () => {
const sameNamespace = sDirect === (sFromBknd as unknown as typeof sDirect);
// If this fails, two instances are being loaded via different specifiers/paths
expect(sameNamespace).toBe(true);
});
it("should resolve specifiers to a single package path", async () => {
const base = await import.meta.resolve("jsonv-ts");
const hono = await import.meta.resolve("jsonv-ts/hono");
const mcp = await import.meta.resolve("jsonv-ts/mcp");
expect(typeof base).toBe("string");
expect(typeof hono).toBe("string");
expect(typeof mcp).toBe("string");
// They can be different files (subpath exports), but they should share the same package root
const pkgRoot = (p: string) => p.slice(0, p.lastIndexOf("jsonv-ts") + "jsonv-ts".length);
expect(pkgRoot(base)).toBe(pkgRoot(hono));
expect(pkgRoot(base)).toBe(pkgRoot(mcp));
});
});

View File

@@ -5,7 +5,7 @@ import { format as sqlFormat } from "sql-formatter";
import type { em as protoEm } from "../src/data/prototype";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { slugify } from "core/utils/strings";
import { slugify } from "bknd/utils";
import { type Connection, SqliteLocalConnection } from "data/connection";
import { EntityManager } from "data/entities/EntityManager";
@@ -39,26 +39,6 @@ export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" };
}
type ConsoleSeverity = "debug" | "log" | "warn" | "error";
const _oldConsoles = {
debug: console.debug,
log: console.log,
warn: console.warn,
error: console.error,
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
}
export function enableConsoleLog() {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
}
export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile();
return { sql, parameters };
@@ -66,7 +46,7 @@ export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile();
console.log("$", sqlFormat(sql), "\n[params]", parameters);
console.info("$", sqlFormat(sql), "\n[params]", parameters);
}
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {

View File

@@ -1,12 +1,9 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth";
import { auth } from "../../src/auth/middlewares";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/modules/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -66,9 +63,10 @@ const configs = {
};
function createAuthApp() {
const { dummyConnection } = getDummyConnection();
const app = createApp({
connection: dummyConnection,
initialConfig: {
config: {
auth: configs.auth,
},
});
@@ -151,8 +149,8 @@ describe("integration auth", () => {
const { data: users } = await app.em.repository("users").findMany();
expect(users.length).toBe(2);
expect(users[0].email).toBe(configs.users.normal.email);
expect(users[1].email).toBe(configs.users.admin.email);
expect(users[0]?.email).toBe(configs.users.normal.email);
expect(users[1]?.email).toBe(configs.users.admin.email);
});
it("should log you in with API", async () => {
@@ -223,7 +221,7 @@ describe("integration auth", () => {
app.server.get("/get", auth(), async (c) => {
return c.json({
user: c.get("auth").user ?? null,
user: c.get("auth")?.user ?? null,
});
});
app.server.get("/wait", auth(), async (c) => {
@@ -242,7 +240,7 @@ describe("integration auth", () => {
{
await new Promise((r) => setTimeout(r, 10));
const res = await app.server.request("/get");
const data = await res.json();
const data = (await res.json()) as any;
expect(data.user).toBe(null);
expect(await $fns.me()).toEqual({ user: null as any });
}

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "bun:test";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { createApp } from "core/test/utils";
import { Api } from "../../src/Api";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("integration config", () => {
it("should create an entity", async () => {

View File

@@ -6,17 +6,20 @@ import { createApp } from "core/test/utils";
import { mergeObject, randomString } from "../../src/core/utils";
import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
import { assetsPath, assetsTmpPath } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => {
//disableConsoleLog();
registries.media.register("local", StorageLocalAdapter);
});
afterAll(enableConsoleLog);
const path = `${assetsPath}/image.png`;
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
const app = createApp({
initialConfig: {
config: {
media: mergeObject(
{
enabled: true,
@@ -40,9 +43,6 @@ function makeName(ext: string) {
return randomString(10) + "." + ext;
}
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test("accepts direct", async () => {
const app = await makeApp();
@@ -94,4 +94,38 @@ describe("MediaController", () => {
expect(res.status).toBe(413);
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
});
test("audio files", async () => {
const app = await makeApp();
const file = Bun.file(`${assetsPath}/test.mp3`);
const name = makeName("mp3");
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file,
});
const result = (await res.json()) as any;
expect(result.data.mime_type).toStartWith("audio/mpeg");
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();
});
test("text files", async () => {
const app = await makeApp();
const file = Bun.file(`${assetsPath}/test.txt`);
const name = makeName("txt");
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file,
});
const result = (await res.json()) as any;
expect(result.data.mime_type).toStartWith("text/plain");
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();
});
});

View File

@@ -71,6 +71,8 @@ describe("media/mime-types", () => {
["application/zip", "zip"],
["text/tab-separated-values", "tsv"],
["application/zip", "zip"],
["application/pdf", "pdf"],
["audio/mpeg", "mp3"],
] as const;
for (const [mime, ext] of tests) {
@@ -88,6 +90,9 @@ describe("media/mime-types", () => {
["image.jpeg", "jpeg"],
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
["file.pdf", "pdf"],
["file.mp3", "mp3"],
["robots.txt", "txt"],
] as const;
for (const [filename, ext] of tests) {
@@ -102,4 +107,36 @@ describe("media/mime-types", () => {
const [, ext] = getRandomizedFilename(file).split(".");
expect(ext).toBe("jpg");
});
test("getRandomizedFilename with body", async () => {
// should keep "pdf"
const [, ext] = getRandomizedFilename(
new File([""], "file.pdf", { type: "application/pdf" }),
).split(".");
expect(ext).toBe("pdf");
{
// no ext, should use "pdf" only for known formats
const [, ext] = getRandomizedFilename(
new File([""], "file", { type: "application/pdf" }),
).split(".");
expect(ext).toBe("pdf");
}
{
// wrong ext, should keep the wrong one
const [, ext] = getRandomizedFilename(
new File([""], "file.what", { type: "application/pdf" }),
).split(".");
expect(ext).toBe("what");
}
{
// txt
const [, ext] = getRandomizedFilename(
new File([""], "file.txt", { type: "text/plain" }),
).split(".");
expect(ext).toBe("txt");
}
});
});

View File

@@ -3,11 +3,14 @@ import { createApp } from "core/test/utils";
import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "data/prototype";
import { AppAuth, type ModuleBuildContext } from "modules";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { makeCtx, moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppAuth", () => {
test.only("...", () => {
test.skip("...", () => {
const auth = new AppAuth({});
console.log(auth.toJSON());
console.log(auth.config);
@@ -147,7 +150,7 @@ describe("AppAuth", () => {
test("registers auth middleware for bknd routes only", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
jwt: {
@@ -177,7 +180,7 @@ describe("AppAuth", () => {
test("should allow additional user fields", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
entity_name: "users",
enabled: true,
@@ -201,7 +204,7 @@ describe("AppAuth", () => {
test("ensure user field configs is always correct", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
},

View File

@@ -7,7 +7,7 @@ import { AppMedia } from "../../src/media/AppMedia";
import { moduleTestSuite } from "./module-test-suite";
describe("AppMedia", () => {
test.only("...", () => {
test.skip("...", () => {
const media = new AppMedia();
console.log(media.toJSON());
});
@@ -18,7 +18,7 @@ describe("AppMedia", () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
initialConfig: {
config: {
media: {
entity_name: "media",
enabled: true,

View File

@@ -0,0 +1,76 @@
import { it, expect, describe } from "bun:test";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { getDummyConnection } from "../helper";
import { TABLE_NAME } from "modules/db/migrations";
describe("DbModuleManager", () => {
it("should extract secrets", async () => {
const { dummyConnection } = getDummyConnection();
const m = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
});
await m.build();
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
await m.save();
});
it("should work with initial secrets", async () => {
const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely;
const m = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "",
},
},
},
secrets: {
"auth.jwt.secret": "test",
},
});
await m.build();
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
const getSecrets = () =>
db
.selectFrom(TABLE_NAME)
.selectAll()
.where("type", "=", "secrets")
.executeTakeFirst()
.then((r) => r?.json);
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
// also after rebuild
await m.build();
await m.save();
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
// and ignore if already present
const m2 = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "",
},
},
},
secrets: {
"auth.jwt.secret": "something completely different",
},
});
await m2.build();
await m2.save();
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
});
});

View File

@@ -1,14 +1,19 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { Module } from "modules/Module";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
import { getDefaultConfig } from "modules/ModuleManager";
import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations";
import { getDummyConnection } from "../helper";
import { s, stripMark } from "core/utils/schema";
import { Connection } from "data/connection/Connection";
import { entity, text } from "data/prototype";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("ModuleManager", async () => {
test("s1: no config, no build", async () => {
const { dummyConnection } = getDummyConnection();
@@ -133,7 +138,7 @@ describe("ModuleManager", async () => {
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json },
initial: { version: version - 1, ...json } as any,
});
await mm2.syncConfigTable();
await db

View File

@@ -1,14 +1,22 @@
import { describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { App, type InitialModuleConfigs, createApp } from "/";
import { type Kysely, sql } from "kysely";
import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json";
import v8 from "./samples/v8.json";
import v8_2 from "./samples/v8-2.json";
import v9 from "./samples/v9.json";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
// app expects migratable config to be present in database
async function createVersionedApp(config: InitialModuleConfigs | any) {
async function createVersionedApp(
config: InitialModuleConfigs | any,
opts?: { beforeCreateApp?: (db: Kysely<any>) => Promise<void> },
) {
const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version");
@@ -34,6 +42,10 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
})
.execute();
if (opts?.beforeCreateApp) {
await opts.beforeCreateApp(db);
}
const app = createApp({
connection: dummyConnection,
});
@@ -41,6 +53,19 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
return app;
}
async function getRawConfig(
app: App,
opts?: { version?: number; types?: ("config" | "diff" | "backup" | "secrets")[] },
) {
const db = app.em.connection.kysely;
return await db
.selectFrom("__bknd")
.selectAll()
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
.execute();
}
describe("Migrations", () => {
/**
* updated auth strategies to have "enabled" prop
@@ -78,4 +103,30 @@ describe("Migrations", () => {
// @ts-expect-error
expect(app.toJSON(true).server.admin).toBeUndefined();
});
test("migration from 9 to 10", async () => {
expect(v9.version).toBe(9);
const app = await createVersionedApp(v9);
expect(app.version()).toBeGreaterThan(9);
// @ts-expect-error
expect(app.toJSON(true).media.adapter.config.secret_access_key).toBe(
"^^s3.secret_access_key^^",
);
const [config, secrets] = (await getRawConfig(app, {
version: 10,
types: ["config", "secrets"],
})) as any;
expect(config.json.auth.jwt.secret).toBe("");
expect(config.json.media.adapter.config.access_key).toBe("");
expect(config.json.media.adapter.config.secret_access_key).toBe("");
expect(secrets.json["auth.jwt.secret"]).toBe("^^jwt.secret^^");
expect(secrets.json["media.adapter.config.access_key"]).toBe("^^s3.access_key^^");
expect(secrets.json["media.adapter.config.secret_access_key"]).toBe(
"^^s3.secret_access_key^^",
);
});
});

View File

@@ -0,0 +1,612 @@
{
"version": 9,
"server": {
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
],
"allow_credentials": true
},
"mcp": { "enabled": false, "path": "/api/system/mcp" }
},
"data": {
"basepath": "/api/data",
"default_primary_format": "integer",
"entities": {
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"path": { "type": "text", "config": { "required": true } },
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": { "type": "text", "config": { "required": false } },
"size": { "type": "number", "config": { "required": false } },
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": { "type": "text", "config": { "required": false } },
"modified_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"reference": { "type": "text", "config": { "required": false } },
"entity_id": { "type": "number", "config": { "required": false } },
"metadata": { "type": "json", "config": { "required": false } }
},
"config": { "sort_field": "id", "sort_dir": "asc" }
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"email": { "type": "text", "config": { "required": true } },
"strategy": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["password"] },
"required": true,
"hidden": ["update", "form"],
"fillable": ["create"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["admin", "guest"] },
"required": false
}
},
"age": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["18-24", "25-34", "35-44", "45-64", "65+"]
},
"required": false
}
},
"height": { "type": "number", "config": { "required": false } },
"gender": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["male", "female"] },
"required": false
}
}
},
"config": { "sort_field": "id", "sort_dir": "asc" }
},
"avatars": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"identifier": { "type": "text", "config": { "required": false } },
"payload": {
"type": "json",
"config": { "required": false, "hidden": ["table"] }
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"started_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"completed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"input": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "avatars"
}
},
"output": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "avatars"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"tryons": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"completed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"avatars_id": {
"type": "relation",
"config": {
"label": "Avatars",
"required": false,
"reference": "avatars",
"target": "avatars",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"output": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "tryons",
"max_items": 1
}
},
"products_id": {
"type": "relation",
"config": {
"label": "Products",
"required": false,
"reference": "products",
"target": "products",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"payload": {
"type": "json",
"config": { "required": false, "hidden": ["table"] }
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"products": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"enabled": { "type": "boolean", "config": { "required": false } },
"title": { "type": "text", "config": { "required": false } },
"url": { "type": "text", "config": { "required": false } },
"image": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "products",
"max_items": 1
}
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"sites_id": {
"type": "relation",
"config": {
"label": "Sites",
"required": false,
"reference": "sites",
"target": "sites",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"garment_type": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["auto", "tops", "bottoms", "one-pieces"]
},
"required": false
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"sites": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"origin": {
"type": "text",
"config": {
"pattern": "^(https?):\\/\\/([a-zA-Z0-9.-]+)(:\\d+)?$",
"required": true
}
},
"name": { "type": "text", "config": { "required": false } },
"active": { "type": "boolean", "config": { "required": false } },
"logo": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "sites",
"max_items": 1
}
},
"instructions": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": { "rows": "2" }
},
"required": false,
"hidden": ["table"]
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"sessions": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": { "format": "uuid", "fillable": false, "required": false }
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": true }
},
"claimed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"url": { "type": "text", "config": { "required": false } },
"sites_id": {
"type": "relation",
"config": {
"label": "Sites",
"required": false,
"reference": "sites",
"target": "sites",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
}
},
"relations": {
"poly_avatars_media_input": {
"type": "poly",
"source": "avatars",
"target": "media",
"config": { "mappedBy": "input" }
},
"poly_avatars_media_output": {
"type": "poly",
"source": "avatars",
"target": "media",
"config": { "mappedBy": "output" }
},
"n1_avatars_users": {
"type": "n:1",
"source": "avatars",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_tryons_avatars": {
"type": "n:1",
"source": "tryons",
"target": "avatars",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_tryons_users": {
"type": "n:1",
"source": "tryons",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"poly_tryons_media_output": {
"type": "poly",
"source": "tryons",
"target": "media",
"config": { "mappedBy": "output", "targetCardinality": 1 }
},
"poly_products_media_image": {
"type": "poly",
"source": "products",
"target": "media",
"config": { "mappedBy": "image", "targetCardinality": 1 }
},
"n1_tryons_products": {
"type": "n:1",
"source": "tryons",
"target": "products",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"poly_sites_media_logo": {
"type": "poly",
"source": "sites",
"target": "media",
"config": { "mappedBy": "logo", "targetCardinality": 1 }
},
"n1_sessions_sites": {
"type": "n:1",
"source": "sessions",
"target": "sites",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_sessions_users": {
"type": "n:1",
"source": "sessions",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_products_sites": {
"type": "n:1",
"source": "products",
"target": "sites",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_sites_origin_active": {
"entity": "sites",
"fields": ["origin", "active"],
"unique": false
},
"idx_sites_active": {
"entity": "sites",
"fields": ["active"],
"unique": false
},
"idx_products_url": {
"entity": "products",
"fields": ["url"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "^^jwt.secret^^",
"alg": "HS256",
"expires": 999999999,
"issuer": "issuer",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "none",
"secure": true,
"httpOnly": true,
"expires": 604800,
"partitioned": false,
"renew": true,
"pathSuccess": "/admin",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": { "hashing": "sha256" }
}
},
"guard": { "enabled": false },
"roles": {
"admin": { "implicit_allow": true },
"guest": { "is_default": true }
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": { "body_max_size": 0 },
"adapter": {
"type": "s3",
"config": {
"access_key": "^^s3.access_key^^",
"secret_access_key": "^^s3.secret_access_key^^",
"url": "https://1234.r2.cloudflarestorage.com/bucket-name"
}
}
},
"flows": { "basepath": "/api/flows", "flows": {} }
}

View File

@@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { Guard } from "auth/authorize/Guard";
import { DebugLogger } from "core/utils/DebugLogger";
import { EventManager } from "core/events";
import { EntityManager } from "data/entities/EntityManager";
import { Module, type ModuleBuildContext } from "modules/Module";
import { getDummyConnection } from "../helper";
import { ModuleHelper } from "modules/ModuleHelper";
import { DebugLogger, McpServer } from "bknd/utils";
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
const { dummyConnection } = getDummyConnection();
@@ -19,6 +19,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
guard: new Guard(),
flags: Module.ctx_flags,
logger: new DebugLogger(false),
mcp: new McpServer(),
...overrides,
};
return {

View File

@@ -102,7 +102,9 @@ describe("json form", () => {
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
for (const [pointer, schema, output] of examples) {
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
expect(utils.isRequired(new Draft2019(schema), pointer, schema), `${pointer} `).toBe(
output,
);
}
});