Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/auth/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ const signup = async (req, res) => {
abilities: orgResult.abilities || [],
organizationSetupRequired: orgResult.organizationSetupRequired || false,
emailVerificationRequired: orgResult.emailVerificationRequired || false,
// deprecated: always null since always-create (A2); superseded by suggestedJoin — remove next release
suggestedOrganization: orgResult.suggestedOrganization || null,
suggestedJoin: orgResult.suggestedJoin || null,
type: 'success',
message: 'Sign up',
});
Expand Down
33 changes: 19 additions & 14 deletions modules/auth/tests/auth.e2e.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ describe('Auth E2E tests:', () => {
secondUser = null;
});

test('should create pending join request when email domain matches existing org', async () => {
test('spec D5: second user with same domain gets own workspace + suggestedJoin (no pending join request)', async () => {
// Spec D5 always-create: domain-match no longer creates a pending join request.
// Both users get their own active workspace.
config.organizations = { enabled: true, autoCreate: true, domainMatching: true };

try {
Expand All @@ -139,7 +141,7 @@ describe('Auth E2E tests:', () => {
expect(firstOrg).toBeDefined();
expect(firstOrg.domain).toBe('e2etest.com');

// Step 2: signup second user with same domain
// Step 2: signup second user with same domain — gets own workspace (spec D5)
const result2 = await agent
.post('/api/auth/signup')
.send({
Expand All @@ -153,26 +155,29 @@ describe('Auth E2E tests:', () => {

secondUser = result2.body.user;

// Verify org is returned with pending flag
// Second user gets their OWN active workspace (not a join on firstOrg)
expect(result2.body.organization).toBeDefined();
expect(result2.body.organization._id).toBe(firstOrg._id);
expect(result2.body.pendingJoin).toBe(true);
expect(result2.body.organization).not.toBeNull();
expect(result2.body.organization._id).not.toBe(firstOrg._id);
// pendingJoin is always false (spec D5)
expect(result2.body.pendingJoin).toBeFalsy();

// Verify second user has a PENDING membership (not active)
const pendingMemberships = await MembershipRepository.list({
// Second user has an ACTIVE membership on their new org (not pending on firstOrg)
const activeMemberships = await MembershipRepository.list({
userId: secondUser.id,
organizationId: firstOrg._id,
status: 'pending',
organizationId: result2.body.organization._id,
status: 'active',
});
expect(pendingMemberships).toHaveLength(1);
expect(activeMemberships).toHaveLength(1);
expect(activeMemberships[0].role).toBe('owner');

// Verify NO active membership
const activeMemberships = await MembershipRepository.list({
// NO pending membership on firstOrg (spec D5: no join request created)
const pendingOnFirst = await MembershipRepository.list({
userId: secondUser.id,
organizationId: firstOrg._id,
status: 'active',
status: 'pending',
});
expect(activeMemberships).toHaveLength(0);
expect(pendingOnFirst).toHaveLength(0);
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand Down
64 changes: 42 additions & 22 deletions modules/auth/tests/auth.signup.organization.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,13 @@ describe('Auth signup organization integration tests:', () => {
});

describe('Signup with organizations enabled + autoCreate + domainMatching', () => {
test('should create pending join request when domain matches existing org', async () => {
test('spec D5: second user with same domain gets own workspace + suggestedJoin hint (no pending request)', async () => {
// Spec D5 always-create: domain-match no longer creates a pending join request.
// Both users get their own active workspace; the second receives a suggestedJoin hint.
config.organizations = { enabled: true, autoCreate: true, domainMatching: true };

// First, create an existing organization with a specific domain
let firstUser;
let secondUser;
// clean up stale users from previous runs on shared databases
await purgeUser('first@matchdomain.com');
await purgeUser('second@matchdomain.com');
try {
Expand All @@ -143,7 +143,7 @@ describe('Auth signup organization integration tests:', () => {
expect(firstOrg).toBeDefined();
expect(firstOrg.domain).toBe('matchdomain.com');

// Sign up second user with same domain — should get pending join request
// Sign up second user with same domain — spec D5: gets own workspace, not a join request
const result2 = await agent
.post('/api/auth/signup')
.send({
Expand All @@ -157,15 +157,29 @@ describe('Auth signup organization integration tests:', () => {

secondUser = result2.body.user;

// Should reference the same org but with pending flag
// Second user gets their OWN active workspace, not a reference to firstOrg
expect(result2.body.organization).toBeDefined();
expect(result2.body.organization._id).toBe(firstOrg._id);
expect(result2.body.pendingJoin).toBe(true);
expect(result2.body.organizationSetupRequired).toBe(false);
expect(result2.body.organization).not.toBeNull();
expect(result2.body.organization._id).not.toBe(firstOrg._id);
expect(result2.body.pendingJoin).toBeFalsy();
expect(result2.body.organizationSetupRequired).toBeFalsy();

// Abilities should be present (without org context)
// Abilities should be present (with org context — user is owner of their own org)
expect(result2.body.abilities).toBeDefined();
expect(result2.body.abilities).toBeInstanceOf(Array);

// A2b: suggestedJoin must be forwarded from the service through the controller response.
// Shape is name-only { orgId: string, orgName: string } — no extra keys (domain, size, etc.)
expect(result2.body.suggestedJoin).toBeDefined();
expect(result2.body.suggestedJoin).not.toBeNull();
expect(typeof result2.body.suggestedJoin.orgId).toBe('string');
expect(typeof result2.body.suggestedJoin.orgName).toBe('string');
expect(result2.body.suggestedJoin.orgId).toBe(String(firstOrg._id));
// name-only: no leaked fields
expect(result2.body.suggestedJoin).not.toHaveProperty('domain');
expect(result2.body.suggestedJoin).not.toHaveProperty('memberCount');
expect(result2.body.suggestedJoin).not.toHaveProperty('membership');
expect(result2.body.suggestedJoin).not.toHaveProperty('plan');
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand Down Expand Up @@ -215,11 +229,10 @@ describe('Auth signup organization integration tests:', () => {
});

describe('Signup with organizations enabled + autoCreate + no domainMatching', () => {
test('should create a personal organization for the user', async () => {
test('should create a personal organization for the user (domainMatching off = personal name)', async () => {
config.organizations = { enabled: true, autoCreate: true, domainMatching: false };

let user;
// clean up stale users from previous runs on shared databases
await purgeUser('personal@somecompany.com');
try {
const result = await agent
Expand All @@ -235,12 +248,12 @@ describe('Auth signup organization integration tests:', () => {

user = result.body.user;

// A personal organization should be created (no domain matching)
// domainMatching off → personal workspace name regardless of domain
expect(result.body.organization).toBeDefined();
expect(result.body.organization).not.toBeNull();
expect(result.body.organization.name).toBe("Personal's organization");
expect(result.body.organization.domain).toBe('');
expect(result.body.organizationSetupRequired).toBe(false);
expect(result.body.organizationSetupRequired).toBeFalsy();

// Abilities should be present
expect(result.body.abilities).toBeDefined();
Expand All @@ -254,12 +267,13 @@ describe('Auth signup organization integration tests:', () => {
});
});

describe('Signup with organizations enabled + no autoCreate', () => {
test('should not create an organization and flag setup as required', async () => {
describe('Signup with organizations enabled + no autoCreate (spec D5: autoCreate is deprecated no-op)', () => {
test('spec D5: autoCreate:false is a no-op — workspace always provisioned', async () => {
// Spec D5: config.organizations.autoCreate is a deprecated no-op.
// Signup ALWAYS provisions a workspace; organizationSetupRequired is never returned.
config.organizations = { enabled: true, autoCreate: false, domainMatching: true };

let user;
// clean up stale users from previous runs on shared databases
await purgeUser('manual@setup.com');
try {
const result = await agent
Expand All @@ -275,11 +289,13 @@ describe('Auth signup organization integration tests:', () => {

user = result.body.user;

// No organization should be created
expect(result.body.organization).toBeNull();
expect(result.body.organizationSetupRequired).toBe(true);
// Workspace is always created now (spec D5)
expect(result.body.organization).not.toBeNull();
expect(result.body.organization).toBeDefined();
// organizationSetupRequired defaults to false (auth controller || false)
expect(result.body.organizationSetupRequired).toBeFalsy();

// Abilities should still be present (guest-level for org context)
// Abilities should be present (user is owner of their new workspace)
expect(result.body.abilities).toBeDefined();
expect(result.body.abilities).toBeInstanceOf(Array);
} catch (err) {
Expand Down Expand Up @@ -312,14 +328,18 @@ describe('Auth signup organization integration tests:', () => {

user = result.body.user;

// Verify response structure includes the new fields
// Verify response structure includes the expected fields
expect(result.body).toHaveProperty('user');
expect(result.body).toHaveProperty('organization');
expect(result.body).toHaveProperty('abilities');
expect(result.body).toHaveProperty('organizationSetupRequired');
// organizationSetupRequired is always false (auth controller || false default)
expect(result.body.organizationSetupRequired).toBeFalsy();
expect(result.body).toHaveProperty('tokenExpiresIn');
expect(result.body.type).toBe('success');
expect(result.body.message).toBe('Sign up');
// A2b: no domain match / orgs disabled → suggestedJoin is present-as-null (not absent)
expect(result.body).toHaveProperty('suggestedJoin');
expect(result.body.suggestedJoin).toBeNull();
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand Down
33 changes: 33 additions & 0 deletions modules/organizations/services/organizations.domain.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Email-domain normalization helpers.
*
* Maintained constant. Not auto-refreshed (spec H2 — freshness out of scope).
*/

export const PUBLIC_DOMAINS = new Set([
'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com',
'yahoo.com', 'icloud.com', 'me.com', 'proton.me', 'protonmail.com',
'aol.com', 'gmx.com', 'mail.com', 'yandex.com', 'zoho.com',
]);

/**
* Extract and normalize the domain portion of an email address.
* @param {*} email - Raw value to parse.
* @returns {string|null} Lowercased, trimmed domain, or null on malformed input.
*/
export function normalizeEmailDomain(email) {
if (typeof email !== 'string') return null;
const at = email.indexOf('@');
if (at === -1 || at !== email.lastIndexOf('@')) return null;
const domain = email.slice(at + 1).trim().toLowerCase();
return domain.length > 0 && domain.includes('.') ? domain : null;
Comment thread
PierreBrisorgueil marked this conversation as resolved.
}

/**
* Return true when the domain belongs to a known public e-mail provider.
* @param {string} domain - Normalized or raw domain string.
* @returns {boolean}
*/
export function isPublicDomain(domain) {
return PUBLIC_DOMAINS.has(String(domain ?? '').trim().toLowerCase());
}
Loading
Loading