Skip to content

Commit 6229f36

Browse files
committed
test: add e2e oidc test
1 parent bf6d8ee commit 6229f36

4 files changed

Lines changed: 278 additions & 3 deletions

File tree

ui/src/tests/dex.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {spawn, execFileSync} from 'child_process';
2+
import getPort from 'get-port';
3+
import fs from 'fs';
4+
import os from 'os';
5+
import path from 'path';
6+
import {rimrafSync} from 'rimraf';
7+
import {stringify} from 'yaml';
8+
// @ts-expect-error no types
9+
import wait from 'wait-on';
10+
11+
// All dex test users share this password. The hash is bcrypt("password").
12+
export const DEX_PASSWORD = 'password';
13+
const DEX_PASSWORD_HASH = '$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W';
14+
15+
const DEX_IMAGE = 'ghcr.io/dexidp/dex:v2.45.1-alpine';
16+
17+
export interface DexUser {
18+
email: string;
19+
username: string;
20+
userID: string;
21+
}
22+
23+
export interface DexInstance {
24+
issuer: string;
25+
close: () => void;
26+
}
27+
28+
const dexConfig = (issuerPort: number, redirectURL: string, users: DexUser[]): string =>
29+
stringify({
30+
issuer: `http://127.0.0.1:${issuerPort}/dex`,
31+
storage: {type: 'memory'},
32+
web: {http: '0.0.0.0:5556'},
33+
oauth2: {skipApprovalScreen: true},
34+
staticClients: [
35+
{
36+
id: 'gotify',
37+
name: 'Gotify',
38+
secret: 'secret',
39+
redirectURIs: [redirectURL],
40+
},
41+
],
42+
enablePasswordDB: true,
43+
staticPasswords: users.map((u) => ({
44+
email: u.email,
45+
hash: DEX_PASSWORD_HASH,
46+
username: u.username,
47+
preferredUsername: u.username,
48+
userID: u.userID,
49+
})),
50+
});
51+
52+
const waitForDex = (port: number): Promise<void> =>
53+
new Promise((resolve, reject) => {
54+
wait(
55+
{
56+
resources: [`http-get://127.0.0.1:${port}/dex/.well-known/openid-configuration`],
57+
timeout: 60000,
58+
},
59+
(error: string) => (error ? reject(error) : resolve())
60+
);
61+
});
62+
63+
export const startDex = async (redirectURL: string, users: DexUser[]): Promise<DexInstance> => {
64+
const port = await getPort();
65+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gotify-dex-'));
66+
fs.writeFileSync(path.join(configDir, 'dex.conf'), dexConfig(port, redirectURL, users));
67+
68+
const userArgs =
69+
process.getuid && process.getgid
70+
? ['--user', `${process.getuid()}:${process.getgid()}`]
71+
: [];
72+
73+
const containerName = `gotify-dex-test-${port}`;
74+
process.stdout.write(`### Starting dex ${containerName}\n`);
75+
const dex = spawn('docker', [
76+
'run',
77+
'--rm',
78+
...userArgs,
79+
'--name',
80+
containerName,
81+
'-p',
82+
`${port}:5556`,
83+
'-v',
84+
`${configDir}:/config`,
85+
DEX_IMAGE,
86+
'dex',
87+
'serve',
88+
'/config/dex.conf',
89+
]);
90+
dex.stdout.pipe(process.stdout);
91+
dex.stderr.pipe(process.stderr);
92+
93+
const crashed = new Promise<never>((_, reject) => {
94+
const abort = (reason: string) => reject(new Error(`dex ${containerName} ${reason}`));
95+
dex.on('exit', (code, signal) =>
96+
abort(`exited unexpectedly (code=${code}, signal=${signal})`)
97+
);
98+
dex.on('error', (err) => abort(`failed to start: ${err.message}`));
99+
});
100+
101+
const cleanup = () => {
102+
try {
103+
execFileSync('docker', ['rm', '-f', containerName], {stdio: 'ignore'});
104+
} catch {
105+
// container may already be gone (e.g. it crashed with --rm)
106+
}
107+
rimrafSync(configDir);
108+
};
109+
110+
try {
111+
await Promise.race([waitForDex(port), crashed]);
112+
} catch (err) {
113+
cleanup();
114+
throw err;
115+
}
116+
117+
return {
118+
issuer: `http://127.0.0.1:${port}/dex`,
119+
close: cleanup,
120+
};
121+
};

ui/src/tests/oidc.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import axios from 'axios';
2+
import {Browser, Page} from 'puppeteer';
3+
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
4+
import {newTest, GotifyTest} from './setup';
5+
import {DEX_PASSWORD, DexUser} from './dex';
6+
import {waitForExists} from './utils';
7+
import * as selector from './selector';
8+
import * as auth from './authentication';
9+
10+
const linkUser: DexUser = {email: 'link@gotify.net', username: 'linkuser', userID: 'id-link'};
11+
const dupUser1: DexUser = {email: 'dup1@gotify.net', username: 'dupuser', userID: 'id-dup-1'};
12+
const dupUser2: DexUser = {email: 'dup2@gotify.net', username: 'dupuser', userID: 'id-dup-2'};
13+
14+
const createLocalUser = async (url: string, name: string, pass: string): Promise<void> =>
15+
axios.post(
16+
`${url}/user`,
17+
{name, pass, admin: false},
18+
{auth: {username: 'admin', password: 'admin'}}
19+
);
20+
21+
const loginWithOIDC = async (page: Page, user: DexUser): Promise<void> => {
22+
await waitForExists(page, selector.heading(), 'Login');
23+
const href = await page.$eval('#oidc-login', (a) => (a as HTMLAnchorElement).href);
24+
await page.goto(href);
25+
26+
await page.waitForSelector('#login');
27+
await page.type('#login', user.email);
28+
await page.type('#password', DEX_PASSWORD);
29+
await page.click('#submit-login');
30+
};
31+
32+
const expectLoggedIn = async (page: Page): Promise<void> => {
33+
await waitForExists(page, selector.heading(), 'All Messages');
34+
await page.waitForSelector('#logout');
35+
};
36+
37+
const oidcError = async (page: Page): Promise<string> => {
38+
await page.waitForFunction(() => document.location.pathname.includes('/auth/oidc/callback'));
39+
return page.evaluate(() => document.body.innerText);
40+
};
41+
42+
const clearSession = async (browser: Browser): Promise<void> => {
43+
await browser.deleteCookie(...(await browser.cookies()));
44+
};
45+
46+
describe('OIDC login of an existing local user without link-by-username', () => {
47+
let gotify: GotifyTest;
48+
let page: Page;
49+
beforeAll(async () => {
50+
gotify = await newTest('', {
51+
oidc: {autoRegister: true, linkByUsername: false, users: [linkUser]},
52+
});
53+
page = gotify.page;
54+
await createLocalUser(gotify.url, linkUser.username, 'localpass');
55+
});
56+
afterAll(async () => await gotify.close());
57+
58+
it('rejects the oidc login because linking is disabled', async () => {
59+
await loginWithOIDC(page, linkUser);
60+
expect(await oidcError(page)).toContain(
61+
`a local user with the username ${linkUser.username} already exists and linking by username is disabled`
62+
);
63+
});
64+
65+
it('still allows the local user to log in with a password', async () => {
66+
await page.goto(gotify.url);
67+
await auth.login(page, 'linkuser', 'localpass');
68+
await auth.logout(page);
69+
});
70+
});
71+
72+
describe('OIDC login of an existing local user with link-by-username', () => {
73+
let gotify: GotifyTest;
74+
let page: Page;
75+
beforeAll(async () => {
76+
gotify = await newTest('', {
77+
oidc: {autoRegister: true, linkByUsername: true, users: [linkUser]},
78+
});
79+
page = gotify.page;
80+
await createLocalUser(gotify.url, linkUser.username, 'localpass');
81+
});
82+
afterAll(async () => await gotify.close());
83+
84+
it('links the existing local user and logs in', async () => {
85+
await loginWithOIDC(page, linkUser);
86+
await expectLoggedIn(page);
87+
});
88+
});
89+
90+
describe('OIDC login with two identities sharing the same username', () => {
91+
let gotify: GotifyTest;
92+
let page: Page;
93+
beforeAll(async () => {
94+
gotify = await newTest('', {
95+
oidc: {autoRegister: true, linkByUsername: true, users: [dupUser1, dupUser2]},
96+
});
97+
page = gotify.page;
98+
});
99+
afterAll(async () => await gotify.close());
100+
101+
it('auto-registers the first identity', async () => {
102+
await loginWithOIDC(page, dupUser1);
103+
await expectLoggedIn(page);
104+
});
105+
106+
it('clears session', () => clearSession(gotify.browser));
107+
it('rejects the second identity with same username', async () => {
108+
await page.goto(gotify.url);
109+
await loginWithOIDC(page, dupUser2);
110+
expect(await oidcError(page)).toContain(
111+
`the user ${dupUser2.username} is already bound to a different OIDC identity`
112+
);
113+
});
114+
});

ui/src/tests/setup.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import fs from 'fs';
77
// @ts-expect-error no types
88
import wait from 'wait-on';
99
import kill from 'tree-kill';
10+
import {startDex, DexUser, DexInstance} from './dex';
1011

1112
export interface GotifyTest {
1213
url: string;
@@ -15,6 +16,12 @@ export interface GotifyTest {
1516
page: Page;
1617
}
1718

19+
export interface OIDCOptions {
20+
autoRegister?: boolean;
21+
linkByUsername?: boolean;
22+
users: DexUser[];
23+
}
24+
1825
const windowsPrefix = process.platform === 'win32' ? '.exe' : '';
1926
const appDotGo = path.join(__dirname, '..', '..', '..', 'app.go');
2027
const testBuildPath = path.join(__dirname, 'build');
@@ -27,14 +34,39 @@ export const newPluginDir = async (plugins: string[]): Promise<string> => {
2734
return dir;
2835
};
2936

30-
export const newTest = async (pluginsDir = ''): Promise<GotifyTest> => {
37+
export interface NewTestOptions {
38+
env?: Record<string, string>;
39+
oidc?: OIDCOptions;
40+
}
41+
42+
export const newTest = async (
43+
pluginsDir = '',
44+
options: NewTestOptions = {}
45+
): Promise<GotifyTest> => {
3146
const port = await getPort();
3247

48+
let dex: DexInstance | undefined;
49+
let env = options.env ?? {};
50+
if (options.oidc) {
51+
const redirectURL = `http://localhost:${port}/auth/oidc/callback`;
52+
dex = await startDex(redirectURL, options.oidc.users);
53+
env = {
54+
...env,
55+
GOTIFY_OIDC_ENABLED: 'true',
56+
GOTIFY_OIDC_ISSUER: dex.issuer,
57+
GOTIFY_OIDC_CLIENTID: 'gotify',
58+
GOTIFY_OIDC_CLIENTSECRET: 'secret',
59+
GOTIFY_OIDC_REDIRECTURL: redirectURL,
60+
GOTIFY_OIDC_AUTOREGISTER: String(options.oidc.autoRegister ?? false),
61+
GOTIFY_OIDC_LINK_BY_USERNAME: String(options.oidc.linkByUsername ?? false),
62+
};
63+
}
64+
3365
const gotifyFile = testFilePath();
3466

3567
await buildGoExecutable(gotifyFile);
3668

37-
const gotifyInstance = startGotify(gotifyFile, port, pluginsDir);
69+
const gotifyInstance = startGotify(gotifyFile, port, pluginsDir, env);
3870

3971
const gotifyURL = 'http://localhost:' + port;
4072
await waitForGotify('http-get://localhost:' + port);
@@ -55,6 +87,7 @@ export const newTest = async (pluginsDir = ''): Promise<GotifyTest> => {
5587
),
5688
]);
5789
rimrafSync(gotifyFile, {maxRetries: 8});
90+
dex?.close();
5891
},
5992
url: gotifyURL,
6093
browser,
@@ -122,14 +155,20 @@ const buildGoExecutable = (filename: string): Promise<void> => {
122155
}
123156
};
124157

125-
const startGotify = (filename: string, port: number, pluginDir: string): ChildProcess => {
158+
const startGotify = (
159+
filename: string,
160+
port: number,
161+
pluginDir: string,
162+
extraEnv: Record<string, string> = {}
163+
): ChildProcess => {
126164
const gotify = spawn(filename, ['serve'], {
127165
env: {
128166
GOTIFY_SERVER_PORT: '' + port,
129167
GOTIFY_DATABASE_CONNECTION: 'file::memory:?mode=memory&cache=shared',
130168
GOTIFY_PLUGINSDIR: pluginDir,
131169
NODE_ENV: process.env.NODE_ENV,
132170
PUBLIC_URL: process.env.PUBLIC_URL,
171+
...extraEnv,
133172
},
134173
});
135174
gotify.stdout.pipe(process.stdout);

ui/src/user/Login.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const Login = observer(() => {
8585
<>
8686
<Divider style={{marginTop: 15, marginBottom: 15}}>or</Divider>
8787
<Button
88+
id="oidc-login"
8889
component="a"
8990
href={
9091
config.get('url') +

0 commit comments

Comments
 (0)