mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
- wrap writePixels in state.storage.transactionSync so a partial multi-chunk failure doesn't leave the canvas half-written or out of sync with the WS broadcast - size new chunk buffer against chunkSize(chunkId) instead of the persisted blob's length so writes after a canvas-grow no longer silently drop OOB bytes in the formerly-last short chunk - refund the cooldown row when writePixels throws so transient storage errors stop soft-DOSing the user (and halving image-uploader throughput) - bound readAllChunks by chunk_id < CHUNK_COUNT and trim oversized blobs so orphan rows from a future shrink no longer crash GET /api/canvas - require a positive Content-Length on /api/place (411) and reject above the pre-parse cap (413); previously a missing or zero header bypassed the cap - drop String(err) from the 500 response body - drain the INSERT cursor symmetrically with the UPDATE branch in tryAcquire - assert CHUNK_BYTES <= 2 MB at module load (DO SQLite per-cell BLOB cap) - correct the inverted webSocketClose comment and guard the re-close call - add tests for missing / zero / oversized Content-Length Plan: plans/260510-0232-fix-do-migration-followups/phase-01-do-storage-atomicity.md
193 lines
7.0 KiB
JavaScript
193 lines
7.0 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from '../src/lib/constants.js';
|
|
|
|
// Worker validation runs at the edge before forwarding to the DO; we don't
|
|
// need the real DO for these tests, just a stub that returns whatever response
|
|
// the test wants to simulate.
|
|
vi.mock('../src/durable-objects/canvas-room.js', () => ({
|
|
CanvasRoom: class {},
|
|
}));
|
|
|
|
import app from '../src/worker.js';
|
|
|
|
/** Helper to create POST request. Computes Content-Length explicitly because
|
|
* the synthetic Request constructor in this environment doesn't auto-set it. */
|
|
function postPlace(body) {
|
|
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
|
|
const bodyBytes = new TextEncoder().encode(bodyStr).byteLength;
|
|
return new Request('http://localhost/api/place', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': String(bodyBytes),
|
|
},
|
|
body: bodyStr,
|
|
});
|
|
}
|
|
|
|
/** Configurable DO stub. Tests that exercise edge-validation never reach the
|
|
* DO; tests that pass validation get this canned response. */
|
|
let doResponse = () => Response.json({ ok: true });
|
|
const env = {
|
|
CANVAS_ROOM: {
|
|
idFromName: () => 'room-id',
|
|
get: () => ({ fetch: () => Promise.resolve(doResponse()) }),
|
|
},
|
|
};
|
|
|
|
describe('POST /api/place validation', () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it('rejects invalid JSON', async () => {
|
|
const req = new Request('http://localhost/api/place', {
|
|
method: 'POST',
|
|
headers: { 'Content-Length': '8' },
|
|
body: 'not json',
|
|
});
|
|
const res = await app.fetch(req, env);
|
|
expect(res.status).toBe(400);
|
|
const data = await res.json();
|
|
expect(data.error).toBe('invalid_json');
|
|
});
|
|
|
|
it('rejects POST without Content-Length', async () => {
|
|
const req = new Request('http://localhost/api/place', {
|
|
method: 'POST',
|
|
body: '{"pixels":[]}',
|
|
});
|
|
const res = await app.fetch(req, env);
|
|
expect(res.status).toBe(411);
|
|
expect((await res.json()).error).toBe('content_length_required');
|
|
});
|
|
|
|
it('rejects POST with zero Content-Length', async () => {
|
|
const req = new Request('http://localhost/api/place', {
|
|
method: 'POST',
|
|
headers: { 'Content-Length': '0' },
|
|
body: '',
|
|
});
|
|
const res = await app.fetch(req, env);
|
|
expect(res.status).toBe(411);
|
|
expect((await res.json()).error).toBe('content_length_required');
|
|
});
|
|
|
|
it('rejects POST with Content-Length above the cap', async () => {
|
|
const req = new Request('http://localhost/api/place', {
|
|
method: 'POST',
|
|
headers: { 'Content-Length': String(MAX_BATCH_SIZE * 64 + 1) },
|
|
body: '{}',
|
|
});
|
|
const res = await app.fetch(req, env);
|
|
expect(res.status).toBe(413);
|
|
expect((await res.json()).error).toBe('body_too_large');
|
|
});
|
|
|
|
it('rejects missing pixels array', async () => {
|
|
const res = await app.fetch(postPlace({}), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('pixels_required');
|
|
});
|
|
|
|
it('rejects empty pixels array', async () => {
|
|
const res = await app.fetch(postPlace({ pixels: [] }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('pixels_required');
|
|
});
|
|
|
|
it('rejects non-array pixels', async () => {
|
|
const res = await app.fetch(postPlace({ pixels: 'not an array' }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('pixels_required');
|
|
});
|
|
|
|
it('rejects batch exceeding MAX_BATCH_SIZE', async () => {
|
|
const pixels = Array.from({ length: MAX_BATCH_SIZE + 1 }, (_, i) => ({
|
|
x: i % CANVAS_WIDTH, y: 0, color: 0,
|
|
}));
|
|
const res = await app.fetch(postPlace({ pixels }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('batch_too_large');
|
|
});
|
|
|
|
it('rejects pixel with x out of bounds', async () => {
|
|
const res = await app.fetch(postPlace({ pixels: [{ x: CANVAS_WIDTH, y: 0, color: 0 }] }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('invalid_pixel');
|
|
});
|
|
|
|
it('rejects pixel with negative x', async () => {
|
|
const res = await app.fetch(postPlace({ pixels: [{ x: -1, y: 0, color: 0 }] }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('invalid_pixel');
|
|
});
|
|
|
|
it('rejects pixel with y out of bounds', async () => {
|
|
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: CANVAS_HEIGHT, color: 0 }] }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('invalid_pixel');
|
|
});
|
|
|
|
it('rejects pixel with color out of range', async () => {
|
|
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: MAX_COLORS }] }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('invalid_pixel');
|
|
});
|
|
|
|
it('rejects pixel with negative color', async () => {
|
|
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: -1 }] }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('invalid_pixel');
|
|
});
|
|
|
|
it('rejects non-integer coordinates', async () => {
|
|
const res = await app.fetch(postPlace({ pixels: [{ x: 1.5, y: 0, color: 0 }] }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('invalid_pixel');
|
|
});
|
|
|
|
it('rejects non-integer color', async () => {
|
|
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 1.5 }] }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('invalid_pixel');
|
|
});
|
|
|
|
it('rejects string values for coordinates', async () => {
|
|
const res = await app.fetch(postPlace({ pixels: [{ x: '0', y: 0, color: 0 }] }), env);
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toBe('invalid_pixel');
|
|
});
|
|
|
|
it('forwards rate-limit response from DO unchanged', async () => {
|
|
doResponse = () => Response.json({ error: 'rate_limited', retryAfter: 1 }, { status: 429 });
|
|
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 0 }] }), env);
|
|
expect(res.status).toBe(429);
|
|
const data = await res.json();
|
|
expect(data.error).toBe('rate_limited');
|
|
expect(data.retryAfter).toBe(1);
|
|
});
|
|
|
|
it('forwards 200 OK from DO on valid placement', async () => {
|
|
doResponse = () => Response.json({ ok: true });
|
|
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 0 }] }), env);
|
|
expect(res.status).toBe(200);
|
|
expect((await res.json()).ok).toBe(true);
|
|
});
|
|
|
|
it('accepts boundary pixel values', async () => {
|
|
doResponse = () => Response.json({ ok: true });
|
|
const res = await app.fetch(postPlace({
|
|
pixels: [{ x: CANVAS_WIDTH - 1, y: CANVAS_HEIGHT - 1, color: MAX_COLORS - 1 }],
|
|
}), env);
|
|
expect(res.status).toBe(200);
|
|
expect((await res.json()).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/ws', () => {
|
|
it('returns 426 without WebSocket upgrade header', async () => {
|
|
const req = new Request('http://localhost/api/ws');
|
|
const res = await app.fetch(req, env);
|
|
expect(res.status).toBe(426);
|
|
});
|
|
});
|