@twasik4/pocket-service 1.0.2 → 1.0.4
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/dist/index.d.ts +36 -2
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +5 -11
- package/src/workers/db/clickhouse/index.ts +5 -5
- package/src/workers/db/mongo/collection.ts +18 -17
- package/src/workers/db/mongo/mongo.ts +126 -10
- package/src/workers/service.ts +111 -64
- package/tests/auth-strategies.spec.ts +150 -0
- package/tests/clickhouse.spec.ts +102 -0
- package/tests/express-types.spec.ts +232 -0
- package/tests/mongo-advanced.spec.ts +172 -0
- package/tests/mongo.spec.ts +49 -0
- package/tests/redis.spec.ts +1 -1
- package/tests/utils.spec.ts +30 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import type { Request } from "express";
|
|
3
|
+
import { SignJWT } from "jose";
|
|
4
|
+
import {
|
|
5
|
+
createJoseJwtVerifier,
|
|
6
|
+
createJwtAuthStrategy,
|
|
7
|
+
createMtlsAuthStrategy,
|
|
8
|
+
} from "../src/workers/auth/strategies";
|
|
9
|
+
import type { RouteDefinition } from "../src/workers/express-types";
|
|
10
|
+
|
|
11
|
+
const route: RouteDefinition<any, any, boolean> = {
|
|
12
|
+
method: "GET",
|
|
13
|
+
fullPath: "/secure",
|
|
14
|
+
requireAuth: true,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("Auth strategies", () => {
|
|
18
|
+
it("maps JWT claims to authenticated user using Bearer token", async () => {
|
|
19
|
+
const strategy = createJwtAuthStrategy({
|
|
20
|
+
verifyToken: async () => ({
|
|
21
|
+
sub: "user-1",
|
|
22
|
+
team: "core",
|
|
23
|
+
flags: ["a", "b"],
|
|
24
|
+
ignoredObject: { x: 1 },
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const req = {
|
|
29
|
+
headers: { authorization: "Bearer signed-token" },
|
|
30
|
+
} as unknown as Request;
|
|
31
|
+
|
|
32
|
+
await expect(strategy.authenticate(req, route)).resolves.toEqual({
|
|
33
|
+
userId: "user-1",
|
|
34
|
+
team: "core",
|
|
35
|
+
flags: ["a", "b"],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns null when token prefix does not match", async () => {
|
|
40
|
+
const verifyToken = vi.fn();
|
|
41
|
+
const strategy = createJwtAuthStrategy({
|
|
42
|
+
verifyToken,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const req = {
|
|
46
|
+
headers: { authorization: "Token signed-token" },
|
|
47
|
+
} as unknown as Request;
|
|
48
|
+
|
|
49
|
+
await expect(strategy.authenticate(req, route)).resolves.toBeNull();
|
|
50
|
+
expect(verifyToken).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("supports custom payload mapping", async () => {
|
|
54
|
+
const strategy = createJwtAuthStrategy({
|
|
55
|
+
verifyToken: async () => ({ sub: "user-2", role: "admin" }),
|
|
56
|
+
mapPayloadToUser: async (claims) => ({
|
|
57
|
+
userId: String(claims.sub),
|
|
58
|
+
role: String(claims.role),
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const req = {
|
|
63
|
+
headers: { authorization: "Bearer signed-token" },
|
|
64
|
+
} as unknown as Request;
|
|
65
|
+
|
|
66
|
+
await expect(strategy.authenticate(req, route)).resolves.toEqual({
|
|
67
|
+
userId: "user-2",
|
|
68
|
+
role: "admin",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("verifies HS256 JWT using jose verifier", async () => {
|
|
73
|
+
const secret = "test-secret";
|
|
74
|
+
const token = await new SignJWT({ scope: "api:read" })
|
|
75
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
76
|
+
.setSubject("subject-1")
|
|
77
|
+
.setIssuer("issuer-a")
|
|
78
|
+
.setAudience("audience-a")
|
|
79
|
+
.setIssuedAt()
|
|
80
|
+
.setExpirationTime("10m")
|
|
81
|
+
.sign(new TextEncoder().encode(secret));
|
|
82
|
+
|
|
83
|
+
const verify = createJoseJwtVerifier({
|
|
84
|
+
secret,
|
|
85
|
+
issuer: "issuer-a",
|
|
86
|
+
audience: "audience-a",
|
|
87
|
+
requiredClaims: ["sub", "scope"],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await expect(verify(token, {} as Request, route)).resolves.toMatchObject({
|
|
91
|
+
sub: "subject-1",
|
|
92
|
+
scope: "api:read",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns null when required JWT claims are missing", async () => {
|
|
97
|
+
const secret = "test-secret";
|
|
98
|
+
const token = await new SignJWT({ role: "reader" })
|
|
99
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
100
|
+
.setSubject("subject-2")
|
|
101
|
+
.setIssuedAt()
|
|
102
|
+
.setExpirationTime("10m")
|
|
103
|
+
.sign(new TextEncoder().encode(secret));
|
|
104
|
+
|
|
105
|
+
const verify = createJoseJwtVerifier({
|
|
106
|
+
secret,
|
|
107
|
+
requiredClaims: ["sub", "scope"],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await expect(verify(token, {} as Request, route)).resolves.toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("extracts mTLS identity from URI SAN", async () => {
|
|
114
|
+
const strategy = createMtlsAuthStrategy();
|
|
115
|
+
const req = {
|
|
116
|
+
socket: {
|
|
117
|
+
authorized: true,
|
|
118
|
+
getPeerCertificate: () => ({
|
|
119
|
+
subjectaltname:
|
|
120
|
+
"DNS:service.local, URI:spiffe://cluster/ns/default/sa/api",
|
|
121
|
+
subject: { CN: "subject-cn" },
|
|
122
|
+
issuer: { CN: "issuer-cn" },
|
|
123
|
+
fingerprint256: "fingerprint",
|
|
124
|
+
serialNumber: "serial",
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
} as unknown as Request;
|
|
128
|
+
|
|
129
|
+
await expect(strategy.authenticate(req, route)).resolves.toMatchObject({
|
|
130
|
+
userId: "spiffe://cluster/ns/default/sa/api",
|
|
131
|
+
certSubjectCn: "subject-cn",
|
|
132
|
+
certIssuerCn: "issuer-cn",
|
|
133
|
+
certFingerprint256: "fingerprint",
|
|
134
|
+
certSerialNumber: "serial",
|
|
135
|
+
mtlsAuthorized: true,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects unauthorized mTLS request by default", async () => {
|
|
140
|
+
const strategy = createMtlsAuthStrategy();
|
|
141
|
+
const req = {
|
|
142
|
+
socket: {
|
|
143
|
+
authorized: false,
|
|
144
|
+
getPeerCertificate: () => ({ subject: { CN: "x" } }),
|
|
145
|
+
},
|
|
146
|
+
} as unknown as Request;
|
|
147
|
+
|
|
148
|
+
await expect(strategy.authenticate(req, route)).resolves.toBeNull();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const commandMock = vi.fn();
|
|
5
|
+
const queryMock = vi.fn();
|
|
6
|
+
const insertMock = vi.fn();
|
|
7
|
+
const closeMock = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("@clickhouse/client", () => ({
|
|
10
|
+
createClient: vi.fn(() => ({
|
|
11
|
+
command: commandMock,
|
|
12
|
+
query: queryMock,
|
|
13
|
+
insert: insertMock,
|
|
14
|
+
close: closeMock,
|
|
15
|
+
})),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { createTypedClickhouse } from "../src/workers/db/clickhouse";
|
|
19
|
+
|
|
20
|
+
describe("Clickhouse wrapper", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
commandMock.mockReset();
|
|
23
|
+
queryMock.mockReset();
|
|
24
|
+
insertMock.mockReset();
|
|
25
|
+
closeMock.mockReset();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("executes createSQL for every configured table", async () => {
|
|
29
|
+
const clickhouse = createTypedClickhouse({
|
|
30
|
+
url: "http://localhost:8123",
|
|
31
|
+
tables: {
|
|
32
|
+
events: {
|
|
33
|
+
name: "events",
|
|
34
|
+
createSQL: "CREATE TABLE events (...)",
|
|
35
|
+
schema: () => ({ id: "", type: "" }),
|
|
36
|
+
},
|
|
37
|
+
metrics: {
|
|
38
|
+
name: "metrics",
|
|
39
|
+
createSQL: "CREATE TABLE metrics (...)",
|
|
40
|
+
schema: () => ({ id: "", value: 0 }),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await clickhouse.ensureTables();
|
|
46
|
+
|
|
47
|
+
expect(commandMock).toHaveBeenCalledTimes(2);
|
|
48
|
+
expect(commandMock).toHaveBeenNthCalledWith(1, {
|
|
49
|
+
query: "CREATE TABLE events (...)",
|
|
50
|
+
});
|
|
51
|
+
expect(commandMock).toHaveBeenNthCalledWith(2, {
|
|
52
|
+
query: "CREATE TABLE metrics (...)",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("builds SELECT query with filters and returns parsed rows", async () => {
|
|
57
|
+
queryMock.mockResolvedValue({
|
|
58
|
+
json: async () => ({
|
|
59
|
+
data: [{ id: "1", type: "login", severity: 2 }],
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const clickhouse = createTypedClickhouse({
|
|
64
|
+
url: "http://localhost:8123",
|
|
65
|
+
tables: {
|
|
66
|
+
events: {
|
|
67
|
+
name: "events",
|
|
68
|
+
createSQL: "CREATE TABLE events (...)",
|
|
69
|
+
schema: () => ({ id: "", type: "", severity: 0 }),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const rows = await clickhouse.events.select({ type: "login", severity: 2 });
|
|
75
|
+
|
|
76
|
+
expect(queryMock).toHaveBeenCalledWith({
|
|
77
|
+
query: "SELECT * FROM events WHERE type = 'login' AND severity = 2",
|
|
78
|
+
});
|
|
79
|
+
expect(rows).toEqual([{ id: "1", type: "login", severity: 2 }]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("inserts rows using JSONEachRow format", async () => {
|
|
83
|
+
const clickhouse = createTypedClickhouse({
|
|
84
|
+
url: "http://localhost:8123",
|
|
85
|
+
tables: {
|
|
86
|
+
events: {
|
|
87
|
+
name: "events",
|
|
88
|
+
createSQL: "CREATE TABLE events (...)",
|
|
89
|
+
schema: () => ({ id: "", type: "" }),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await clickhouse.events.insert([{ id: "1", type: "login" }]);
|
|
95
|
+
|
|
96
|
+
expect(insertMock).toHaveBeenCalledWith({
|
|
97
|
+
table: "events",
|
|
98
|
+
values: [{ id: "1", type: "login" }],
|
|
99
|
+
format: "JSONEachRow",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import type { Request } from "express";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
import {
|
|
5
|
+
createAuthResolver,
|
|
6
|
+
createMetaRouterWithAuth,
|
|
7
|
+
defaultHeaderAuthResolver,
|
|
8
|
+
} from "../src/workers/express-types";
|
|
9
|
+
|
|
10
|
+
function createMockResponse() {
|
|
11
|
+
return {
|
|
12
|
+
statusCode: 200,
|
|
13
|
+
body: undefined as unknown,
|
|
14
|
+
status(code: number) {
|
|
15
|
+
this.statusCode = code;
|
|
16
|
+
return this;
|
|
17
|
+
},
|
|
18
|
+
json(payload: unknown) {
|
|
19
|
+
this.body = payload;
|
|
20
|
+
return this;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("express-types", () => {
|
|
26
|
+
it("reads user from x-user-meta header when valid JSON is provided", () => {
|
|
27
|
+
const req = {
|
|
28
|
+
headers: {
|
|
29
|
+
"x-user-meta": JSON.stringify({ userId: "user-1", role: "admin" }),
|
|
30
|
+
},
|
|
31
|
+
} as unknown as Request;
|
|
32
|
+
|
|
33
|
+
expect(defaultHeaderAuthResolver(req)).toEqual({
|
|
34
|
+
userId: "user-1",
|
|
35
|
+
role: "admin",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns null when x-user-meta is invalid JSON", () => {
|
|
40
|
+
const req = {
|
|
41
|
+
headers: {
|
|
42
|
+
"x-user-id": "user-1",
|
|
43
|
+
"x-user-meta": "{invalid-json",
|
|
44
|
+
},
|
|
45
|
+
} as unknown as Request;
|
|
46
|
+
|
|
47
|
+
expect(defaultHeaderAuthResolver(req)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("falls back to x-user-id header when x-user-meta is not set", () => {
|
|
51
|
+
const req = {
|
|
52
|
+
headers: { "x-user-id": "user-2" },
|
|
53
|
+
} as unknown as Request;
|
|
54
|
+
|
|
55
|
+
expect(defaultHeaderAuthResolver(req)).toEqual({ userId: "user-2" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("uses auth strategies in order and then fallback resolver", async () => {
|
|
59
|
+
const authResolver = createAuthResolver(
|
|
60
|
+
[
|
|
61
|
+
{
|
|
62
|
+
name: "first",
|
|
63
|
+
canHandle: async () => false,
|
|
64
|
+
authenticate: async () => ({ userId: "ignored" }),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "second",
|
|
68
|
+
authenticate: async () => null,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
{
|
|
72
|
+
fallbackResolver: async () => ({ userId: "fallback" }),
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
authResolver({ headers: {} } as unknown as Request, {
|
|
78
|
+
method: "GET",
|
|
79
|
+
fullPath: "/",
|
|
80
|
+
requireAuth: true,
|
|
81
|
+
}),
|
|
82
|
+
).resolves.toEqual({ userId: "fallback" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns route metadata from createMetaRouterWithAuth", () => {
|
|
86
|
+
const { routes, addRoute } = createMetaRouterWithAuth();
|
|
87
|
+
|
|
88
|
+
addRoute(
|
|
89
|
+
{
|
|
90
|
+
method: "POST",
|
|
91
|
+
fullPath: "/items/:id",
|
|
92
|
+
requireAuth: true,
|
|
93
|
+
bodyValidator: z.object({ name: z.string() }),
|
|
94
|
+
paramsValidator: z.object({ id: z.string() }),
|
|
95
|
+
meta: { description: "Create item" },
|
|
96
|
+
},
|
|
97
|
+
(_req, res) => {
|
|
98
|
+
res.status(200).json({ ok: true });
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(routes).toHaveLength(1);
|
|
103
|
+
expect(routes[0]).toMatchObject({
|
|
104
|
+
method: "POST",
|
|
105
|
+
fullPath: "/items/:id",
|
|
106
|
+
requireAuth: true,
|
|
107
|
+
meta: { description: "Create item" },
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns 401 for protected route when auth resolver returns null", async () => {
|
|
112
|
+
const { router, addRoute } = createMetaRouterWithAuth({
|
|
113
|
+
authResolver: async () => null,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
addRoute(
|
|
117
|
+
{
|
|
118
|
+
method: "POST",
|
|
119
|
+
fullPath: "/secure",
|
|
120
|
+
requireAuth: true,
|
|
121
|
+
bodyValidator: z.object({ name: z.string() }),
|
|
122
|
+
},
|
|
123
|
+
(_req, res) => {
|
|
124
|
+
res.status(200).json({ ok: true });
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const layer = (router as any).stack.find(
|
|
129
|
+
(l: any) => l.route?.path === "/secure",
|
|
130
|
+
);
|
|
131
|
+
const authMiddleware = layer.route.stack[0].handle;
|
|
132
|
+
|
|
133
|
+
const req = { headers: {}, body: { name: "a" } } as unknown as Request;
|
|
134
|
+
const res = createMockResponse();
|
|
135
|
+
const next = vi.fn();
|
|
136
|
+
|
|
137
|
+
await authMiddleware(req, res, next);
|
|
138
|
+
|
|
139
|
+
expect(next).not.toHaveBeenCalled();
|
|
140
|
+
expect(res.statusCode).toBe(401);
|
|
141
|
+
expect(res.body).toEqual({ isSuccess: false, message: "Unauthorized" });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("parses JSON body string when object schema is used", () => {
|
|
145
|
+
const { router, addRoute } = createMetaRouterWithAuth();
|
|
146
|
+
|
|
147
|
+
addRoute(
|
|
148
|
+
{
|
|
149
|
+
method: "POST",
|
|
150
|
+
fullPath: "/body",
|
|
151
|
+
bodyValidator: z.object({ count: z.number() }),
|
|
152
|
+
},
|
|
153
|
+
(_req, res) => {
|
|
154
|
+
res.status(200).json({ ok: true });
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const layer = (router as any).stack.find(
|
|
159
|
+
(l: any) => l.route?.path === "/body",
|
|
160
|
+
);
|
|
161
|
+
const bodyMiddleware = layer.route.stack[0].handle;
|
|
162
|
+
|
|
163
|
+
const req = { body: '{"count":1}' } as Request;
|
|
164
|
+
const res = createMockResponse();
|
|
165
|
+
const next = vi.fn();
|
|
166
|
+
|
|
167
|
+
bodyMiddleware(req, res as any, next);
|
|
168
|
+
|
|
169
|
+
expect(next).toHaveBeenCalledOnce();
|
|
170
|
+
expect(req.body).toEqual({ count: 1 });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("returns 400 for invalid JSON body string", () => {
|
|
174
|
+
const { router, addRoute } = createMetaRouterWithAuth();
|
|
175
|
+
|
|
176
|
+
addRoute(
|
|
177
|
+
{
|
|
178
|
+
method: "POST",
|
|
179
|
+
fullPath: "/invalid-body",
|
|
180
|
+
bodyValidator: z.object({ count: z.number() }),
|
|
181
|
+
},
|
|
182
|
+
(_req, res) => {
|
|
183
|
+
res.status(200).json({ ok: true });
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const layer = (router as any).stack.find(
|
|
188
|
+
(l: any) => l.route?.path === "/invalid-body",
|
|
189
|
+
);
|
|
190
|
+
const bodyMiddleware = layer.route.stack[0].handle;
|
|
191
|
+
|
|
192
|
+
const req = { body: "{bad" } as Request;
|
|
193
|
+
const res = createMockResponse();
|
|
194
|
+
const next = vi.fn();
|
|
195
|
+
|
|
196
|
+
bodyMiddleware(req, res as any, next);
|
|
197
|
+
|
|
198
|
+
expect(next).not.toHaveBeenCalled();
|
|
199
|
+
expect(res.statusCode).toBe(400);
|
|
200
|
+
expect((res.body as any).message).toBe("Invalid JSON in request body");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("returns 400 when params validation fails", () => {
|
|
204
|
+
const { router, addRoute } = createMetaRouterWithAuth();
|
|
205
|
+
|
|
206
|
+
addRoute(
|
|
207
|
+
{
|
|
208
|
+
method: "GET",
|
|
209
|
+
fullPath: "/items/:id",
|
|
210
|
+
paramsValidator: z.object({ id: z.uuid() }),
|
|
211
|
+
},
|
|
212
|
+
(_req, res) => {
|
|
213
|
+
res.status(200).json({ ok: true });
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const layer = (router as any).stack.find(
|
|
218
|
+
(l: any) => l.route?.path === "/items/:id",
|
|
219
|
+
);
|
|
220
|
+
const paramsMiddleware = layer.route.stack[0].handle;
|
|
221
|
+
|
|
222
|
+
const req = { params: { id: "not-a-uuid" } } as unknown as Request;
|
|
223
|
+
const res = createMockResponse();
|
|
224
|
+
const next = vi.fn();
|
|
225
|
+
|
|
226
|
+
paramsMiddleware(req, res as any, next);
|
|
227
|
+
|
|
228
|
+
expect(next).not.toHaveBeenCalled();
|
|
229
|
+
expect(res.statusCode).toBe(400);
|
|
230
|
+
expect((res.body as any).message).toBe("Invalid URL parameters");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { MongoMemoryServer } from "mongodb-memory-server";
|
|
3
|
+
import { ObjectId } from "mongodb";
|
|
4
|
+
import z from "zod";
|
|
5
|
+
import {
|
|
6
|
+
createMongo,
|
|
7
|
+
defineMongoCollection,
|
|
8
|
+
type MongoCollectionsConfig,
|
|
9
|
+
} from "../src/workers/db/mongo/mongo";
|
|
10
|
+
|
|
11
|
+
describe("Mongo abstraction advanced behaviors", () => {
|
|
12
|
+
let mongoServer: MongoMemoryServer;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
mongoServer = await MongoMemoryServer.create();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(async () => {
|
|
19
|
+
await mongoServer.stop();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("supports soft delete filtering and deletedBy attribution", async () => {
|
|
23
|
+
const mongo = createMongo(
|
|
24
|
+
"soft_delete_db",
|
|
25
|
+
{
|
|
26
|
+
users: defineMongoCollection({
|
|
27
|
+
schema: z.object({
|
|
28
|
+
name: z.string(),
|
|
29
|
+
deletedAt: z.date().optional(),
|
|
30
|
+
deletedBy: z.string().optional(),
|
|
31
|
+
}),
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
{ uri: mongoServer.getUri() },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
await mongo.connect();
|
|
38
|
+
|
|
39
|
+
await mongo.users.insert({ name: "Ada" });
|
|
40
|
+
await mongo.users.delete({ name: "Ada" });
|
|
41
|
+
|
|
42
|
+
await mongo.users.insert({ name: "Grace" });
|
|
43
|
+
await mongo.users.delete({ name: "Grace" }, "admin-user");
|
|
44
|
+
|
|
45
|
+
const visible = await mongo.users.get({ name: "Ada" });
|
|
46
|
+
const rawAda = await mongo.db.collection("users").findOne({ name: "Ada" });
|
|
47
|
+
const rawGrace = await mongo.db.collection("users").findOne({
|
|
48
|
+
name: "Grace",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(visible).toEqual([]);
|
|
52
|
+
expect(rawAda?.deletedAt).toBeInstanceOf(Date);
|
|
53
|
+
expect(rawAda?.deletedBy).toBe("system");
|
|
54
|
+
expect(rawGrace?.deletedBy).toBe("admin-user");
|
|
55
|
+
|
|
56
|
+
await mongo.disconnect();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("tracks revisions and updates updatedAt/revision on updateOne", async () => {
|
|
60
|
+
const config = {
|
|
61
|
+
records: {
|
|
62
|
+
...defineMongoCollection({
|
|
63
|
+
schema: z.object({
|
|
64
|
+
key: z.string(),
|
|
65
|
+
value: z.string(),
|
|
66
|
+
revision: z.number().default(0),
|
|
67
|
+
updatedAt: z.date().optional(),
|
|
68
|
+
}),
|
|
69
|
+
}),
|
|
70
|
+
trackRevisions: true,
|
|
71
|
+
},
|
|
72
|
+
} satisfies MongoCollectionsConfig;
|
|
73
|
+
|
|
74
|
+
const mongo = createMongo("revisions_db", config, {
|
|
75
|
+
uri: mongoServer.getUri(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await mongo.connect();
|
|
79
|
+
|
|
80
|
+
await mongo.records.insert({
|
|
81
|
+
key: "k1",
|
|
82
|
+
value: "v1",
|
|
83
|
+
revision: 0,
|
|
84
|
+
updatedAt: new Date("2020-01-01T00:00:00.000Z"),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await mongo.records.updateOne({ key: "k1" }, { value: "v2" });
|
|
88
|
+
|
|
89
|
+
const current = await mongo.records.getOne({ key: "k1" });
|
|
90
|
+
const revisions = await mongo.records.getRevisions({ key: "k1" });
|
|
91
|
+
const mostRecent = await mongo.records.getMostRecentRevision();
|
|
92
|
+
|
|
93
|
+
expect(current?.value).toBe("v2");
|
|
94
|
+
expect(current?.revision).toBe(1);
|
|
95
|
+
expect(current?.updatedAt).toBeInstanceOf(Date);
|
|
96
|
+
expect(revisions.length).toBeGreaterThanOrEqual(2);
|
|
97
|
+
expect(mostRecent?.value).toBe("v2");
|
|
98
|
+
|
|
99
|
+
await mongo.disconnect();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("creates configured indexes during setup", async () => {
|
|
103
|
+
const mongo = createMongo(
|
|
104
|
+
"index_setup_db",
|
|
105
|
+
{
|
|
106
|
+
users: defineMongoCollection({
|
|
107
|
+
schema: z.object({
|
|
108
|
+
email: z.string(),
|
|
109
|
+
createdAt: z.date(),
|
|
110
|
+
}),
|
|
111
|
+
indexes: [
|
|
112
|
+
{ key: { email: 1 }, unique: true, name: "users_email_unique" },
|
|
113
|
+
{ key: { createdAt: 1 }, name: "users_created_at" },
|
|
114
|
+
],
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
{ uri: mongoServer.getUri() },
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await mongo.connect();
|
|
121
|
+
|
|
122
|
+
const indexes = await mongo.db.collection("users").indexes();
|
|
123
|
+
|
|
124
|
+
expect(indexes.some((idx) => idx.name === "users_email_unique")).toBe(true);
|
|
125
|
+
expect(indexes.some((idx) => idx.name === "users_created_at")).toBe(true);
|
|
126
|
+
|
|
127
|
+
await mongo.disconnect();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("fails when enabling timeSeries on an existing non-time-series collection", async () => {
|
|
131
|
+
const dbName = `timeseries_mismatch_${new ObjectId().toHexString()}`;
|
|
132
|
+
|
|
133
|
+
const initial = createMongo(
|
|
134
|
+
dbName,
|
|
135
|
+
{
|
|
136
|
+
metrics: defineMongoCollection({
|
|
137
|
+
schema: z.object({
|
|
138
|
+
timestamp: z.date(),
|
|
139
|
+
value: z.number(),
|
|
140
|
+
}),
|
|
141
|
+
}),
|
|
142
|
+
},
|
|
143
|
+
{ uri: mongoServer.getUri() },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await initial.connect();
|
|
147
|
+
await initial.db.createCollection("legacy_metrics");
|
|
148
|
+
await initial.disconnect();
|
|
149
|
+
|
|
150
|
+
const withTimeSeries = createMongo(
|
|
151
|
+
dbName,
|
|
152
|
+
{
|
|
153
|
+
legacy_metrics: defineMongoCollection({
|
|
154
|
+
schema: z.object({
|
|
155
|
+
timestamp: z.date(),
|
|
156
|
+
value: z.number(),
|
|
157
|
+
}),
|
|
158
|
+
timeSeries: {
|
|
159
|
+
timeField: "timestamp",
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
},
|
|
163
|
+
{ uri: mongoServer.getUri() },
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await expect(withTimeSeries.connect()).rejects.toThrow(
|
|
167
|
+
"already exists and is not a time-series collection",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await withTimeSeries.disconnect();
|
|
171
|
+
});
|
|
172
|
+
});
|
package/tests/mongo.spec.ts
CHANGED
|
@@ -89,4 +89,53 @@ describe("MongoDB Dependency", () => {
|
|
|
89
89
|
expect(res[0].name).toEqual("updated");
|
|
90
90
|
console.log("Updated document:", res[0]);
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
it("Should support creating and using time-series collections", async () => {
|
|
94
|
+
const tsService = new Service()
|
|
95
|
+
.withName("admin-service")
|
|
96
|
+
.withPort(4121)
|
|
97
|
+
.withRedis("redis://localhost:6379")
|
|
98
|
+
.withMongo({
|
|
99
|
+
dbName: "test_timeseries",
|
|
100
|
+
uri: mongoServer.getUri(),
|
|
101
|
+
collections: {
|
|
102
|
+
metrics: defineMongoCollection({
|
|
103
|
+
schema: z.object({
|
|
104
|
+
timestamp: z.date(),
|
|
105
|
+
host: z.string(),
|
|
106
|
+
value: z.number(),
|
|
107
|
+
}),
|
|
108
|
+
timeSeries: {
|
|
109
|
+
timeField: "timestamp",
|
|
110
|
+
metaField: "host",
|
|
111
|
+
granularity: "seconds",
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
.build();
|
|
117
|
+
|
|
118
|
+
await tsService.mongo.connect();
|
|
119
|
+
|
|
120
|
+
const collectionInfo = await tsService.mongo.db
|
|
121
|
+
.listCollections({ name: "metrics" }, { nameOnly: false })
|
|
122
|
+
.next();
|
|
123
|
+
|
|
124
|
+
expect(
|
|
125
|
+
collectionInfo?.type === "timeseries" ||
|
|
126
|
+
Boolean((collectionInfo?.options as any)?.timeseries),
|
|
127
|
+
).toBe(true);
|
|
128
|
+
|
|
129
|
+
await tsService.db.metrics.insert({
|
|
130
|
+
timestamp: new Date(),
|
|
131
|
+
host: "app-1",
|
|
132
|
+
value: 42,
|
|
133
|
+
});
|
|
134
|
+
const values = await tsService.db.metrics.get({ host: "app-1" });
|
|
135
|
+
|
|
136
|
+
expect(values.length).toBe(1);
|
|
137
|
+
expect(values[0].value).toBe(42);
|
|
138
|
+
|
|
139
|
+
await tsService.mongo.disconnect();
|
|
140
|
+
});
|
|
92
141
|
});
|