bunsane 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +47 -0
- package/.claude/skills/update-memory.md +74 -0
- package/.prettierrc +4 -0
- package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
- package/.serena/memories/architecture.md +154 -0
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
- package/.serena/memories/code_style_and_conventions.md +76 -0
- package/.serena/memories/project_overview.md +43 -0
- package/.serena/memories/schema-dsl-plan.md +107 -0
- package/.serena/memories/suggested_commands.md +80 -0
- package/.serena/memories/typescript-compilation-status.md +54 -0
- package/.serena/project.yml +114 -0
- package/TODO.md +1 -7
- package/bun.lock +150 -4
- package/bunfig.toml +10 -0
- package/config/cache.config.ts +77 -0
- package/config/upload.config.ts +4 -5
- package/core/App.ts +870 -123
- package/core/ArcheType.ts +2268 -377
- package/core/BatchLoader.ts +181 -71
- package/core/Config.ts +153 -0
- package/core/Decorators.ts +4 -1
- package/core/Entity.ts +621 -92
- package/core/EntityHookManager.ts +1 -1
- package/core/EntityInterface.ts +3 -1
- package/core/EntityManager.ts +1 -13
- package/core/ErrorHandler.ts +8 -2
- package/core/Logger.ts +9 -0
- package/core/Middleware.ts +34 -0
- package/core/RequestContext.ts +5 -1
- package/core/RequestLoaders.ts +227 -93
- package/core/SchedulerManager.ts +193 -52
- package/core/cache/CacheAnalytics.ts +399 -0
- package/core/cache/CacheFactory.ts +145 -0
- package/core/cache/CacheManager.ts +520 -0
- package/core/cache/CacheProvider.ts +34 -0
- package/core/cache/CacheWarmer.ts +157 -0
- package/core/cache/CompressionUtils.ts +110 -0
- package/core/cache/MemoryCache.ts +251 -0
- package/core/cache/MultiLevelCache.ts +180 -0
- package/core/cache/NoOpCache.ts +53 -0
- package/core/cache/RedisCache.ts +464 -0
- package/core/cache/TTLStrategy.ts +254 -0
- package/core/cache/index.ts +6 -0
- package/core/components/BaseComponent.ts +120 -0
- package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
- package/core/components/Decorators.ts +88 -0
- package/core/components/Interfaces.ts +7 -0
- package/core/components/index.ts +5 -0
- package/core/decorators/EntityHooks.ts +0 -3
- package/core/decorators/IndexedField.ts +26 -0
- package/core/decorators/ScheduledTask.ts +0 -47
- package/core/events/EntityLifecycleEvents.ts +1 -1
- package/core/health.ts +112 -0
- package/core/metadata/definitions/ArcheType.ts +14 -0
- package/core/metadata/definitions/Component.ts +9 -0
- package/core/metadata/definitions/gqlObject.ts +1 -1
- package/core/metadata/index.ts +42 -1
- package/core/metadata/metadata-storage.ts +28 -2
- package/core/middleware/AccessLog.ts +59 -0
- package/core/middleware/RequestId.ts +38 -0
- package/core/middleware/SecurityHeaders.ts +62 -0
- package/core/middleware/index.ts +3 -0
- package/core/scheduler/DistributedLock.ts +266 -0
- package/core/scheduler/index.ts +15 -0
- package/core/validateEnv.ts +92 -0
- package/database/DatabaseHelper.ts +416 -40
- package/database/IndexingStrategy.ts +342 -0
- package/database/PreparedStatementCache.ts +226 -0
- package/database/index.ts +32 -7
- package/database/sqlHelpers.ts +14 -2
- package/endpoints/archetypes.ts +362 -0
- package/endpoints/components.ts +58 -0
- package/endpoints/entity.ts +80 -0
- package/endpoints/index.ts +27 -0
- package/endpoints/query.ts +93 -0
- package/endpoints/stats.ts +76 -0
- package/endpoints/tables.ts +212 -0
- package/endpoints/types.ts +155 -0
- package/gql/ArchetypeOperations.ts +32 -86
- package/gql/Generator.ts +27 -315
- package/gql/GeneratorV2.ts +37 -0
- package/gql/builders/InputTypeBuilder.ts +99 -0
- package/gql/builders/ResolverBuilder.ts +234 -0
- package/gql/builders/TypeDefBuilder.ts +105 -0
- package/gql/builders/index.ts +3 -0
- package/gql/decorators/Upload.ts +1 -1
- package/gql/depthLimit.ts +85 -0
- package/gql/graph/GraphNode.ts +224 -0
- package/gql/graph/SchemaGraph.ts +278 -0
- package/gql/helpers.ts +8 -2
- package/gql/index.ts +56 -4
- package/gql/middleware.ts +79 -0
- package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
- package/gql/orchestration/index.ts +1 -0
- package/gql/scanner/ServiceScanner.ts +347 -0
- package/gql/schema/index.ts +458 -0
- package/gql/strategies/TypeGenerationStrategy.ts +329 -0
- package/gql/types.ts +1 -0
- package/gql/utils/TypeSignature.ts +220 -0
- package/gql/utils/index.ts +1 -0
- package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
- package/gql/visitors/DeduplicationVisitor.ts +82 -0
- package/gql/visitors/GraphVisitor.ts +78 -0
- package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
- package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
- package/gql/visitors/TypeCollectorVisitor.ts +79 -0
- package/gql/visitors/VisitorComposer.ts +96 -0
- package/gql/visitors/index.ts +7 -0
- package/package.json +59 -37
- package/plugins/index.ts +2 -2
- package/query/CTENode.ts +97 -0
- package/query/ComponentInclusionNode.ts +689 -0
- package/query/FilterBuilder.ts +127 -0
- package/query/FilterBuilderRegistry.ts +202 -0
- package/query/OrNode.ts +517 -0
- package/query/OrQuery.ts +42 -0
- package/query/Query.ts +1022 -0
- package/query/QueryContext.ts +170 -0
- package/query/QueryDAG.ts +122 -0
- package/query/QueryNode.ts +65 -0
- package/query/SourceNode.ts +53 -0
- package/query/builders/FullTextSearchBuilder.ts +236 -0
- package/query/index.ts +21 -0
- package/scheduler/index.ts +40 -8
- package/service/Service.ts +2 -1
- package/service/ServiceRegistry.ts +6 -5
- package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
- package/storage/S3StorageProvider.ts +316 -0
- package/{core/storage → storage}/StorageProvider.ts +7 -3
- package/studio/bun.lock +482 -0
- package/studio/index.html +13 -0
- package/studio/package.json +39 -0
- package/studio/postcss.config.js +6 -0
- package/studio/src/components/DataTable.tsx +211 -0
- package/studio/src/components/Layout.tsx +13 -0
- package/studio/src/components/PageContainer.tsx +9 -0
- package/studio/src/components/PageHeader.tsx +13 -0
- package/studio/src/components/SearchBar.tsx +57 -0
- package/studio/src/components/Sidebar.tsx +294 -0
- package/studio/src/components/ui/button.tsx +56 -0
- package/studio/src/components/ui/checkbox.tsx +26 -0
- package/studio/src/components/ui/input.tsx +25 -0
- package/studio/src/hooks/useDataTable.ts +131 -0
- package/studio/src/index.css +36 -0
- package/studio/src/lib/api.ts +186 -0
- package/studio/src/lib/utils.ts +13 -0
- package/studio/src/main.tsx +17 -0
- package/studio/src/pages/ArcheType.tsx +239 -0
- package/studio/src/pages/Components.tsx +124 -0
- package/studio/src/pages/EntityInspector.tsx +302 -0
- package/studio/src/pages/QueryRunner.tsx +246 -0
- package/studio/src/pages/Table.tsx +94 -0
- package/studio/src/pages/Welcome.tsx +241 -0
- package/studio/src/routes.tsx +45 -0
- package/studio/src/store/archeTypeSettings.ts +30 -0
- package/studio/src/store/studio.ts +65 -0
- package/studio/src/utils/columnHelpers.tsx +114 -0
- package/studio/studio-instructions.md +81 -0
- package/studio/tailwind.config.js +77 -0
- package/studio/tsconfig.json +24 -0
- package/studio/utils.ts +54 -0
- package/studio/vite.config.js +19 -0
- package/swagger/generator.ts +1 -1
- package/tests/e2e/http.test.ts +126 -0
- package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
- package/tests/fixtures/components/TestOrder.ts +23 -0
- package/tests/fixtures/components/TestProduct.ts +23 -0
- package/tests/fixtures/components/TestUser.ts +20 -0
- package/tests/fixtures/components/index.ts +6 -0
- package/tests/graphql/SchemaGeneration.test.ts +90 -0
- package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
- package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
- package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
- package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
- package/tests/integration/entity/Entity.persistence.test.ts +333 -0
- package/tests/integration/query/Query.exec.test.ts +523 -0
- package/tests/pglite-setup.ts +61 -0
- package/tests/setup.ts +164 -0
- package/tests/stress/BenchmarkRunner.ts +203 -0
- package/tests/stress/DataSeeder.ts +190 -0
- package/tests/stress/StressTestReporter.ts +229 -0
- package/tests/stress/cursor-perf-test.ts +171 -0
- package/tests/stress/fixtures/StressTestComponents.ts +58 -0
- package/tests/stress/index.ts +7 -0
- package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
- package/tests/unit/BatchLoader.test.ts +82 -0
- package/tests/unit/archetype/ArcheType.test.ts +107 -0
- package/tests/unit/cache/CacheManager.test.ts +347 -0
- package/tests/unit/cache/MemoryCache.test.ts +260 -0
- package/tests/unit/cache/RedisCache.test.ts +411 -0
- package/tests/unit/entity/Entity.components.test.ts +244 -0
- package/tests/unit/entity/Entity.test.ts +345 -0
- package/tests/unit/gql/depthLimit.test.ts +203 -0
- package/tests/unit/gql/operationMiddleware.test.ts +293 -0
- package/tests/unit/health/Health.test.ts +129 -0
- package/tests/unit/middleware/AccessLog.test.ts +37 -0
- package/tests/unit/middleware/Middleware.test.ts +98 -0
- package/tests/unit/middleware/RequestId.test.ts +54 -0
- package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
- package/tests/unit/query/FilterBuilder.test.ts +111 -0
- package/tests/unit/query/Query.test.ts +308 -0
- package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
- package/tests/unit/schema/schema-integration.test.ts +426 -0
- package/tests/unit/schema/schema.test.ts +580 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
- package/tests/unit/upload/RestUpload.test.ts +267 -0
- package/tests/unit/validateEnv.test.ts +82 -0
- package/tests/utils/entity-tracker.ts +57 -0
- package/tests/utils/index.ts +13 -0
- package/tests/utils/test-context.ts +149 -0
- package/tsconfig.json +5 -1
- package/types/archetype.types.ts +6 -0
- package/types/hooks.types.ts +1 -1
- package/types/query.types.ts +110 -0
- package/types/scheduler.types.ts +68 -7
- package/types/upload.types.ts +1 -0
- package/{core → upload}/FileValidator.ts +10 -1
- package/upload/RestUpload.ts +130 -0
- package/{core/components → upload}/UploadComponent.ts +11 -11
- package/{core → upload}/UploadManager.ts +3 -3
- package/upload/index.ts +23 -7
- package/utils/UploadHelper.ts +27 -6
- package/utils/cronParser.ts +16 -6
- package/.github/workflows/deploy-docs.yml +0 -57
- package/core/Components.ts +0 -202
- package/core/EntityCache.ts +0 -15
- package/core/Query.ts +0 -880
- package/docs/README.md +0 -149
- package/docs/_coverpage.md +0 -36
- package/docs/_sidebar.md +0 -23
- package/docs/api/core.md +0 -568
- package/docs/api/hooks.md +0 -554
- package/docs/api/index.md +0 -222
- package/docs/api/query.md +0 -678
- package/docs/api/service.md +0 -744
- package/docs/core-concepts/archetypes.md +0 -512
- package/docs/core-concepts/components.md +0 -498
- package/docs/core-concepts/entity.md +0 -314
- package/docs/core-concepts/hooks.md +0 -683
- package/docs/core-concepts/query.md +0 -588
- package/docs/core-concepts/services.md +0 -647
- package/docs/examples/code-examples.md +0 -425
- package/docs/getting-started.md +0 -337
- package/docs/index.html +0 -97
- package/tests/bench/insert.bench.ts +0 -60
- package/tests/bench/relations.bench.ts +0 -270
- package/tests/bench/sorting.bench.ts +0 -416
- package/tests/component-hooks-simple.test.ts +0 -117
- package/tests/component-hooks.test.ts +0 -1461
- package/tests/component.test.ts +0 -339
- package/tests/errorHandling.test.ts +0 -155
- package/tests/hooks.test.ts +0 -667
- package/tests/query-sorting.test.ts +0 -101
- package/tests/query.test.ts +0 -81
- package/tests/relations.test.ts +0 -170
- package/tests/scheduler.test.ts +0 -724
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { GraphQLError } from "graphql";
|
|
3
|
+
import {
|
|
4
|
+
Middleware,
|
|
5
|
+
composeOperationMiddleware,
|
|
6
|
+
type OperationMiddleware,
|
|
7
|
+
} from "../../../gql/middleware";
|
|
8
|
+
|
|
9
|
+
// --- Test helpers ---
|
|
10
|
+
|
|
11
|
+
function createMockService() {
|
|
12
|
+
return {
|
|
13
|
+
getUser: async (args: any, context: any, _info: any) => {
|
|
14
|
+
return { id: args.id, name: "Test User", role: context.user?.role };
|
|
15
|
+
},
|
|
16
|
+
createUser: async (args: any, _context: any, _info: any) => {
|
|
17
|
+
return { id: "new-1", name: args.name };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const Authenticate: OperationMiddleware = async (args, ctx, info, next) => {
|
|
23
|
+
if (!ctx.user) {
|
|
24
|
+
throw new GraphQLError("Unauthenticated", {
|
|
25
|
+
extensions: { code: "UNAUTHENTICATED", http: { status: 401 } },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return next();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function Authorize(...permissions: string[]): OperationMiddleware {
|
|
32
|
+
return async (args, ctx, info, next) => {
|
|
33
|
+
const userPerms: string[] = ctx.user?.permissions ?? [];
|
|
34
|
+
if (!permissions.every((p) => userPerms.includes(p))) {
|
|
35
|
+
throw new GraphQLError("Forbidden", {
|
|
36
|
+
extensions: { code: "FORBIDDEN", http: { status: 403 } },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return next();
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Tests ---
|
|
44
|
+
|
|
45
|
+
describe("composeOperationMiddleware", () => {
|
|
46
|
+
it("calls handler directly with empty middleware array", async () => {
|
|
47
|
+
const handler = async (args: any) => args.value;
|
|
48
|
+
const chain = composeOperationMiddleware([], handler, null);
|
|
49
|
+
const result = await chain({ value: 42 }, {}, {});
|
|
50
|
+
expect(result).toBe(42);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("executes middleware in order", async () => {
|
|
54
|
+
const order: number[] = [];
|
|
55
|
+
const mw1: OperationMiddleware = async (a, c, i, next) => {
|
|
56
|
+
order.push(1);
|
|
57
|
+
const result = await next();
|
|
58
|
+
order.push(4);
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
const mw2: OperationMiddleware = async (a, c, i, next) => {
|
|
62
|
+
order.push(2);
|
|
63
|
+
const result = await next();
|
|
64
|
+
order.push(3);
|
|
65
|
+
return result;
|
|
66
|
+
};
|
|
67
|
+
const handler = async () => {
|
|
68
|
+
order.push(0);
|
|
69
|
+
return "done";
|
|
70
|
+
};
|
|
71
|
+
const chain = composeOperationMiddleware([mw1, mw2], handler, null);
|
|
72
|
+
await chain({}, {}, {});
|
|
73
|
+
expect(order).toEqual([1, 2, 0, 3, 4]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("short-circuits when middleware throws", async () => {
|
|
77
|
+
const handlerCalled = { value: false };
|
|
78
|
+
const mw: OperationMiddleware = async () => {
|
|
79
|
+
throw new GraphQLError("Blocked");
|
|
80
|
+
};
|
|
81
|
+
const handler = async () => {
|
|
82
|
+
handlerCalled.value = true;
|
|
83
|
+
};
|
|
84
|
+
const chain = composeOperationMiddleware([mw], handler, null);
|
|
85
|
+
expect(chain({}, {}, {})).rejects.toThrow("Blocked");
|
|
86
|
+
expect(handlerCalled.value).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("middleware can transform the return value", async () => {
|
|
90
|
+
const transform: OperationMiddleware = async (a, c, i, next) => {
|
|
91
|
+
const result = await next();
|
|
92
|
+
return { ...result, extra: true };
|
|
93
|
+
};
|
|
94
|
+
const handler = async () => ({ name: "test" });
|
|
95
|
+
const chain = composeOperationMiddleware([transform], handler, null);
|
|
96
|
+
const result = await chain({}, {}, {});
|
|
97
|
+
expect(result).toEqual({ name: "test", extra: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("preserves thisArg for the handler", async () => {
|
|
101
|
+
const service = {
|
|
102
|
+
prefix: "Hello",
|
|
103
|
+
greet: async function (args: any) {
|
|
104
|
+
return `${this.prefix} ${args.name}`;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
const passthrough: OperationMiddleware = async (a, c, i, next) => next();
|
|
108
|
+
const chain = composeOperationMiddleware(
|
|
109
|
+
[passthrough],
|
|
110
|
+
service.greet,
|
|
111
|
+
service,
|
|
112
|
+
);
|
|
113
|
+
const result = await chain({ name: "World" }, {}, {});
|
|
114
|
+
expect(result).toBe("Hello World");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("@Middleware decorator", () => {
|
|
119
|
+
it("wraps a method with middleware chain", async () => {
|
|
120
|
+
const log: string[] = [];
|
|
121
|
+
const logger: OperationMiddleware = async (a, c, i, next) => {
|
|
122
|
+
log.push("before");
|
|
123
|
+
const result = await next();
|
|
124
|
+
log.push("after");
|
|
125
|
+
return result;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
class TestService {
|
|
129
|
+
@Middleware([logger])
|
|
130
|
+
async getData(args: any, _ctx: any, _info: any) {
|
|
131
|
+
log.push("handler");
|
|
132
|
+
return { value: args.id };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const svc = new TestService();
|
|
137
|
+
const result = await svc.getData({ id: 1 }, {}, {});
|
|
138
|
+
expect(result).toEqual({ value: 1 });
|
|
139
|
+
expect(log).toEqual(["before", "handler", "after"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("chains multiple middleware in order", async () => {
|
|
143
|
+
const order: string[] = [];
|
|
144
|
+
const first: OperationMiddleware = async (a, c, i, next) => {
|
|
145
|
+
order.push("first-in");
|
|
146
|
+
const r = await next();
|
|
147
|
+
order.push("first-out");
|
|
148
|
+
return r;
|
|
149
|
+
};
|
|
150
|
+
const second: OperationMiddleware = async (a, c, i, next) => {
|
|
151
|
+
order.push("second-in");
|
|
152
|
+
const r = await next();
|
|
153
|
+
order.push("second-out");
|
|
154
|
+
return r;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
class TestService {
|
|
158
|
+
@Middleware([first, second])
|
|
159
|
+
async doWork(_args: any, _ctx: any, _info: any) {
|
|
160
|
+
order.push("resolver");
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await new TestService().doWork({}, {}, {});
|
|
166
|
+
expect(order).toEqual([
|
|
167
|
+
"first-in",
|
|
168
|
+
"second-in",
|
|
169
|
+
"resolver",
|
|
170
|
+
"second-out",
|
|
171
|
+
"first-out",
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("preserves this context of the service", async () => {
|
|
176
|
+
class TestService {
|
|
177
|
+
private secret = "s3cret";
|
|
178
|
+
|
|
179
|
+
@Middleware([async (a, c, i, next) => next()])
|
|
180
|
+
async getSecret(_args: any, _ctx: any, _info: any) {
|
|
181
|
+
return this.secret;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result = await new TestService().getSecret({}, {}, {});
|
|
186
|
+
expect(result).toBe("s3cret");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("Auth guard middleware", () => {
|
|
191
|
+
it("Authenticate allows requests with user in context", async () => {
|
|
192
|
+
const service = createMockService();
|
|
193
|
+
|
|
194
|
+
class UserService {
|
|
195
|
+
@Middleware([Authenticate])
|
|
196
|
+
async getUser(args: any, context: any, info: any) {
|
|
197
|
+
return service.getUser(args, context, info);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const svc = new UserService();
|
|
202
|
+
const result = await svc.getUser(
|
|
203
|
+
{ id: "1" },
|
|
204
|
+
{ user: { role: "admin" } },
|
|
205
|
+
{},
|
|
206
|
+
);
|
|
207
|
+
expect(result).toEqual({ id: "1", name: "Test User", role: "admin" });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("Authenticate rejects requests without user", async () => {
|
|
211
|
+
class UserService {
|
|
212
|
+
@Middleware([Authenticate])
|
|
213
|
+
async getUser(args: any, ctx: any, info: any) {
|
|
214
|
+
return { id: args.id };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await new UserService().getUser({ id: "1" }, {}, {});
|
|
220
|
+
expect.unreachable("Should have thrown");
|
|
221
|
+
} catch (err: any) {
|
|
222
|
+
expect(err).toBeInstanceOf(GraphQLError);
|
|
223
|
+
expect(err.extensions.code).toBe("UNAUTHENTICATED");
|
|
224
|
+
expect(err.extensions.http.status).toBe(401);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("Authorize allows requests with correct permissions", async () => {
|
|
229
|
+
class UserService {
|
|
230
|
+
@Middleware([Authenticate, Authorize("users.read")])
|
|
231
|
+
async getUser(args: any, ctx: any, info: any) {
|
|
232
|
+
return { id: args.id };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const ctx = { user: { permissions: ["users.read", "users.write"] } };
|
|
237
|
+
const result = await new UserService().getUser({ id: "1" }, ctx, {});
|
|
238
|
+
expect(result).toEqual({ id: "1" });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("Authorize rejects requests with missing permissions", async () => {
|
|
242
|
+
class UserService {
|
|
243
|
+
@Middleware([Authenticate, Authorize("users.delete")])
|
|
244
|
+
async deleteUser(args: any, ctx: any, info: any) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const ctx = { user: { permissions: ["users.read"] } };
|
|
250
|
+
try {
|
|
251
|
+
await new UserService().deleteUser({ id: "1" }, ctx, {});
|
|
252
|
+
expect.unreachable("Should have thrown");
|
|
253
|
+
} catch (err: any) {
|
|
254
|
+
expect(err).toBeInstanceOf(GraphQLError);
|
|
255
|
+
expect(err.extensions.code).toBe("FORBIDDEN");
|
|
256
|
+
expect(err.extensions.http.status).toBe(403);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("Authenticate runs before Authorize in the chain", async () => {
|
|
261
|
+
class UserService {
|
|
262
|
+
@Middleware([Authenticate, Authorize("admin")])
|
|
263
|
+
async adminOp(_args: any, _ctx: any, _info: any) {
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// No user → should get UNAUTHENTICATED, not FORBIDDEN
|
|
269
|
+
try {
|
|
270
|
+
await new UserService().adminOp({}, {}, {});
|
|
271
|
+
expect.unreachable("Should have thrown");
|
|
272
|
+
} catch (err: any) {
|
|
273
|
+
expect(err.extensions.code).toBe("UNAUTHENTICATED");
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("resolver does not execute when middleware rejects", async () => {
|
|
278
|
+
let resolverCalled = false;
|
|
279
|
+
|
|
280
|
+
class UserService {
|
|
281
|
+
@Middleware([Authenticate])
|
|
282
|
+
async getUser(_args: any, _ctx: any, _info: any) {
|
|
283
|
+
resolverCalled = true;
|
|
284
|
+
return {};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
await new UserService().getUser({}, {}, {});
|
|
290
|
+
} catch {}
|
|
291
|
+
expect(resolverCalled).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
deepHealthCheck,
|
|
4
|
+
readinessCheck,
|
|
5
|
+
type HealthDeps,
|
|
6
|
+
} from "../../../core/health";
|
|
7
|
+
|
|
8
|
+
let dbUp: boolean;
|
|
9
|
+
let cacheUp: boolean;
|
|
10
|
+
|
|
11
|
+
function makeDeps(): HealthDeps {
|
|
12
|
+
return {
|
|
13
|
+
pingDb: async () => {
|
|
14
|
+
if (!dbUp) throw new Error("connection refused");
|
|
15
|
+
return true;
|
|
16
|
+
},
|
|
17
|
+
pingCache: async () => cacheUp,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("deepHealthCheck", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
dbUp = true;
|
|
24
|
+
cacheUp = true;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns ok when DB and cache are up", async () => {
|
|
28
|
+
const { result, httpStatus } = await deepHealthCheck(makeDeps());
|
|
29
|
+
|
|
30
|
+
expect(httpStatus).toBe(200);
|
|
31
|
+
expect(result.status).toBe("ok");
|
|
32
|
+
expect(result.checks.database.status).toBe("up");
|
|
33
|
+
expect(result.checks.cache.status).toBe("up");
|
|
34
|
+
expect(typeof result.checks.database.latency_ms).toBe("number");
|
|
35
|
+
expect(typeof result.checks.cache.latency_ms).toBe("number");
|
|
36
|
+
expect(typeof result.timestamp).toBe("string");
|
|
37
|
+
expect(typeof result.uptime).toBe("number");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns degraded when DB is up but cache is down", async () => {
|
|
41
|
+
cacheUp = false;
|
|
42
|
+
|
|
43
|
+
const { result, httpStatus } = await deepHealthCheck(makeDeps());
|
|
44
|
+
|
|
45
|
+
expect(httpStatus).toBe(200);
|
|
46
|
+
expect(result.status).toBe("degraded");
|
|
47
|
+
expect(result.checks.database.status).toBe("up");
|
|
48
|
+
expect(result.checks.cache.status).toBe("down");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns unavailable (503) when DB is down", async () => {
|
|
52
|
+
dbUp = false;
|
|
53
|
+
|
|
54
|
+
const { result, httpStatus } = await deepHealthCheck(makeDeps());
|
|
55
|
+
|
|
56
|
+
expect(httpStatus).toBe(503);
|
|
57
|
+
expect(result.status).toBe("unavailable");
|
|
58
|
+
expect(result.checks.database.status).toBe("down");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns unavailable (503) when both DB and cache are down", async () => {
|
|
62
|
+
dbUp = false;
|
|
63
|
+
cacheUp = false;
|
|
64
|
+
|
|
65
|
+
const { result, httpStatus } = await deepHealthCheck(makeDeps());
|
|
66
|
+
|
|
67
|
+
expect(httpStatus).toBe(503);
|
|
68
|
+
expect(result.status).toBe("unavailable");
|
|
69
|
+
expect(result.checks.database.status).toBe("down");
|
|
70
|
+
expect(result.checks.cache.status).toBe("down");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("readinessCheck", () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
dbUp = true;
|
|
77
|
+
cacheUp = true;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("returns 503 when isReady is false", async () => {
|
|
81
|
+
const { result, httpStatus } = await readinessCheck(
|
|
82
|
+
false,
|
|
83
|
+
false,
|
|
84
|
+
makeDeps(),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(httpStatus).toBe(503);
|
|
88
|
+
expect(result.status).toBe("unavailable");
|
|
89
|
+
expect(result.checks.database.status).toBe("unknown");
|
|
90
|
+
expect(result.checks.cache.status).toBe("unknown");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("returns 503 when isShuttingDown is true", async () => {
|
|
94
|
+
const { result, httpStatus } = await readinessCheck(
|
|
95
|
+
true,
|
|
96
|
+
true,
|
|
97
|
+
makeDeps(),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(httpStatus).toBe(503);
|
|
101
|
+
expect(result.status).toBe("unavailable");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("delegates to deepHealthCheck when ready", async () => {
|
|
105
|
+
const { result, httpStatus } = await readinessCheck(
|
|
106
|
+
true,
|
|
107
|
+
false,
|
|
108
|
+
makeDeps(),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(httpStatus).toBe(200);
|
|
112
|
+
expect(result.status).toBe("ok");
|
|
113
|
+
expect(result.checks.database.status).toBe("up");
|
|
114
|
+
expect(result.checks.cache.status).toBe("up");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("returns 503 when ready but DB is down", async () => {
|
|
118
|
+
dbUp = false;
|
|
119
|
+
|
|
120
|
+
const { result, httpStatus } = await readinessCheck(
|
|
121
|
+
true,
|
|
122
|
+
false,
|
|
123
|
+
makeDeps(),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(httpStatus).toBe(503);
|
|
127
|
+
expect(result.status).toBe("unavailable");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
2
|
+
import { accessLog } from '../../../core/middleware/AccessLog';
|
|
3
|
+
|
|
4
|
+
const ok = async () => new Response('ok');
|
|
5
|
+
|
|
6
|
+
describe('accessLog middleware', () => {
|
|
7
|
+
test('passes through and returns the response', async () => {
|
|
8
|
+
const mw = accessLog();
|
|
9
|
+
const res = await mw(new Request('http://localhost/test'), ok);
|
|
10
|
+
|
|
11
|
+
expect(res.status).toBe(200);
|
|
12
|
+
expect(await res.text()).toBe('ok');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('skips configured paths', async () => {
|
|
16
|
+
const mw = accessLog({ skip: ['/health'] });
|
|
17
|
+
|
|
18
|
+
// Should still work, just not log
|
|
19
|
+
const res = await mw(new Request('http://localhost/health'), ok);
|
|
20
|
+
expect(res.status).toBe(200);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('propagates errors from handler', async () => {
|
|
24
|
+
const mw = accessLog();
|
|
25
|
+
const failing = async () => { throw new Error('boom'); };
|
|
26
|
+
|
|
27
|
+
expect(mw(new Request('http://localhost/'), failing)).rejects.toThrow('boom');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('handles non-200 responses', async () => {
|
|
31
|
+
const mw = accessLog();
|
|
32
|
+
const notFound = async () => new Response('not found', { status: 404 });
|
|
33
|
+
const res = await mw(new Request('http://localhost/missing'), notFound);
|
|
34
|
+
|
|
35
|
+
expect(res.status).toBe(404);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { composeMiddleware, type Middleware } from '../../../core/Middleware';
|
|
3
|
+
|
|
4
|
+
describe('composeMiddleware', () => {
|
|
5
|
+
test('calls final handler when no middleware', async () => {
|
|
6
|
+
const handler = async (req: Request) => new Response('ok');
|
|
7
|
+
const composed = composeMiddleware([], handler);
|
|
8
|
+
const res = await composed(new Request('http://localhost/'));
|
|
9
|
+
expect(await res.text()).toBe('ok');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('executes middleware in order (onion model)', async () => {
|
|
13
|
+
const order: string[] = [];
|
|
14
|
+
|
|
15
|
+
const mw1: Middleware = async (req, next) => {
|
|
16
|
+
order.push('mw1-before');
|
|
17
|
+
const res = await next();
|
|
18
|
+
order.push('mw1-after');
|
|
19
|
+
return res;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const mw2: Middleware = async (req, next) => {
|
|
23
|
+
order.push('mw2-before');
|
|
24
|
+
const res = await next();
|
|
25
|
+
order.push('mw2-after');
|
|
26
|
+
return res;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handler = async (req: Request) => {
|
|
30
|
+
order.push('handler');
|
|
31
|
+
return new Response('ok');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const composed = composeMiddleware([mw1, mw2], handler);
|
|
35
|
+
await composed(new Request('http://localhost/'));
|
|
36
|
+
|
|
37
|
+
expect(order).toEqual([
|
|
38
|
+
'mw1-before',
|
|
39
|
+
'mw2-before',
|
|
40
|
+
'handler',
|
|
41
|
+
'mw2-after',
|
|
42
|
+
'mw1-after',
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('middleware can short-circuit (skip next)', async () => {
|
|
47
|
+
const mw: Middleware = async (req, next) => {
|
|
48
|
+
return new Response('blocked', { status: 403 });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handler = async (req: Request) => new Response('ok');
|
|
52
|
+
const composed = composeMiddleware([mw], handler);
|
|
53
|
+
const res = await composed(new Request('http://localhost/'));
|
|
54
|
+
expect(res.status).toBe(403);
|
|
55
|
+
expect(await res.text()).toBe('blocked');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('middleware can modify the response', async () => {
|
|
59
|
+
const mw: Middleware = async (req, next) => {
|
|
60
|
+
const res = await next();
|
|
61
|
+
const headers = new Headers(res.headers);
|
|
62
|
+
headers.set('X-Custom', 'test');
|
|
63
|
+
return new Response(res.body, {
|
|
64
|
+
status: res.status,
|
|
65
|
+
headers,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handler = async (req: Request) => new Response('ok');
|
|
70
|
+
const composed = composeMiddleware([mw], handler);
|
|
71
|
+
const res = await composed(new Request('http://localhost/'));
|
|
72
|
+
expect(res.headers.get('X-Custom')).toBe('test');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('errors propagate through middleware chain', async () => {
|
|
76
|
+
const mw: Middleware = async (req, next) => {
|
|
77
|
+
return next();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handler = async (req: Request) => {
|
|
81
|
+
throw new Error('boom');
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const composed = composeMiddleware([mw], handler);
|
|
85
|
+
expect(composed(new Request('http://localhost/'))).rejects.toThrow('boom');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('rejects if next() called multiple times', async () => {
|
|
89
|
+
const mw: Middleware = async (req, next) => {
|
|
90
|
+
await next();
|
|
91
|
+
return next(); // second call should reject
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handler = async (req: Request) => new Response('ok');
|
|
95
|
+
const composed = composeMiddleware([mw], handler);
|
|
96
|
+
expect(composed(new Request('http://localhost/'))).rejects.toThrow('next() called multiple times');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { requestId, getRequestId, requestStore } from '../../../core/middleware/RequestId';
|
|
3
|
+
|
|
4
|
+
const ok = async () => new Response('ok');
|
|
5
|
+
|
|
6
|
+
describe('requestId middleware', () => {
|
|
7
|
+
test('generates a UUID and sets X-Request-Id header', async () => {
|
|
8
|
+
const mw = requestId();
|
|
9
|
+
const res = await mw(new Request('http://localhost/'), ok);
|
|
10
|
+
|
|
11
|
+
const id = res.headers.get('X-Request-Id');
|
|
12
|
+
expect(id).toBeTruthy();
|
|
13
|
+
// UUID format check
|
|
14
|
+
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('respects incoming X-Request-Id header', async () => {
|
|
18
|
+
const mw = requestId();
|
|
19
|
+
const req = new Request('http://localhost/', {
|
|
20
|
+
headers: { 'X-Request-Id': 'custom-id-123' },
|
|
21
|
+
});
|
|
22
|
+
const res = await mw(req, ok);
|
|
23
|
+
|
|
24
|
+
expect(res.headers.get('X-Request-Id')).toBe('custom-id-123');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('getRequestId() returns current request ID within middleware', async () => {
|
|
28
|
+
const mw = requestId();
|
|
29
|
+
let capturedId: string | undefined;
|
|
30
|
+
|
|
31
|
+
const handler = async () => {
|
|
32
|
+
capturedId = getRequestId();
|
|
33
|
+
return new Response('ok');
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const res = await mw(new Request('http://localhost/'), handler);
|
|
37
|
+
const headerId = res.headers.get('X-Request-Id')!;
|
|
38
|
+
|
|
39
|
+
expect(capturedId).toBe(headerId);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('getRequestId() returns undefined outside request context', () => {
|
|
43
|
+
expect(getRequestId()).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('preserves original response body and status', async () => {
|
|
47
|
+
const handler = async () => new Response('hello', { status: 201 });
|
|
48
|
+
const mw = requestId();
|
|
49
|
+
const res = await mw(new Request('http://localhost/'), handler);
|
|
50
|
+
|
|
51
|
+
expect(res.status).toBe(201);
|
|
52
|
+
expect(await res.text()).toBe('hello');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { securityHeaders } from '../../../core/middleware/SecurityHeaders';
|
|
3
|
+
|
|
4
|
+
const ok = async () => new Response('ok');
|
|
5
|
+
|
|
6
|
+
describe('securityHeaders middleware', () => {
|
|
7
|
+
test('adds default security headers', async () => {
|
|
8
|
+
const mw = securityHeaders();
|
|
9
|
+
const res = await mw(new Request('http://localhost/'), ok);
|
|
10
|
+
|
|
11
|
+
expect(res.headers.get('X-Frame-Options')).toBe('DENY');
|
|
12
|
+
expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff');
|
|
13
|
+
expect(res.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('sets HSTS in production', async () => {
|
|
17
|
+
const mw = securityHeaders({ hsts: true });
|
|
18
|
+
const res = await mw(new Request('http://localhost/'), ok);
|
|
19
|
+
|
|
20
|
+
expect(res.headers.get('Strict-Transport-Security')).toBe('max-age=31536000; includeSubDomains');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('custom hstsMaxAge', async () => {
|
|
24
|
+
const mw = securityHeaders({ hsts: true, hstsMaxAge: 86400 });
|
|
25
|
+
const res = await mw(new Request('http://localhost/'), ok);
|
|
26
|
+
|
|
27
|
+
expect(res.headers.get('Strict-Transport-Security')).toBe('max-age=86400; includeSubDomains');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('frameOptions SAMEORIGIN', async () => {
|
|
31
|
+
const mw = securityHeaders({ frameOptions: 'SAMEORIGIN' });
|
|
32
|
+
const res = await mw(new Request('http://localhost/'), ok);
|
|
33
|
+
|
|
34
|
+
expect(res.headers.get('X-Frame-Options')).toBe('SAMEORIGIN');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('frameOptions disabled', async () => {
|
|
38
|
+
const mw = securityHeaders({ frameOptions: false });
|
|
39
|
+
const res = await mw(new Request('http://localhost/'), ok);
|
|
40
|
+
|
|
41
|
+
expect(res.headers.get('X-Frame-Options')).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('noSniff disabled', async () => {
|
|
45
|
+
const mw = securityHeaders({ noSniff: false });
|
|
46
|
+
const res = await mw(new Request('http://localhost/'), ok);
|
|
47
|
+
|
|
48
|
+
expect(res.headers.get('X-Content-Type-Options')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('referrerPolicy disabled', async () => {
|
|
52
|
+
const mw = securityHeaders({ referrerPolicy: false });
|
|
53
|
+
const res = await mw(new Request('http://localhost/'), ok);
|
|
54
|
+
|
|
55
|
+
expect(res.headers.get('Referrer-Policy')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('preserves original response body and status', async () => {
|
|
59
|
+
const handler = async () => new Response('hello', { status: 201 });
|
|
60
|
+
const mw = securityHeaders();
|
|
61
|
+
const res = await mw(new Request('http://localhost/'), handler);
|
|
62
|
+
|
|
63
|
+
expect(res.status).toBe(201);
|
|
64
|
+
expect(await res.text()).toBe('hello');
|
|
65
|
+
});
|
|
66
|
+
});
|