Files
EdgeSSH/tests/deployment-auth.test.ts

191 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import { parse, stringify } from 'smol-toml';
import { CloudflareApi } from '../scripts/cloudflare-api.ts';
import { prepareAuthentication, requiredAuthSecrets } from '../scripts/deployment-auth.ts';
import { createDeploymentConfig, readDeploymentSettings } from '../scripts/deployment-config.ts';
import { readWorkspaceState, updateWorkspaceState, type DeploymentWorkspaceState } from '../scripts/workspace-state.ts';
const template = parse(await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8'));
const environment = {
CLOUDFLARE_API_TOKEN: 'cf-token', AUTH_PROVIDER: 'github',
GH_CLIENT_ID: 'test-client', GH_CLIENT_SECRET: 'test-client-secret', GH_ADMIN: 'admin',
};
const settings = readDeploymentSettings(template, environment);
const database = { uuid: 'test-db', name: settings.databaseName };
const envelope = (result: unknown) => Response.json({ success: true, result });
const queryEnvelope = (results: unknown[]) => envelope([{ success: true, results }]);
test('each deployment mode validates only its own inputs', () => {
assert.deepEqual(settings.identityProviderIds, []);
assert.equal(readDeploymentSettings(template, { ...environment, GH_ADMIN: '' }).githubAdmin, undefined);
assert.equal(readDeploymentSettings(template, {
...environment, GH_ADMIN: '', GH_ADMIN_ID: '123', ADMIN_EMAIL: 'invalid', ACCESS_TEAM_DOMAIN: 'invalid', ACCESS_IDP_IDS: 'invalid',
}).githubAdminId, '123');
assert.equal(readDeploymentSettings(template, { CLOUDFLARE_API_TOKEN: 'token', AUTH_PROVIDER: 'cloudflare', GH_ADMIN: 'invalid value' }).authProvider, 'cloudflare');
for (const field of ['GH_CLIENT_ID', 'GH_CLIENT_SECRET']) {
assert.throws(() => readDeploymentSettings(template, { ...environment, [field]: '' }), new RegExp(field));
}
assert.throws(() => readDeploymentSettings(template, { ...environment, GH_ADMIN_ID: 'name' }), /GH_ADMIN_ID/);
assert.throws(() => readDeploymentSettings(template, { ...environment, AUTH_PROVIDER: 'other' }), /AUTH_PROVIDER/);
});
test('first GitHub deployment resolves a numeric administrator without Zero Trust API', async () => {
const cloudflare = new CloudflareApi('token', async () => { throw Error('must not call Zero Trust'); });
const calls: string[] = [];
const prepared = await prepareAuthentication(cloudflare, settings, 'ssh.example.com', {
fetcher: async (url) => {
calls.push(String(url));
return String(url).includes('/api/auth/me')
? new Response('', { status: 401 }) : Response.json({ id: 123, type: 'User' });
},
});
assert.deepEqual(calls, ['https://ssh.example.com/api/auth/me', 'https://api.github.com/users/admin']);
assert.equal(prepared.githubAdminId, '123');
assert.deepEqual(prepared.secrets, { GH_CLIENT_SECRET: environment.GH_CLIENT_SECRET });
assert.deepEqual(requiredAuthSecrets('github'), ['GH_CLIENT_SECRET']);
const config = createDeploymentConfig(template, settings, database, { hostname: 'ssh.example.com', githubAdminId: '123' });
const vars = config.vars as Record<string, string>;
assert.equal(vars.AUTH_PROVIDER, 'github');
assert.equal(vars.ADMIN_ACCOUNT_ID, undefined);
assert.equal(vars.APP_ORIGIN, 'https://ssh.example.com');
assert.equal(vars.GH_ADMIN_ID, '123');
assert.equal(stringify(config).includes(environment.GH_CLIENT_SECRET), false);
});
test('ordinary GitHub redeployments retain the fixed ID and explicit numeric changes are visible', async () => {
let githubLookups = 0;
const fetcher: typeof fetch = async (url) => {
if (String(url).includes('/users/')) githubLookups++;
return new Response('', { status: 401 });
};
const retained = await prepareAuthentication(new CloudflareApi('token'), settings, 'ssh.example.com', {
fixedGithubAdminId: '123', fetcher,
});
assert.equal(retained.githubAdminId, '123');
const explicit = readDeploymentSettings(template, { ...environment, GH_ADMIN_ID: '456', GH_ADMIN: 'renamed-user' });
assert.equal((await prepareAuthentication(new CloudflareApi('token'), explicit, 'ssh.example.com', {
fixedGithubAdminId: '123', fetcher,
})).githubAdminId, '456');
assert.equal(githubLookups, 0);
});
test('organization names and unresolved first administrators are rejected', async () => {
const cloudflare = new CloudflareApi('token');
for (const response of [new Response('', { status: 404 }), Response.json({ id: 123, type: 'Organization' })]) {
let calls = 0;
await assert.rejects(prepareAuthentication(cloudflare, settings, 'ssh.example.com', {
fetcher: async () => (++calls === 1 ? new Response('', { status: 401 }) : response),
}), /GH_ADMIN/);
}
const noName = readDeploymentSettings(template, { ...environment, GH_ADMIN: '' });
await assert.rejects(prepareAuthentication(cloudflare, noName, 'ssh.example.com', {
fetcher: async () => new Response('', { status: 401 }),
}), /首次配置/);
});
test('a mode switch stops rather than leaving GitHub behind an existing Access login', async () => {
const cloudflare = new CloudflareApi('token', async () => { throw Error('must not mutate Access'); });
await assert.rejects(prepareAuthentication(cloudflare, settings, 'ssh.example.com', {
existingSecrets: new Set(['ACCESS_TEAM_DOMAIN']),
previousProvider: 'cloudflare',
fetcher: async () => new Response(null, {
status: 302, headers: { Location: 'https://team.cloudflareaccess.com/cdn-cgi/access/login' },
}),
}), /解除/);
await assert.rejects(prepareAuthentication(cloudflare, settings, 'ssh.example.com', {
existingSecrets: new Set(['ACCESS_AUD']), previousProvider: 'cloudflare',
fetcher: async () => { throw Error('dns failure'); },
}), /无法确认入口/);
});
test('ordinary Cloudflare redeployment confirms the live Access gateway without requiring Zero Trust API', async () => {
const cf = readDeploymentSettings(template, { CLOUDFLARE_API_TOKEN: 'token' });
const api = new CloudflareApi('token', async () => { throw Error('must not call Zero Trust'); });
assert.deepEqual(await prepareAuthentication(api, cf, 'ssh.example.com', {
previousProvider: 'cloudflare',
existingSecrets: new Set(['ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']),
fetcher: async () => new Response(null, {
status: 302, headers: { Location: 'https://team.cloudflareaccess.com/cdn-cgi/access/login' },
}),
}), { secrets: {} });
});
test('switching to Cloudflare validates the real Access app instead of trusting old secret names', async () => {
const cf = readDeploymentSettings(template, { CLOUDFLARE_API_TOKEN: 'token' });
const paths: string[] = [];
const api = new CloudflareApi('token', async (url) => {
const path = new URL(String(url)).pathname;
paths.push(path);
if (path.endsWith('/organizations')) return envelope({ auth_domain: 'team.cloudflareaccess.com' });
if (path.endsWith('/apps')) return envelope([{ id: 'app', aud: 'current-aud', type: 'self_hosted', domain: 'ssh.example.com' }]);
if (path.endsWith('/policies')) return envelope([{
id: 'manual', name: 'Manual identities', decision: 'allow', include: [{ email: { email: 'one@example.com' } }],
}]);
throw Error(`unexpected ${path}`);
});
assert.deepEqual(await prepareAuthentication(api, cf, 'ssh.example.com', {
previousProvider: 'github', existingSecrets: new Set(['ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']),
}), {
secrets: { ACCESS_TEAM_DOMAIN: 'team.cloudflareaccess.com', ACCESS_AUD: 'current-aud' },
});
assert.ok(paths.some((path) => path.endsWith('/organizations')));
assert.ok(paths.some((path) => path.endsWith('/apps')));
assert.ok(paths.some((path) => path.endsWith('/policies')));
});
function workspaceApi(options: { owners?: string[]; state?: DeploymentWorkspaceState }) {
const memory = options.state ? {
account_id: options.state.accountId,
auth_provider: options.state.authProvider!,
auth_revision: options.state.authRevision,
access_not_before: options.state.accessNotBefore,
github_admin_id: options.state.githubAdminId ?? null,
} : undefined;
const api = new CloudflareApi('token', async (_url, init) => {
const { sql, params = [] } = JSON.parse(String(init?.body)) as { sql: string; params?: unknown[] };
if (sql.includes('sqlite_master')) {
return queryEnvelope(memory ? [{ name: 'hosts' }, { name: 'workspace_state' }] : [{ name: 'hosts' }]);
}
if (sql.includes('SELECT DISTINCT account_id')) return queryEnvelope((options.owners ?? []).map((account_id) => ({ account_id })));
if (sql.startsWith('SELECT account_id')) return queryEnvelope(memory ? [{ ...memory }] : []);
if (sql.startsWith('UPDATE workspace_state')) {
memory!.auth_provider = params[0] as 'cloudflare' | 'github';
memory!.auth_revision = params[1] as number;
memory!.access_not_before = params[2] as number;
memory!.github_admin_id = params[3] as string | null;
return queryEnvelope([]);
}
throw Error(`unexpected SQL: ${sql}`);
});
return { api, memory };
}
test('single-owner legacy databases initialize safely and multiple owners stop', async () => {
for (const owners of [[], ['legacy-sub'], ['admin']]) {
const { api } = workspaceApi({ owners });
assert.equal((await readWorkspaceState(api, settings, database)).accountId, owners[0] ?? 'admin');
}
const multiple = workspaceApi({ owners: ['first', 'second'] });
await assert.rejects(readWorkspaceState(multiple.api, settings, database), /多个资料所有者/);
});
test('persisted workspace survives an empty host table and provider changes revoke old sessions', async () => {
const initial: DeploymentWorkspaceState = {
accountId: 'legacy-owner', authProvider: 'cloudflare', authRevision: 4, accessNotBefore: 10, githubAdminId: '123',
};
const { api } = workspaceApi({ owners: [], state: initial });
const loaded = await readWorkspaceState(api, settings, database);
assert.equal(loaded.accountId, 'legacy-owner');
const switched = await updateWorkspaceState(api, settings, database, loaded, '123', 100);
assert.deepEqual(switched, {
accountId: 'legacy-owner', authProvider: 'github', authRevision: 5,
accessNotBefore: 101, githubAdminId: '123',
});
const renamed = await updateWorkspaceState(api, { ...settings, githubAdminId: '456' }, database, switched, '456', 200);
assert.equal(renamed.accountId, 'legacy-owner');
assert.equal(renamed.authRevision, 6);
assert.equal(renamed.githubAdminId, '456');
});