Adds the client that will let the intake path remove backgrounds from submitted photos via the rembg sidecar over HTTP. isRembgConfigured() reports whether REMBG_URL is set (unconfigured is a normal, working state, not a failure), and removeBackground() posts a file to /api/remove and resolves with the PNG bytes it gets back, rejecting on every failure — unconfigured, unreachable, a non-2xx response, or a body that fails the same magic-byte PNG check the upload path already uses. The one hard rule: every request names model=u2net explicitly and this is never configurable. The sidecar's default model, reached simply by omitting the parameter, is bria-rmbg, which is licensed non-commercial — a licensing problem that a shop cannot silently ship, and one that would produce a perfectly good image with nothing in it to reveal the mistake. The test that posts against a real stub HTTP server and asserts model=u2net appears on the wire is the only thing guarding against that regressing. Wires REMBG_URL into both docker-compose.qa.yml and docker-compose.prod.yml as an optional variable, right after ANTHROPIC_WORKSPACE_ID, following the existing style in each file's environment block and header comment. It is deliberately left out of envValidation.ts's ALWAYS_REQUIRED — requiring it would make an environment with no sidecar refuse to boot, which is exactly the failure mode this feature is designed to avoid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
4.6 KiB
TypeScript
135 lines
4.6 KiB
TypeScript
import http from 'http';
|
|
import { AddressInfo } from 'net';
|
|
import { isRembgConfigured, removeBackground } from '../../src/intake/rembgClient';
|
|
|
|
/** A real PNG header, so the client's own signature check sees what it expects. */
|
|
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
|
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
|
|
interface Capture {
|
|
url: string;
|
|
body: string;
|
|
}
|
|
|
|
/**
|
|
* A stub sidecar on an ephemeral port.
|
|
*
|
|
* Port 0 rather than a fixed number: several ports in the 55000s are
|
|
* Hyper-V-reserved on the development machine and bind with EACCES, and a
|
|
* fixed port would also stop this suite running beside itself.
|
|
*
|
|
* A real HTTP server rather than a mocked `fetch`, because what is being
|
|
* checked is the shape of the request that reaches the wire — above all that
|
|
* `model=u2net` is in it.
|
|
*/
|
|
async function withStub(
|
|
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
|
run: (capture: Capture) => Promise<void>
|
|
): Promise<void> {
|
|
const capture: Capture = { url: '', body: '' };
|
|
const server = http.createServer((req, res) => {
|
|
capture.url = req.url ?? '';
|
|
const chunks: Buffer[] = [];
|
|
req.on('data', (c: Buffer) => chunks.push(c));
|
|
req.on('end', () => {
|
|
capture.body = Buffer.concat(chunks).toString('latin1');
|
|
handler(req, res);
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
process.env.REMBG_URL = `http://127.0.0.1:${port}`;
|
|
|
|
try {
|
|
await run(capture);
|
|
} finally {
|
|
delete process.env.REMBG_URL;
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
}
|
|
|
|
function respondWithPng(_req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
res.writeHead(200, { 'Content-Type': 'image/png' });
|
|
res.end(PNG);
|
|
}
|
|
|
|
describe('whether the sidecar is configured', () => {
|
|
it('is false when REMBG_URL is unset', () => {
|
|
delete process.env.REMBG_URL;
|
|
expect(isRembgConfigured()).toBe(false);
|
|
});
|
|
|
|
// A variable set to spaces is a configuration mistake, not a value — the
|
|
// same reading envValidation applies everywhere else.
|
|
it('is false when REMBG_URL is blank', () => {
|
|
process.env.REMBG_URL = ' ';
|
|
expect(isRembgConfigured()).toBe(false);
|
|
delete process.env.REMBG_URL;
|
|
});
|
|
|
|
it('is true when REMBG_URL is set', () => {
|
|
process.env.REMBG_URL = 'http://rembg-syn:7000';
|
|
expect(isRembgConfigured()).toBe(true);
|
|
delete process.env.REMBG_URL;
|
|
});
|
|
});
|
|
|
|
describe('asking the sidecar to remove a background', () => {
|
|
/**
|
|
* The single most important assertion in this file.
|
|
*
|
|
* The image's default model is `bria-rmbg`, licensed non-commercial, and it
|
|
* is selected by simply not naming a model. Nothing about the returned image
|
|
* would reveal it had been used, so this is the only place it can be caught.
|
|
*/
|
|
it('names u2net explicitly, because the default is licensed non-commercial', async () => {
|
|
await withStub(respondWithPng, async (capture) => {
|
|
await removeBackground(JPEG, 'image/jpeg');
|
|
expect(capture.body).toContain('name="model"');
|
|
expect(capture.body).toContain('u2net');
|
|
});
|
|
});
|
|
|
|
it('posts the file to /api/remove and returns the PNG it gets back', async () => {
|
|
await withStub(respondWithPng, async (capture) => {
|
|
const out = await removeBackground(JPEG, 'image/jpeg');
|
|
expect(capture.url).toBe('/api/remove');
|
|
expect(capture.body).toContain('name="file"');
|
|
expect(out.subarray(0, 8)).toEqual(PNG.subarray(0, 8));
|
|
});
|
|
});
|
|
|
|
it('rejects rather than returning bytes when the sidecar errors', async () => {
|
|
await withStub(
|
|
(_req, res) => {
|
|
res.writeHead(500);
|
|
res.end('boom');
|
|
},
|
|
async () => {
|
|
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/500/);
|
|
}
|
|
);
|
|
});
|
|
|
|
// The failure that would otherwise write an HTML error page over a
|
|
// photograph. Checked with the same magic-byte helper the upload path uses,
|
|
// rather than by trusting the Content-Type the sidecar sent.
|
|
it('rejects a response that is not actually a PNG', async () => {
|
|
await withStub(
|
|
(_req, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'image/png' });
|
|
res.end('<html>not an image</html>');
|
|
},
|
|
async () => {
|
|
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/not a PNG/);
|
|
}
|
|
);
|
|
});
|
|
|
|
it('rejects when it is not configured at all', async () => {
|
|
delete process.env.REMBG_URL;
|
|
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/REMBG_URL/);
|
|
});
|
|
});
|