|
9 | 9 | contextUpdatesOfType, |
10 | 10 | type TestStack, |
11 | 11 | } from './harness.js'; |
12 | | -import { actions, agents, messages, nodeProviders, nodes, workspaceEvents } from '../../db/schema.js'; |
| 12 | +import { actions, agents, nodeProviders, nodes } from '../../db/schema.js'; |
13 | 13 |
|
14 | 14 | type Cap = { name: string; kind?: string; global?: boolean; queue?: boolean }; |
15 | 15 |
|
@@ -456,76 +456,80 @@ describe('node providers', () => { |
456 | 456 | expect(reply.data?.handler_node_id).toBe('node_a'); |
457 | 457 | }); |
458 | 458 |
|
459 | | - it('posts a channel message from a node.message frame and fans out to observers', async () => { |
460 | | - const ws = await createWorkspace(stack.app, 'np-node-message'); |
461 | | - await registerAgent(stack.app, ws.workspaceKey, 'reporter'); |
462 | | - await enrollNode(ws, 'node_a', 'alpha'); |
463 | | - const py = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', [{ name: 'run-etl', kind: 'action' }]); |
| 459 | + // ctx.sendMessage posts through the canonical message route with a node token |
| 460 | + // and a required `from`, so node-attributed posts get delivery routing, |
| 461 | + // observer events, and triggers by construction (no parallel posting path). |
| 462 | + async function enrollNodeWithToken(ws: { workspaceKey: string }, nodeId: string, name: string): Promise<string> { |
| 463 | + const res = await stack.app.request('/v1/nodes', { |
| 464 | + method: 'POST', |
| 465 | + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, |
| 466 | + body: JSON.stringify({ node_id: nodeId, name, role: 'broker', capabilities: [], max_agents: 4, tags: ['test'], version: 'v0' }), |
| 467 | + }); |
| 468 | + expect(res.status).toBe(201); |
| 469 | + return (await res.json() as { data: { token: string } }).data.token; |
| 470 | + } |
464 | 471 |
|
465 | | - await py.handle.handleMessage(JSON.stringify({ |
466 | | - v: 1, |
467 | | - id: 'msg-1', |
468 | | - type: 'node.message', |
469 | | - to: 'general', |
470 | | - from: 'reporter', |
471 | | - text: 'etl finished', |
472 | | - })); |
| 472 | + function postMessage(token: string, channel: string, body: Record<string, unknown>) { |
| 473 | + return stack.app.request(`/v1/channels/${channel}/messages`, { |
| 474 | + method: 'POST', |
| 475 | + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, |
| 476 | + body: JSON.stringify(body), |
| 477 | + }); |
| 478 | + } |
473 | 479 |
|
474 | | - const reply = py.sock.ofType('reply').at(-1) as { ok?: boolean; id?: string; data?: { id?: string; text?: string } }; |
475 | | - expect(reply).toMatchObject({ ok: true, id: 'msg-1' }); |
476 | | - expect(reply.data?.text).toBe('etl finished'); |
| 480 | + it('posts a node-token message attributed to `from`, delivered to node-hosted recipients', async () => { |
| 481 | + const ws = await createWorkspace(stack.app, 'nt-msg'); |
| 482 | + await registerAgent(stack.app, ws.workspaceKey, 'poster'); |
| 483 | + const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha'); |
| 484 | + const py = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', [{ name: 'run-etl', kind: 'action' }]); |
| 485 | + // A worker agent hosted by py joins #general, so it is a delivery recipient. |
| 486 | + await py.handle.handleMessage(JSON.stringify({ v: 1, id: 'agent-1', type: 'agent.register', name: 'worker', session_ref: 'pty://py/worker' })); |
| 487 | + py.sock.received.length = 0; |
477 | 488 |
|
478 | | - const rows = await stack.runtime.handle.db |
479 | | - .select({ body: messages.body, agentId: messages.agentId }) |
480 | | - .from(messages) |
481 | | - .where(eq(messages.workspaceId, ws.workspaceId)); |
482 | | - expect(rows.map((r) => r.body)).toContain('etl finished'); |
483 | | - |
484 | | - const events = await stack.runtime.handle.db |
485 | | - .select({ type: workspaceEvents.type }) |
486 | | - .from(workspaceEvents) |
487 | | - .where(and(eq(workspaceEvents.workspaceId, ws.workspaceId), eq(workspaceEvents.type, 'message.created'))); |
488 | | - expect(events.length).toBeGreaterThanOrEqual(1); |
489 | | - }); |
| 489 | + const res = await postMessage(nodeToken, 'general', { text: 'etl finished', from: 'poster' }); |
| 490 | + expect(res.status).toBe(201); |
| 491 | + const body = await res.json() as { data: { agent_name: string; text: string } }; |
| 492 | + expect(body.data).toMatchObject({ agent_name: 'poster', text: 'etl finished' }); |
490 | 493 |
|
491 | | - it('rejects a node.message from an unknown agent or to an unknown channel', async () => { |
492 | | - const ws = await createWorkspace(stack.app, 'np-node-message-errors'); |
493 | | - await registerAgent(stack.app, ws.workspaceKey, 'reporter'); |
494 | | - await enrollNode(ws, 'node_a', 'alpha'); |
495 | | - const py = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', [{ name: 'run-etl', kind: 'action' }]); |
| 494 | + await new Promise((r) => setTimeout(r, 60)); |
| 495 | + // Delivery routing came for free from the canonical route. |
| 496 | + expect(deliverFramesOfType(py.sock, 'message.created').length).toBeGreaterThanOrEqual(1); |
| 497 | + }); |
496 | 498 |
|
497 | | - await py.handle.handleMessage(JSON.stringify({ |
498 | | - v: 1, id: 'bad-agent', type: 'node.message', to: 'general', from: 'ghost', text: 'hi', |
499 | | - })); |
500 | | - expect(py.sock.ofType('error').at(-1)).toMatchObject({ id: 'bad-agent', code: 'agent_not_found' }); |
| 499 | + it('requires `from` on a node-token message', async () => { |
| 500 | + const ws = await createWorkspace(stack.app, 'nt-msg-from-required'); |
| 501 | + const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha'); |
| 502 | + const res = await postMessage(nodeToken, 'general', { text: 'hi' }); |
| 503 | + expect(res.status).toBe(400); |
| 504 | + expect((await res.json() as { error: { code: string } }).error.code).toBe('from_required'); |
| 505 | + }); |
501 | 506 |
|
502 | | - await py.handle.handleMessage(JSON.stringify({ |
503 | | - v: 1, id: 'bad-channel', type: 'node.message', to: 'nowhere', from: 'reporter', text: 'hi', |
504 | | - })); |
505 | | - expect(py.sock.ofType('error').at(-1)).toMatchObject({ id: 'bad-channel', code: 'channel_not_found' }); |
| 507 | + it('rejects a node-token message whose `from` agent does not exist', async () => { |
| 508 | + const ws = await createWorkspace(stack.app, 'nt-msg-unknown'); |
| 509 | + const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha'); |
| 510 | + const res = await postMessage(nodeToken, 'general', { text: 'hi', from: 'ghost' }); |
| 511 | + expect(res.status).toBe(404); |
| 512 | + expect((await res.json() as { error: { code: string } }).error.code).toBe('agent_not_found'); |
506 | 513 | }); |
507 | 514 |
|
508 | | - it('scopes node.message attribution to the node workspace, rejecting a cross-workspace from', async () => { |
509 | | - // The `from` agent resolves only within the authenticated node's workspace, |
510 | | - // so a node token cannot post as an agent that exists in another workspace. |
511 | | - const other = await createWorkspace(stack.app, 'np-msg-other-ws'); |
| 515 | + it('scopes node-token `from` to the node workspace, rejecting a cross-workspace agent', async () => { |
| 516 | + const other = await createWorkspace(stack.app, 'nt-msg-other-ws'); |
512 | 517 | await registerAgent(stack.app, other.workspaceKey, 'outsider'); |
513 | 518 |
|
514 | | - const ws = await createWorkspace(stack.app, 'np-msg-scope'); |
515 | | - await enrollNode(ws, 'node_a', 'alpha'); |
516 | | - const py = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', [{ name: 'run-etl', kind: 'action' }]); |
| 519 | + const ws = await createWorkspace(stack.app, 'nt-msg-scope'); |
| 520 | + const nodeToken = await enrollNodeWithToken(ws, 'node_a', 'alpha'); |
| 521 | + const res = await postMessage(nodeToken, 'general', { text: 'hi', from: 'outsider' }); |
| 522 | + expect(res.status).toBe(404); |
| 523 | + expect((await res.json() as { error: { code: string } }).error.code).toBe('agent_not_found'); |
| 524 | + }); |
517 | 525 |
|
518 | | - await py.handle.handleMessage(JSON.stringify({ |
519 | | - v: 1, id: 'cross-ws', type: 'node.message', to: 'general', from: 'outsider', text: 'hi', |
520 | | - })); |
521 | | - expect(py.sock.ofType('error').at(-1)).toMatchObject({ id: 'cross-ws', code: 'agent_not_found' }); |
522 | | - |
523 | | - // No message leaked into the other workspace under the outsider's name. |
524 | | - const leaked = await stack.runtime.handle.db |
525 | | - .select({ id: messages.id }) |
526 | | - .from(messages) |
527 | | - .where(eq(messages.workspaceId, other.workspaceId)); |
528 | | - expect(leaked).toHaveLength(0); |
| 526 | + it('rejects `from` on an agent-token message', async () => { |
| 527 | + const ws = await createWorkspace(stack.app, 'nt-msg-agent-from'); |
| 528 | + const agent = await registerAgent(stack.app, ws.workspaceKey, 'poster'); |
| 529 | + const other = await registerAgent(stack.app, ws.workspaceKey, 'other'); |
| 530 | + const res = await postMessage(agent.token, 'general', { text: 'hi', from: other.name }); |
| 531 | + expect(res.status).toBe(400); |
| 532 | + expect((await res.json() as { error: { code: string } }).error.code).toBe('from_not_allowed'); |
529 | 533 | }); |
530 | 534 |
|
531 | 535 | it('removes a provider through DELETE /v1/nodes/:node/providers/:name', async () => { |
|
0 commit comments