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
23 changes: 21 additions & 2 deletions TECHNICAL_DEBT.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ Adopt industry-standard data-fetching library (React Query or SWR):

- [Permission Architecture Pattern](file:///.gemini/antigravity/brain/d18e0ee0-ce96-4db3-9e9a-041242a6c761/permission_architecture_pattern.md)

### 2. CI Integration for E2E Tests

**Location**: `apps/web/e2e`
**Added**: 2026-02-19
**Impact**: Reliability, Automation
**Effort**: Medium (1-2 days)

**Current State**:

- E2E tests run successfully in local environment with `start-test-env.sh`.
- Tests rely on a local Mailpit instance (`docker compose up`) and local DB.
- No automated CI workflow for these tests.

**Recommended Solution**:

- Configure a service container for Mailpit in GitHub Actions.
- Ensure the database is accessible or service-containerized in CI.
- Update the CI workflow to enable `VITE_AUTH_GOOGLE_ENABLED=true`.

---

## Medium Priority
Expand All @@ -54,12 +73,12 @@ Adopt industry-standard data-fetching library (React Query or SWR):

---

## Low Priority
### Low Priority

*No items currently tracked*

---

## Completed Items
### Completed Items
Comment on lines +76 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Heading hierarchy broken — Low Priority and Completed Items demoted to ###.

High Priority (line 7) and Medium Priority (line 70) use ##, but Low Priority and Completed Items were changed to ###. This makes them appear as subsections of "Medium Priority" rather than peer sections in the document outline.

Proposed fix
-### Low Priority
+## Low Priority
-### Completed Items
+## Completed Items
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TECHNICAL_DEBT.md` around lines 76 - 82, The headings "Low Priority" and
"Completed Items" were incorrectly demoted to third-level headers (###) and
should be restored to second-level (##) to match "High Priority" and "Medium
Priority" and make them peer sections; update the "Low Priority" and "Completed
Items" headings in TECHNICAL_DEBT.md to use the same header level (## Low
Priority and ## Completed Items) so they appear at the same document hierarchy
as "High Priority" and "Medium Priority".


*Items resolved will be moved here with completion date*
114 changes: 114 additions & 0 deletions apps/web/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { test, expect } from './test';
import { createVerifiedUser } from './helpers/auth-setup';


test.describe('Authentication Flows', () => {
test.describe.configure({ mode: 'serial' });
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('Sign Up with Email/Password', async ({ page, mailpit, db }) => {
// Ensure fresh email for this run
const userEmail = `auth-test-${Date.now()}@example.com`;
const userPassword = 'password123';
const userFirstName = 'Playwright';
const userLastName = 'TestUser';

console.log(`Starting Sign Up test for: ${userEmail}`);

// Cleanup before test (just in case)
await db.cleanupUser(userEmail);

try {
await page.goto('/signup');

// Fill signup form
await page.fill('input#firstName', userFirstName);
await page.fill('input#lastName', userLastName);
await page.fill('input[type="email"]', userEmail);

// Password fields
await page.locator('input[type="password"]').first().fill(userPassword);
await page.locator('input[type="password"]').nth(1).fill(userPassword);

// Click submit
await page.getByRole('button', { name: 'Sign Up', exact: true }).click();

// Verify redirect - expect to go to verify-email page
await expect(page).toHaveURL(/\/verify-email/);
await expect(page.getByText('Check your email')).toBeVisible({ timeout: 15000 });

// --- AUTOMATED VERIFICATION (Mailpit) ---
// Wait for email in Mailpit
const email = await mailpit.waitForEmail(userEmail, 'Verify your email');

// Extract link - dynamically derive host to support CI/Docker
const authUrl = process.env.BETTER_AUTH_URL || 'http://localhost:3002/api/auth';
const cleanAuthUrl = authUrl.replace(/\/$/, '');
const escapedAuthUrl = cleanAuthUrl.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
const verificationUrl = mailpit.extractLink(email, new RegExp(`(${escapedAuthUrl}\\/verify-email\\?token=[^"\\s]+)`));

console.log(`[Mailpit] Found URL: ${verificationUrl}`);

// Verify user state in DB BEFORE verification (should be unverified)
const exists = await db.userExists(userEmail);
expect(exists).toBe(true);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const verifiedBefore = await db.isEmailVerified(userEmail);
expect(verifiedBefore).toBe(false);

// Navigate to verification link
await page.goto(verificationUrl);

// Assert we were redirected away from the verification URL
await expect(page).not.toHaveURL(/\/verify-email\?/, { timeout: 10000 });
Comment on lines +60 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Negative URL assertion passes on error pages — consider a positive destination check.

not.toHaveURL(/\/verify-email\?/) is satisfied by any URL, including /500 or /error. A complementary positive assertion on the expected redirect target (or a visible element on that page) would make the verification step more meaningful.

♻️ Proposed refinement
         // Assert we were redirected away from the verification URL
         await expect(page).not.toHaveURL(/\/verify-email\?/, { timeout: 10000 });
+        // Verify landing on a known post-verification destination
+        await expect(page.getByRole('main').getByText('Dashboard')).toBeVisible({ timeout: 10000 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/e2e/auth.spec.ts` around lines 60 - 61, The negative URL check in
apps/web/e2e/auth.spec.ts using await
expect(page).not.toHaveURL(/\/verify-email\?/) can pass on error pages; update
the test to include a complementary positive assertion after that line — for
example assert the expected redirect target URL or a stable page element/text
(use expect(page).toHaveURL('<expected-path>') or
expect(page.getByText('<expected-heading>')).toBeVisible()) to ensure you
actually landed on the intended page; modify the test around the existing
not.toHaveURL(...) assertion to add this positive check referencing that exact
assertion location.

// Verify landing on a known post-verification destination
await expect(page.getByRole('main').getByText('Dashboard')).toBeVisible({ timeout: 10000 });

// Verify user state in DB AFTER verification (should be verified)
// Add retry/wait logic as DB update might be slightly async
await expect.poll(async () => {
return await db.isEmailVerified(userEmail);
}, {
message: 'User email should be verified in DB',
timeout: 5000
}).toBe(true);
} finally {
// Enterprise Grade: Always clean up data, even if test fails
console.log(`[Teardown] Cleaning up user: ${userEmail}`);
await db.cleanupUser(userEmail);
}
});

test('Sign In with Email/Password', async ({ page, mailpit, db }) => {
let authUser: { email: string; password: string } | undefined;

try {
// --- SETUP: Create a verified user ---
console.log('[Setup] Creating verified user...');

// Use helper to create and verify user
// This helper also handles logging them in initially
authUser = await createVerifiedUser(page, mailpit, db, {
emailPrefix: 'auth-signin'
});

console.log(`Starting Sign In test for: ${authUser.email}`);

// The helper leaves the user logged in. We need to log out to test explicit Sign In.
await page.context().clearCookies();
await page.goto('/login');

// --- TEST: Sign In ---
await page.fill('input[type="email"]', authUser.email);
// Re-type the password (helper returns it)
await page.fill('input[type="password"]', authUser.password);
await page.getByRole('button', { name: /login/i }).click();

// Should now see Dashboard
await expect(page.getByRole('main').getByText('Dashboard')).toBeVisible({ timeout: 15000 });

} finally {
if (authUser?.email) {
await db.cleanupUser(authUser.email);
}
}
});
});
7 changes: 1 addition & 6 deletions apps/web/e2e/example.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ test('has title', async ({ page }) => {
await page.goto('/');

// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Nexiom/);
await expect(page).toHaveTitle(/nexiom/i);
});

test('redirects to login', async ({ page }) => {
await page.goto('/');

// If unauthenticated, it should redirect to login
await expect(page).toHaveURL(/.*login/);
});
142 changes: 142 additions & 0 deletions apps/web/e2e/fixtures/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Pool } from 'pg';
import { randomUUID } from 'node:crypto';

export class DbFixture {
private pool: Pool;

constructor() {
// DB connection string from docker-compose or environment
// Assuming default local dev credentials
const connectionString = process.env.DATABASE_URL || 'postgres://user:password@localhost:5432/nexiom_local';

this.pool = new Pool({
connectionString,
});
}

async close(): Promise<void> {
await this.pool.end();
}

/**
* Checks if a user's email is marked as verified in the database.
*/
async isEmailVerified(email: string): Promise<boolean> {
const query = `
SELECT "emailVerified"
FROM "user"
WHERE email = $1
`;
const result = await this.pool.query(query, [email]);

if (result.rows.length === 0) {
throw new Error(`[DB] User not found: ${email}`);
}

return result.rows[0].emailVerified;
}

/**
* Verify user exists
*/
async userExists(email: string): Promise<boolean> {
const query = 'SELECT 1 FROM "user" WHERE email = $1';
const result = await this.pool.query(query, [email]);
return result.rowCount ? result.rowCount > 0 : false;
}

/**
* Promotes a user to System Owner (Super Admin)
*/
async makeSystemAdmin(email: string): Promise<void> {
// IDs from apps/api/src/constants.ts / admin-bootstrap.ts
const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000';
const OWNER_ROLE_ID = 'owner';
console.log(`[DB] Promoting user to System Admin: ${email}`);
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// 1. Get User ID
const userRes = await client.query('SELECT id FROM "user" WHERE email = $1', [email]);
if (userRes.rows.length === 0) throw new Error(`User not found: ${email}`);
const userId = userRes.rows[0].id;
// 2. Check if System Tenant exists (create if not)
await client.query(`
INSERT INTO "organization" (id, name, slug, status, "isSystem")
VALUES ($1, 'Nexiom Platform', 'system', 'active', true)
ON CONFLICT (id) DO NOTHING
`, [SYSTEM_TENANT_ID]);
// 3. Ensure Owner Role exists
await client.query(`
INSERT INTO "role" (id, name, "isSystem", description)
VALUES ($1, 'Owner', true, 'Full access')
ON CONFLICT (id) DO NOTHING
`, [OWNER_ROLE_ID]);
// 4. Enforce Single Tenant Policy: Remove existing memberships
// The app is strict single-tenant, and signup likely created a default tenant/member.
await client.query('DELETE FROM "member" WHERE "userId" = $1', [userId]);
// 5. Insert Member Record
const memberId = randomUUID();
await client.query(`
INSERT INTO "member" (id, "userId", "organizationId", role)
VALUES ($1, $2, $3, $4)
`, [memberId, userId, SYSTEM_TENANT_ID, OWNER_ROLE_ID]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}

/**
* Hard delete a user for cleanup
*/
async cleanupUser(email: string): Promise<void> {
console.log(`[DB] Cleaning up user: ${email}`);

const client = await this.pool.connect();
try {
await client.query('BEGIN');

// 1. Delete verification tokens (No FK cascade)
await client.query('DELETE FROM "verification" WHERE identifier = $1', [email]);

// 2. Delete user (Cascades to session, account, member, invitation)
await client.query('DELETE FROM "user" WHERE email = $1', [email]);

await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}

// Note: This leaves orphaned Organizations if the user was the sole member.
// For strict E2E, we might want to clean those too, but it requires more complex logic
// (checking member counts) which is skipped here for safety.
}
/**
* Delete an organization by slug for cleanup
*/
async cleanupOrganization(slug: string): Promise<void> {
console.log(`[DB] Cleaning up organization: ${slug}`);
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Delete the organization. Cascading rules usually handle members/invitations,
// but if strict FKs exist without cascade, we might need to delete members first.
// Assuming ON DELETE CASCADE is set up or we'll wrap in try/catch.
// For now, let's try direct delete.
await client.query('DELETE FROM "organization" WHERE slug = $1', [slug]);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
Loading