house worker
This commit is contained in:
105
src/lib/house-tracker/worker-api.test.ts
Normal file
105
src/lib/house-tracker/worker-api.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createWorkerApi } from './worker-api';
|
||||
|
||||
const config = {
|
||||
url: 'https://dev.stanrunge.dev',
|
||||
token: 'test-worker-token-0000000000000000',
|
||||
accessClientId: 'test-client.access',
|
||||
accessClientSecret: 'test-access-secret'
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('worker API', () => {
|
||||
it('authenticates the health check with both tokens without sending a body', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(Response.json({ ok: true }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await expect(createWorkerApi(config)('health')).resolves.toEqual({ ok: true });
|
||||
expect(fetch).toHaveBeenCalledExactlyOnceWith(
|
||||
'https://dev.stanrunge.dev/projects/house-tracker/api/worker/health',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
redirect: 'manual',
|
||||
headers: expect.objectContaining({
|
||||
authorization: `Bearer ${config.token}`,
|
||||
'CF-Access-Client-Id': config.accessClientId,
|
||||
'CF-Access-Client-Secret': config.accessClientSecret
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('can POST to a server without Cloudflare Access', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(Response.json({ newIds: [] }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await createWorkerApi({ url: config.url, token: config.token })('sync', { cards: [] });
|
||||
expect(fetch.mock.calls[0][1]).toMatchObject({ method: 'POST', body: '{"cards":[]}' });
|
||||
expect(fetch.mock.calls[0][1].headers).not.toHaveProperty('CF-Access-Client-Secret');
|
||||
});
|
||||
|
||||
it.each([302, 401, 403, 429])(
|
||||
'fails immediately on HTTP %i without exposing response text',
|
||||
async (status) => {
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
new Response('private login response', {
|
||||
status,
|
||||
headers: { location: 'https://login.example.com/' }
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const error = await createWorkerApi(config)('health').catch((error: Error) => error);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain(`API health ${status}`);
|
||||
expect((error as Error).message).not.toContain('private login response');
|
||||
expect((error as Error).message).not.toContain(config.accessClientSecret);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch.mock.calls[0][1].redirect).toBe('manual');
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects a login page returned with HTTP 200', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
new Response('<html>login</html>', {
|
||||
headers: { 'content-type': 'text/html' }
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await expect(createWorkerApi(config)('health')).rejects.toThrow('expected JSON');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries transient server failures and preserves sync data', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 502 }))
|
||||
.mockResolvedValueOnce(Response.json({ newIds: [] }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const result = createWorkerApi(config)('sync', { cards: [] });
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await expect(result).resolves.toEqual({ newIds: [] });
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(fetch.mock.calls[1][1].body).toBe('{"cards":[]}');
|
||||
});
|
||||
|
||||
it('does not retry a one-attempt connection check', async () => {
|
||||
const fetch = vi.fn().mockRejectedValue(new Error('connection failed'));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await expect(createWorkerApi(config, 1)('health')).rejects.toThrow('could not reach');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('requires the complete Access credential pair', () => {
|
||||
expect(() => createWorkerApi({ ...config, accessClientSecret: undefined })).toThrow('Set both');
|
||||
expect(() => createWorkerApi({ ...config, accessClientId: undefined })).toThrow('Set both');
|
||||
});
|
||||
|
||||
it('rejects insecure remote URLs and permits local development', () => {
|
||||
expect(() => createWorkerApi({ ...config, url: 'http://dev.stanrunge.dev' })).toThrow('HTTPS');
|
||||
expect(() => createWorkerApi({ ...config, url: 'http://127.0.0.1:5173' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
78
src/lib/house-tracker/worker-api.ts
Normal file
78
src/lib/house-tracker/worker-api.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// Only requests to our website use these credentials. Listing fetches stay separate.
|
||||
type WorkerApiConfig = {
|
||||
url: string;
|
||||
token: string;
|
||||
accessClientId?: string;
|
||||
accessClientSecret?: string;
|
||||
};
|
||||
|
||||
type WorkerApiPath = 'health' | 'sync' | 'details' | 'report';
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function createWorkerApi(config: WorkerApiConfig, attempts = 4) {
|
||||
if (!config.url || config.token.length < 32)
|
||||
throw new Error('HOUSE_API_URL and HOUSE_WORKER_TOKEN (32+ chars) are required');
|
||||
if (Boolean(config.accessClientId) !== Boolean(config.accessClientSecret))
|
||||
throw new Error('Set both HOUSE_CF_ACCESS_CLIENT_ID and HOUSE_CF_ACCESS_CLIENT_SECRET');
|
||||
const base = new URL(config.url);
|
||||
const local = ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname);
|
||||
if (base.protocol !== 'https:' && !(base.protocol === 'http:' && local))
|
||||
throw new Error('HOUSE_API_URL must use HTTPS (HTTP is allowed for localhost)');
|
||||
if (base.username || base.password || base.search || base.hash || base.pathname !== '/')
|
||||
throw new Error('HOUSE_API_URL must be just the website origin, without a path or credentials');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${config.token}`
|
||||
};
|
||||
if (config.accessClientId && config.accessClientSecret) {
|
||||
headers['CF-Access-Client-Id'] = config.accessClientId;
|
||||
headers['CF-Access-Client-Secret'] = config.accessClientSecret;
|
||||
}
|
||||
|
||||
return async function request<T>(path: WorkerApiPath, body?: unknown): Promise<T> {
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
if (attempt) await sleep(5000 * 2 ** (attempt - 1));
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${base.origin}/projects/house-tracker/api/worker/${path}`, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
// Never forward Access credentials to a redirect (including the login page).
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(30_000)
|
||||
});
|
||||
} catch {
|
||||
if (attempt + 1 < attempts) continue;
|
||||
throw new Error(`API ${path}: could not reach the website`);
|
||||
}
|
||||
if (response.status >= 500 && attempt + 1 < attempts) {
|
||||
await response.body?.cancel();
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
const hint =
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'redirect refused; check the Cloudflare Access service token and Service Auth policy'
|
||||
: response.status === 401 || response.status === 403
|
||||
? 'check the worker token and Cloudflare Access service token/policy'
|
||||
: 'check the website configuration';
|
||||
throw new Error(`API ${path} ${response.status}: ${hint}`);
|
||||
}
|
||||
if (!response.headers.get('content-type')?.toLowerCase().includes('application/json')) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`API ${path}: expected JSON; check Cloudflare Access and HOUSE_API_URL`);
|
||||
}
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
throw new Error(`API ${path}: invalid JSON response`);
|
||||
}
|
||||
}
|
||||
throw new Error(`API ${path}: no request attempts configured`);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { requireWorker } from '$lib/server/house-tracker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Authenticated connectivity check; no database writes or listing-site requests.
|
||||
export const GET: RequestHandler = async ({ request }) => {
|
||||
requireWorker(request);
|
||||
return json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
|
||||
};
|
||||
Reference in New Issue
Block a user