mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(auth): fix approval signups and auto-join invites (#3467)
This commit is contained in:
1 parent
e403fb6cce
commit
d5ca5d8293
64 files changed
+622
-72
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {startAttachmentDecayReconcile} from '@app/api/attachment/AttachmentDecayReconcileRun';
|
||||
import {maybeAutoJoinInvite} from '@app/api/auth/AuthRegistration';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {
|
||||
@@ -768,7 +769,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
operationId: 'update_admin_pending_registration',
|
||||
summary: 'Approve or reject a pending registration',
|
||||
description:
|
||||
'Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.',
|
||||
'Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait and joins the account to the community it was invited to and the single community, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.',
|
||||
responseSchema: PendingRegistrationListResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -777,7 +778,10 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const userId = ctx.req.valid('param').user_id.toString();
|
||||
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
|
||||
await updatePendingRegistrationUser(ctx, userId, decision);
|
||||
const pendingRegistration = (await instanceConfigRepository.getPendingRegistrations()).find(
|
||||
(entry) => entry.user_id === userId,
|
||||
);
|
||||
await updatePendingRegistrationUser(ctx, userId, decision, pendingRegistration?.invite_code ?? null);
|
||||
await instanceConfigRepository.removePendingRegistration(userId);
|
||||
return ctx.json({pending_registrations: await instanceConfigRepository.getPendingRegistrations()});
|
||||
},
|
||||
@@ -875,6 +879,7 @@ async function updatePendingRegistrationUser(
|
||||
ctx: Context<HonoEnv>,
|
||||
userId: string,
|
||||
decision: 'approve' | 'reject',
|
||||
inviteCode: string | null,
|
||||
): Promise<void> {
|
||||
const userRepository = ctx.get('userRepository');
|
||||
const user = await userRepository.findUnique(createUserID(BigInt(userId)));
|
||||
@@ -896,6 +901,11 @@ async function updatePendingRegistrationUser(
|
||||
}
|
||||
await userRepository.patchUpsert(user.id, {traits: traits.size > 0 ? traits : null}, user.toRow());
|
||||
if (decision === 'approve' && wasPendingApproval) {
|
||||
await maybeAutoJoinInvite(ctx.get('inviteService'), {
|
||||
userId: user.id,
|
||||
inviteCode,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
await ctx.get('singleCommunityService').joinStockCommunity(user.id, ctx.get('requestCache'));
|
||||
}
|
||||
await recordAdminWrite(ctx, {
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
createUniqueUsername,
|
||||
setUserACLs,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createChannelInvite, createGuild, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
@@ -21,6 +22,18 @@ interface PendingRegistrationResponse {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
function invitedRegistrationBody(prefix: string, inviteCode?: string): Record<string, unknown> {
|
||||
return {
|
||||
email: createUniqueEmail(prefix),
|
||||
username: createUniqueUsername(prefix),
|
||||
global_name: 'Invited person',
|
||||
password: 'invited-since-1999',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
invite_code: inviteCode,
|
||||
};
|
||||
}
|
||||
|
||||
describe('pending registration approval and the stock community', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
@@ -102,6 +115,198 @@ describe('pending registration approval and the stock community', () => {
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('joins the invited community on approval', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('invitedapproval', invite.code))
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
|
||||
.body({status: 'approved'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('lists the invite code on a pending registration', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('invitedlisted', invite.code))
|
||||
.execute();
|
||||
|
||||
const listed = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
|
||||
.get('/admin/instance/pending-registrations')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(listed.pending_registrations).toEqual([
|
||||
expect.objectContaining({user_id: pending.user_id, invite_code: invite.code}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not join the invited community on rejection', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('invitedrejected', invite.code))
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
|
||||
.body({status: 'rejected'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('joins the auto-join community on approval of a registration without an invite', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
|
||||
Config.instance.autoJoinInviteCode = invite.code;
|
||||
try {
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('autojoinapproval'))
|
||||
.execute();
|
||||
|
||||
const listed = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
|
||||
.get('/admin/instance/pending-registrations')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(listed.pending_registrations).toEqual([
|
||||
expect.objectContaining({user_id: pending.user_id, invite_code: invite.code}),
|
||||
]);
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
|
||||
.body({status: 'approved'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
} finally {
|
||||
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not join the auto-join community when the pending entry has no invite', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('noinviteapproval'))
|
||||
.execute();
|
||||
|
||||
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
|
||||
Config.instance.autoJoinInviteCode = invite.code;
|
||||
try {
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
|
||||
.body({status: 'approved'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
} finally {
|
||||
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
|
||||
}
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('approves and joins the single community when the invite is gone', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild: stockGuild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invitedGuild = await createGuild(harness, owner.token, 'Invited Guild');
|
||||
const invite = await createChannelInvite(harness, owner.token, invitedGuild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
single_community_enabled: true,
|
||||
single_community_guild_id: stockGuild.id,
|
||||
});
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('invitegone', invite.code))
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
|
||||
.body({status: 'approved'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${stockGuild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${invitedGuild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('lets the review ACL alone list and decide registrations', async () => {
|
||||
const reviewer = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
|
||||
@@ -55,6 +55,7 @@ import {getRegionalMinimumAge} from '@fluxer/constants/src/RegionalMinimumAge';
|
||||
import {ProfileFieldPrivacyFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
|
||||
import {RegistrationCredentialsRequiredError} from '@fluxer/errors/src/domains/auth/RegistrationCredentialsRequiredError';
|
||||
import {RegistrationUrlInvalidError} from '@fluxer/errors/src/domains/auth/RegistrationUrlInvalidError';
|
||||
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -163,6 +164,9 @@ async function registerAccount(
|
||||
const registrationAccess = setupTokenVerified
|
||||
? {pendingApproval: false, registrationUrl: null}
|
||||
: await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
|
||||
if (registrationAccess.pendingApproval && !hasSignInCredentials(data, usernameMode)) {
|
||||
throw new RegistrationCredentialsRequiredError();
|
||||
}
|
||||
const geoipResult = await lookupGeoip(clientIp);
|
||||
const countryCode = geoipResult.countryCode;
|
||||
const collectDateOfBirth = appPublicConfig.registration.collect_date_of_birth;
|
||||
@@ -303,6 +307,7 @@ async function registerAccount(
|
||||
requested_at: now.toISOString(),
|
||||
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
|
||||
client_ip: clientIp,
|
||||
invite_code: data.invite_code?.trim() || config.instance.autoJoinInviteCode || null,
|
||||
});
|
||||
}
|
||||
grantBootstrapAdmin = bootstrapAdminEligible && (await instanceConfigRepository.claimAdminBootstrap(userId));
|
||||
@@ -405,6 +410,11 @@ function assertUsernameModeRegistration(data: RegisterRequest): void {
|
||||
}
|
||||
}
|
||||
|
||||
function hasSignInCredentials(data: RegisterRequest, usernameMode: boolean): boolean {
|
||||
if (!data.password) return false;
|
||||
return usernameMode || Boolean(data.email);
|
||||
}
|
||||
|
||||
function requestedUsernameFromOlderApp(config: APIConfig, email: string | null | undefined): string | undefined {
|
||||
if (email == null) return undefined;
|
||||
const localPart = getLocalPartAtInstance(config, email.trim());
|
||||
@@ -495,7 +505,7 @@ async function resolveRegistrationAccess(
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeAutoJoinInvite(
|
||||
export async function maybeAutoJoinInvite(
|
||||
inviteService: InviteService,
|
||||
params: {
|
||||
userId: UserID;
|
||||
@@ -513,7 +523,7 @@ async function maybeAutoJoinInvite(
|
||||
requestCache,
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.warn({inviteCode: normalizedInviteCode, error}, 'Failed to auto-join invite on registration');
|
||||
Logger.warn({inviteCode: normalizedInviteCode, error}, 'Failed to auto-join invite');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {createHash, randomBytes} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {maybeAutoJoinInvite} from '@app/api/auth/AuthRegistration';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {assertEmailNotBlocklisted} from '@app/api/auth/EmailBlocklist';
|
||||
import {SsoIdentityRepository} from '@app/api/auth/services/SsoIdentityRepository';
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
isTestSsoProvider,
|
||||
validateSsoPublicOutboundUrl,
|
||||
} from '@app/api/instance/SsoConfigValidation';
|
||||
import type {InviteService} from '@app/api/invite/InviteService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
@@ -265,6 +267,7 @@ export class SsoService {
|
||||
private readonly discriminatorService: DiscriminatorService,
|
||||
private readonly kvActivityTracker: KVActivityTracker,
|
||||
private readonly singleCommunityService: SingleCommunityService,
|
||||
private readonly inviteService: InviteService,
|
||||
) {}
|
||||
|
||||
async getPublicStatus(): Promise<PublicSsoStatus> {
|
||||
@@ -406,10 +409,16 @@ export class SsoService {
|
||||
}
|
||||
await assertEmailNotBlocklisted(emailLower, 'email');
|
||||
const pendingApproval = registrationConfig.mode === 'approval';
|
||||
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
|
||||
const autoJoinInviteCode = this.apiContext.services.config.instance.autoJoinInviteCode?.trim() || null;
|
||||
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval, autoJoinInviteCode});
|
||||
if (pendingApproval) {
|
||||
throw new RegistrationPendingApprovalError();
|
||||
}
|
||||
await maybeAutoJoinInvite(this.inviteService, {
|
||||
userId: user.id,
|
||||
inviteCode: autoJoinInviteCode,
|
||||
requestCache,
|
||||
});
|
||||
await this.singleCommunityService.joinStockCommunity(user.id, requestCache);
|
||||
return user;
|
||||
}
|
||||
@@ -468,6 +477,7 @@ export class SsoService {
|
||||
config: ResolvedSsoConfig,
|
||||
options?: {
|
||||
pendingApproval?: boolean;
|
||||
autoJoinInviteCode?: string | null;
|
||||
},
|
||||
): Promise<User> {
|
||||
const {users, snowflake, cache} = this.apiContext.services;
|
||||
@@ -535,6 +545,7 @@ export class SsoService {
|
||||
requested_at: now.toISOString(),
|
||||
registration_url_id: null,
|
||||
client_ip: null,
|
||||
invite_code: options.autoJoinInviteCode ?? null,
|
||||
});
|
||||
}
|
||||
createAttempted = true;
|
||||
|
||||
@@ -91,4 +91,67 @@ describe('Auth registration policy', () => {
|
||||
.expect(403, APIErrorCodes.REGISTRATION_PENDING_APPROVAL)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses an approval registration without sign-in credentials', async () => {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({global_name: 'Nobody Signs In', date_of_birth: '2000-01-01', consent: true})
|
||||
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
|
||||
.execute();
|
||||
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses an approval registration with an email but no password', async () => {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const {password: _password, ...body} = registrationBody('nopassword');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body(body)
|
||||
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
|
||||
.execute();
|
||||
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses an approval registration with a password but no email', async () => {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const {email: _email, ...body} = registrationBody('noemail');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body(body)
|
||||
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
|
||||
.execute();
|
||||
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the registration URL seat when an approval registration is refused', async () => {
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
|
||||
const {code} = await repository.createRegistrationUrl({
|
||||
label: 'Reviewed link',
|
||||
createdByUserId: '1',
|
||||
expiresAt: null,
|
||||
maxUses: 1,
|
||||
approvalRequired: true,
|
||||
});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({global_name: 'Seat Keeper', date_of_birth: '2000-01-01', consent: true, registration_url_code: code})
|
||||
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
|
||||
.execute();
|
||||
const registration = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body({...registrationBody('seatkeeper'), registration_url_code: code})
|
||||
.execute();
|
||||
expect(registration.registration_pending_approval).toBe(true);
|
||||
});
|
||||
|
||||
it('still accepts an unclaimed registration in open mode', async () => {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'open'});
|
||||
const registration = await createBuilderWithoutAuth<RegistrationTokenResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body({global_name: 'Open Unclaimed', date_of_birth: '2000-01-01', consent: true})
|
||||
.execute();
|
||||
expect(registration.token.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -428,8 +428,11 @@ describe('signups racing on registration URLs and pending approvals', () => {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const fresh = await register('pendingafter');
|
||||
|
||||
const listed = (await readPendingRegistrations()).map((entry) => entry.user_id);
|
||||
expect(listed.toSorted()).toEqual([account.userId, fresh.json.user_id].toSorted());
|
||||
const pendingRegistrations = await readPendingRegistrations();
|
||||
expect(pendingRegistrations.map((entry) => entry.user_id).toSorted()).toEqual(
|
||||
[account.userId, fresh.json.user_id].toSorted(),
|
||||
);
|
||||
expect(pendingRegistrations.find((entry) => entry.user_id === account.userId)?.invite_code).toBeNull();
|
||||
|
||||
const decided = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${account.userId}`)
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
setUserACLs,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createChannelInvite, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {PendingRegistrationListResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface SsoStartResponse {
|
||||
@@ -503,6 +506,79 @@ describe('Auth SSO flow', () => {
|
||||
.expect(200)
|
||||
.execute();
|
||||
});
|
||||
it('joins a provisioned user to the auto-join invite community', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await enableSso(harness, admin.token);
|
||||
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
|
||||
Config.instance.autoJoinInviteCode = invite.code;
|
||||
try {
|
||||
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
|
||||
.post('/auth/sso/start')
|
||||
.body({})
|
||||
.execute();
|
||||
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
|
||||
.post('/auth/sso/complete')
|
||||
.body({
|
||||
code: `sso-auto-join-${Date.now()}@example.com`,
|
||||
state: startData.state,
|
||||
})
|
||||
.execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${completeData.user_id}`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
} finally {
|
||||
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
|
||||
}
|
||||
});
|
||||
it('replays the auto-join invite when an SSO registration is approved', async () => {
|
||||
const reviewer = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await enableSso(harness, admin.token);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
|
||||
Config.instance.autoJoinInviteCode = invite.code;
|
||||
try {
|
||||
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
|
||||
.post('/auth/sso/start')
|
||||
.body({})
|
||||
.execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/sso/complete')
|
||||
.body({
|
||||
code: `sso-auto-join-approval-${Date.now()}@example.com`,
|
||||
state: startData.state,
|
||||
})
|
||||
.expect(403, 'REGISTRATION_PENDING_APPROVAL')
|
||||
.execute();
|
||||
const listed = await createBuilder<PendingRegistrationListResponse>(harness, reviewer.token)
|
||||
.get('/admin/instance/pending-registrations')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(listed.pending_registrations).toEqual([expect.objectContaining({invite_code: invite.code})]);
|
||||
const pendingUserId = listed.pending_registrations[0]!.user_id;
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pendingUserId}`)
|
||||
.expect(404)
|
||||
.execute();
|
||||
await createBuilder(harness, reviewer.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pendingUserId}`)
|
||||
.body({status: 'approved'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pendingUserId}`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
} finally {
|
||||
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('existing user login', () => {
|
||||
let admin: TestAccount;
|
||||
|
||||
@@ -404,6 +404,29 @@ describe('Username sign-in', () => {
|
||||
expect(user.username.toLowerCase().startsWith('sharedname')).toBe(true);
|
||||
expect(user.isUnclaimedAccount()).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a credential-less approval registration on a username instance', async () => {
|
||||
await useUsernameMode();
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({global_name: 'No Credentials', date_of_birth: '2000-01-01'})
|
||||
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
|
||||
.execute();
|
||||
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts an approval registration with a username and password', async () => {
|
||||
await useUsernameMode();
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const registration = await createBuilderWithoutAuth<{registration_pending_approval: true; user_id: string}>(
|
||||
harness,
|
||||
)
|
||||
.post('/auth/register')
|
||||
.body({username: 'reviewedperson', password: PASSWORD, date_of_birth: '2000-01-01'})
|
||||
.execute();
|
||||
expect(registration).toEqual({registration_pending_approval: true, user_id: expect.any(String)});
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
|
||||
@@ -936,6 +936,7 @@ function pendingRegistration(userId: string) {
|
||||
requested_at: `2026-09-01T00:00:${userId.slice(-2)}.000Z`,
|
||||
registration_url_id: null,
|
||||
client_ip: '127.0.0.1',
|
||||
invite_code: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1093,6 +1093,7 @@ const StoredPendingRegistrationSchema = PendingRegistrationResponse.extend({
|
||||
requested_at: StoredRegistrationTimestampSchema,
|
||||
registration_url_id: StoredNullableStringSchema.pipe(StoredRegistrationUrlIdSchema.nullable()),
|
||||
client_ip: StoredNullableStringSchema,
|
||||
invite_code: StoredNullableStringSchema,
|
||||
}) satisfies z.ZodType<InstancePendingRegistration>;
|
||||
|
||||
function isRegistrationUrlUsable(registrationUrl: InstanceRegistrationUrl, now: Date): boolean {
|
||||
|
||||
@@ -521,6 +521,7 @@ class RequestServices implements RequestScopedServices {
|
||||
getDiscriminatorService(),
|
||||
getKVActivityTracker(),
|
||||
this.singleCommunityService,
|
||||
this.inviteService,
|
||||
);
|
||||
return this.cachedSsoService;
|
||||
}
|
||||
|
||||
Generated
+2
@@ -39859,6 +39859,7 @@
|
||||
"REACTION_EMAIL_VERIFICATION_REQUIRED",
|
||||
"REPORT_EMAIL_VERIFICATION_REQUIRED",
|
||||
"REGISTRATION_CLOSED",
|
||||
"REGISTRATION_CREDENTIALS_REQUIRED",
|
||||
"REGISTRATION_PENDING_APPROVAL",
|
||||
"REGISTRATION_REJECTED",
|
||||
"REGISTRATION_URL_INVALID",
|
||||
@@ -40154,6 +40155,7 @@
|
||||
"Email verification is required for this action.",
|
||||
"Email verification is required for this action.",
|
||||
"Registration is closed on this instance.",
|
||||
"This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.",
|
||||
"This registration is waiting for admin approval.",
|
||||
"This registration request was rejected.",
|
||||
"This registration link is invalid or has expired.",
|
||||
|
||||
Reference in new issue
Block a user