mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(config)!: validate and derive config, drop the unread keys (#2482)
This commit is contained in:
46 files changed
+1310
-199
No files matched your search
@@ -1,9 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {createServer} from '@fluxer/hono/src/Server';
|
||||
import {Hono} from 'hono';
|
||||
import {afterAll, afterEach, describe, expect, test, vi} from 'vitest';
|
||||
import {afterAll, afterEach, beforeAll, describe, expect, it, test, vi} from 'vitest';
|
||||
import {buildAPIConfigFromMaster, buildAPIServerOptions} from './Config';
|
||||
|
||||
interface ListeningServer {
|
||||
@@ -63,3 +64,50 @@ describe('buildAPIServerOptions', () => {
|
||||
expect(server.headersTimeout).toBe(45_000);
|
||||
});
|
||||
});
|
||||
|
||||
function withUploadRelaySecret(master: MasterConfig, secretBase64: string): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
services: {
|
||||
...master.services,
|
||||
media_proxy: {
|
||||
...master.services.media_proxy,
|
||||
upload_relay: {
|
||||
...master.services.media_proxy.upload_relay,
|
||||
secret_base64: secretBase64,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster upload relay secret', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('refuses to build without FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64', () => {
|
||||
expect(() => buildAPIConfigFromMaster(withUploadRelaySecret(master, ''))).toThrow(
|
||||
/FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64/,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a secret that decodes to fewer than 32 bytes', () => {
|
||||
const secret = Buffer.alloc(16, 7).toString('base64');
|
||||
expect(() => buildAPIConfigFromMaster(withUploadRelaySecret(master, secret))).toThrow(/at least 32 bytes/);
|
||||
});
|
||||
|
||||
it('accepts a secret that decodes to 32 bytes', () => {
|
||||
const secret = Buffer.alloc(32, 7).toString('base64');
|
||||
expect(
|
||||
buildAPIConfigFromMaster(withUploadRelaySecret(master, secret)).mediaProxy.uploadRelay.relaySecretBase64,
|
||||
).toBe(secret);
|
||||
});
|
||||
|
||||
it('reads the relay secret from the loaded config rather than the environment', () => {
|
||||
expect(buildAPIConfigFromMaster(master).mediaProxy.uploadRelay.relaySecretBase64).toBe(
|
||||
master.services.media_proxy.upload_relay.secret_base64,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -113,17 +113,18 @@ function mapPushProviderApps(
|
||||
project_id?: string;
|
||||
}>
|
||||
| undefined,
|
||||
configName: string,
|
||||
): APIConfig['push']['apns']['apps'] {
|
||||
return (apps ?? []).flatMap((app) => {
|
||||
if (!app.app_id) return [];
|
||||
return [
|
||||
{
|
||||
appId: app.app_id,
|
||||
topic: app.topic,
|
||||
environment: app.environment,
|
||||
projectId: app.project_id,
|
||||
},
|
||||
];
|
||||
return (apps ?? []).map((app) => {
|
||||
if (!app.app_id) {
|
||||
throw new Error(`${configName} contains an entry with no app_id`);
|
||||
}
|
||||
return {
|
||||
appId: app.app_id,
|
||||
topic: app.topic,
|
||||
environment: app.environment,
|
||||
projectId: app.project_id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,7 +140,13 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
serviceName: 'api',
|
||||
});
|
||||
const uploadRelayConfig = master.services.media_proxy.upload_relay;
|
||||
const uploadRelaySecretBase64 = process.env.FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 ?? '';
|
||||
const uploadRelaySecretBase64 = uploadRelayConfig.secret_base64;
|
||||
if (uploadRelaySecretBase64.length === 0) {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required for the API');
|
||||
}
|
||||
if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
|
||||
}
|
||||
if (!s3Config) {
|
||||
throw new Error('S3 configuration is required for the API');
|
||||
}
|
||||
@@ -149,7 +156,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
downloads: '',
|
||||
reports: '',
|
||||
harvests: '',
|
||||
static: '',
|
||||
};
|
||||
if (master.database.backend === 'cassandra' && !cassandraSource) {
|
||||
throw new Error('Cassandra configuration is required.');
|
||||
@@ -494,7 +500,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
privateKey: master.integrations.push.apns.private_key,
|
||||
privateKeyPath: master.integrations.push.apns.private_key_path,
|
||||
defaultEnvironment: master.integrations.push.apns.default_environment ?? 'production',
|
||||
apps: mapPushProviderApps(master.integrations.push.apns.apps),
|
||||
apps: mapPushProviderApps(master.integrations.push.apns.apps, 'FLUXER_PUSH_APNS_APPS'),
|
||||
},
|
||||
fcm: {
|
||||
enabled: master.integrations.push.fcm.enabled,
|
||||
@@ -504,7 +510,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
privateKeyPath: master.integrations.push.fcm.private_key_path,
|
||||
serviceAccountJsonPath: master.integrations.push.fcm.service_account_json_path,
|
||||
tokenUri: master.integrations.push.fcm.token_uri ?? 'https://oauth2.googleapis.com/token',
|
||||
apps: mapPushProviderApps(master.integrations.push.fcm.apps),
|
||||
apps: mapPushProviderApps(master.integrations.push.fcm.apps, 'FLUXER_PUSH_FCM_APPS'),
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {BlueskyOAuthConfig} from '../config/APIConfig';
|
||||
import {MockKVProvider} from '../test/mocks/MockKVProvider';
|
||||
import {BlueskyOAuthService} from './BlueskyOAuthService';
|
||||
|
||||
const API_PUBLIC_ENDPOINT = 'https://chat.example.com';
|
||||
|
||||
function generateSigningKey(): string {
|
||||
const {privateKey} = generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
|
||||
publicKeyEncoding: {type: 'spki', format: 'pem'},
|
||||
});
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
function buildConfig(overrides: Partial<BlueskyOAuthConfig> = {}): BlueskyOAuthConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
client_name: 'Fluxer',
|
||||
client_uri: '',
|
||||
logo_uri: '',
|
||||
tos_uri: '',
|
||||
policy_uri: '',
|
||||
keys: [{kid: 'test-key', private_key: generateSigningKey()}],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function serveClientMetadata(config: BlueskyOAuthConfig): Promise<Record<string, unknown>> {
|
||||
const service = await BlueskyOAuthService.create(config, new MockKVProvider(), API_PUBLIC_ENDPOINT);
|
||||
return JSON.parse(JSON.stringify(service.clientMetadata)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('BlueskyOAuthService', () => {
|
||||
it('omits the legal URLs from the served client document when none are configured', async () => {
|
||||
const metadata = await serveClientMetadata(buildConfig());
|
||||
|
||||
expect(metadata).not.toHaveProperty('tos_uri');
|
||||
expect(metadata).not.toHaveProperty('policy_uri');
|
||||
expect(metadata).not.toHaveProperty('logo_uri');
|
||||
expect(metadata.client_id).toBe(`${API_PUBLIC_ENDPOINT}/connections/bluesky/client-metadata.json`);
|
||||
});
|
||||
|
||||
it('echoes configured legal URLs verbatim', async () => {
|
||||
const metadata = await serveClientMetadata(
|
||||
buildConfig({
|
||||
tos_uri: 'https://chat.example.com/terms',
|
||||
policy_uri: 'https://chat.example.com/privacy',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(metadata.tos_uri).toBe('https://chat.example.com/terms');
|
||||
expect(metadata.policy_uri).toBe('https://chat.example.com/privacy');
|
||||
});
|
||||
});
|
||||
@@ -153,7 +153,6 @@ export interface APIConfig {
|
||||
reports: string;
|
||||
harvests: string;
|
||||
downloads: string;
|
||||
static: string;
|
||||
};
|
||||
};
|
||||
s3Downloads: ResolvedDownloadsProvider;
|
||||
|
||||
@@ -61,7 +61,6 @@ const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
|
||||
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
|
||||
const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app';
|
||||
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download';
|
||||
const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases';
|
||||
|
||||
@@ -687,11 +686,7 @@ export class DownloadService {
|
||||
}
|
||||
|
||||
private buildBaseUrl(baseUrl?: string): string {
|
||||
const configuredBaseUrl = (baseUrl ?? Config.endpoints.apiClient).trim();
|
||||
if (configuredBaseUrl.length > 0) {
|
||||
return configuredBaseUrl.replace(/\/+$/u, '');
|
||||
}
|
||||
return DEFAULT_API_CLIENT_BASE_URL;
|
||||
return (baseUrl ?? Config.endpoints.apiClient).trim().replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
private buildDesktopVersionUrl(params: {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const PREFIX = 'desktop/stable/darwin/x64';
|
||||
const MANIFEST_KEY = `${PREFIX}/manifest.json`;
|
||||
const FILENAME = 'Fluxer-1.3.0-mac-universal.dmg';
|
||||
const SHA256 = 'a'.repeat(64);
|
||||
|
||||
const LATEST_PARAMS = {
|
||||
channel: 'stable',
|
||||
plat: 'darwin',
|
||||
arch: 'x64',
|
||||
} as const;
|
||||
|
||||
const MANIFEST_BODY = JSON.stringify({
|
||||
channel: 'stable',
|
||||
platform: 'darwin',
|
||||
arch: 'x64',
|
||||
version: '1.3.0',
|
||||
pub_date: '2026-08-17T00:00:00Z',
|
||||
files: {dmg: {filename: FILENAME, sha256: SHA256}},
|
||||
});
|
||||
|
||||
function createService() {
|
||||
const objectKeys = [`${PREFIX}/${FILENAME}`];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
if (params.key !== MANIFEST_KEY) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(MANIFEST_BODY, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async () => objectKeys.map((key) => ({key})),
|
||||
getObjectMetadata: async (_bucket: string, key: string) =>
|
||||
objectKeys.includes(key) ? {contentLength: 1, contentType: 'application/x-apple-diskimage'} : null,
|
||||
} as unknown as IStorageService;
|
||||
return new DownloadService(storageService);
|
||||
}
|
||||
|
||||
describe('desktop download base url', () => {
|
||||
it('builds artifact urls from the configured client API endpoint', async () => {
|
||||
const version = await createService().getLatestDesktopVersion({...LATEST_PARAMS});
|
||||
const base = Config.endpoints.apiClient.replace(/\/+$/u, '');
|
||||
expect(base.length).toBeGreaterThan(0);
|
||||
expect(version?.files.dmg?.url).toBe(`${base}/dl/desktop/stable/darwin/x64/1.3.0/dmg`);
|
||||
expect(version?.files.dmg?.checksum_url).toBe(`${base}/dl/desktop/stable/darwin/x64/1.3.0/dmg.sha256`);
|
||||
});
|
||||
|
||||
it('prefers an explicit base url and strips its trailing slashes', async () => {
|
||||
const version = await createService().getLatestDesktopVersion({
|
||||
...LATEST_PARAMS,
|
||||
baseUrl: 'https://chat.example.com/api//',
|
||||
});
|
||||
expect(version?.files.dmg?.url).toBe('https://chat.example.com/api/dl/desktop/stable/darwin/x64/1.3.0/dmg');
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,6 @@ interface S3BucketConfigOverrides {
|
||||
downloads?: string;
|
||||
reports?: string;
|
||||
harvests?: string;
|
||||
static?: string;
|
||||
}
|
||||
|
||||
interface S3ConfigOverrides {
|
||||
|
||||
@@ -115,6 +115,38 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the effective captcha provider as none while the selected pair is incomplete', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
});
|
||||
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
turnstile_secret_key: 'turnstile-secret-key',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({
|
||||
enabled: true,
|
||||
provider: 'turnstile',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the registration URL id as the admin-visible registration code', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -1262,8 +1262,8 @@ export class InstanceConfigRepository {
|
||||
? Boolean(turnstileSiteKey && turnstileSecretKey)
|
||||
: false;
|
||||
return {
|
||||
enabled: provider !== 'none' && providerReady,
|
||||
provider,
|
||||
enabled: providerReady,
|
||||
provider: providerReady ? provider : 'none',
|
||||
hcaptcha_site_key: hcaptchaSiteKey,
|
||||
hcaptcha_secret_key: hcaptchaSecretKey,
|
||||
turnstile_site_key: turnstileSiteKey,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
import type {SsoService} from '../auth/services/SsoService';
|
||||
import {setCassandraQueryExecutorForTesting} from '../database/CassandraQueryExecution';
|
||||
import type {LimitConfigService} from '../limits/LimitConfigService';
|
||||
import {InMemoryCassandraQueryExecutor} from '../test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '../test/mocks/MockKVProvider';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
import {InstanceConfigRepository} from './InstanceConfigRepository';
|
||||
import {InstanceController} from './InstanceController';
|
||||
|
||||
interface DiscoveryCaptcha {
|
||||
provider: string;
|
||||
hcaptcha_site_key: string | null;
|
||||
turnstile_site_key: string | null;
|
||||
}
|
||||
|
||||
describe('InstanceController discovery captcha', () => {
|
||||
const repositories: Array<InstanceConfigRepository> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const repository of repositories) {
|
||||
repository.shutdown();
|
||||
}
|
||||
repositories.length = 0;
|
||||
});
|
||||
|
||||
function createRepository(): InstanceConfigRepository {
|
||||
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
|
||||
const repository = new InstanceConfigRepository(new MockKVProvider());
|
||||
repositories.push(repository);
|
||||
return repository;
|
||||
}
|
||||
|
||||
function createApp(repository: InstanceConfigRepository): Hono<HonoEnv> {
|
||||
const app = new Hono<HonoEnv>({strict: true});
|
||||
app.use('*', async (ctx, next) => {
|
||||
ctx.set('instanceConfigRepository', repository);
|
||||
ctx.set('limitConfigService', {
|
||||
getConfigWireFormat: () => ({version: 2, traitDefinitions: [], rules: [], defaultsHash: 'test'}),
|
||||
} as unknown as LimitConfigService);
|
||||
ctx.set('ssoService', {
|
||||
getPublicStatus: async () => ({
|
||||
enabled: false,
|
||||
enforced: false,
|
||||
display_name: null,
|
||||
redirect_uri: '',
|
||||
}),
|
||||
} as unknown as SsoService);
|
||||
await next();
|
||||
});
|
||||
InstanceController(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function readCaptcha(repository: InstanceConfigRepository): Promise<DiscoveryCaptcha> {
|
||||
const response = await createApp(repository).request('http://localhost/.well-known/fluxer');
|
||||
expect(response.status).toBe(200);
|
||||
return ((await response.json()) as {captcha: DiscoveryCaptcha}).captcha;
|
||||
}
|
||||
|
||||
it('advertises no provider and no site key while the selected pair is incomplete', async () => {
|
||||
const repository = createRepository();
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(readCaptcha(repository)).resolves.toEqual({
|
||||
provider: 'none',
|
||||
hcaptcha_site_key: null,
|
||||
turnstile_site_key: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises only the site key that matches the named provider', async () => {
|
||||
const repository = createRepository();
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
turnstile_secret_key: 'turnstile-secret-key',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(readCaptcha(repository)).resolves.toEqual({
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: null,
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -57,8 +57,8 @@ function buildDiscoveryStaticInput(
|
||||
},
|
||||
captcha: {
|
||||
provider: runtime.captcha.provider,
|
||||
hcaptcha_site_key: runtime.captcha.hcaptcha_site_key,
|
||||
turnstile_site_key: runtime.captcha.turnstile_site_key,
|
||||
hcaptcha_site_key: runtime.captcha.provider === 'hcaptcha' ? runtime.captcha.hcaptcha_site_key : null,
|
||||
turnstile_site_key: runtime.captcha.provider === 'turnstile' ? runtime.captcha.turnstile_site_key : null,
|
||||
},
|
||||
features: {
|
||||
voice_enabled: Config.voice.enabled,
|
||||
|
||||
@@ -45,7 +45,7 @@ function resolveCaptchaProvider(
|
||||
}
|
||||
const secretKey = resolveProviderSecret(config, requestedProvider);
|
||||
if (!secretKey) {
|
||||
throw new Error(`Captcha provider ${requestedProvider} is enabled but has no configured secret key`);
|
||||
throw new InvalidCaptchaError();
|
||||
}
|
||||
return createCaptchaProvider({mode: requestedProvider, secretKey});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import type {InstanceCaptchaEffectiveConfig, InstanceConfigRepository} from '../../instance/InstanceConfigRepository';
|
||||
import type {HonoEnv} from '../../types/HonoEnv';
|
||||
import {CaptchaMiddleware} from '../CaptchaMiddleware';
|
||||
|
||||
const HCAPTCHA_ONLY: InstanceCaptchaEffectiveConfig = {
|
||||
enabled: true,
|
||||
provider: 'hcaptcha',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
turnstile_site_key: null,
|
||||
turnstile_secret_key: null,
|
||||
};
|
||||
|
||||
function createHarness(
|
||||
captcha: InstanceCaptchaEffectiveConfig,
|
||||
): (headers: Record<string, string>) => Promise<Response> {
|
||||
const repository = {
|
||||
getEffectiveCaptchaConfig: async () => captcha,
|
||||
} as unknown as InstanceConfigRepository;
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
ctx.set('instanceConfigRepository', repository);
|
||||
await next();
|
||||
});
|
||||
app.use(CaptchaMiddleware);
|
||||
app.post('/auth/register', (ctx) => ctx.text('ok'));
|
||||
app.onError(AppErrorHandler);
|
||||
return async (headers) => app.request('http://localhost/auth/register', {method: 'POST', headers});
|
||||
}
|
||||
|
||||
describe('CaptchaMiddleware provider header', () => {
|
||||
let previousTestModeEnabled: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.dev.testModeEnabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
});
|
||||
|
||||
it('rejects a header naming a provider the instance holds no secret key for with 400 INVALID_CAPTCHA', async () => {
|
||||
const request = createHarness(HCAPTCHA_ONLY);
|
||||
const response = await request({'x-captcha-token': 'solution', 'x-captcha-type': 'turnstile'});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({code: 'INVALID_CAPTCHA'});
|
||||
});
|
||||
|
||||
it('rejects a request with no proof with 400 CAPTCHA_REQUIRED', async () => {
|
||||
const request = createHarness(HCAPTCHA_ONLY);
|
||||
const response = await request({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({code: 'CAPTCHA_REQUIRED'});
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,29 @@
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import type {Hono} from 'hono';
|
||||
import {Config} from '../Config';
|
||||
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '../RateLimitConfig';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
import {resolveAssetPath} from '../utils/AssetPaths';
|
||||
|
||||
const SPEC_PATH = resolveAssetPath('openapi', 'openapi.json');
|
||||
const SPEC_BODY = fs.readFileSync(SPEC_PATH, 'utf-8');
|
||||
const SPEC_DOCUMENT = JSON.parse(fs.readFileSync(SPEC_PATH, 'utf-8')) as Record<string, unknown>;
|
||||
|
||||
let specBody: string | null = null;
|
||||
|
||||
export function buildOpenAPISpecBody(apiClientEndpoint: string): string {
|
||||
return JSON.stringify({
|
||||
...SPEC_DOCUMENT,
|
||||
servers: [{url: `${apiClientEndpoint.trim().replace(/\/+$/u, '')}/v1`, description: 'This deployment'}],
|
||||
});
|
||||
}
|
||||
|
||||
export function OpenAPIController(app: Hono<HonoEnv>): void {
|
||||
app.get('/openapi.json', RateLimitMiddleware(RateLimitConfigs.INSTANCE_INFO), (ctx) => {
|
||||
specBody ??= buildOpenAPISpecBody(Config.endpoints.apiClient);
|
||||
ctx.header('Access-Control-Allow-Origin', '*');
|
||||
ctx.header('Content-Type', 'application/json; charset=utf-8');
|
||||
return ctx.body(SPEC_BODY);
|
||||
return ctx.body(specBody);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import type {HonoEnv} from '../../types/HonoEnv';
|
||||
import {buildOpenAPISpecBody, OpenAPIController} from '../OpenAPIController';
|
||||
|
||||
interface ServerEntry {
|
||||
url: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function parseServers(body: string): Array<ServerEntry> {
|
||||
return (JSON.parse(body) as {servers: Array<ServerEntry>}).servers;
|
||||
}
|
||||
|
||||
describe('OpenAPI server entry', () => {
|
||||
it('declares the deployment client API endpoint', () => {
|
||||
expect(parseServers(buildOpenAPISpecBody('https://chat.example.com/api'))).toEqual([
|
||||
{url: 'https://chat.example.com/api/v1', description: 'This deployment'},
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips trailing slashes from the configured endpoint', () => {
|
||||
expect(parseServers(buildOpenAPISpecBody('https://chat.example.com/api//'))[0].url).toBe(
|
||||
'https://chat.example.com/api/v1',
|
||||
);
|
||||
});
|
||||
|
||||
it('serves the configured endpoint instead of an upstream host', async () => {
|
||||
const app = new Hono<HonoEnv>();
|
||||
OpenAPIController(app);
|
||||
const response = await app.request('/openapi.json');
|
||||
expect(response.status).toBe(200);
|
||||
const spec = (await response.json()) as {servers: Array<ServerEntry>; paths: Record<string, unknown>};
|
||||
expect(spec.servers).toEqual([
|
||||
{url: `${Config.endpoints.apiClient.replace(/\/+$/u, '')}/v1`, description: 'This deployment'},
|
||||
]);
|
||||
expect(spec.servers[0].url).not.toContain('api.fluxer.app');
|
||||
expect(Object.keys(spec.paths).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -68,11 +68,10 @@ function setDefaultTestEnv(): void {
|
||||
FLUXER_APP_PROXY_PORT: '8773',
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: 'http://127.0.0.1:8088/media',
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'test-gateway-rpc-token',
|
||||
FLUXER_GATEWAY_PUSH_ENABLED: 'false',
|
||||
FLUXER_SUDO_MODE_SECRET: 'test-sudo-secret',
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: 'test-connection-secret',
|
||||
FLUXER_VAPID_PUBLIC_KEY: 'test-vapid-public-key',
|
||||
FLUXER_VAPID_PRIVATE_KEY: 'test-vapid-private-key',
|
||||
FLUXER_VAPID_PUBLIC_KEY: 'BB76bTFIuoqmxJtTfZX0yGTn1f_qu9H03B_nkj8OyExJFkN7Y-HBZZzShnHZoEhXKc5ZRy3jFu7OkBbnaQG-4aw',
|
||||
FLUXER_VAPID_PRIVATE_KEY: 'Xgi-3P8J-I3Q6U1HlCcXMuc_tKLGAM9nIfznX3Hz68o',
|
||||
FLUXER_VAPID_EMAIL: '[email protected]',
|
||||
FLUXER_PASSKEY_RP_NAME: 'Fluxer Test',
|
||||
FLUXER_PASSKEY_RP_ID: 'localhost',
|
||||
|
||||
Reference in new issue
Block a user