feat(web,ui): per-provider git forge API base URL and detection URLs

Configure a default API base URL per git provider (github/gitlab/gitea)
in settings.json gitProviders, with GitHub Enterprise support (Octokit
baseUrl + device-flow web origin derived from the API base), and replace
the client-side custom-domains list with server-persisted detection URL
chips (SSH/HTTPS forms normalized to hosts). The configured API base host
auto-counts as a detection host. Settings round-trip through the existing
/api/config/settings sanitizer; the UI store hydrates from server settings
with a one-time localStorage migration.
This commit is contained in:
2026-08-17 09:55:11 +00:00
parent a87b3fd228
commit 0fc857959e
55 changed files with 1685 additions and 272 deletions
+42 -2
View File
@@ -1,7 +1,7 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
@@ -201,7 +201,6 @@ describe('Gitea auth routes', () => {
test('me returns the connected user', async () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitea/me');
expect(response.status).toBe(200);
@@ -216,6 +215,47 @@ describe('Gitea auth routes', () => {
});
});
describe('Gitea configured default base URL', () => {
const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json');
afterEach(() => {
if (fs.existsSync(SETTINGS_FILE)) {
fs.unlinkSync(SETTINGS_FILE);
}
});
test('auth/status reports the configured defaultBaseUrl', async () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
gitProviders: { gitea: { apiBaseUrl: 'https://gitea.example.com' } },
}));
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'https://gitea.example.com', user: aliceUser });
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitea/auth/status');
expect(response.status).toBe(200);
expect(response.body.connected).toBe(true);
expect(response.body.defaultBaseUrl).toBe('https://gitea.example.com');
});
test('auth/connect falls back to the configured default base URL when baseUrl is blank', async () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
gitProviders: { gitea: { apiBaseUrl: 'https://gitea.example.com' } },
}));
const fetchMock = scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/auth/connect')
.send({ accessToken: 'gitea-valid' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true });
expect(fetchMock.mock.calls[0][0]).toBe('https://gitea.example.com/api/v1/user');
expect(getGiteaAuth()?.baseUrl).toBe('https://gitea.example.com');
});
});
describe('Gitea data routes', () => {
beforeEach(() => {
resetAuthFile();