@owlmeans/server-api 0.1.18-rc.6 → 0.1.18
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/README.md +203 -46
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/server-api/SKILL.md +142 -22
- package/build/consts.local.d.ts +2 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +2 -0
- package/build/consts.local.js.map +1 -0
- package/build/errors.d.ts +12 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +16 -0
- package/build/errors.js.map +1 -1
- package/build/helper.d.ts +3 -6
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +10 -1
- package/build/helper.js.map +1 -1
- package/build/hold.d.ts +35 -0
- package/build/hold.d.ts.map +1 -0
- package/build/hold.js +57 -0
- package/build/hold.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/protocol.d.ts +18 -0
- package/build/protocol.d.ts.map +1 -0
- package/build/protocol.js +87 -0
- package/build/protocol.js.map +1 -0
- package/build/server.d.ts +2 -3
- package/build/server.d.ts.map +1 -1
- package/build/server.js +42 -34
- package/build/server.js.map +1 -1
- package/build/types.d.ts +31 -2
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +38 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/consts.d.ts +2 -0
- package/build/utils/consts.d.ts.map +1 -0
- package/build/utils/consts.js +2 -0
- package/build/utils/consts.js.map +1 -0
- package/build/utils/consts.local.d.ts +5 -0
- package/build/utils/consts.local.d.ts.map +1 -0
- package/build/utils/consts.local.js +5 -0
- package/build/utils/consts.local.js.map +1 -0
- package/build/utils/context/types.d.ts +13 -0
- package/build/utils/context/types.d.ts.map +1 -0
- package/build/utils/context/types.js +2 -0
- package/build/utils/context/types.js.map +1 -0
- package/build/utils/context.d.ts +3 -3
- package/build/utils/context.d.ts.map +1 -1
- package/build/utils/context.js +5 -2
- package/build/utils/context.js.map +1 -1
- package/build/utils/error/types.d.ts +52 -0
- package/build/utils/error/types.d.ts.map +1 -0
- package/build/utils/error/types.js +2 -0
- package/build/utils/error/types.js.map +1 -0
- package/build/utils/error.d.ts +5 -2
- package/build/utils/error.d.ts.map +1 -1
- package/build/utils/error.js +135 -13
- package/build/utils/error.js.map +1 -1
- package/build/utils/guards.d.ts +1 -5
- package/build/utils/guards.d.ts.map +1 -1
- package/build/utils/guards.js +15 -19
- package/build/utils/guards.js.map +1 -1
- package/build/utils/index.d.ts +6 -1
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js +2 -1
- package/build/utils/index.js.map +1 -1
- package/build/utils/log/consts.d.ts +14 -0
- package/build/utils/log/consts.d.ts.map +1 -0
- package/build/utils/log/consts.js +17 -0
- package/build/utils/log/consts.js.map +1 -0
- package/build/utils/log/types.d.ts +42 -0
- package/build/utils/log/types.d.ts.map +1 -0
- package/build/utils/log/types.js +2 -0
- package/build/utils/log/types.js.map +1 -0
- package/build/utils/log.d.ts +4 -0
- package/build/utils/log.d.ts.map +1 -0
- package/build/utils/log.js +170 -0
- package/build/utils/log.js.map +1 -0
- package/build/utils/payload/types.d.ts +18 -0
- package/build/utils/payload/types.d.ts.map +1 -0
- package/build/utils/payload/types.js +2 -0
- package/build/utils/payload/types.js.map +1 -0
- package/build/utils/payload.d.ts +3 -15
- package/build/utils/payload.d.ts.map +1 -1
- package/build/utils/payload.js +41 -57
- package/build/utils/payload.js.map +1 -1
- package/build/utils/schema.d.ts +7 -0
- package/build/utils/schema.d.ts.map +1 -0
- package/build/utils/schema.js +20 -0
- package/build/utils/schema.js.map +1 -0
- package/build/utils/server.d.ts +3 -6
- package/build/utils/server.d.ts.map +1 -1
- package/build/utils/server.js +12 -13
- package/build/utils/server.js.map +1 -1
- package/build/utils/types.d.ts +18 -0
- package/build/utils/types.d.ts.map +1 -0
- package/build/utils/types.js +2 -0
- package/build/utils/types.js.map +1 -0
- package/build/utils/types.local.d.ts +9 -0
- package/build/utils/types.local.d.ts.map +1 -0
- package/build/utils/types.local.js +2 -0
- package/build/utils/types.local.js.map +1 -0
- package/package.json +23 -19
- package/src/consts.local.ts +1 -0
- package/src/errors.ts +18 -0
- package/src/helper.ts +13 -8
- package/src/hold.ts +66 -0
- package/src/index.ts +2 -0
- package/src/protocol.ts +149 -0
- package/src/server.ts +72 -69
- package/src/types.local.ts +44 -0
- package/src/types.ts +38 -4
- package/src/utils/consts.local.ts +7 -0
- package/src/utils/consts.ts +1 -0
- package/src/utils/context/types.ts +13 -0
- package/src/utils/context.ts +9 -4
- package/src/utils/error/types.ts +52 -0
- package/src/utils/error.ts +157 -13
- package/src/utils/guards.ts +16 -26
- package/src/utils/index.ts +6 -1
- package/src/utils/log/consts.ts +18 -0
- package/src/utils/log/types.ts +43 -0
- package/src/utils/log.ts +193 -0
- package/src/utils/payload/types.ts +18 -0
- package/src/utils/payload.ts +42 -60
- package/src/utils/schema.ts +23 -0
- package/src/utils/server.ts +16 -21
- package/src/utils/types.local.ts +12 -0
- package/src/utils/types.ts +18 -0
- package/tests/error-status.spec.ts +349 -0
- package/tests/handlers.spec.ts +83 -0
- package/tests/json-body.spec.ts +72 -0
- package/tests/log-adapter.spec.ts +62 -0
- package/tests/log-redaction.spec.ts +181 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +1 -0
- package/build/.gitkeep +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { EntrypointProtocolDeclaration } from '@owlmeans/entrypoint'
|
|
3
|
+
import { handlers } from '../src/protocol.js'
|
|
4
|
+
import { HandlerMisconfiguredError } from '../src/errors.js'
|
|
5
|
+
import { addLogPlugin, memoryPlugin, removeLogPlugin } from '@owlmeans/log'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A protocol-bound implementation is run with the smallest binding context the transport needs —
|
|
9
|
+
* same pattern as `@owlmeans/server-auth-token`'s own `handlers.spec.ts`.
|
|
10
|
+
*/
|
|
11
|
+
const invoke = async (handler: any, context: any, req: any = {}): Promise<any> => {
|
|
12
|
+
const res: any = { resolve: (value: unknown) => { res.value = value }, reject: (e: Error) => { res.error = e } }
|
|
13
|
+
await handler.bind({ ref: { ctx: context } })(req, res)
|
|
14
|
+
if (res.error != null) throw res.error
|
|
15
|
+
|
|
16
|
+
return res.value
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const protocolFor = (alias: string): EntrypointProtocolDeclaration =>
|
|
20
|
+
({ alias } as unknown as EntrypointProtocolDeclaration)
|
|
21
|
+
|
|
22
|
+
const context = { alias: 'test-context' } as any
|
|
23
|
+
const api = handlers<any>()
|
|
24
|
+
|
|
25
|
+
describe('@owlmeans/server-api — handlers() wrap-once tolerance', () => {
|
|
26
|
+
test('a plain function is wrapped once and called with the context', async () => {
|
|
27
|
+
const protocol = protocolFor('api.thing.plain')
|
|
28
|
+
const handler = api.request(protocol, async (_req: any, ctx: any) => ({ ok: true, ctx }))
|
|
29
|
+
|
|
30
|
+
const result = await invoke(handler, context, {})
|
|
31
|
+
expect(result).toEqual({ ok: true, ctx: context })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('a handler already bound to the SAME protocol is returned unchanged by body/params/request and still answers', async () => {
|
|
35
|
+
for (const method of ['body', 'params', 'request'] as const) {
|
|
36
|
+
const protocol = protocolFor(`api.thing.${method}`)
|
|
37
|
+
const bound = api.request(protocol, async () => ({ from: method }))
|
|
38
|
+
|
|
39
|
+
const wrapped = (api as any)[method](protocol, bound)
|
|
40
|
+
expect(wrapped).toBe(bound)
|
|
41
|
+
|
|
42
|
+
const result = await invoke(wrapped, context, {})
|
|
43
|
+
expect(result).toEqual({ from: method })
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('a handler bound to a DIFFERENT protocol answers with HandlerMisconfiguredError', async () => {
|
|
48
|
+
const protocolA = protocolFor('api.thing.mismatch-a')
|
|
49
|
+
const protocolB = protocolFor('api.thing.mismatch-b')
|
|
50
|
+
const boundToA = api.request(protocolA, async () => ({ from: 'a' }))
|
|
51
|
+
|
|
52
|
+
const wrapped = api.request(protocolB, boundToA as any)
|
|
53
|
+
expect(wrapped).not.toBe(boundToA)
|
|
54
|
+
await expect(invoke(wrapped, context, {})).rejects.toThrow(HandlerMisconfiguredError)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a value that is neither a function nor a bound handler answers with HandlerMisconfiguredError', async () => {
|
|
58
|
+
const protocol = protocolFor('api.thing.not-a-function')
|
|
59
|
+
const wrapped = api.request(protocol, { not: 'a function' } as any)
|
|
60
|
+
|
|
61
|
+
await expect(invoke(wrapped, context, {})).rejects.toThrow(HandlerMisconfiguredError)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('the double-wrap warning fires once per alias, not once per call', async () => {
|
|
65
|
+
const protocol = protocolFor('api.thing.warn-once')
|
|
66
|
+
const bound = api.request(protocol, async () => 'ok')
|
|
67
|
+
|
|
68
|
+
const memory = memoryPlugin('handlers-warn')
|
|
69
|
+
addLogPlugin(memory)
|
|
70
|
+
try {
|
|
71
|
+
// Each call is a deliberate double wrap — the exact shape `tsc` rejects at the real call
|
|
72
|
+
// site (TS2345) — so it is exercised here through `any`, the same way `body`/`params`
|
|
73
|
+
// are reached dynamically above.
|
|
74
|
+
(api as any).request(protocol, bound);
|
|
75
|
+
(api as any).body(protocol, bound);
|
|
76
|
+
(api as any).params(protocol, bound)
|
|
77
|
+
} finally {
|
|
78
|
+
removeLogPlugin('handlers-warn')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect(memory.records.filter(record => record.level === 'warn').length).toBe(1)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AppType } from '@owlmeans/context'
|
|
3
|
+
import { contract, protocol, schema, typed } from '@owlmeans/entrypoint'
|
|
4
|
+
import { backend, route, RouteMethod } from '@owlmeans/route'
|
|
5
|
+
import { config, makeServerContext } from '@owlmeans/server-context'
|
|
6
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
7
|
+
import { bind } from '@owlmeans/server-entrypoint'
|
|
8
|
+
import type { FastifyInstance } from 'fastify'
|
|
9
|
+
import { handlers } from '../src/protocol.js'
|
|
10
|
+
import { appendApiServer } from '../src/server.js'
|
|
11
|
+
|
|
12
|
+
interface Echo { value: unknown, type: string }
|
|
13
|
+
|
|
14
|
+
const echoOf = <T>(alias: string, body: ReturnType<typeof schema<T>>) => protocol(
|
|
15
|
+
route(`server-api-test:${alias}`, `/echo/${alias}`, backend(null, RouteMethod.POST)),
|
|
16
|
+
contract(body, typed<Echo>()),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const text = echoOf('text', schema<string>({ type: 'string' }))
|
|
20
|
+
const count = echoOf('count', schema<number>({ type: 'number' }))
|
|
21
|
+
const flag = echoOf('flag', schema<boolean>({ type: 'boolean' }))
|
|
22
|
+
const record = echoOf('record', schema<{ a: number }>({ type: 'object', properties: { a: { type: 'number' } }, required: ['a'] }))
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A scalar JSON body through the package's own server — `appendApiServer`, its Fastify instance,
|
|
26
|
+
* its body validation and a protocol-bound handler — exactly as `@owlmeans/api` now sends it:
|
|
27
|
+
* JSON text. The client half of the round trip is `@owlmeans/api`'s `body.spec.ts`.
|
|
28
|
+
*/
|
|
29
|
+
describe('@owlmeans/server-api — a scalar JSON body', () => {
|
|
30
|
+
let server: FastifyInstance
|
|
31
|
+
let context: ServerContext<ServerConfig>
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
const service = 'server-api-tests'
|
|
35
|
+
context = makeServerContext(config<ServerConfig>(service, {
|
|
36
|
+
services: { [service]: { service, type: AppType.Backend, host: '127.0.0.1', default: true } },
|
|
37
|
+
} as Partial<ServerConfig>)) as ServerContext<ServerConfig>
|
|
38
|
+
const served = appendApiServer(context)
|
|
39
|
+
const api = handlers<ServerContext<ServerConfig>>()
|
|
40
|
+
for (const echo of [text, count, flag, record]) {
|
|
41
|
+
context.registerEntrypoint(bind(echo, api.request(echo, async request => ({ value: request.body, type: typeof request.body }))))
|
|
42
|
+
}
|
|
43
|
+
context.configure()
|
|
44
|
+
await context.init()
|
|
45
|
+
server = served.getApiServer().server
|
|
46
|
+
await server.ready()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterAll(async () => { await server?.close() })
|
|
50
|
+
|
|
51
|
+
const post = async (alias: string, payload: string, type: string = 'application/json') =>
|
|
52
|
+
await server.inject({ method: 'POST', url: `/echo/${alias}`, headers: { 'content-type': type }, payload })
|
|
53
|
+
|
|
54
|
+
const echoed = async (alias: string, payload: string): Promise<Echo> => (await post(alias, payload)).json<Echo>()
|
|
55
|
+
|
|
56
|
+
test('a JSON string, number or boolean reaches the handler as that value', async () => {
|
|
57
|
+
expect(await echoed('text', '"abc"')).toEqual({ value: 'abc', type: 'string' })
|
|
58
|
+
expect(await echoed('text', '""')).toEqual({ value: '', type: 'string' })
|
|
59
|
+
expect(await echoed('text', '"123"')).toEqual({ value: '123', type: 'string' })
|
|
60
|
+
expect(await echoed('count', '42')).toEqual({ value: 42, type: 'number' })
|
|
61
|
+
expect(await echoed('flag', 'false')).toEqual({ value: false, type: 'boolean' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('an object body is unchanged', async () => {
|
|
65
|
+
expect(await echoed('record', '{"a":1}')).toEqual({ value: { a: 1 }, type: 'object' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('an unquoted string or an empty JSON body is refused with 400 — what the client no longer sends', async () => {
|
|
69
|
+
expect((await post('text', 'abc')).statusCode).toBe(400)
|
|
70
|
+
expect((await post('text', '')).statusCode).toBe(400)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { addLogPlugin, configureLog, memoryPlugin, resetLog } from '@owlmeans/log'
|
|
3
|
+
import { fastifyLogUtils } from '../src/utils/log.js'
|
|
4
|
+
|
|
5
|
+
const fastifyLogger = fastifyLogUtils.logger
|
|
6
|
+
|
|
7
|
+
const memory = memoryPlugin('adapter')
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
resetLog()
|
|
11
|
+
addLogPlugin(memory)
|
|
12
|
+
configureLog({ level: 'debug', console: 'native' })
|
|
13
|
+
})
|
|
14
|
+
afterEach(() => { memory.clear(); resetLog() })
|
|
15
|
+
|
|
16
|
+
describe('@owlmeans/server-api — the Fastify logger adapter', () => {
|
|
17
|
+
test('lowers the listening line, keeps the rest', () => {
|
|
18
|
+
const log = fastifyLogger() as unknown as Record<string, (...args: unknown[]) => void>
|
|
19
|
+
log.info('Server listening at http://127.0.0.1:80')
|
|
20
|
+
log.info('something else')
|
|
21
|
+
log.warn({ a: 1 }, 'warned')
|
|
22
|
+
log.error({ err: new Error('boom') }, 'failed')
|
|
23
|
+
|
|
24
|
+
expect(memory.records.map(r => [r.level, r.message])).toEqual([
|
|
25
|
+
['debug', 'Server listening at http://127.0.0.1:80'],
|
|
26
|
+
['info', 'something else'],
|
|
27
|
+
['warn', 'warned'],
|
|
28
|
+
['error', 'failed'],
|
|
29
|
+
])
|
|
30
|
+
expect(memory.records[3].error?.message).toBe('boom')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('the controller writes no per-request pair and a missing route without its query', () => {
|
|
34
|
+
const controller = fastifyLogUtils.controller()
|
|
35
|
+
const request = { method: 'GET', url: '/x?token=abc' } as never
|
|
36
|
+
controller.incomingRequest(request, {} as never)
|
|
37
|
+
controller.requestCompleted(null, request, { statusCode: 200 } as never)
|
|
38
|
+
controller.routeNotFound(request, {} as never)
|
|
39
|
+
|
|
40
|
+
expect(memory.records.map(r => [r.level, r.message, r.data])).toEqual([
|
|
41
|
+
['debug', 'Route not found', { method: 'GET', path: '/x' }],
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('child loggers bind their fields and the level is the process level', () => {
|
|
46
|
+
const base = fastifyLogger()
|
|
47
|
+
const child = (base as unknown as { child: (b: object) => Record<string, (...a: unknown[]) => void> }).child({ reqId: 'r1' })
|
|
48
|
+
child.warn('hello')
|
|
49
|
+
expect(memory.records[0].data).toEqual({ reqId: 'r1' })
|
|
50
|
+
expect(base.level).toBe('debug')
|
|
51
|
+
configureLog({ level: 'warn' })
|
|
52
|
+
expect(base.level).toBe('warn')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('at info level, debug lines are not written', () => {
|
|
56
|
+
configureLog({ level: 'info' })
|
|
57
|
+
const log = fastifyLogger() as unknown as Record<string, (...args: unknown[]) => void>
|
|
58
|
+
log.debug('quiet')
|
|
59
|
+
log.info('shown')
|
|
60
|
+
expect(memory.records.map(r => r.message)).toEqual(['shown'])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AppType } from '@owlmeans/context'
|
|
3
|
+
import { addLogPlugin, configureLog, logStateHelper, memoryPlugin, resetLog } from '@owlmeans/log'
|
|
4
|
+
import type { LogRecord, NativeConsole } from '@owlmeans/log'
|
|
5
|
+
import { contract, protocol, schema, typed } from '@owlmeans/entrypoint'
|
|
6
|
+
import { backend, route, RouteMethod } from '@owlmeans/route'
|
|
7
|
+
import { config, makeServerContext } from '@owlmeans/server-context'
|
|
8
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
9
|
+
import { bind } from '@owlmeans/server-entrypoint'
|
|
10
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
|
11
|
+
import { handlers } from '../src/protocol.js'
|
|
12
|
+
import { appendApiServer } from '../src/server.js'
|
|
13
|
+
import { fastifyLogUtils } from '../src/utils/log.js'
|
|
14
|
+
|
|
15
|
+
/** What a caller sent; none of it may reach a log record or a written line. */
|
|
16
|
+
const MARKER = 'pii-marker-7f3a'
|
|
17
|
+
const SECRETS = ['auth-secret-91c2', 'cookie-secret-44d0', 'query-secret-0b5e']
|
|
18
|
+
|
|
19
|
+
interface Inquiry { email: string, message: string }
|
|
20
|
+
|
|
21
|
+
const inquiry = protocol(
|
|
22
|
+
route('server-api-test:inquiry', '/inquiry', backend(null, RouteMethod.POST)),
|
|
23
|
+
contract(schema<Inquiry>({
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: { email: { type: 'string', format: 'email' }, message: { type: 'string', maxLength: 10 } },
|
|
26
|
+
required: ['email', 'message'],
|
|
27
|
+
}), typed<{ ok: boolean }>()),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const memory = memoryPlugin('redaction')
|
|
31
|
+
const written: string[] = []
|
|
32
|
+
const saved: Partial<NativeConsole> = {}
|
|
33
|
+
|
|
34
|
+
/** Every record as a plugin receives it, plus the error's own text: the sink writes no more than that. */
|
|
35
|
+
const recorded = (records: LogRecord[]): string => JSON.stringify(records.map(record => ({
|
|
36
|
+
...record, error: record.error != null ? { message: record.error.message, stack: record.error.stack } : undefined,
|
|
37
|
+
})))
|
|
38
|
+
|
|
39
|
+
const leaks = (): string[] => {
|
|
40
|
+
const everything = `${recorded(memory.records)}\n${written.join('\n')}`
|
|
41
|
+
return [MARKER, ...SECRETS].filter(secret => everything.includes(secret))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
memory.clear()
|
|
46
|
+
written.length = 0
|
|
47
|
+
addLogPlugin(memory)
|
|
48
|
+
configureLog({ level: 'debug', console: 'native' })
|
|
49
|
+
// The sink itself: read what it writes, through the captured natives it writes with.
|
|
50
|
+
const native = logStateHelper.nativeConsole()
|
|
51
|
+
for (const method of ['debug', 'info', 'warn', 'error'] as const) {
|
|
52
|
+
saved[method] = native[method]
|
|
53
|
+
native[method] = (...args: unknown[]) => { written.push(args.map(String).join(' ')) }
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
Object.assign(logStateHelper.nativeConsole(), saved)
|
|
59
|
+
resetLog()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('@owlmeans/server-api — a request never reaches the log', () => {
|
|
63
|
+
let server: FastifyInstance
|
|
64
|
+
|
|
65
|
+
beforeAll(async () => {
|
|
66
|
+
const service = 'server-api-redaction'
|
|
67
|
+
const context = makeServerContext(config<ServerConfig>(service, {
|
|
68
|
+
services: { [service]: { service, type: AppType.Backend, host: '127.0.0.1', default: true } },
|
|
69
|
+
} as Partial<ServerConfig>)) as ServerContext<ServerConfig>
|
|
70
|
+
const served = appendApiServer(context)
|
|
71
|
+
const api = handlers<ServerContext<ServerConfig>>()
|
|
72
|
+
context.registerEntrypoint(bind(inquiry, api.request(inquiry, async () => ({ ok: true }))))
|
|
73
|
+
context.configure()
|
|
74
|
+
await context.init()
|
|
75
|
+
server = served.getApiServer().server
|
|
76
|
+
await server.ready()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterAll(async () => { await server?.close() })
|
|
80
|
+
|
|
81
|
+
const headers = {
|
|
82
|
+
authorization: `Bearer ${SECRETS[0]}`, cookie: `session=${SECRETS[1]}`,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('a JSON body the schema refuses: method, path, status, code and message only', async () => {
|
|
86
|
+
const response = await server.inject({
|
|
87
|
+
method: 'POST', url: `/inquiry?token=${SECRETS[2]}`,
|
|
88
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
89
|
+
payload: JSON.stringify({ email: `${MARKER}@`, message: `${MARKER} wrote this` }),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
expect(response.statusCode).toBe(400)
|
|
93
|
+
expect(leaks()).toEqual([])
|
|
94
|
+
const refused = memory.records.find(record => record.message === 'Request refused')
|
|
95
|
+
expect(refused).toMatchObject({
|
|
96
|
+
level: 'debug', scope: 'http',
|
|
97
|
+
data: { method: 'POST', path: '/inquiry', status: 400, code: 'FST_ERR_VALIDATION' },
|
|
98
|
+
})
|
|
99
|
+
expect(Object.keys(refused!.data as object).sort()).toEqual(['code', 'message', 'method', 'path', 'status'])
|
|
100
|
+
// Nothing about a refused request is written at info.
|
|
101
|
+
expect(memory.records.filter(record => record.level !== 'debug')).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('a multipart body the schema refuses: no field, no file byte', async () => {
|
|
105
|
+
const boundary = 'owlmeansboundary'
|
|
106
|
+
const payload = [
|
|
107
|
+
`--${boundary}`, 'Content-Disposition: form-data; name="email"', '', `${MARKER}@`,
|
|
108
|
+
`--${boundary}`, 'Content-Disposition: form-data; name="message"', '', `${MARKER} wrote this`,
|
|
109
|
+
`--${boundary}`, 'Content-Disposition: form-data; name="file"; filename="note.txt"', 'Content-Type: text/plain', '',
|
|
110
|
+
`${MARKER} file bytes`, `--${boundary}--`, '',
|
|
111
|
+
].join('\r\n')
|
|
112
|
+
const response = await server.inject({
|
|
113
|
+
method: 'POST', url: '/inquiry', payload,
|
|
114
|
+
headers: { ...headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(response.statusCode).toBe(400)
|
|
118
|
+
expect(leaks()).toEqual([])
|
|
119
|
+
expect(memory.records.some(record => record.message === 'Request refused')).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('an unparsable body and a missing route: no body, no query', async () => {
|
|
123
|
+
const broken = await server.inject({
|
|
124
|
+
method: 'POST', url: `/inquiry?token=${SECRETS[2]}`,
|
|
125
|
+
headers: { ...headers, 'content-type': 'application/json' }, payload: `{"email":"${MARKER}`,
|
|
126
|
+
})
|
|
127
|
+
const missing = await server.inject({ method: 'GET', url: `/nowhere?token=${SECRETS[2]}`, headers })
|
|
128
|
+
|
|
129
|
+
expect(broken.statusCode).toBe(400)
|
|
130
|
+
expect(missing.statusCode).toBe(404)
|
|
131
|
+
expect(leaks()).toEqual([])
|
|
132
|
+
expect(memory.records.find(record => record.message === 'Route not found')?.data)
|
|
133
|
+
.toEqual({ method: 'GET', path: '/nowhere' })
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('@owlmeans/server-api — what the Fastify log adapter writes', () => {
|
|
138
|
+
const request = {
|
|
139
|
+
method: 'POST', url: `/inquiry?token=${SECRETS[2]}`, id: 'req-1',
|
|
140
|
+
headers: { ...{ authorization: `Bearer ${SECRETS[0]}`, cookie: `session=${SECRETS[1]}` }, 'x-note': MARKER },
|
|
141
|
+
body: { email: `${MARKER}@example.com` }, rawBody: `{"email":"${MARKER}@example.com"}`,
|
|
142
|
+
query: { token: SECRETS[2] },
|
|
143
|
+
}
|
|
144
|
+
const reply = { statusCode: 500, request, header: () => reply }
|
|
145
|
+
|
|
146
|
+
test('a request becomes its method and path, a reply its status, bytes their size', () => {
|
|
147
|
+
const fastifyRequest = Object.assign(Object.create({ kind: 'request' }) as object, request)
|
|
148
|
+
const fastifyReply = Object.assign(Object.create({ kind: 'reply' }) as object, reply)
|
|
149
|
+
const log = fastifyLogUtils.logger() as unknown as Record<string, (...args: unknown[]) => void>
|
|
150
|
+
log.error({ req: fastifyRequest, res: fastifyReply, err: new Error('boom') }, 'failed')
|
|
151
|
+
log.info({ busboyOptions: { headers: request.headers, limits: { files: 5 } } }, 'parsing')
|
|
152
|
+
log.warn({ body: request.body, rawBody: request.rawBody, file: new TextEncoder().encode(MARKER) }, 'dumped')
|
|
153
|
+
log.warn({ upload: new TextEncoder().encode(MARKER), url: request.url }, `Reply was already sent in "${request.url}"`)
|
|
154
|
+
|
|
155
|
+
expect(leaks()).toEqual([])
|
|
156
|
+
expect(memory.records.map(record => record.data)).toEqual([
|
|
157
|
+
{ req: { method: 'POST', path: '/inquiry' }, res: { statusCode: 500 } },
|
|
158
|
+
{ busboyOptions: { limits: { files: 5 } } },
|
|
159
|
+
{},
|
|
160
|
+
{ upload: `[Uint8Array ${MARKER.length} bytes]`, url: '/inquiry' },
|
|
161
|
+
])
|
|
162
|
+
expect(memory.records[3].message).toBe('Reply was already sent in "/inquiry"')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('the controller logs a request the default error handler answers by its status alone', () => {
|
|
166
|
+
const controller = fastifyLogUtils.controller()
|
|
167
|
+
const asRequest = request as unknown as FastifyRequest
|
|
168
|
+
controller.defaultErrorLog(new Error('fault'), asRequest, { ...reply, statusCode: 500 } as unknown as FastifyReply)
|
|
169
|
+
controller.defaultErrorLog(new Error('denied'), asRequest, { ...reply, statusCode: 403 } as unknown as FastifyReply)
|
|
170
|
+
controller.incomingRequest(asRequest, reply as unknown as FastifyReply)
|
|
171
|
+
controller.requestCompleted(null, asRequest, reply as unknown as FastifyReply)
|
|
172
|
+
|
|
173
|
+
expect(leaks()).toEqual([])
|
|
174
|
+
expect(memory.records.map(record => [record.level, record.message, record.data])).toEqual([
|
|
175
|
+
['error', 'Request failed', { method: 'POST', path: '/inquiry', status: 500 }],
|
|
176
|
+
['warn', 'Access forbidden', { message: 'denied', method: 'POST', path: '/inquiry', status: 403 }],
|
|
177
|
+
])
|
|
178
|
+
expect(memory.records[0].error?.message).toBe('fault')
|
|
179
|
+
expect(memory.records[1].event).toBe('access.forbidden')
|
|
180
|
+
})
|
|
181
|
+
})
|
package/tsconfig.json
CHANGED
package/build/.gitkeep
DELETED
|
File without changes
|