1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 11:36:46 +00:00

feat: self-service user signup (#672)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-06-27 15:01:10 -05:00
committed by GitHub
parent 1fdb058386
commit dcd1ae96e0
49 changed files with 7366 additions and 5729 deletions

View File

@@ -1,99 +1,134 @@
export const users = {
tim: {
id: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
firstname: 'Tim',
lastname: 'Cook',
email: 'tim.cook@test.com',
username: 'tim'
},
craig: {
id: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
firstname: 'Craig',
lastname: 'Federighi',
email: 'craig.federighi@test.com',
username: 'craig'
},
steve: {
firstname: 'Steve',
lastname: 'Jobs',
email: 'steve.jobs@test.com',
username: 'steve'
}
tim: {
id: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
firstname: 'Tim',
lastname: 'Cook',
email: 'tim.cook@test.com',
username: 'tim',
},
craig: {
id: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
firstname: 'Craig',
lastname: 'Federighi',
email: 'craig.federighi@test.com',
username: 'craig',
},
steve: {
firstname: 'Steve',
lastname: 'Jobs',
email: 'steve.jobs@test.com',
username: 'steve',
},
};
export const oidcClients = {
nextcloud: {
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
name: 'Nextcloud',
callbackUrl: 'http://nextcloud/auth/callback',
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
},
immich: {
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
name: 'Immich',
callbackUrl: 'http://immich/auth/callback',
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
},
federated: {
id: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
name: 'Federated',
callbackUrl: 'http://federated/auth/callback',
federatedJWT: {
issuer: 'https://external-idp.local',
audience: 'api://PocketID',
subject: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
},
accessCodes: ['federated']
},
pingvinShare: {
name: 'Pingvin Share',
callbackUrl: 'http://pingvin.share/auth/callback',
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
}
nextcloud: {
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
name: 'Nextcloud',
callbackUrl: 'http://nextcloud/auth/callback',
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY',
},
immich: {
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
name: 'Immich',
callbackUrl: 'http://immich/auth/callback',
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x',
},
federated: {
id: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
name: 'Federated',
callbackUrl: 'http://federated/auth/callback',
federatedJWT: {
issuer: 'https://external-idp.local',
audience: 'api://PocketID',
subject: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
},
accessCodes: ['federated'],
},
pingvinShare: {
name: 'Pingvin Share',
callbackUrl: 'http://pingvin.share/auth/callback',
secondCallbackUrl: 'http://pingvin.share/auth/callback2',
},
};
export const userGroups = {
developers: {
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
friendlyName: 'Developers',
name: 'developers'
},
designers: {
id: 'adab18bf-f89d-4087-9ee1-70ff15b48211',
friendlyName: 'Designers',
name: 'designers'
},
humanResources: {
friendlyName: 'Human Resources',
name: 'human_resources'
}
developers: {
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
friendlyName: 'Developers',
name: 'developers',
},
designers: {
id: 'adab18bf-f89d-4087-9ee1-70ff15b48211',
friendlyName: 'Designers',
name: 'designers',
},
humanResources: {
friendlyName: 'Human Resources',
name: 'human_resources',
},
};
export const oneTimeAccessTokens = [
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
{ token: 'YCGDtftvsvYWiXd0', expired: true }
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
{ token: 'YCGDtftvsvYWiXd0', expired: true },
];
export const apiKeys = [
{
id: '5f1fa856-c164-4295-961e-175a0d22d725',
key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20',
name: 'Test API Key'
}
{
id: '5f1fa856-c164-4295-961e-175a0d22d725',
key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20',
name: 'Test API Key',
},
];
export const refreshTokens = [
{
token: 'ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo',
clientId: oidcClients.nextcloud.id,
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
expired: false
},
{
token: 'X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4',
clientId: oidcClients.nextcloud.id,
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
expired: true
}
{
token: 'ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo',
clientId: oidcClients.nextcloud.id,
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
expired: false,
},
{
token: 'X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4',
clientId: oidcClients.nextcloud.id,
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
expired: true,
},
];
export const signupTokens = {
valid: {
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
token: 'VALID1234567890A',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
usageLimit: 1,
usageCount: 0,
createdAt: new Date().toISOString(),
},
partiallyUsed: {
id: 'b2c3d4e5-f6g7-8901-bcde-f12345678901',
token: 'PARTIAL567890ABC',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
usageLimit: 5,
usageCount: 2,
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
},
expired: {
id: 'c3d4e5f6-g7h8-9012-cdef-123456789012',
token: 'EXPIRED34567890B',
expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
usageLimit: 3,
usageCount: 1,
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
},
fullyUsed: {
id: 'd4e5f6g7-h8i9-0123-def0-234567890123',
token: 'FULLYUSED567890C',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
usageLimit: 1,
usageCount: 1,
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
},
};

View File

@@ -4,91 +4,85 @@ import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(cleanupBackend);
test.describe('LDAP Integration', () => {
test.skip(process.env.SKIP_LDAP_TESTS === "true", 'Skipping LDAP tests due to SKIP_LDAP_TESTS environment variable');
test('LDAP configuration is working properly', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
test.skip(process.env.SKIP_LDAP_TESTS === 'true', 'Skipping LDAP tests due to SKIP_LDAP_TESTS environment variable');
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
test('LDAP configuration is working properly', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
await expect(page.getByRole('button', { name: 'Disable' })).toBeVisible();
await expect(page.getByLabel('LDAP URL')).toHaveValue(/ldap:\/\/.*/);
await expect(page.getByLabel('LDAP Base DN')).not.toBeEmpty();
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
await expect(page.getByLabel('User Unique Identifier Attribute')).not.toBeEmpty();
await expect(page.getByLabel('Username Attribute')).not.toBeEmpty();
await expect(page.getByLabel('User Mail Attribute')).not.toBeEmpty();
await expect(page.getByLabel('Group Name Attribute')).not.toBeEmpty();
await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeVisible();
await expect(page.getByLabel('LDAP URL')).toHaveValue(/ldap:\/\/.*/);
await expect(page.getByLabel('LDAP Base DN')).not.toBeEmpty();
const syncButton = page.getByRole('button', { name: 'Sync now' });
await syncButton.click();
await expect(page.locator('[data-type="success"]')).toHaveText('LDAP sync finished');
});
await expect(page.getByLabel('User Unique Identifier Attribute')).not.toBeEmpty();
await expect(page.getByLabel('Username Attribute')).not.toBeEmpty();
await expect(page.getByLabel('User Mail Attribute')).not.toBeEmpty();
await expect(page.getByLabel('Group Name Attribute')).not.toBeEmpty();
test('LDAP users are synced into PocketID', async ({ page }) => {
// Navigate to user management
await page.goto('/settings/admin/users');
const syncButton = page.getByRole('button', { name: 'Sync now' });
await syncButton.click();
await expect(page.locator('[data-type="success"]')).toHaveText('LDAP sync finished');
});
// Verify the LDAP users exist
await expect(page.getByText('testuser1@pocket-id.org')).toBeVisible();
await expect(page.getByText('testuser2@pocket-id.org')).toBeVisible();
test('LDAP users are synced into PocketID', async ({ page }) => {
// Navigate to user management
await page.goto('/settings/admin/users');
// Check LDAP user details
await page.getByRole('row', { name: 'testuser1' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Verify the LDAP users exist
await expect(page.getByText('testuser1@pocket-id.org')).toBeVisible();
await expect(page.getByText('testuser2@pocket-id.org')).toBeVisible();
// Verify user source is LDAP
await expect(page.getByText('LDAP').first()).toBeVisible();
// Check LDAP user details
await page.getByRole('row', { name: 'testuser1' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Verify essential fields are filled
await expect(page.getByLabel('Username')).not.toBeEmpty();
await expect(page.getByLabel('Email')).not.toBeEmpty();
});
// Verify user source is LDAP
await expect(page.getByText('LDAP').first()).toBeVisible();
test('LDAP groups are synced into PocketID', async ({ page }) => {
// Navigate to user groups
await page.goto('/settings/admin/user-groups');
// Verify essential fields are filled
await expect(page.getByLabel('Username')).not.toBeEmpty();
await expect(page.getByLabel('Email')).not.toBeEmpty();
});
// Verify LDAP groups exist
await expect(page.getByRole('cell', { name: 'test_group' }).first()).toBeVisible();
await expect(page.getByRole('cell', { name: 'admin_group' }).first()).toBeVisible();
test('LDAP groups are synced into PocketID', async ({ page }) => {
// Navigate to user groups
await page.goto('/settings/admin/user-groups');
await page
.getByRole('row', { name: 'test_group' })
.getByRole('button', { name: 'Toggle menu' })
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Verify LDAP groups exist
await expect(page.getByRole('cell', { name: 'test_group' }).first()).toBeVisible();
await expect(page.getByRole('cell', { name: 'admin_group' }).first()).toBeVisible();
// Verify group source is LDAP
await expect(page.getByText('LDAP').first()).toBeVisible();
});
await page.getByRole('row', { name: 'test_group' }).getByRole('button', { name: 'Toggle menu' }).click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
test('LDAP users cannot be modified in PocketID', async ({ page }) => {
// Navigate to LDAP user details
await page.goto('/settings/admin/users');
await page.waitForLoadState('networkidle');
// Verify group source is LDAP
await expect(page.getByText('LDAP').first()).toBeVisible();
});
await page.getByRole('row', { name: 'testuser1' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
test('LDAP users cannot be modified in PocketID', async ({ page }) => {
// Navigate to LDAP user details
await page.goto('/settings/admin/users');
await page.waitForLoadState('networkidle');
// Verify key fields are disabled
const usernameInput = page.getByLabel('Username');
await expect(usernameInput).toBeDisabled();
});
await page.getByRole('row', { name: 'testuser1' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
test('LDAP groups cannot be modified in PocketID', async ({ page }) => {
// Navigate to LDAP group details
await page.goto('/settings/admin/user-groups');
await page.waitForLoadState('networkidle');
// Verify key fields are disabled
const usernameInput = page.getByLabel('Username');
await expect(usernameInput).toBeDisabled();
});
await page
.getByRole('row', { name: 'test_group' })
.getByRole('button', { name: 'Toggle menu' })
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
test('LDAP groups cannot be modified in PocketID', async ({ page }) => {
// Navigate to LDAP group details
await page.goto('/settings/admin/user-groups');
await page.waitForLoadState('networkidle');
// Verify key fields are disabled
const nameInput = page.getByLabel('Name', { exact: true });
await expect(nameInput).toBeDisabled();
});
await page.getByRole('row', { name: 'test_group' }).getByRole('button', { name: 'Toggle menu' }).click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Verify key fields are disabled
const nameInput = page.getByLabel('Name', { exact: true });
await expect(nameInput).toBeDisabled();
});
});

View File

@@ -0,0 +1,175 @@
import test, { expect } from '@playwright/test';
import { cleanupBackend } from '../utils/cleanup.util';
import passkeyUtil from '../utils/passkey.util';
import { users, signupTokens } from 'data';
test.beforeEach(cleanupBackend);
test.describe('User Signup', () => {
async function setSignupMode(page: any, mode: 'Disabled' | 'Signup with token' | 'Open Signup') {
await page.goto('/settings/admin/application-configuration');
await page.getByLabel('Enable user signups').click();
await page.getByRole('option', { name: mode }).click();
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.locator('[data-type="success"]')).toHaveText('Application configuration updated successfully');
await page.waitForLoadState('networkidle');
await page.context().clearCookies();
await page.goto('/login');
}
test('Signup is disabled - shows error message', async ({ page }) => {
await setSignupMode(page, 'Disabled');
await page.goto('/signup');
await expect(page.getByText('User signups are currently disabled')).toBeVisible();
});
test('Signup with token - success flow', async ({ page }) => {
await setSignupMode(page, 'Signup with token');
await page.goto(`/st/${signupTokens.valid.token}`);
await page.getByLabel('First name').fill('John');
await page.getByLabel('Last name').fill('Doe');
await page.getByLabel('Username').fill('johndoe');
await page.getByLabel('Email').fill('john.doe@test.com');
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
await expect(page.getByText('Set up your passkey')).toBeVisible();
});
test('Signup with token - invalid token shows error', async ({ page }) => {
await setSignupMode(page, 'Signup with token');
await page.goto('/st/invalid-token-123');
await page.getByLabel('First name').fill('Complete');
await page.getByLabel('Last name').fill('User');
await page.getByLabel('Username').fill('completeuser');
await page.getByLabel('Email').fill('complete.user@test.com');
await page.getByRole('button', { name: 'Sign Up' }).click();
await expect(page.getByText('Token is invalid or expired.')).toBeVisible();
});
test('Signup with token - no token in URL shows error', async ({ page }) => {
await setSignupMode(page, 'Signup with token');
await page.goto('/signup');
await expect(page.getByText('A valid signup token is required to create an account.')).toBeVisible();
});
test('Open signup - success flow', async ({ page }) => {
await setSignupMode(page, 'Open Signup');
await page.goto('/signup');
await expect(page.getByText('Create your account to get started')).toBeVisible();
await page.getByLabel('First name').fill('Jane');
await page.getByLabel('Last name').fill('Smith');
await page.getByLabel('Username').fill('janesmith');
await page.getByLabel('Email').fill('jane.smith@test.com');
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
await expect(page.getByText('Set up your passkey')).toBeVisible();
});
test('Open signup - validation errors', async ({ page }) => {
await setSignupMode(page, 'Open Signup');
await page.goto('/signup');
await page.getByRole('button', { name: 'Sign Up' }).click();
await expect(page.getByText('Invalid input').first()).toBeVisible();
});
test('Open signup - duplicate email shows error', async ({ page }) => {
await setSignupMode(page, 'Open Signup');
await page.goto('/signup');
await page.getByLabel('First name').fill('Test');
await page.getByLabel('Last name').fill('User');
await page.getByLabel('Username').fill('testuser123');
await page.getByLabel('Email').fill(users.tim.email);
await page.getByRole('button', { name: 'Sign Up' }).click();
await expect(page.getByText('Email is already in use.')).toBeVisible();
});
test('Open signup - duplicate username shows error', async ({ page }) => {
await setSignupMode(page, 'Open Signup');
await page.goto('/signup');
await page.getByLabel('First name').fill('Test');
await page.getByLabel('Last name').fill('User');
await page.getByLabel('Username').fill(users.tim.username);
await page.getByLabel('Email').fill('newuser@test.com');
await page.getByRole('button', { name: 'Sign Up' }).click();
await expect(page.getByText('Username is already in use.')).toBeVisible();
});
test('Complete signup flow with passkey creation', async ({ page }) => {
await setSignupMode(page, 'Open Signup');
await page.goto('/signup');
await page.getByLabel('First name').fill('Complete');
await page.getByLabel('Last name').fill('User');
await page.getByLabel('Username').fill('completeuser');
await page.getByLabel('Email').fill('complete.user@test.com');
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
await (await passkeyUtil.init(page)).addPasskey('timNew');
await page.getByRole('button', { name: 'Add Passkey' }).click();
await page.waitForURL('/settings/account');
await expect(page.getByText('Single Passkey Configured')).toBeVisible();
});
test('Skip passkey creation during signup', async ({ page }) => {
await setSignupMode(page, 'Open Signup');
await page.goto('/signup');
await page.getByLabel('First name').fill('Skip');
await page.getByLabel('Last name').fill('User');
await page.getByLabel('Username').fill('skipuser');
await page.getByLabel('Email').fill('skip.user@test.com');
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
await page.getByRole('button', { name: 'Skip for now' }).click();
await page.waitForURL('/settings/account');
await expect(page.getByText('Passkey missing')).toBeVisible();
});
test('Token usage limit is enforced', async ({ page }) => {
await setSignupMode(page, 'Signup with token');
await page.goto(`/st/${signupTokens.fullyUsed.token}`);
await page.getByLabel('First name').fill('Complete');
await page.getByLabel('Last name').fill('User');
await page.getByLabel('Username').fill('completeuser');
await page.getByLabel('Email').fill('complete.user@test.com');
await page.getByRole('button', { name: 'Sign Up' }).click();
await expect(page.getByText('Token is invalid or expired.')).toBeVisible();
});
});