@opensaas/stack-core 0.39.1 → 0.40.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +156 -0
- package/CLAUDE.md +51 -0
- package/dist/access/access-filter.d.ts +83 -10
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +139 -18
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +224 -12
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/engine.d.ts +43 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +58 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +28 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +38 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +45 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +5 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +4 -2
- package/dist/access/index.js.map +1 -1
- package/dist/config/types.d.ts +77 -11
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +61 -17
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +54 -13
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +8 -5
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +278 -12
- package/src/access/access-filter.ts +207 -19
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +81 -1
- package/src/access/errors.ts +42 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +75 -10
- package/src/access/index.ts +14 -0
- package/src/config/types.ts +77 -11
- package/src/context/index.ts +95 -15
- package/src/context/nested-operations.ts +61 -16
- package/src/context/write-pipeline.ts +8 -4
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +259 -12
- package/tests/field-types.test.ts +113 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +65 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import * as z from 'zod'
|
|
2
3
|
import { createMcpHandlers } from '../src/mcp/handler.js'
|
|
3
4
|
import type { OpenSaasConfig } from '../src/config/types.js'
|
|
4
5
|
import type { AccessContext } from '../src/access/types.js'
|
|
5
6
|
import type { McpSession, McpSessionProvider } from '../src/mcp/types.js'
|
|
7
|
+
import { HashedPassword } from '../src/utils/password.js'
|
|
8
|
+
import {
|
|
9
|
+
AccessScopeDepthExceededError,
|
|
10
|
+
RelationFilterAccessDeniedError,
|
|
11
|
+
} from '../src/access/errors.js'
|
|
6
12
|
|
|
7
13
|
describe('MCP Handler', () => {
|
|
8
14
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -360,6 +366,41 @@ describe('MCP Handler', () => {
|
|
|
360
366
|
expect(result.items[0].occurredAtMs).toBe('9007199254740993')
|
|
361
367
|
})
|
|
362
368
|
|
|
369
|
+
// Regression test (issue #965): the response's JSON.stringify replacer runs
|
|
370
|
+
// AFTER toJSON, so it can't intercept a HashedPassword that leaks its hash
|
|
371
|
+
// from toJSON — the redaction has to live in HashedPassword itself.
|
|
372
|
+
it('should not leak a bcrypt hash for a password field in the query response', async () => {
|
|
373
|
+
const hash = '$2b$04$q5yvruv7NaUYCQvV.UGzf.92.dpteicGq5MoSHYNWqzIm.bsIIJrW'
|
|
374
|
+
const mockResults = [{ id: '1', title: 'Post 1', password: new HashedPassword(hash) }]
|
|
375
|
+
mockPrisma.post.findMany.mockResolvedValue(mockResults)
|
|
376
|
+
|
|
377
|
+
const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
|
|
378
|
+
|
|
379
|
+
const request = new Request('http://localhost/api/mcp', {
|
|
380
|
+
method: 'POST',
|
|
381
|
+
headers: { 'Content-Type': 'application/json' },
|
|
382
|
+
body: JSON.stringify({
|
|
383
|
+
jsonrpc: '2.0',
|
|
384
|
+
id: 1,
|
|
385
|
+
method: 'tools/call',
|
|
386
|
+
params: {
|
|
387
|
+
name: 'list_post_query',
|
|
388
|
+
arguments: { where: {}, take: 10 },
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
const response = await handlers.POST(request)
|
|
394
|
+
expect(response.status).toBe(200)
|
|
395
|
+
|
|
396
|
+
const data = await response.json()
|
|
397
|
+
const responseBody = data.result.content[0].text
|
|
398
|
+
expect(responseBody).not.toContain(hash)
|
|
399
|
+
|
|
400
|
+
const result = JSON.parse(responseBody)
|
|
401
|
+
expect(result.items[0].password).toEqual({ isSet: true })
|
|
402
|
+
})
|
|
403
|
+
|
|
363
404
|
it('should execute create operation', async () => {
|
|
364
405
|
const mockResult = { id: '1', title: 'New Post', content: 'New Content' }
|
|
365
406
|
mockPrisma.post.create.mockResolvedValue(mockResult)
|
|
@@ -510,10 +551,12 @@ describe('MCP Handler', () => {
|
|
|
510
551
|
})
|
|
511
552
|
|
|
512
553
|
const response = await handlers.POST(request)
|
|
513
|
-
expect(response.status).toBe(
|
|
554
|
+
expect(response.status).toBe(200)
|
|
514
555
|
|
|
515
556
|
const data = await response.json()
|
|
516
|
-
expect(data.error
|
|
557
|
+
expect(data.error).toBeUndefined()
|
|
558
|
+
expect(data.result.isError).toBe(true)
|
|
559
|
+
expect(data.result.content[0].text).toContain('Access denied')
|
|
517
560
|
})
|
|
518
561
|
})
|
|
519
562
|
|
|
@@ -601,6 +644,109 @@ describe('MCP Handler', () => {
|
|
|
601
644
|
const data = await response.json()
|
|
602
645
|
expect(data.error.message).toContain('Unknown tool')
|
|
603
646
|
})
|
|
647
|
+
|
|
648
|
+
it('should return a custom tool input schema validation failure as a tool result', async () => {
|
|
649
|
+
const configWithCustomTools = {
|
|
650
|
+
...config,
|
|
651
|
+
lists: {
|
|
652
|
+
Post: {
|
|
653
|
+
...config.lists.Post,
|
|
654
|
+
mcp: {
|
|
655
|
+
customTools: [
|
|
656
|
+
{
|
|
657
|
+
name: 'publishPost',
|
|
658
|
+
description: 'Publish a post',
|
|
659
|
+
inputSchema: z.object({ postId: z.string() }),
|
|
660
|
+
handler: vi.fn(),
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const handlers = createMcpHandlers({
|
|
669
|
+
config: configWithCustomTools,
|
|
670
|
+
getSession: mockGetSession,
|
|
671
|
+
getContext,
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
const request = new Request('http://localhost/api/mcp', {
|
|
675
|
+
method: 'POST',
|
|
676
|
+
headers: { 'Content-Type': 'application/json' },
|
|
677
|
+
body: JSON.stringify({
|
|
678
|
+
jsonrpc: '2.0',
|
|
679
|
+
id: 1,
|
|
680
|
+
method: 'tools/call',
|
|
681
|
+
params: {
|
|
682
|
+
name: 'publishPost',
|
|
683
|
+
arguments: {},
|
|
684
|
+
},
|
|
685
|
+
}),
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
const response = await handlers.POST(request)
|
|
689
|
+
expect(response.status).toBe(200)
|
|
690
|
+
|
|
691
|
+
const data = await response.json()
|
|
692
|
+
expect(data.error).toBeUndefined()
|
|
693
|
+
expect(data.result.isError).toBe(true)
|
|
694
|
+
expect(data.result.content[0].text).toContain('Invalid params')
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('should return a thrown custom tool error as a tool result', async () => {
|
|
698
|
+
const configWithCustomTools = {
|
|
699
|
+
...config,
|
|
700
|
+
lists: {
|
|
701
|
+
Post: {
|
|
702
|
+
...config.lists.Post,
|
|
703
|
+
mcp: {
|
|
704
|
+
customTools: [
|
|
705
|
+
{
|
|
706
|
+
name: 'publishPost',
|
|
707
|
+
description: 'Publish a post',
|
|
708
|
+
inputSchema: {
|
|
709
|
+
type: 'object' as const,
|
|
710
|
+
properties: { postId: { type: 'string' } },
|
|
711
|
+
},
|
|
712
|
+
handler: vi.fn(async () => {
|
|
713
|
+
throw new Error('cannot publish an already-published post')
|
|
714
|
+
}),
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const handlers = createMcpHandlers({
|
|
723
|
+
config: configWithCustomTools,
|
|
724
|
+
getSession: mockGetSession,
|
|
725
|
+
getContext,
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
const request = new Request('http://localhost/api/mcp', {
|
|
729
|
+
method: 'POST',
|
|
730
|
+
headers: { 'Content-Type': 'application/json' },
|
|
731
|
+
body: JSON.stringify({
|
|
732
|
+
jsonrpc: '2.0',
|
|
733
|
+
id: 1,
|
|
734
|
+
method: 'tools/call',
|
|
735
|
+
params: {
|
|
736
|
+
name: 'publishPost',
|
|
737
|
+
arguments: { postId: 'post-123' },
|
|
738
|
+
},
|
|
739
|
+
}),
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
const response = await handlers.POST(request)
|
|
743
|
+
expect(response.status).toBe(200)
|
|
744
|
+
|
|
745
|
+
const data = await response.json()
|
|
746
|
+
expect(data.error).toBeUndefined()
|
|
747
|
+
expect(data.result.isError).toBe(true)
|
|
748
|
+
expect(data.result.content[0].text).toContain('cannot publish an already-published post')
|
|
749
|
+
})
|
|
604
750
|
})
|
|
605
751
|
|
|
606
752
|
describe('error handling', () => {
|
|
@@ -668,10 +814,74 @@ describe('MCP Handler', () => {
|
|
|
668
814
|
})
|
|
669
815
|
|
|
670
816
|
const response = await handlers.POST(request)
|
|
671
|
-
expect(response.status).toBe(
|
|
817
|
+
expect(response.status).toBe(200)
|
|
818
|
+
|
|
819
|
+
const data = await response.json()
|
|
820
|
+
expect(data.error).toBeUndefined()
|
|
821
|
+
expect(data.result.isError).toBe(true)
|
|
822
|
+
expect(data.result.content[0].text).toContain('Database error')
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
it('should return a relation-filter access-denial error as a tool result naming the relation and related list', async () => {
|
|
826
|
+
mockPrisma.post.findMany.mockRejectedValue(
|
|
827
|
+
new RelationFilterAccessDeniedError('Post', 'author', 'User'),
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
|
|
831
|
+
|
|
832
|
+
const request = new Request('http://localhost/api/mcp', {
|
|
833
|
+
method: 'POST',
|
|
834
|
+
headers: { 'Content-Type': 'application/json' },
|
|
835
|
+
body: JSON.stringify({
|
|
836
|
+
jsonrpc: '2.0',
|
|
837
|
+
id: 1,
|
|
838
|
+
method: 'tools/call',
|
|
839
|
+
params: {
|
|
840
|
+
name: 'list_post_query',
|
|
841
|
+
arguments: { where: { author: { is: {} } } },
|
|
842
|
+
},
|
|
843
|
+
}),
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
const response = await handlers.POST(request)
|
|
847
|
+
expect(response.status).toBe(200)
|
|
848
|
+
|
|
849
|
+
const data = await response.json()
|
|
850
|
+
expect(data.error).toBeUndefined()
|
|
851
|
+
expect(data.result.isError).toBe(true)
|
|
852
|
+
expect(data.result.content[0].text).toContain('Post.author')
|
|
853
|
+
expect(data.result.content[0].text).toContain('User')
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
it('should return a depth-exceeded error as a tool result worded as a cost refusal', async () => {
|
|
857
|
+
mockPrisma.post.findMany.mockRejectedValue(
|
|
858
|
+
new AccessScopeDepthExceededError('Post', 'author', 3),
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
|
|
862
|
+
|
|
863
|
+
const request = new Request('http://localhost/api/mcp', {
|
|
864
|
+
method: 'POST',
|
|
865
|
+
headers: { 'Content-Type': 'application/json' },
|
|
866
|
+
body: JSON.stringify({
|
|
867
|
+
jsonrpc: '2.0',
|
|
868
|
+
id: 1,
|
|
869
|
+
method: 'tools/call',
|
|
870
|
+
params: {
|
|
871
|
+
name: 'list_post_query',
|
|
872
|
+
arguments: {},
|
|
873
|
+
},
|
|
874
|
+
}),
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
const response = await handlers.POST(request)
|
|
878
|
+
expect(response.status).toBe(200)
|
|
672
879
|
|
|
673
880
|
const data = await response.json()
|
|
674
|
-
expect(data.error
|
|
881
|
+
expect(data.error).toBeUndefined()
|
|
882
|
+
expect(data.result.isError).toBe(true)
|
|
883
|
+
expect(data.result.content[0].text).toContain('Post.author')
|
|
884
|
+
expect(data.result.content[0].text).toContain('cost limit')
|
|
675
885
|
})
|
|
676
886
|
})
|
|
677
887
|
|
|
@@ -718,3 +928,484 @@ describe('MCP Handler', () => {
|
|
|
718
928
|
})
|
|
719
929
|
})
|
|
720
930
|
})
|
|
931
|
+
|
|
932
|
+
// Coverage for issue #851 / ADR-0033: the `query` tool's `fields` projection
|
|
933
|
+
// argument, its generated per-session schema, and `tools/list` becoming
|
|
934
|
+
// per-session. Uses the same lightweight mocked-`context.db` harness as the
|
|
935
|
+
// suite above — these tests exercise the MCP-layer translation (schema
|
|
936
|
+
// shape, `fields` → `include` translation, response projection) rather than
|
|
937
|
+
// the real access-control engine, which is covered separately in
|
|
938
|
+
// `mcp-fields-projection-access.test.ts` against a real `getContext`.
|
|
939
|
+
describe('MCP Handler — fields projection (#851)', () => {
|
|
940
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
941
|
+
let mockPrisma: any
|
|
942
|
+
let config: OpenSaasConfig
|
|
943
|
+
let getContext: (session?: { userId: string }) => AccessContext
|
|
944
|
+
let mockGetSession: McpSessionProvider
|
|
945
|
+
|
|
946
|
+
beforeEach(() => {
|
|
947
|
+
mockPrisma = {
|
|
948
|
+
user: { findMany: vi.fn() },
|
|
949
|
+
comment: { findMany: vi.fn() },
|
|
950
|
+
post: { findMany: vi.fn() },
|
|
951
|
+
secret: { findMany: vi.fn() },
|
|
952
|
+
draft: { findMany: vi.fn() },
|
|
953
|
+
category: { findMany: vi.fn() },
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
config = {
|
|
957
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
958
|
+
mcp: { enabled: true, basePath: '/api/mcp' },
|
|
959
|
+
lists: {
|
|
960
|
+
User: {
|
|
961
|
+
fields: {
|
|
962
|
+
name: { type: 'text' },
|
|
963
|
+
email: { type: 'text' },
|
|
964
|
+
},
|
|
965
|
+
access: { operation: { query: () => true } },
|
|
966
|
+
},
|
|
967
|
+
Comment: {
|
|
968
|
+
fields: {
|
|
969
|
+
text: { type: 'text' },
|
|
970
|
+
approved: { type: 'checkbox' },
|
|
971
|
+
// Field-level read denied — used to prove a nested `where`/
|
|
972
|
+
// `orderBy` can't name a field this session cannot read.
|
|
973
|
+
internalNote: { type: 'text', access: { read: () => false } },
|
|
974
|
+
post: { type: 'relationship', ref: 'Post.comments' },
|
|
975
|
+
},
|
|
976
|
+
access: { operation: { query: () => true } },
|
|
977
|
+
},
|
|
978
|
+
Post: {
|
|
979
|
+
fields: {
|
|
980
|
+
title: { type: 'text' },
|
|
981
|
+
content: { type: 'text' },
|
|
982
|
+
author: { type: 'relationship', ref: 'User' },
|
|
983
|
+
comments: { type: 'relationship', ref: 'Comment.post', many: true },
|
|
984
|
+
// Field-level read denied on the RELATIONSHIP FIELD ITSELF —
|
|
985
|
+
// distinct from a related list's operation-level access — used
|
|
986
|
+
// to prove `count` can't bypass it.
|
|
987
|
+
restrictedComments: {
|
|
988
|
+
type: 'relationship',
|
|
989
|
+
ref: 'Comment',
|
|
990
|
+
many: true,
|
|
991
|
+
access: { read: () => false },
|
|
992
|
+
},
|
|
993
|
+
secretInfo: { type: 'relationship', ref: 'Secret' },
|
|
994
|
+
draftRef: { type: 'relationship', ref: 'Draft' },
|
|
995
|
+
},
|
|
996
|
+
access: { operation: { query: () => true } },
|
|
997
|
+
},
|
|
998
|
+
// Denies query access outright — must vanish from `tools/list` as a
|
|
999
|
+
// tool owner AND from `Post`'s `fields` schema as a relation target.
|
|
1000
|
+
Secret: {
|
|
1001
|
+
fields: { value: { type: 'text' } },
|
|
1002
|
+
access: { operation: { query: () => false } },
|
|
1003
|
+
},
|
|
1004
|
+
// MCP-disabled — same double omission as `Secret`, different cause.
|
|
1005
|
+
Draft: {
|
|
1006
|
+
fields: { title: { type: 'text' } },
|
|
1007
|
+
access: { operation: { query: () => true } },
|
|
1008
|
+
mcp: { enabled: false },
|
|
1009
|
+
},
|
|
1010
|
+
// Self-referential — the schema must terminate at depth 2 rather
|
|
1011
|
+
// than recursing forever.
|
|
1012
|
+
Category: {
|
|
1013
|
+
fields: {
|
|
1014
|
+
name: { type: 'text' },
|
|
1015
|
+
parent: { type: 'relationship', ref: 'Category.children' },
|
|
1016
|
+
children: { type: 'relationship', ref: 'Category.parent', many: true },
|
|
1017
|
+
},
|
|
1018
|
+
access: { operation: { query: () => true } },
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
getContext = vi.fn((session?: { userId: string }) => ({
|
|
1024
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1025
|
+
db: mockPrisma as any,
|
|
1026
|
+
session: session || null,
|
|
1027
|
+
prisma: mockPrisma,
|
|
1028
|
+
}))
|
|
1029
|
+
|
|
1030
|
+
mockGetSession = vi.fn(async () => ({ userId: 'user-123' }))
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
async function listTools() {
|
|
1034
|
+
const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
|
|
1035
|
+
const request = new Request('http://localhost/api/mcp', {
|
|
1036
|
+
method: 'POST',
|
|
1037
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1038
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
|
|
1039
|
+
})
|
|
1040
|
+
const response = await handlers.POST(request)
|
|
1041
|
+
const data = await response.json()
|
|
1042
|
+
return data.result.tools as Array<{ name: string; inputSchema: Record<string, unknown> }>
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async function callQuery(
|
|
1046
|
+
name: string,
|
|
1047
|
+
args: Record<string, unknown>,
|
|
1048
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1049
|
+
): Promise<any> {
|
|
1050
|
+
const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
|
|
1051
|
+
const request = new Request('http://localhost/api/mcp', {
|
|
1052
|
+
method: 'POST',
|
|
1053
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1054
|
+
body: JSON.stringify({
|
|
1055
|
+
jsonrpc: '2.0',
|
|
1056
|
+
id: 1,
|
|
1057
|
+
method: 'tools/call',
|
|
1058
|
+
params: { name, arguments: args },
|
|
1059
|
+
}),
|
|
1060
|
+
})
|
|
1061
|
+
const response = await handlers.POST(request)
|
|
1062
|
+
return response.json()
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
describe('generated `fields` schema on tools/list', () => {
|
|
1066
|
+
it('advertises scalars as booleans and relations as nested selectors', async () => {
|
|
1067
|
+
const tools = await listTools()
|
|
1068
|
+
const postQuery = tools.find((t) => t.name === 'list_post_query')!
|
|
1069
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1070
|
+
const fieldsSchema = (postQuery.inputSchema.properties as any).fields
|
|
1071
|
+
|
|
1072
|
+
expect(fieldsSchema.properties.title).toMatchObject({ type: 'boolean' })
|
|
1073
|
+
expect(fieldsSchema.properties.content).toMatchObject({ type: 'boolean' })
|
|
1074
|
+
|
|
1075
|
+
// To-one: nested `fields` only, required, no where/orderBy/take/skip/count.
|
|
1076
|
+
expect(fieldsSchema.properties.author.required).toEqual(['fields'])
|
|
1077
|
+
const authorFields = fieldsSchema.properties.author.properties.fields.properties
|
|
1078
|
+
expect(authorFields.name).toMatchObject({ type: 'boolean' })
|
|
1079
|
+
expect(authorFields.email).toMatchObject({ type: 'boolean' })
|
|
1080
|
+
expect(fieldsSchema.properties.author.properties.where).toBeUndefined()
|
|
1081
|
+
|
|
1082
|
+
// To-many: fields optional, plus where/orderBy/take/skip/count.
|
|
1083
|
+
expect(fieldsSchema.properties.comments.required).toBeUndefined()
|
|
1084
|
+
const commentFields = fieldsSchema.properties.comments.properties.fields.properties
|
|
1085
|
+
expect(commentFields.text).toMatchObject({ type: 'boolean' })
|
|
1086
|
+
expect(commentFields.approved).toMatchObject({ type: 'boolean' })
|
|
1087
|
+
expect(fieldsSchema.properties.comments.properties.where).toBeDefined()
|
|
1088
|
+
expect(fieldsSchema.properties.comments.properties.orderBy).toBeDefined()
|
|
1089
|
+
expect(fieldsSchema.properties.comments.properties.take).toBeDefined()
|
|
1090
|
+
expect(fieldsSchema.properties.comments.properties.skip).toBeDefined()
|
|
1091
|
+
expect(fieldsSchema.properties.comments.properties.count).toEqual({
|
|
1092
|
+
type: 'boolean',
|
|
1093
|
+
description: expect.any(String),
|
|
1094
|
+
})
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
it('omits a relation whose target list denies query access', async () => {
|
|
1098
|
+
const tools = await listTools()
|
|
1099
|
+
const postQuery = tools.find((t) => t.name === 'list_post_query')!
|
|
1100
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1101
|
+
const fieldsSchema = (postQuery.inputSchema.properties as any).fields
|
|
1102
|
+
expect(fieldsSchema.properties.secretInfo).toBeUndefined()
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
it('omits a relation whose target list has mcp.enabled: false', async () => {
|
|
1106
|
+
const tools = await listTools()
|
|
1107
|
+
const postQuery = tools.find((t) => t.name === 'list_post_query')!
|
|
1108
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1109
|
+
const fieldsSchema = (postQuery.inputSchema.properties as any).fields
|
|
1110
|
+
expect(fieldsSchema.properties.draftRef).toBeUndefined()
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
it('terminates a self-referential relationship at depth 2 (no relation fields at level 2)', async () => {
|
|
1114
|
+
const tools = await listTools()
|
|
1115
|
+
const categoryQuery = tools.find((t) => t.name === 'list_category_query')!
|
|
1116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1117
|
+
const fieldsSchema = (categoryQuery.inputSchema.properties as any).fields
|
|
1118
|
+
|
|
1119
|
+
// Only scalars (plus the always-present system fields) — no `parent`/
|
|
1120
|
+
// `children` at this depth, which is what actually terminates the
|
|
1121
|
+
// self-reference.
|
|
1122
|
+
const parentFields = fieldsSchema.properties.parent.properties.fields.properties
|
|
1123
|
+
expect(Object.keys(parentFields).sort()).toEqual(['createdAt', 'id', 'name', 'updatedAt'])
|
|
1124
|
+
expect(parentFields.name).toMatchObject({ type: 'boolean' })
|
|
1125
|
+
|
|
1126
|
+
const childrenFields = fieldsSchema.properties.children.properties.fields.properties
|
|
1127
|
+
expect(Object.keys(childrenFields).sort()).toEqual(['createdAt', 'id', 'name', 'updatedAt'])
|
|
1128
|
+
expect(childrenFields.name).toMatchObject({ type: 'boolean' })
|
|
1129
|
+
})
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
describe('tools/list is per-session', () => {
|
|
1133
|
+
it('omits all four CRUD tools for a list whose query access denies the session', async () => {
|
|
1134
|
+
const tools = await listTools()
|
|
1135
|
+
const toolNames = tools.map((t) => t.name)
|
|
1136
|
+
expect(toolNames).not.toContain('list_secret_query')
|
|
1137
|
+
expect(toolNames).not.toContain('list_secret_create')
|
|
1138
|
+
expect(toolNames).not.toContain('list_secret_update')
|
|
1139
|
+
expect(toolNames).not.toContain('list_secret_delete')
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
it('omits all four CRUD tools for a list with mcp.enabled: false', async () => {
|
|
1143
|
+
const tools = await listTools()
|
|
1144
|
+
const toolNames = tools.map((t) => t.name)
|
|
1145
|
+
expect(toolNames).not.toContain('list_draft_query')
|
|
1146
|
+
})
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
describe('query dispatch with `fields`', () => {
|
|
1150
|
+
it('selects only scalar fields, issuing no `include`', async () => {
|
|
1151
|
+
mockPrisma.post.findMany.mockResolvedValue([{ id: '1', title: 'Hello', content: 'World' }])
|
|
1152
|
+
|
|
1153
|
+
const data = await callQuery('list_post_query', { fields: { title: true } })
|
|
1154
|
+
|
|
1155
|
+
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
|
|
1156
|
+
expect.not.objectContaining({ include: expect.anything() }),
|
|
1157
|
+
)
|
|
1158
|
+
const result = JSON.parse(data.result.content[0].text)
|
|
1159
|
+
expect(result.items).toEqual([{ id: '1', title: 'Hello' }])
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
it('selects a to-one relation with nested fields', async () => {
|
|
1163
|
+
mockPrisma.post.findMany.mockResolvedValue([
|
|
1164
|
+
{ id: '1', title: 'Hello', author: { id: 'u1', name: 'Ada', email: 'ada@example.com' } },
|
|
1165
|
+
])
|
|
1166
|
+
|
|
1167
|
+
const data = await callQuery('list_post_query', {
|
|
1168
|
+
fields: { title: true, author: { fields: { name: true } } },
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
|
|
1172
|
+
expect.objectContaining({ include: { author: true } }),
|
|
1173
|
+
)
|
|
1174
|
+
const result = JSON.parse(data.result.content[0].text)
|
|
1175
|
+
expect(result.items).toEqual([{ id: '1', title: 'Hello', author: { id: 'u1', name: 'Ada' } }])
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
it('honours nested where/orderBy/take/skip on a to-many relation, applying the default take ceiling when omitted', async () => {
|
|
1179
|
+
mockPrisma.post.findMany.mockResolvedValue([{ id: '1', comments: [] }])
|
|
1180
|
+
|
|
1181
|
+
await callQuery('list_post_query', {
|
|
1182
|
+
fields: {
|
|
1183
|
+
comments: {
|
|
1184
|
+
fields: { text: true },
|
|
1185
|
+
where: { approved: { equals: true } },
|
|
1186
|
+
orderBy: { text: 'asc' },
|
|
1187
|
+
skip: 2,
|
|
1188
|
+
},
|
|
1189
|
+
},
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
|
|
1193
|
+
expect.objectContaining({
|
|
1194
|
+
include: {
|
|
1195
|
+
comments: {
|
|
1196
|
+
where: { approved: { equals: true } },
|
|
1197
|
+
orderBy: { text: 'asc' },
|
|
1198
|
+
take: 5,
|
|
1199
|
+
skip: 2,
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
}),
|
|
1203
|
+
)
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
it('clamps a nested take above the hard cap', async () => {
|
|
1207
|
+
mockPrisma.post.findMany.mockResolvedValue([{ id: '1', comments: [] }])
|
|
1208
|
+
|
|
1209
|
+
await callQuery('list_post_query', {
|
|
1210
|
+
fields: { comments: { fields: { text: true }, take: 9999 } },
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
|
|
1214
|
+
expect.objectContaining({
|
|
1215
|
+
include: { comments: { take: 50 } },
|
|
1216
|
+
}),
|
|
1217
|
+
)
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
it('requests a to-many count in place of its rows', async () => {
|
|
1221
|
+
// The relation is still named in the include, fetched with the SAME
|
|
1222
|
+
// real rows a `fields`-and-`count` request would get (never a
|
|
1223
|
+
// synthetic `take: 0`, which would make field-visibility's read-access
|
|
1224
|
+
// check see a permanently-empty relation) — see projectMcpResult's doc
|
|
1225
|
+
// comment. The rows themselves are simply never added to
|
|
1226
|
+
// `fieldSelection`, so they never reach the projected output.
|
|
1227
|
+
mockPrisma.post.findMany.mockResolvedValue([
|
|
1228
|
+
{ id: '1', comments: [{ text: 'hi' }], _count: { comments: 7 } },
|
|
1229
|
+
])
|
|
1230
|
+
|
|
1231
|
+
const data = await callQuery('list_post_query', {
|
|
1232
|
+
fields: { comments: { count: true } },
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
|
|
1236
|
+
expect.objectContaining({
|
|
1237
|
+
include: { comments: { take: 5 }, _count: { select: { comments: true } } },
|
|
1238
|
+
}),
|
|
1239
|
+
)
|
|
1240
|
+
const result = JSON.parse(data.result.content[0].text)
|
|
1241
|
+
expect(result.items).toEqual([{ id: '1', comments: 7 }])
|
|
1242
|
+
})
|
|
1243
|
+
|
|
1244
|
+
it('requests a to-many count alongside its rows', async () => {
|
|
1245
|
+
mockPrisma.post.findMany.mockResolvedValue([
|
|
1246
|
+
{ id: '1', comments: [{ text: 'hi' }], _count: { comments: 7 } },
|
|
1247
|
+
])
|
|
1248
|
+
|
|
1249
|
+
const data = await callQuery('list_post_query', {
|
|
1250
|
+
fields: { comments: { fields: { text: true }, count: true } },
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
|
|
1254
|
+
expect.objectContaining({
|
|
1255
|
+
include: {
|
|
1256
|
+
comments: { take: 5 },
|
|
1257
|
+
_count: { select: { comments: true } },
|
|
1258
|
+
},
|
|
1259
|
+
}),
|
|
1260
|
+
)
|
|
1261
|
+
const result = JSON.parse(data.result.content[0].text)
|
|
1262
|
+
expect(result.items).toEqual([{ id: '1', comments: { items: [{ text: 'hi' }], count: 7 } }])
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
it('refuses an unadvertised field name as an isError tool result', async () => {
|
|
1266
|
+
const data = await callQuery('list_post_query', { fields: { madeUpField: true } })
|
|
1267
|
+
|
|
1268
|
+
expect(data.error).toBeUndefined()
|
|
1269
|
+
expect(data.result.isError).toBe(true)
|
|
1270
|
+
expect(data.result.content[0].text).toContain('madeUpField')
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1273
|
+
it('refuses a relation named two levels deep as an isError tool result', async () => {
|
|
1274
|
+
const data = await callQuery('list_post_query', {
|
|
1275
|
+
fields: { comments: { fields: { post: true } } },
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
expect(data.error).toBeUndefined()
|
|
1279
|
+
expect(data.result.isError).toBe(true)
|
|
1280
|
+
expect(data.result.content[0].text).toContain('post')
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
it('refuses a relation field selected with `true` instead of a selector object', async () => {
|
|
1284
|
+
const data = await callQuery('list_post_query', { fields: { author: true } })
|
|
1285
|
+
|
|
1286
|
+
expect(data.result.isError).toBe(true)
|
|
1287
|
+
})
|
|
1288
|
+
|
|
1289
|
+
it('refuses a scalar field selected with a non-true value', async () => {
|
|
1290
|
+
const data = await callQuery('list_post_query', { fields: { title: 'yes' } })
|
|
1291
|
+
|
|
1292
|
+
expect(data.result.isError).toBe(true)
|
|
1293
|
+
})
|
|
1294
|
+
|
|
1295
|
+
it('refuses a relation whose target list denies query access', async () => {
|
|
1296
|
+
const data = await callQuery('list_post_query', {
|
|
1297
|
+
fields: { secretInfo: { fields: { value: true } } },
|
|
1298
|
+
})
|
|
1299
|
+
|
|
1300
|
+
expect(data.result.isError).toBe(true)
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
it('always returns id, even when the projection never names it', async () => {
|
|
1304
|
+
mockPrisma.post.findMany.mockResolvedValue([{ id: '1', title: 'Hello' }])
|
|
1305
|
+
|
|
1306
|
+
const data = await callQuery('list_post_query', { fields: { title: true } })
|
|
1307
|
+
|
|
1308
|
+
const result = JSON.parse(data.result.content[0].text)
|
|
1309
|
+
expect(result.items).toEqual([{ id: '1', title: 'Hello' }])
|
|
1310
|
+
})
|
|
1311
|
+
|
|
1312
|
+
it('accepts id/createdAt/updatedAt as explicitly selectable system fields', async () => {
|
|
1313
|
+
mockPrisma.post.findMany.mockResolvedValue([
|
|
1314
|
+
{ id: '1', title: 'Hello', createdAt: 't1', updatedAt: 't2' },
|
|
1315
|
+
])
|
|
1316
|
+
|
|
1317
|
+
const data = await callQuery('list_post_query', {
|
|
1318
|
+
fields: { id: true, createdAt: true, updatedAt: true },
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
const result = JSON.parse(data.result.content[0].text)
|
|
1322
|
+
expect(result.items).toEqual([{ id: '1', createdAt: 't1', updatedAt: 't2' }])
|
|
1323
|
+
})
|
|
1324
|
+
|
|
1325
|
+
it('refuses a malformed nested where/take/orderBy/skip/count with a clear message rather than an opaque Prisma error', async () => {
|
|
1326
|
+
const badWhere = await callQuery('list_post_query', {
|
|
1327
|
+
fields: { comments: { fields: { text: true }, where: 'not-an-object' } },
|
|
1328
|
+
})
|
|
1329
|
+
expect(badWhere.result.isError).toBe(true)
|
|
1330
|
+
expect(badWhere.result.content[0].text).toContain('where')
|
|
1331
|
+
|
|
1332
|
+
const badTake = await callQuery('list_post_query', {
|
|
1333
|
+
fields: { comments: { fields: { text: true }, take: 'lots' } },
|
|
1334
|
+
})
|
|
1335
|
+
expect(badTake.result.isError).toBe(true)
|
|
1336
|
+
expect(badTake.result.content[0].text).toContain('take')
|
|
1337
|
+
|
|
1338
|
+
const badCount = await callQuery('list_post_query', {
|
|
1339
|
+
fields: { comments: { count: 'yes' } },
|
|
1340
|
+
})
|
|
1341
|
+
expect(badCount.result.isError).toBe(true)
|
|
1342
|
+
expect(badCount.result.content[0].text).toContain('count')
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
it('refuses a negative nested take rather than allowing reverse-pagination to bypass the row cap', async () => {
|
|
1346
|
+
const data = await callQuery('list_post_query', {
|
|
1347
|
+
fields: { comments: { fields: { text: true }, take: -100000 } },
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
expect(data.result.isError).toBe(true)
|
|
1351
|
+
expect(data.result.content[0].text).toContain('take')
|
|
1352
|
+
expect(mockPrisma.post.findMany).not.toHaveBeenCalled()
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
it('refuses a nested where naming a field-level-denied field on the related list', async () => {
|
|
1356
|
+
const data = await callQuery('list_post_query', {
|
|
1357
|
+
fields: {
|
|
1358
|
+
comments: { fields: { text: true }, where: { internalNote: { equals: 'x' } } },
|
|
1359
|
+
},
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
expect(data.result.isError).toBe(true)
|
|
1363
|
+
expect(data.result.content[0].text).toContain('internalNote')
|
|
1364
|
+
expect(mockPrisma.post.findMany).not.toHaveBeenCalled()
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
it('refuses a nested orderBy naming an undeclared field on the related list', async () => {
|
|
1368
|
+
const data = await callQuery('list_post_query', {
|
|
1369
|
+
fields: {
|
|
1370
|
+
comments: { fields: { text: true }, orderBy: { bogusField: 'asc' } },
|
|
1371
|
+
},
|
|
1372
|
+
})
|
|
1373
|
+
|
|
1374
|
+
expect(data.result.isError).toBe(true)
|
|
1375
|
+
expect(data.result.content[0].text).toContain('bogusField')
|
|
1376
|
+
})
|
|
1377
|
+
|
|
1378
|
+
it('names a count-only relation in the include with real rows (never a synthetic take: 0) so field-visibility can evaluate its own read access against the true value', async () => {
|
|
1379
|
+
mockPrisma.post.findMany.mockResolvedValue([
|
|
1380
|
+
{ id: '1', restrictedComments: [{ text: 'hi' }], _count: { restrictedComments: 3 } },
|
|
1381
|
+
])
|
|
1382
|
+
|
|
1383
|
+
await callQuery('list_post_query', { fields: { restrictedComments: { count: true } } })
|
|
1384
|
+
|
|
1385
|
+
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
|
|
1386
|
+
expect.objectContaining({
|
|
1387
|
+
include: expect.objectContaining({ restrictedComments: { take: 5 } }),
|
|
1388
|
+
}),
|
|
1389
|
+
)
|
|
1390
|
+
})
|
|
1391
|
+
|
|
1392
|
+
// This mocked harness bypasses the real `filterReadableFields`, so it
|
|
1393
|
+
// can only simulate the outcome (a denied relation key absent from the
|
|
1394
|
+
// row `context.db` returns) rather than exercise the real per-row
|
|
1395
|
+
// access check — see `mcp-fields-projection-access.test.ts`'s
|
|
1396
|
+
// "suppresses a relation count denied by the relationship field's own
|
|
1397
|
+
// access" for the end-to-end version through the real pipeline.
|
|
1398
|
+
it('suppresses a count whose relation key is absent from the row (what a field-visibility denial produces)', async () => {
|
|
1399
|
+
mockPrisma.post.findMany.mockResolvedValue([
|
|
1400
|
+
{ id: '1', _count: { restrictedComments: 3 } }, // no `restrictedComments` key
|
|
1401
|
+
])
|
|
1402
|
+
|
|
1403
|
+
const data = await callQuery('list_post_query', {
|
|
1404
|
+
fields: { restrictedComments: { count: true } },
|
|
1405
|
+
})
|
|
1406
|
+
|
|
1407
|
+
const result = JSON.parse(data.result.content[0].text)
|
|
1408
|
+
expect(result.items).toEqual([{ id: '1' }])
|
|
1409
|
+
})
|
|
1410
|
+
})
|
|
1411
|
+
})
|