Added auth and request storage
This commit is contained in:
@@ -7,6 +7,8 @@ import { ApplicationRouteStream } from '../../source/services/route-stream.ts';
|
||||
import type { Database } from '../../source/services/storage/database.ts';
|
||||
import { TestConnection } from '../helpers/test-connection.ts';
|
||||
import { HTTP_STATUS_CODE_NOT_ACCEPTED } from '../../source/constants.ts';
|
||||
import type { AuthSecp256k1 } from '../../source/auth/auth.ts';
|
||||
import { Accounts } from '../../source/auth/accounts.ts';
|
||||
|
||||
const createBroadcasterStub = (): BaseBroadcaster => {
|
||||
return {
|
||||
@@ -17,6 +19,31 @@ const createBroadcasterStub = (): BaseBroadcaster => {
|
||||
} as unknown as BaseBroadcaster;
|
||||
};
|
||||
|
||||
const createAuthStub = (): AuthSecp256k1 => {
|
||||
return {
|
||||
verifySignature: vi.fn().mockImplementation(() => {
|
||||
return true;
|
||||
}),
|
||||
} as unknown as AuthSecp256k1;
|
||||
};
|
||||
|
||||
const createAccountsStub = (): Accounts => {
|
||||
return {
|
||||
getBalance: vi.fn().mockImplementation(() => {
|
||||
return 0;
|
||||
}),
|
||||
setBalance: vi.fn().mockImplementation(() => {
|
||||
return;
|
||||
}),
|
||||
deductBalance: vi.fn().mockImplementation(() => {
|
||||
return;
|
||||
}),
|
||||
hasSufficientBalance: vi.fn().mockImplementation(() => {
|
||||
return true;
|
||||
}),
|
||||
} as unknown as Accounts;
|
||||
};
|
||||
|
||||
describe('DataRoute subscriptions', (): void => {
|
||||
it('subscribes to future resource changes until removal', async (): Promise<void> => {
|
||||
let resolveRemoved: () => void = () => undefined;
|
||||
@@ -31,10 +58,14 @@ describe('DataRoute subscriptions', (): void => {
|
||||
const broadcaster = createBroadcasterStub();
|
||||
vi.mocked(broadcaster.subscribe).mockReturnValue(removed);
|
||||
const connection = new TestConnection(true, false);
|
||||
const stream = new ApplicationRouteStream(connection, {
|
||||
resourceId: [ 'a', 'b' ],
|
||||
});
|
||||
const route = new DataRoute(storage, broadcaster, 0);
|
||||
const stream = new ApplicationRouteStream(
|
||||
connection,
|
||||
{
|
||||
resourceId: [ 'a', 'b' ],
|
||||
},
|
||||
'/data/subscribe',
|
||||
);
|
||||
const route = new DataRoute(storage, broadcaster, createAuthStub(), createAccountsStub(), 0);
|
||||
|
||||
const execution = route.subscribeData(stream);
|
||||
|
||||
@@ -55,8 +86,8 @@ describe('DataRoute subscriptions', (): void => {
|
||||
} as unknown as Database;
|
||||
const broadcaster = createBroadcasterStub();
|
||||
const connection = new TestConnection(true, true);
|
||||
const stream = new ApplicationRouteStream(connection, { resourceId: [ 'a' ] }, 'unsubscribe-1');
|
||||
const route = new DataRoute(storage, broadcaster, 0);
|
||||
const stream = new ApplicationRouteStream(connection, { resourceId: [ 'a' ] }, '/data/unsubscribe', 'unsubscribe-1');
|
||||
const route = new DataRoute(storage, broadcaster, createAuthStub(), createAccountsStub(), 0);
|
||||
|
||||
await route.unsubscribeData(stream);
|
||||
|
||||
@@ -78,10 +109,14 @@ describe('DataRoute subscriptions', (): void => {
|
||||
},
|
||||
} as unknown as Database;
|
||||
const broadcaster = createBroadcasterStub();
|
||||
const stream = new ApplicationRouteStream(new TestConnection(true, false), {
|
||||
resourceId: [ 'a' ],
|
||||
});
|
||||
const route = new DataRoute(storage, broadcaster, 0);
|
||||
const stream = new ApplicationRouteStream(
|
||||
new TestConnection(true, false),
|
||||
{
|
||||
resourceId: [ 'a' ],
|
||||
},
|
||||
'/data/unsubscribe',
|
||||
);
|
||||
const route = new DataRoute(storage, broadcaster, createAuthStub(), createAccountsStub(), 0);
|
||||
|
||||
await expect(route.unsubscribeData(stream)).rejects.toMatchObject({ statusCode: HTTP_STATUS_CODE_NOT_ACCEPTED });
|
||||
expect(broadcaster.unsubscribe).not.toHaveBeenCalled();
|
||||
@@ -97,7 +132,7 @@ describe('DataRoute resource write auth', (): void => {
|
||||
} as unknown as Database;
|
||||
|
||||
const broadcaster = createBroadcasterStub();
|
||||
const route = new DataRoute(storage, broadcaster, 0);
|
||||
const route = new DataRoute(storage, broadcaster, createAuthStub(), createAccountsStub(), 0);
|
||||
|
||||
await expect(route.writeData({
|
||||
connection: new TestConnection(true, true),
|
||||
@@ -115,6 +150,11 @@ describe('DataRoute resource write auth', (): void => {
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: {
|
||||
publicKey: 'public-key',
|
||||
signature: 'signature',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
} as unknown as ApplicationRouteStream)).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
|
||||
expect(storage.db.transaction).not.toHaveBeenCalled();
|
||||
|
||||
@@ -10,7 +10,7 @@ const createBroadcaster = (): Broadcaster => {
|
||||
};
|
||||
|
||||
const routeStream = (connection: TestConnection): ApplicationRouteStream => {
|
||||
return new ApplicationRouteStream(connection, undefined);
|
||||
return new ApplicationRouteStream(connection, undefined, '/test');
|
||||
};
|
||||
|
||||
const expectPending = async (promise: Promise<void>): Promise<void> => {
|
||||
|
||||
@@ -33,13 +33,15 @@ describe('ApplicationRouter dispatch', (): void => {
|
||||
url: '/echo',
|
||||
handler: async (stream): Promise<void> => {
|
||||
expect(stream.connection).toBe(connection);
|
||||
expect(stream.path).toBe('/echo');
|
||||
expect(stream.headers).toEqual({ 'x-request-token': 'route-1' });
|
||||
await stream.send(stream.body);
|
||||
},
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
await router.dispatch({ path: '/echo', body: { value: 1 }, requestId: 'request-1' }, connection);
|
||||
await router.dispatch({ path: '/echo', body: { value: 1 }, requestId: 'request-1', headers: { 'x-request-token': 'route-1' } }, connection);
|
||||
|
||||
expect(connection.messages).toEqual([
|
||||
{
|
||||
@@ -61,16 +63,23 @@ describe('ApplicationRouter dispatch', (): void => {
|
||||
url: '/delayed',
|
||||
handler: async (stream): Promise<void> => {
|
||||
const key = (stream.body as { key: string }).key;
|
||||
const token = stream.headers['x-request-token'];
|
||||
await new Promise<void>((resolve) => completions.set(key, resolve));
|
||||
await stream.send({ key });
|
||||
await stream.send({ key, token });
|
||||
},
|
||||
},
|
||||
]),
|
||||
]);
|
||||
const connection = new TestConnection(true, true);
|
||||
|
||||
const first = router.dispatch({ path: '/delayed', body: { key: 'A' }, requestId: 'A' }, connection);
|
||||
const second = router.dispatch({ path: '/delayed', body: { key: 'B' }, requestId: 'B' }, connection);
|
||||
const first = router.dispatch(
|
||||
{ path: '/delayed', body: { key: 'A' }, requestId: 'A', headers: { 'x-request-token': 'token-a' } },
|
||||
connection,
|
||||
);
|
||||
const second = router.dispatch(
|
||||
{ path: '/delayed', body: { key: 'B' }, requestId: 'B', headers: { 'x-request-token': 'token-b' } },
|
||||
connection,
|
||||
);
|
||||
|
||||
completions.get('B')?.();
|
||||
await second;
|
||||
@@ -82,13 +91,13 @@ describe('ApplicationRouter dispatch', (): void => {
|
||||
id: 'B',
|
||||
type: 'response',
|
||||
statusCode: 200,
|
||||
body: { key: 'B' },
|
||||
body: { key: 'B', token: 'token-b' },
|
||||
},
|
||||
{
|
||||
id: 'A',
|
||||
type: 'response',
|
||||
statusCode: 200,
|
||||
body: { key: 'A' },
|
||||
body: { key: 'A', token: 'token-a' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -70,6 +70,23 @@ describe('HttpTransportRouter', (): void => {
|
||||
expect(await response.text()).toBe('');
|
||||
});
|
||||
|
||||
it('passes normalized HTTP request headers to the route stream', async (): Promise<void> => {
|
||||
const app = await createApp([
|
||||
{
|
||||
url: '/headers',
|
||||
handler: async (stream): Promise<void> => stream.send({ path: stream.path, token: stream.headers['x-request-token'] }),
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await app.request('/headers', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Request-Token': 'http-token' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ path: '/headers', token: 'http-token' });
|
||||
});
|
||||
|
||||
it('returns normalized errors for non-streaming requests', async (): Promise<void> => {
|
||||
const app = await createApp([]);
|
||||
|
||||
@@ -149,6 +166,27 @@ describe('HttpTransportRouter', (): void => {
|
||||
expect(events).toContain('data: {"ok":true}');
|
||||
});
|
||||
|
||||
it('passes normalized HTTP request headers to an SSE route stream', async (): Promise<void> => {
|
||||
const app = await createApp([
|
||||
{
|
||||
url: '/headers',
|
||||
handler: (stream): Promise<void> => stream.send({ token: stream.headers['x-request-token'] }),
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await app.request('/headers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'text/event-stream',
|
||||
'X-Request-Token': 'sse-token',
|
||||
},
|
||||
});
|
||||
const events = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(events).toContain('data: {"token":"sse-token"}');
|
||||
});
|
||||
|
||||
it("keeps SSE open until the route's subscription promise resolves", async (): Promise<void> => {
|
||||
let removeSubscription: () => Promise<void> = async () => undefined;
|
||||
let markSubscribed: () => void = () => undefined;
|
||||
|
||||
@@ -13,19 +13,36 @@ describe('WebSocket request decoding', (): void => {
|
||||
id: 'request-1',
|
||||
path: '/data/write',
|
||||
body: { value: new Uint8Array([ 1, 2, 3 ]) },
|
||||
headers: { 'X-Request-Token': 'ws-token' },
|
||||
}))).resolves.toEqual({
|
||||
requestId: 'request-1',
|
||||
path: '/data/write',
|
||||
body: { value: new Uint8Array([ 1, 2, 3 ]) },
|
||||
headers: { 'x-request-token': 'ws-token' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([ '{}', '{"path":42}', '{"path":"/data/get","id":1}', '{"path":"/data/get","method":"POST"}' ])(
|
||||
'rejects an invalid envelope: %s',
|
||||
async (payload) => {
|
||||
await expect(WsTransportRouter.decodeWebSocketRequest(payload)).rejects.toBeInstanceOf(z.ZodError);
|
||||
},
|
||||
);
|
||||
it('allows the optional headers object to be omitted', async (): Promise<void> => {
|
||||
await expect(WsTransportRouter.decodeWebSocketRequest('{"path":"/data/get"}')).resolves.toEqual({ path: '/data/get' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{}',
|
||||
'{"path":42}',
|
||||
'{"path":"/data/get","id":1}',
|
||||
'{"path":"/data/get","method":"POST"}',
|
||||
'{"path":"/data/get","headers":{"x-request-token":1}}',
|
||||
])('rejects an invalid envelope: %s', async (payload) => {
|
||||
await expect(WsTransportRouter.decodeWebSocketRequest(payload)).rejects.toBeInstanceOf(z.ZodError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{"path":"/data/get","headers":{"bad header":"value"}}',
|
||||
'{"path":"/data/get","headers":{"x-request-token":"first","X-Request-Token":"second"}}',
|
||||
'{"path":"/data/get","headers":{"x-request-token":"first\\r\\nsecond"}}',
|
||||
])('rejects malformed request headers: %s', async (payload): Promise<void> => {
|
||||
await expect(WsTransportRouter.decodeWebSocketRequest(payload)).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects malformed JSON', async (): Promise<void> => {
|
||||
await expect(WsTransportRouter.decodeWebSocketRequest('{')).rejects.toMatchObject({
|
||||
|
||||
Reference in New Issue
Block a user