test(e2e): more cases for oidc
This commit is contained in:
parent
6f560ecae9
commit
3b94794aa3
2 changed files with 201 additions and 21 deletions
|
|
@ -7,19 +7,43 @@ const discoveryEndpoint = `${issuer}/.well-known/openid-configuration`;
|
|||
const appBaseUrl = `http://${process.env.SERVER_IP ?? "localhost"}:4096`;
|
||||
|
||||
const providerIds = {
|
||||
register: "test-oidc-register",
|
||||
uninvited: "test-oidc-uninvited",
|
||||
invited: "test-oidc-invited",
|
||||
autoLink: "test-oidc",
|
||||
autoLinkNoInvite: "test-oidc-register",
|
||||
autoLink: "test-oidc-autolink",
|
||||
} as const;
|
||||
|
||||
const dexPassword = "password";
|
||||
const uninvitedUserEmail = "admin@example.com";
|
||||
const invitedUserEmail = "user@example.com";
|
||||
const autoLinkUninvitedLocalEmail = "linkguard@example.com";
|
||||
const autoLinkTargetEmail = "test@example.com";
|
||||
const autoLinkTargetUsername = "sso-link-target";
|
||||
const autoLinkUninvitedLocalUsername = "sso-link-guard";
|
||||
const inviteOnlyMessage =
|
||||
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
||||
const accountLinkRequiredMessage =
|
||||
"Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.";
|
||||
|
||||
type OrgMembersResponse = {
|
||||
members: {
|
||||
id: string;
|
||||
user: {
|
||||
email: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
type SsoSettingsResponse = {
|
||||
invitations: {
|
||||
email: string;
|
||||
status: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type SsoSignInResponse = {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
async function openSsoSettings(page: Page) {
|
||||
await gotoAndWaitForAppReady(page, "/settings?tab=organization");
|
||||
|
|
@ -57,7 +81,7 @@ async function createPendingInvitation(page: Page, email: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function createAutoLinkTargetUser(page: Page, email: string, username: string) {
|
||||
async function createLocalUser(page: Page, email: string, username: string) {
|
||||
const response = await page.request.post("/api/auth/admin/create-user", {
|
||||
headers: {
|
||||
Origin: appBaseUrl,
|
||||
|
|
@ -75,10 +99,56 @@ async function createAutoLinkTargetUser(page: Page, email: string, username: str
|
|||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to create auto-link target user: ${await response.text()}`);
|
||||
throw new Error(`Failed to create local user ${email}: ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrgMemberIdByEmail(page: Page, email: string) {
|
||||
const response = await page.request.get("/api/v1/auth/org-members", {
|
||||
headers: {
|
||||
Origin: appBaseUrl,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to get organization members: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as OrgMembersResponse;
|
||||
const member = body.members.find((entry) => entry.user.email.toLowerCase() === email.toLowerCase());
|
||||
|
||||
return member?.id ?? null;
|
||||
}
|
||||
|
||||
async function removeOrgMemberById(page: Page, memberId: string) {
|
||||
const response = await page.request.delete(`/api/v1/auth/org-members/${memberId}`, {
|
||||
headers: {
|
||||
Origin: appBaseUrl,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to remove org member ${memberId}: ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getInvitationStatusByEmail(page: Page, email: string) {
|
||||
const response = await page.request.get("/api/v1/auth/sso-settings", {
|
||||
headers: {
|
||||
Origin: appBaseUrl,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to read SSO settings: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as SsoSettingsResponse;
|
||||
const invitation = body.invitations.find((entry) => entry.email.toLowerCase() === email.toLowerCase());
|
||||
|
||||
return invitation?.status ?? null;
|
||||
}
|
||||
|
||||
async function setProviderAutoLinking(page: Page, providerId: string, enabled: boolean) {
|
||||
await openSsoSettings(page);
|
||||
const providerRow = page
|
||||
|
|
@ -98,6 +168,31 @@ async function setProviderAutoLinking(page: Page, providerId: string, enabled: b
|
|||
await expect(autoLinkSwitch).toHaveAttribute("aria-checked", expectedState);
|
||||
}
|
||||
|
||||
async function startSsoLogin(page: Page, providerId: string) {
|
||||
const response = await page.request.post("/api/auth/sign-in/sso", {
|
||||
headers: {
|
||||
Origin: appBaseUrl,
|
||||
},
|
||||
data: {
|
||||
providerId,
|
||||
callbackURL: "/volumes",
|
||||
errorCallbackURL: "/api/v1/auth/login-error",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to start SSO login for ${providerId}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as SsoSignInResponse;
|
||||
|
||||
if (!body.url) {
|
||||
throw new Error(`SSO login response missing redirect URL for ${providerId}`);
|
||||
}
|
||||
|
||||
return body.url;
|
||||
}
|
||||
|
||||
async function withOidcLoginAttempt(
|
||||
browser: Browser,
|
||||
providerId: string,
|
||||
|
|
@ -113,13 +208,17 @@ async function withOidcLoginAttempt(
|
|||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await gotoAndWaitForAppReady(page, `${appBaseUrl}/login`);
|
||||
await page.getByRole("button", { name: `Log in with ${providerId}`, exact: true }).click();
|
||||
await page.waitForURL(/\/dex\/auth/, { timeout: 60000 });
|
||||
const ssoUrl = await startSsoLogin(page, providerId);
|
||||
await page.goto(ssoUrl);
|
||||
|
||||
await page.locator('input[name="login"]').fill(dexLogin);
|
||||
await page.locator('input[name="password"]').fill(dexPassword);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
const dexLoginInput = page.locator('input[name="login"]');
|
||||
const dexLoginIsVisible = await dexLoginInput.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (dexLoginIsVisible) {
|
||||
await dexLoginInput.fill(dexLogin);
|
||||
await page.locator('input[name="password"]').fill(dexPassword);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
}
|
||||
|
||||
await assertions(page);
|
||||
} finally {
|
||||
|
|
@ -127,40 +226,117 @@ async function withOidcLoginAttempt(
|
|||
}
|
||||
}
|
||||
|
||||
test("admin can register an OIDC provider", async ({ page }) => {
|
||||
await registerOidcProvider(page, providerIds.register);
|
||||
});
|
||||
async function expectInviteOnlyLoginError(page: Page) {
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const url = page.url();
|
||||
return /\/login(\/error)?/.test(url) || /\/api\/auth\/sso\/callback\//.test(url);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (/\/login(\/error)?/.test(page.url())) {
|
||||
await waitForAppReady(page);
|
||||
await expect(page.getByText(inviteOnlyMessage)).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.getByText(/invite-only/i)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectAccountLinkRequiredLoginError(page: Page) {
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const url = page.url();
|
||||
return /\/login(\/error)?/.test(url) || /\/api\/auth\/sso\/callback\//.test(url);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (/\/login(\/error)?/.test(page.url())) {
|
||||
await waitForAppReady(page);
|
||||
await expect(page.getByText(accountLinkRequiredMessage)).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.getByText(/account exists but is not linked/i)).toBeVisible();
|
||||
}
|
||||
|
||||
test("uninvited OIDC users are blocked", async ({ page, browser }) => {
|
||||
await registerOidcProvider(page, providerIds.uninvited);
|
||||
|
||||
await withOidcLoginAttempt(browser, providerIds.uninvited, uninvitedUserEmail, async (ssoPage) => {
|
||||
await ssoPage.waitForURL(/\/login(\/error)?/, { timeout: 60000 });
|
||||
await waitForAppReady(ssoPage);
|
||||
await expect(ssoPage.getByText(inviteOnlyMessage)).toBeVisible();
|
||||
await expectInviteOnlyLoginError(ssoPage);
|
||||
});
|
||||
});
|
||||
|
||||
test("invited OIDC users can sign in", async ({ page, browser }) => {
|
||||
test("invited OIDC users can sign in, retain access, and are blocked after removal", async ({ page, browser }) => {
|
||||
await registerOidcProvider(page, providerIds.invited);
|
||||
await createPendingInvitation(page, invitedUserEmail);
|
||||
|
||||
await withOidcLoginAttempt(browser, providerIds.invited, invitedUserEmail, async (ssoPage) => {
|
||||
await ssoPage.waitForURL(/\/volumes/, { timeout: 60000 });
|
||||
await ssoPage.waitForURL(/\/volumes/, { timeout: 30000 });
|
||||
await waitForAppReady(ssoPage);
|
||||
await expect(ssoPage).toHaveURL(/\/volumes/);
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return getInvitationStatusByEmail(page, invitedUserEmail);
|
||||
})
|
||||
.toBe("accepted");
|
||||
|
||||
await withOidcLoginAttempt(browser, providerIds.invited, invitedUserEmail, async (ssoPage) => {
|
||||
await ssoPage.waitForURL(/\/volumes/, { timeout: 30000 });
|
||||
await waitForAppReady(ssoPage);
|
||||
await expect(ssoPage).toHaveURL(/\/volumes/);
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return getOrgMemberIdByEmail(page, invitedUserEmail);
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
const memberId = await getOrgMemberIdByEmail(page, invitedUserEmail);
|
||||
|
||||
if (!memberId) {
|
||||
throw new Error(`Missing organization member for ${invitedUserEmail}`);
|
||||
}
|
||||
|
||||
await removeOrgMemberById(page, memberId);
|
||||
|
||||
await withOidcLoginAttempt(browser, providerIds.invited, invitedUserEmail, async (ssoPage) => {
|
||||
await expectInviteOnlyLoginError(ssoPage);
|
||||
});
|
||||
});
|
||||
|
||||
test("auto-link setting can be enabled for an OIDC provider", async ({ page, browser }) => {
|
||||
test("auto-link policy enforces invitation and controls account linking", async ({ page, browser }) => {
|
||||
await registerOidcProvider(page, providerIds.autoLinkNoInvite);
|
||||
await createLocalUser(page, autoLinkUninvitedLocalEmail, autoLinkUninvitedLocalUsername);
|
||||
await setProviderAutoLinking(page, providerIds.autoLinkNoInvite, true);
|
||||
|
||||
await withOidcLoginAttempt(browser, providerIds.autoLinkNoInvite, autoLinkUninvitedLocalEmail, async (ssoPage) => {
|
||||
await expectInviteOnlyLoginError(ssoPage);
|
||||
});
|
||||
|
||||
await registerOidcProvider(page, providerIds.autoLink);
|
||||
await createAutoLinkTargetUser(page, autoLinkTargetEmail, autoLinkTargetUsername);
|
||||
await createLocalUser(page, autoLinkTargetEmail, autoLinkTargetUsername);
|
||||
await createPendingInvitation(page, autoLinkTargetEmail);
|
||||
await setProviderAutoLinking(page, providerIds.autoLink, false);
|
||||
|
||||
await withOidcLoginAttempt(browser, providerIds.autoLink, autoLinkTargetEmail, async (ssoPage) => {
|
||||
await expectAccountLinkRequiredLoginError(ssoPage);
|
||||
});
|
||||
|
||||
await setProviderAutoLinking(page, providerIds.autoLink, true);
|
||||
|
||||
await withOidcLoginAttempt(browser, providerIds.autoLink, autoLinkTargetEmail, async (ssoPage) => {
|
||||
await ssoPage.waitForURL(/\/volumes/, { timeout: 60000 });
|
||||
await ssoPage.waitForURL(/\/volumes/, { timeout: 30000 });
|
||||
await waitForAppReady(ssoPage);
|
||||
await expect(ssoPage).toHaveURL(/\/volumes/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ staticPasswords:
|
|||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||
username: "test-local"
|
||||
userID: "004"
|
||||
- email: "linkguard@example.com"
|
||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||
username: "linkguard"
|
||||
userID: "005"
|
||||
|
||||
staticClients:
|
||||
- id: zerobyte-test
|
||||
|
|
|
|||
Loading…
Reference in a new issue