blob: a2d25e713c29775a09c4713fed197d5938323c1d [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { RuntimeHostProtocolError } from '../protocol/errors.js';
import assert from 'node:assert/strict';
import test from 'node:test';
import {
decodeSessionTranscriptBootstrap,
decodeSessionTranscriptPage,
decodeSessionTranscriptPageInput,
encodeProtocolMessage,
HOST_OPERATION_SPECS,
RUNTIME_HOST_MAX_MESSAGE_BYTES,
SESSION_TRANSCRIPT_PAGE_MAX_BYTES,
} from '../protocol/index.js';
const input = {
subscriptionId: 'subscription-1',
source: 'durable' as const,
direction: 'older' as const,
throughSequence: 3,
cursor: null,
anchorSequence: 2,
maxBytes: 1024,
};
const payloadDigest = `sha256:${'a'.repeat(64)}` as const;
const page = {
kind: 'page' as const,
sessionId: 'session-1',
source: 'durable' as const,
direction: 'older' as const,
throughSequence: 3,
rawBytes: 4,
fragments: [
{
kind: 'durable' as const,
sequence: 2,
byteOffset: 0,
totalBytes: 4,
payloadDigest: null,
data: Buffer.from('test').toString('base64'),
},
],
rangeBoundarySequence: 2,
protectedTurnSequence: 2,
nextCursor: 'opaque-cursor',
};
test('Session transcript protocol accepts bounded correlated pages and bootstraps', () => {
assert.deepEqual(decodeSessionTranscriptPageInput(input), input);
assert.deepEqual(decodeSessionTranscriptPage(page), page);
assert.doesNotThrow(() =>
HOST_OPERATION_SPECS['session.transcript.page'].assertOutputForInput?.(input, page),
);
const bootstrap = {
throughSequence: 3,
overlayMessageCount: 0,
durable: { ...page, direction: 'older' as const },
overlay: {
kind: 'page' as const,
sessionId: 'session-1',
source: 'overlay' as const,
direction: 'older' as const,
throughSequence: 3,
rawBytes: 0,
fragments: [],
rangeBoundarySequence: null,
protectedTurnSequence: null,
nextCursor: null,
},
};
assert.deepEqual(decodeSessionTranscriptBootstrap(bootstrap), bootstrap);
assert.throws(
() => decodeSessionTranscriptBootstrap({ ...bootstrap, overlayMessageCount: 4_097 }),
isProtocolError,
);
const release = { subscriptionId: 'subscription-1' };
assert.deepEqual(
HOST_OPERATION_SPECS['session.transcript.overlay.release'].decodeInput(release),
release,
);
assert.deepEqual(
HOST_OPERATION_SPECS['session.transcript.overlay.release'].decodeOutput(release),
release,
);
});
test('a maximum single-fragment continuation remains transport safe', () => {
const data = Buffer.alloc(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, 0x61);
const result = {
...page,
sessionId: 's'.repeat(128),
rawBytes: data.byteLength,
fragments: [
{
kind: 'durable' as const,
sequence: 2,
byteOffset: 1,
totalBytes: data.byteLength + 1,
payloadDigest,
data: data.toString('base64'),
},
],
nextCursor: 'c'.repeat(1_024),
};
assert.deepEqual(decodeSessionTranscriptPage(result), result);
const encoded = encodeProtocolMessage({
requestId: 'r'.repeat(128),
operation: 'session.transcript.page',
ok: true,
result,
});
assert.ok(encoded.byteLength <= RUNTIME_HOST_MAX_MESSAGE_BYTES);
});
test('a maximum multi-message page remains transport safe', () => {
const fragmentBytes = SESSION_TRANSCRIPT_PAGE_MAX_BYTES / 256;
const result = {
...page,
sessionId: 's'.repeat(128),
direction: 'newer' as const,
throughSequence: Number.MAX_SAFE_INTEGER,
rawBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES,
fragments: Array.from({ length: 256 }, (_, sequence) => ({
kind: 'durable' as const,
sequence,
byteOffset: 0,
totalBytes: fragmentBytes,
payloadDigest,
data: Buffer.alloc(fragmentBytes, 0x61).toString('base64'),
})),
nextCursor: 'c'.repeat(1_024),
};
assert.deepEqual(decodeSessionTranscriptPage(result), result);
assert.ok(
encodeProtocolMessage({
requestId: 'r'.repeat(128),
operation: 'session.transcript.page',
ok: true,
result,
}).byteLength <= RUNTIME_HOST_MAX_MESSAGE_BYTES,
);
});
test('Session transcript protocol rejects malformed and uncorrelated values', () => {
assert.throws(
() => decodeSessionTranscriptPageInput({ ...input, cursor: 'cursor', anchorSequence: 2 }),
isProtocolError,
);
assert.throws(
() => decodeSessionTranscriptPage({ ...page, rangeBoundarySequence: 4 }),
isProtocolError,
);
assert.throws(
() => decodeSessionTranscriptPage({ ...page, protectedTurnSequence: 4 }),
isProtocolError,
);
assert.throws(
() =>
decodeSessionTranscriptPage({
...page,
source: 'overlay',
rawBytes: 0,
fragments: [],
rangeBoundarySequence: null,
protectedTurnSequence: 2,
nextCursor: null,
}),
isProtocolError,
);
assert.throws(
() =>
decodeSessionTranscriptPage({
...page,
fragments: [{ ...page.fragments[0], data: 'not base64' }],
}),
isProtocolError,
);
assert.throws(
() =>
HOST_OPERATION_SPECS['session.transcript.page'].assertOutputForInput?.(input, {
...page,
throughSequence: 4,
}),
isProtocolError,
);
assert.throws(
() =>
decodeSessionTranscriptBootstrap({
throughSequence: 3,
overlayMessageCount: 0,
durable: page,
overlay: { ...page, source: 'overlay', throughSequence: 2 },
}),
isProtocolError,
);
});
function isProtocolError(error: unknown): boolean {
return error instanceof RuntimeHostProtocolError && error.code === 'invalid_frame';
}