Files
wehub-resource-sync 3a28426bf4
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s
chore: import upstream snapshot with attribution
2026-07-13 12:23:40 +08:00

282 lines
11 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, type MockInstance } from 'vitest';
import { ERROR_CODES } from '@insforge/shared-schemas';
import jwt from 'jsonwebtoken';
vi.mock('@/infra/config/app.config.js', () => {
const c = {
cloud: { apiHost: 'https://cloud.test', projectId: 'proj-1' },
app: { jwtSecret: 'secret-1' },
};
return {
config: c,
appConfig: c,
};
});
import { CloudComputeProvider } from '@/providers/compute/cloud.provider.js';
import { MachineGoneError } from '@/providers/compute/compute.provider.js';
type FetchMock = MockInstance<Parameters<typeof fetch>, ReturnType<typeof fetch>>;
describe('CloudComputeProvider', () => {
let fetchMock: FetchMock;
beforeEach(() => {
fetchMock = vi.fn() as unknown as FetchMock;
global.fetch = fetchMock as unknown as typeof fetch;
});
it('createApp POSTs to /apps with sign header containing JWT { sub: project_id }', async () => {
fetchMock.mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ appId: 'ifc-proj-test' }),
} as Response);
const provider = CloudComputeProvider.getInstance();
const result = await provider.createApp({
name: 'test',
network: 'test',
org: 'unused-in-cloud-mode',
});
const call = fetchMock.mock.calls[0];
expect(call[0]).toBe('https://cloud.test/projects/v1/proj-1/compute/apps');
const headers = (call[1] as RequestInit).headers as Record<string, string>;
const decoded = jwt.verify(headers.sign, 'secret-1') as { sub: string };
expect(decoded.sub).toBe('proj-1');
expect(result.appId).toBe('ifc-proj-test');
});
// Regression: live e2e on prod (project 2163e1eb-...) showed Fly 422
// "Validation failed: Name not a valid network name" because the caller
// (services.service.ts) used to pass `${projectId}-network` (~44 chars)
// which exceeded Fly's network-name validator on stricter orgs. The
// service now uses APP_KEY (~8 chars) — these tests pin the wire format.
it('createApp forwards network when caller passes a (short) value', async () => {
fetchMock.mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ appId: 'ifc-proj-test' }),
} as Response);
const provider = CloudComputeProvider.getInstance();
await provider.createApp({
name: 'test',
network: 'd9byq46t',
org: 'unused-in-cloud-mode',
});
const call = fetchMock.mock.calls[0];
const sentBody = JSON.parse((call[1] as RequestInit).body as string);
expect(sentBody).toEqual({ name: 'test', network: 'd9byq46t' });
});
it('createApp omits network field when caller does not pass one', async () => {
fetchMock.mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ appId: 'ifc-proj-test' }),
} as Response);
const provider = CloudComputeProvider.getInstance();
await provider.createApp({
name: 'test',
org: 'unused-in-cloud-mode',
});
const call = fetchMock.mock.calls[0];
const sentBody = JSON.parse((call[1] as RequestInit).body as string);
expect(sentBody).toEqual({ name: 'test' });
expect('network' in sentBody).toBe(false);
});
it('throws COMPUTE_CLOUD_UNAVAILABLE on network error', async () => {
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
const provider = CloudComputeProvider.getInstance();
await expect(provider.createApp({ name: 't', network: 't', org: 'o' })).rejects.toThrow(
/COMPUTE_CLOUD_UNAVAILABLE/
);
});
it('throws AppError when cloud returns non-2xx with body', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 403,
text: async () =>
JSON.stringify({
code: ERROR_CODES.COMPUTE_QUOTA_EXCEEDED,
error: 'limit reached',
}),
} as Response);
const provider = CloudComputeProvider.getInstance();
await expect(provider.createApp({ name: 't', network: 't', org: 'o' })).rejects.toThrow(
new RegExp(`limit reached|${ERROR_CODES.COMPUTE_QUOTA_EXCEEDED}`)
);
});
it('startMachine POSTs to /machines/:id/start with appId in body', async () => {
fetchMock.mockResolvedValue({ ok: true, text: async () => '' } as Response);
const provider = CloudComputeProvider.getInstance();
await provider.startMachine('myapp', 'machine-1');
const call = fetchMock.mock.calls[0];
expect(call[0]).toBe('https://cloud.test/projects/v1/proj-1/compute/machines/machine-1/start');
expect(JSON.parse((call[1] as RequestInit).body as string)).toEqual({ appId: 'myapp' });
});
it('listMachines GETs /machines with appId in query', async () => {
fetchMock.mockResolvedValue({
ok: true,
text: async () => JSON.stringify([{ id: 'm1', state: 'started', region: 'iad' }]),
} as Response);
const provider = CloudComputeProvider.getInstance();
const result = await provider.listMachines('myapp');
const call = fetchMock.mock.calls[0];
expect(call[0]).toBe('https://cloud.test/projects/v1/proj-1/compute/machines?appId=myapp');
expect((call[1] as RequestInit).method).toBe('GET');
expect(result).toEqual([{ id: 'm1', state: 'started', region: 'iad' }]);
});
it('getEvents forwards limit in query', async () => {
fetchMock.mockResolvedValue({
ok: true,
text: async () => JSON.stringify([]),
} as Response);
const provider = CloudComputeProvider.getInstance();
await provider.getEvents('myapp', 'machine-1', { limit: 50 });
const call = fetchMock.mock.calls[0];
expect(call[0]).toContain('appId=myapp');
expect(call[0]).toContain('limit=50');
});
it('getLogs GETs /machines/:id/logs forwarding appId, limit, next_token and returns the payload', async () => {
const payload = { lines: [{ timestamp: 1, message: 'hi' }], nextToken: '42' };
fetchMock.mockResolvedValue({
ok: true,
text: async () => JSON.stringify(payload),
} as Response);
const provider = CloudComputeProvider.getInstance();
const result = await provider.getLogs('myapp', 'machine-1', { limit: 200, nextToken: 'cur' });
const call = fetchMock.mock.calls[0];
expect(call[0]).toContain('/compute/machines/machine-1/logs');
expect(call[0]).toContain('appId=myapp');
expect(call[0]).toContain('limit=200');
expect(call[0]).toContain('next_token=cur');
expect((call[1] as RequestInit).method).toBe('GET');
expect(result).toEqual(payload);
});
it('getLogs returns empty result when the cloud responds with no body', async () => {
fetchMock.mockResolvedValue({ ok: true, text: async () => '' } as Response);
const provider = CloudComputeProvider.getInstance();
const result = await provider.getLogs('myapp', 'machine-1');
expect(result).toEqual({ lines: [], nextToken: null });
});
it('throws COMPUTE_CLOUD_UNAVAILABLE on AbortError (timeout)', async () => {
const abortError = new DOMException('The operation was aborted', 'AbortError');
fetchMock.mockRejectedValue(abortError);
const provider = CloudComputeProvider.getInstance();
await expect(provider.createApp({ name: 't', network: 't', org: 'o' })).rejects.toMatchObject({
code: 'COMPUTE_CLOUD_UNAVAILABLE',
});
});
it('surfaces COMPUTE_NOT_CONFIGURED when config is missing (not masked as CLOUD_UNAVAILABLE)', async () => {
const { AppError } = await import('@/utils/errors.js');
const provider = CloudComputeProvider.getInstance();
// Force signToken to throw COMPUTE_NOT_CONFIGURED, as it would when isConfigured() is false
vi.spyOn(provider as unknown as { signToken: () => string }, 'signToken').mockImplementation(
() => {
throw new AppError(
'Cloud compute not configured (need PROJECT_ID, CLOUD_API_HOST, JWT_SECRET)',
500,
ERROR_CODES.COMPUTE_NOT_CONFIGURED
);
}
);
await expect(provider.createApp({ name: 't', network: 't', org: 'o' })).rejects.toThrow(
/COMPUTE_NOT_CONFIGURED|not configured/
);
});
});
// A 404 from a machine-scoped cloud call means the machine no longer exists
// (cloud control plane heals its own row and returns COMPUTE_MACHINE_NOT_FOUND).
// The provider must translate that into MachineGoneError so the service layer
// can heal local DB state; non-404s must pass through untouched.
describe('CloudComputeProvider machine-gone translation', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
// The COMPUTE_NOT_CONFIGURED test above leaves a throwing spy on the
// singleton's signToken — undo it so calls here reach the real fetch.
vi.restoreAllMocks();
fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof fetch;
});
it('translates 404s on machine-scoped calls into MachineGoneError', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
text: async () =>
JSON.stringify({ code: 'COMPUTE_MACHINE_NOT_FOUND', error: 'Machine m1 not found' }),
} as Response);
const provider = CloudComputeProvider.getInstance();
await expect(provider.getEvents('app-1', 'm1')).rejects.toBeInstanceOf(MachineGoneError);
await expect(provider.getMachineStatus('app-1', 'm1')).rejects.toBeInstanceOf(MachineGoneError);
await expect(provider.getLogs('app-1', 'm1')).rejects.toBeInstanceOf(MachineGoneError);
await expect(provider.startMachine('app-1', 'm1')).rejects.toBeInstanceOf(MachineGoneError);
await expect(provider.stopMachine('app-1', 'm1')).rejects.toBeInstanceOf(MachineGoneError);
await expect(provider.destroyMachine('app-1', 'm1')).rejects.toBeInstanceOf(MachineGoneError);
});
it('does NOT translate a bare 404 without the COMPUTE_MACHINE_NOT_FOUND body code', async () => {
// e.g. the cloud-side service row is missing or the path is mis-routed —
// healing here would orphan a live, billing machine.
fetchMock.mockResolvedValue({
ok: false,
status: 404,
text: async () => JSON.stringify({ code: 'COMPUTE_SERVICE_NOT_FOUND', error: 'no row' }),
} as Response);
const provider = CloudComputeProvider.getInstance();
const err = await provider.getEvents('app-1', 'm1').catch((e: unknown) => e);
expect(err).not.toBeInstanceOf(MachineGoneError);
expect((err as { statusCode: number }).statusCode).toBe(404);
});
it('waitForState fails fast when the machine is gone (no 60s poll)', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
text: async () =>
JSON.stringify({ code: 'COMPUTE_MACHINE_NOT_FOUND', error: 'Machine m1 not found' }),
} as Response);
const provider = CloudComputeProvider.getInstance();
await expect(provider.waitForState('app-1', 'm1', ['stopped'], 60_000)).rejects.toBeInstanceOf(
MachineGoneError
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does NOT translate non-404 cloud errors', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 500,
text: async () => 'internal',
} as Response);
const provider = CloudComputeProvider.getInstance();
const err = await provider.getEvents('app-1', 'm1').catch((e: unknown) => e);
expect(err).not.toBeInstanceOf(MachineGoneError);
expect((err as { statusCode: number }).statusCode).toBe(500);
});
});