Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,103 @@ describe('HttpDispatcher', () => {
expect(updatePackage).toHaveBeenCalledWith({ packageId: 'a.b', patch: { description: 'hi' } });
});

it('POST /packages creates a new package (201) after checking the id is free', async () => {
const installPackage = vi
.fn()
.mockReturnValue({ manifest: { id: 'com.acme.new', name: 'New', version: '0.1.0' } });
const mockRegistry = {
getPackage: vi.fn().mockReturnValue(undefined),
installPackage,
getAllPackages: vi.fn().mockReturnValue([]),
};
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
return null; // no protocol service → fall back to registry.installPackage
});

const result = await dispatcher.handlePackages(
'',
'POST',
{ manifest: { id: 'com.acme.new', name: 'New', version: '0.1.0', type: 'app' } },
{},
{ request: {} },
);
expect(result.response?.status).toBe(201);
expect(mockRegistry.getPackage).toHaveBeenCalledWith('com.acme.new');
expect(installPackage).toHaveBeenCalled();
});

it('POST /packages rejects a duplicate id with 409 instead of silently overwriting', async () => {
const installPackage = vi.fn();
const mockRegistry = {
getPackage: vi.fn().mockReturnValue({ manifest: { id: 'com.acme.crm', name: 'Existing' } }),
installPackage,
getAllPackages: vi.fn().mockReturnValue([]),
};
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
return null;
});

const result = await dispatcher.handlePackages(
'',
'POST',
{ manifest: { id: 'com.acme.crm', name: 'Clobber', version: '9.9.9' } },
{},
{ request: {} },
);
expect(result.response?.status).toBe(409);
// The existing manifest must NOT be overwritten.
expect(installPackage).not.toHaveBeenCalled();
});

it('POST /packages?overwrite=true allows intentional overwrite of an existing id', async () => {
const installPackage = vi
.fn()
.mockReturnValue({ manifest: { id: 'com.acme.crm', name: 'Upgraded', version: '2.0.0' } });
const mockRegistry = {
getPackage: vi.fn().mockReturnValue({ manifest: { id: 'com.acme.crm' } }),
installPackage,
getAllPackages: vi.fn().mockReturnValue([]),
};
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
return null;
});

const result = await dispatcher.handlePackages(
'',
'POST',
{ manifest: { id: 'com.acme.crm', name: 'Upgraded', version: '2.0.0' } },
{ overwrite: 'true' },
{ request: {} },
);
expect(result.response?.status).toBe(201);
expect(installPackage).toHaveBeenCalled();
});

it('POST /packages rejects a missing id with 400', async () => {
const mockRegistry = {
getPackage: vi.fn(),
installPackage: vi.fn(),
getAllPackages: vi.fn().mockReturnValue([]),
};
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
return null;
});

const result = await dispatcher.handlePackages(
'',
'POST',
{ manifest: { name: 'No Id' } },
{},
{ request: {} },
);
expect(result.response?.status).toBe(400);
expect(mockRegistry.installPackage).not.toHaveBeenCalled();
});

it('PATCH /packages/:id rejects an empty patch with 400', async () => {
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
Expand Down Expand Up @@ -1409,7 +1506,11 @@ describe('HttpDispatcher', () => {
package: { manifest: { id: 'app.demo' }, status: 'installed' },
message: 'Installed package: app.demo',
});
const mockRegistry = { installPackage: vi.fn(), getAllPackages: vi.fn().mockReturnValue([]) };
const mockRegistry = {
installPackage: vi.fn(),
getPackage: vi.fn().mockReturnValue(undefined),
getAllPackages: vi.fn().mockReturnValue([]),
};
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'protocol') return Promise.resolve({ installPackage });
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
Expand All @@ -1429,6 +1530,7 @@ describe('HttpDispatcher', () => {
it('falls back to registry.installPackage when the protocol lacks the method', async () => {
const mockRegistry = {
installPackage: vi.fn().mockReturnValue({ manifest: { id: 'app.fb' }, status: 'installed' }),
getPackage: vi.fn().mockReturnValue(undefined),
getAllPackages: vi.fn().mockReturnValue([]),
};
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
Expand Down
19 changes: 19 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2316,6 +2316,25 @@ export class HttpDispatcher {
// registry write only when the protocol service/method is unavailable.
if (parts.length === 0 && m === 'POST') {
const manifest = body.manifest || body;
const pkgId = typeof manifest?.id === 'string' ? manifest.id.trim() : '';
// A package id is mandatory — without one the install cannot be keyed.
if (!pkgId) {
return { handled: true, response: this.error('Package id is required', 400) };
}
// Duplicate-detection: POST /packages CREATES a package. If one with
// this id already exists, silently overwriting it destroys the existing
// manifest (name/version/…) with no warning — a data-loss footgun
// surfaced in Studio package-create dogfooding. Reject with 409 Conflict
// instead. Intentional upgrade / re-install flows opt back in with
// `overwrite: true` (body) or `?overwrite=true`.
const overwrite =
body?.overwrite === true || query?.overwrite === 'true' || query?.overwrite === true;
if (!overwrite && registry.getPackage(pkgId)) {
return {
handled: true,
response: this.error(`Package '${pkgId}' already exists`, 409),
};
}
let pkg: any;
const protocolSvc: any = await this.resolveService('protocol').catch(() => null);
if (protocolSvc && typeof protocolSvc.installPackage === 'function') {
Expand Down