feat: Tailscale sync jobs
Adds a background job system that polls the Tailscale API on a configurable interval and syncs tailscale status and IPs to instances by hostname match. - New config table (key/value) in SQLite for persistent server-side settings - New server/jobs.js: runTailscaleSync + restartJobs scheduler - GET/PUT /api/config — read and write Tailscale settings; API key masked as **REDACTED** on GET - POST /api/jobs/tailscale/run — immediate manual sync - Settings modal: new Tailscale Sync section with enable toggle, tailnet, API key, poll interval, Save + Run Now buttons, last-run status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -453,3 +453,73 @@ describe('error handling — unexpected DB failures', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/config ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/config', () => {
|
||||
it('returns 200 with all config keys', async () => {
|
||||
const res = await request(app).get('/api/config')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toHaveProperty('tailscale_enabled')
|
||||
expect(res.body).toHaveProperty('tailscale_api_key')
|
||||
expect(res.body).toHaveProperty('tailscale_poll_minutes')
|
||||
})
|
||||
|
||||
it('returns empty string for api key when not set', async () => {
|
||||
expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('')
|
||||
})
|
||||
|
||||
it('masks api key as **REDACTED** when set', async () => {
|
||||
await request(app).put('/api/config').send({ tailscale_api_key: 'tskey-secret' })
|
||||
expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('**REDACTED**')
|
||||
})
|
||||
})
|
||||
|
||||
// ── PUT /api/config ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('PUT /api/config', () => {
|
||||
it('saves config and returns ok', async () => {
|
||||
const res = await request(app).put('/api/config').send({ tailscale_tailnet: 'example.com' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('does not overwrite api key when **REDACTED** is sent', async () => {
|
||||
await request(app).put('/api/config').send({ tailscale_api_key: 'real-key' })
|
||||
await request(app).put('/api/config').send({ tailscale_api_key: '**REDACTED**' })
|
||||
expect(dbModule.getConfig('tailscale_api_key')).toBe('real-key')
|
||||
})
|
||||
})
|
||||
|
||||
// ── POST /api/jobs/tailscale/run ──────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/jobs/tailscale/run', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('returns 400 when not configured', async () => {
|
||||
const res = await request(app).post('/api/jobs/tailscale/run')
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toMatch(/not configured/i)
|
||||
})
|
||||
|
||||
it('updates matching instance and returns count', async () => {
|
||||
await request(app).put('/api/config').send({
|
||||
tailscale_api_key: 'tskey-test',
|
||||
tailscale_tailnet: 'example.com',
|
||||
})
|
||||
await request(app).post('/api/instances').send({ ...base, name: 'traefik', vmid: 100 })
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ devices: [{ hostname: 'traefik', addresses: ['100.64.0.2'] }] }),
|
||||
}))
|
||||
|
||||
const res = await request(app).post('/api/jobs/tailscale/run')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.updated).toBe(1)
|
||||
|
||||
const inst = await request(app).get('/api/instances/100')
|
||||
expect(inst.body.tailscale).toBe(1)
|
||||
expect(inst.body.tailscale_ip).toBe('100.64.0.2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
_resetForTest,
|
||||
getInstances, getInstance, getDistinctStacks,
|
||||
createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory,
|
||||
getConfig, setConfig,
|
||||
} from '../server/db.js'
|
||||
|
||||
beforeEach(() => _resetForTest());
|
||||
@@ -269,3 +270,32 @@ describe('test environment boot isolation', () => {
|
||||
expect(getInstances()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getConfig / setConfig ─────────────────────────────────────────────────────
|
||||
|
||||
describe('getConfig / setConfig', () => {
|
||||
it('returns defaultVal when key does not exist', () => {
|
||||
expect(getConfig('missing', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('returns empty string by default', () => {
|
||||
expect(getConfig('missing')).toBe('');
|
||||
});
|
||||
|
||||
it('stores and retrieves a value', () => {
|
||||
setConfig('tailscale_api_key', 'tskey-test');
|
||||
expect(getConfig('tailscale_api_key')).toBe('tskey-test');
|
||||
});
|
||||
|
||||
it('overwrites an existing key', () => {
|
||||
setConfig('tailscale_enabled', '0');
|
||||
setConfig('tailscale_enabled', '1');
|
||||
expect(getConfig('tailscale_enabled')).toBe('1');
|
||||
});
|
||||
|
||||
it('config is cleared by _resetForTest', () => {
|
||||
setConfig('tailscale_api_key', 'tskey-test');
|
||||
_resetForTest();
|
||||
expect(getConfig('tailscale_api_key')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user