2026-05-24 07:28:18 +00:00
|
|
|
import { assertEquals, assertThrows } from '@std/assert';
|
|
|
|
|
|
|
|
|
|
import { AppStoreManager } from '../ts/classes/appstore.ts';
|
|
|
|
|
import { OneboxDockerManager } from '../ts/classes/docker.ts';
|
2026-05-25 03:10:18 +00:00
|
|
|
import type * as servezoneInterfaces from '@serve.zone/interfaces';
|
2026-05-24 07:28:18 +00:00
|
|
|
import type { IService } from '../ts/types.ts';
|
|
|
|
|
|
2026-05-25 03:10:18 +00:00
|
|
|
type IAppStoreVersionConfig = servezoneInterfaces.appstore.IAppStoreVersionConfig;
|
|
|
|
|
|
2026-05-24 07:28:18 +00:00
|
|
|
const createAppStore = () => new AppStoreManager({} as any);
|
|
|
|
|
|
2026-05-25 03:10:18 +00:00
|
|
|
const baseConfig: IAppStoreVersionConfig = {
|
2026-05-24 07:28:18 +00:00
|
|
|
image: 'example/app:1.0.0',
|
|
|
|
|
port: 3000,
|
|
|
|
|
envVars: [
|
|
|
|
|
{
|
|
|
|
|
key: 'APP_PORT',
|
|
|
|
|
value: '3000',
|
|
|
|
|
description: 'Application port',
|
|
|
|
|
required: true,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const baseService: IService = {
|
|
|
|
|
id: 1,
|
|
|
|
|
name: 'test-service',
|
|
|
|
|
image: 'example/app:1.0.0',
|
|
|
|
|
envVars: {},
|
|
|
|
|
port: 3000,
|
|
|
|
|
status: 'stopped',
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Deno.test('appstore normalizes and validates app template runtime fields', () => {
|
|
|
|
|
const appStore = createAppStore();
|
|
|
|
|
|
|
|
|
|
const normalizedVolumes = appStore.normalizeVolumes([
|
|
|
|
|
'/data/app',
|
|
|
|
|
{ mountPath: '/config', readOnly: true },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
assertEquals(normalizedVolumes, [
|
|
|
|
|
{ mountPath: '/data/app' },
|
|
|
|
|
{ mountPath: '/config', readOnly: true },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
appStore.validateAppVersionConfig({
|
|
|
|
|
...baseConfig,
|
|
|
|
|
volumes: normalizedVolumes,
|
|
|
|
|
publishedPorts: [
|
|
|
|
|
{ targetPort: 3000, publishedPort: 3000, protocol: 'tcp' },
|
|
|
|
|
{ targetPort: 20000, targetPortEnd: 20002, publishedPort: 20000, publishedPortEnd: 20002, protocol: 'udp' },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Deno.test('appstore rejects invalid template ports and volumes', () => {
|
|
|
|
|
const appStore = createAppStore();
|
|
|
|
|
|
|
|
|
|
assertThrows(
|
|
|
|
|
() => appStore.validateAppVersionConfig({ ...baseConfig, port: 70000 }),
|
|
|
|
|
Error,
|
|
|
|
|
'Invalid app config port',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assertThrows(
|
|
|
|
|
() => appStore.normalizeVolumes([{ mountPath: 'relative/path' }]),
|
|
|
|
|
Error,
|
|
|
|
|
'mountPath must be an absolute path',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assertThrows(
|
|
|
|
|
() => appStore.validateAppVersionConfig({
|
|
|
|
|
...baseConfig,
|
|
|
|
|
publishedPorts: [
|
|
|
|
|
{ targetPort: 3000, targetPortEnd: 3002, publishedPort: 3000, publishedPortEnd: 3001, protocol: 'tcp' },
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
Error,
|
|
|
|
|
'ranges must have the same size',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-25 01:39:59 +00:00
|
|
|
Deno.test('appstore resolves repo manifests and docker digest-tracked latest images', async () => {
|
2026-05-25 03:10:18 +00:00
|
|
|
const appStoreBaseUrl = 'https://appstore.example.test';
|
|
|
|
|
const manifestUrl = 'https://code.example.test/cloudly/servezone.appstore.json';
|
2026-05-25 01:39:59 +00:00
|
|
|
const digest = 'sha256:1234567890abcdef';
|
|
|
|
|
|
|
|
|
|
const fakeFetch: typeof fetch = async (input, init) => {
|
|
|
|
|
const url = input instanceof Request ? input.url : input.toString();
|
|
|
|
|
const method = init?.method || 'GET';
|
|
|
|
|
|
2026-05-25 03:10:18 +00:00
|
|
|
if (url === `${appStoreBaseUrl}/appstore.resolved.json`) {
|
2026-05-25 01:39:59 +00:00
|
|
|
return new Response('not found', { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 03:10:18 +00:00
|
|
|
if (url === `${appStoreBaseUrl}/appstore.json`) {
|
2026-05-25 01:39:59 +00:00
|
|
|
return Response.json({
|
|
|
|
|
schemaVersion: 1,
|
|
|
|
|
updatedAt: '2026-05-24T00:00:00Z',
|
|
|
|
|
apps: [
|
|
|
|
|
{
|
|
|
|
|
id: 'cloudly',
|
|
|
|
|
name: 'Cloudly',
|
|
|
|
|
description: 'Central metadata can stay curated.',
|
|
|
|
|
category: 'Dev Tools',
|
|
|
|
|
latestVersion: '1.0.0',
|
|
|
|
|
source: {
|
|
|
|
|
type: 'repoManifest',
|
|
|
|
|
url: manifestUrl,
|
|
|
|
|
ref: 'main',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (url === manifestUrl) {
|
|
|
|
|
return Response.json({
|
|
|
|
|
schemaVersion: 1,
|
|
|
|
|
app: {
|
|
|
|
|
id: 'cloudly',
|
|
|
|
|
name: 'Cloudly',
|
|
|
|
|
description: 'Manifest-owned app metadata.',
|
|
|
|
|
category: 'Dev Tools',
|
|
|
|
|
maintainer: 'serve.zone',
|
|
|
|
|
},
|
|
|
|
|
latestVersion: 'latest',
|
|
|
|
|
source: {
|
|
|
|
|
type: 'dockerImage',
|
|
|
|
|
image: 'registry.example.test/serve.zone/cloudly:latest',
|
|
|
|
|
tracking: 'digest',
|
|
|
|
|
},
|
|
|
|
|
runtime: {
|
|
|
|
|
image: 'registry.example.test/serve.zone/cloudly:latest',
|
|
|
|
|
port: 80,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
url === 'https://registry.example.test/v2/serve.zone/cloudly/manifests/latest' &&
|
|
|
|
|
method === 'HEAD'
|
|
|
|
|
) {
|
|
|
|
|
return new Response(null, {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: { 'docker-content-digest': digest },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Response(`unexpected ${method} ${url}`, { status: 500 });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const appStore = new AppStoreManager({} as any, {
|
2026-05-25 03:10:18 +00:00
|
|
|
baseUrl: appStoreBaseUrl,
|
2026-05-25 01:39:59 +00:00
|
|
|
fetch: fakeFetch,
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-25 03:10:18 +00:00
|
|
|
const appStoreIndex = await appStore.getAppStore();
|
|
|
|
|
assertEquals(appStoreIndex.apps[0].latestVersion, `latest@${digest}`);
|
|
|
|
|
assertEquals(appStoreIndex.apps[0].resolvedSource?.manifestHash?.length, 64);
|
|
|
|
|
assertEquals(appStoreIndex.apps[0].upgradeStrategy, 'dockerDigest');
|
2026-05-25 01:39:59 +00:00
|
|
|
|
|
|
|
|
const appMeta = await appStore.getAppMeta('cloudly');
|
|
|
|
|
assertEquals(appMeta.latestVersion, `latest@${digest}`);
|
|
|
|
|
assertEquals(appMeta.versions, [`latest@${digest}`]);
|
|
|
|
|
|
|
|
|
|
const config = await appStore.getAppVersionConfig('cloudly', appMeta.latestVersion);
|
|
|
|
|
assertEquals(config.image, 'registry.example.test/serve.zone/cloudly:latest');
|
2026-05-25 03:10:18 +00:00
|
|
|
assertEquals(config.appStoreVersion, `latest@${digest}`);
|
2026-05-25 01:39:59 +00:00
|
|
|
assertEquals(config.resolvedImageDigest, digest);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-24 07:28:18 +00:00
|
|
|
Deno.test('docker service spec validation rejects unsafe volume and port declarations', () => {
|
|
|
|
|
const dockerManager = new OneboxDockerManager();
|
|
|
|
|
|
|
|
|
|
dockerManager.validateServiceSpec({
|
|
|
|
|
...baseService,
|
|
|
|
|
volumes: [{ mountPath: '/data/app' }],
|
|
|
|
|
publishedPorts: [{ targetPort: 3000, publishedPort: 3000, protocol: 'tcp' }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assertThrows(
|
|
|
|
|
() => dockerManager.validateServiceSpec({
|
|
|
|
|
...baseService,
|
|
|
|
|
volumes: [{ mountPath: 'relative/path' }],
|
|
|
|
|
}),
|
|
|
|
|
Error,
|
|
|
|
|
'must be an absolute path',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assertThrows(
|
|
|
|
|
() => dockerManager.validateServiceSpec({
|
|
|
|
|
...baseService,
|
|
|
|
|
publishedPorts: [
|
|
|
|
|
{ targetPort: 3001, publishedPort: 3000, hostIp: '127.0.0.1', protocol: 'tcp' },
|
|
|
|
|
{ targetPort: 3000, publishedPort: 3000, protocol: 'tcp' },
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
Error,
|
|
|
|
|
'Duplicate published port',
|
|
|
|
|
);
|
|
|
|
|
});
|