From 319469f44b50e50ccb1e96830824ce1f86887ead Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 2 Dec 2025 08:53:49 +0100 Subject: [PATCH] fix: putting schema related endpoints behind schema permission and add tests --- .../authorize/http/DataController.test.ts | 40 ++++ .../authorize/http/SystemController.spec.ts | 20 -- .../authorize/http/SystemController.test.ts | 41 +++++ app/__test__/auth/authorize/http/shared.ts | 171 ++++++++++++++++++ app/src/data/api/DataController.ts | 11 +- app/src/modules/permissions/index.ts | 2 + app/src/modules/server/SystemController.ts | 16 +- 7 files changed, 276 insertions(+), 25 deletions(-) create mode 100644 app/__test__/auth/authorize/http/DataController.test.ts delete mode 100644 app/__test__/auth/authorize/http/SystemController.spec.ts create mode 100644 app/__test__/auth/authorize/http/SystemController.test.ts create mode 100644 app/__test__/auth/authorize/http/shared.ts diff --git a/app/__test__/auth/authorize/http/DataController.test.ts b/app/__test__/auth/authorize/http/DataController.test.ts new file mode 100644 index 0000000..0a4fc52 --- /dev/null +++ b/app/__test__/auth/authorize/http/DataController.test.ts @@ -0,0 +1,40 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { createAuthTestApp } from "./shared"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; +import { em, entity, text } from "data/prototype"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +const schema = em( + { + posts: entity("posts", { + title: text(), + content: text(), + }), + comments: entity("comments", { + content: text(), + }), + }, + ({ relation }, { posts, comments }) => { + relation(posts).manyToOne(comments); + }, +); + +describe("DataController (auth)", () => { + test("reading schema.json", async () => { + const { request } = await createAuthTestApp( + { + permission: ["system.access.api", "data.entity.read", "system.schema.read"], + request: new Request("http://localhost/api/data/schema.json"), + }, + { + config: { data: schema.toJSON() }, + }, + ); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); +}); diff --git a/app/__test__/auth/authorize/http/SystemController.spec.ts b/app/__test__/auth/authorize/http/SystemController.spec.ts deleted file mode 100644 index 40e6493..0000000 --- a/app/__test__/auth/authorize/http/SystemController.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 = {}) { - 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)); - }); -}); diff --git a/app/__test__/auth/authorize/http/SystemController.test.ts b/app/__test__/auth/authorize/http/SystemController.test.ts new file mode 100644 index 0000000..7974a0d --- /dev/null +++ b/app/__test__/auth/authorize/http/SystemController.test.ts @@ -0,0 +1,41 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { createAuthTestApp } from "./shared"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("SystemController (auth)", () => { + test("reading info", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.info"], + request: new Request("http://localhost/api/system/info"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); + + test("reading permissions", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.schema.read"], + request: new Request("http://localhost/api/system/permissions"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); + + test("access openapi", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.openapi"], + request: new Request("http://localhost/api/system/openapi.json"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); +}); diff --git a/app/__test__/auth/authorize/http/shared.ts b/app/__test__/auth/authorize/http/shared.ts new file mode 100644 index 0000000..7ec79f9 --- /dev/null +++ b/app/__test__/auth/authorize/http/shared.ts @@ -0,0 +1,171 @@ +import { createApp } from "core/test/utils"; +import type { CreateAppConfig } from "App"; +import type { RoleSchema } from "auth/authorize/Role"; +import { isPlainObject } from "core/utils"; + +export type AuthTestConfig = { + guest?: RoleSchema; + member?: RoleSchema; + authorized?: RoleSchema; +}; + +export async function createAuthTestApp( + testConfig: { + permission: AuthTestConfig | string | string[]; + request: Request; + }, + config: Partial = {}, +) { + let member: RoleSchema | undefined; + let authorized: RoleSchema | undefined; + let guest: RoleSchema | undefined; + if (isPlainObject(testConfig.permission)) { + if (testConfig.permission.guest) + guest = { + ...testConfig.permission.guest, + is_default: true, + }; + if (testConfig.permission.member) member = testConfig.permission.member; + if (testConfig.permission.authorized) authorized = testConfig.permission.authorized; + } else { + member = { + permissions: [], + }; + authorized = { + permissions: Array.isArray(testConfig.permission) + ? testConfig.permission + : [testConfig.permission], + }; + guest = { + permissions: [], + is_default: true, + }; + } + + console.log("authorized", authorized); + + const app = createApp({ + ...config, + config: { + ...config.config, + auth: { + ...config.config?.auth, + enabled: true, + guard: { + enabled: true, + ...config.config?.auth?.guard, + }, + jwt: { + ...config.config?.auth?.jwt, + secret: "secret", + }, + roles: { + ...config.config?.auth?.roles, + guest, + member, + authorized, + admin: { + implicit_allow: true, + }, + }, + }, + }, + }); + await app.build(); + + const users = { + guest: null, + member: await app.createUser({ + email: "member@test.com", + password: "12345678", + role: "member", + }), + authorized: await app.createUser({ + email: "authorized@test.com", + password: "12345678", + role: "authorized", + }), + admin: await app.createUser({ + email: "admin@test.com", + password: "12345678", + role: "admin", + }), + } as const; + + const tokens = {} as Record; + for (const [key, user] of Object.entries(users)) { + if (user) { + tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user); + } + } + + async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) { + const headers = new Headers(init.headers ?? {}); + if (user in tokens) { + headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`); + } + const res = await app.server.request(input, { + ...init, + headers, + }); + + let data: any; + if (res.headers.get("Content-Type")?.startsWith("application/json")) { + data = await res.json(); + } else if (res.headers.get("Content-Type")?.startsWith("text/")) { + data = await res.text(); + } + + return { + status: res.status, + ok: res.ok, + headers: Object.fromEntries(res.headers.entries()), + data, + }; + } + + const requestFn = new Proxy( + {}, + { + get(_, prop: keyof typeof users) { + return async (input: string, init: RequestInit = {}) => { + return makeRequest(prop, input, init); + }; + }, + }, + ) as { + [K in keyof typeof users]: ( + input: string, + init?: RequestInit, + ) => Promise<{ + status: number; + ok: boolean; + headers: Record; + data: any; + }>; + }; + + const request = new Proxy( + {}, + { + get(_, prop: keyof typeof users) { + return async () => { + return makeRequest(prop, testConfig.request.url, { + headers: testConfig.request.headers, + method: testConfig.request.method, + body: testConfig.request.body, + }); + }; + }, + }, + ) as { + [K in keyof typeof users]: () => Promise<{ + status: number; + ok: boolean; + headers: Record; + data: any; + }>; + }; + + return { app, users, request, requestFn }; +} diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 082ae0c..531be0c 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -96,6 +96,9 @@ export class DataController extends Controller { // read entity schema hono.get( "/schema.json", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), @@ -124,6 +127,9 @@ export class DataController extends Controller { // read schema hono.get( "/schemas/:entity/:context?", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), @@ -161,7 +167,7 @@ export class DataController extends Controller { hono.get( "/types", permission(SystemPermissions.schemaRead, { - context: (c) => ({ module: "data" }), + context: (_c) => ({ module: "data" }), }), describeRoute({ summary: "Retrieve data typescript definitions", @@ -182,6 +188,9 @@ export class DataController extends Controller { */ hono.get( "/info/:entity", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index 152072d..0a58180 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -33,3 +33,5 @@ export const schemaRead = new Permission( ); export const build = new Permission("system.build"); export const mcp = new Permission("system.mcp"); +export const info = new Permission("system.info"); +export const openapi = new Permission("system.openapi"); diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 1790f9c..45988ad 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -1,5 +1,3 @@ -/// - import type { App } from "App"; import { datetimeStringLocal, @@ -359,7 +357,7 @@ export class SystemController extends Controller { override getController() { const { permission, auth } = this.middlewares; - const hono = this.create().use(auth()); + const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {})); this.registerConfigController(hono); @@ -434,6 +432,9 @@ export class SystemController extends Controller { hono.get( "/permissions", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "auth" }), + }), describeRoute({ summary: "Get the permissions", tags: ["system"], @@ -446,6 +447,7 @@ export class SystemController extends Controller { hono.post( "/build", + permission(SystemPermissions.build, {}), describeRoute({ summary: "Build the app", tags: ["system"], @@ -476,6 +478,7 @@ export class SystemController extends Controller { hono.get( "/info", + permission(SystemPermissions.info, {}), mcpTool("system_info"), describeRoute({ summary: "Get the server info", @@ -509,6 +512,7 @@ export class SystemController extends Controller { hono.get( "/openapi.json", + permission(SystemPermissions.openapi, {}), openAPISpecs(this.ctx.server, { info: { title: "bknd API", @@ -516,7 +520,11 @@ export class SystemController extends Controller { }, }), ); - hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" })); + hono.get( + "/swagger", + permission(SystemPermissions.openapi, {}), + swaggerUI({ url: "/api/system/openapi.json" }), + ); return hono; }