122 lines
4.9 KiB
JavaScript
122 lines
4.9 KiB
JavaScript
// tests/e2e/98-security-basics.spec.mjs
|
|
// @ts-check
|
|
import { test, expect } from '@playwright/test';
|
|
import { loadTestUser } from '../utils/testUser.js';
|
|
|
|
test.describe('@p0 Security basics', () => {
|
|
test.setTimeout(20000);
|
|
|
|
test('wrong creds are generic; no cookie set; rate-limit headers present', async ({ page }) => {
|
|
const badUser = `nope_${Date.now()}`;
|
|
const badPass = 'Bad#12345!';
|
|
|
|
// A) Via raw API (check headers/body semantics)
|
|
const resp = await page.request.post('/api/signin', {
|
|
data: { username: badUser, password: badPass },
|
|
});
|
|
expect(resp.status()).toBe(401);
|
|
const body = await resp.json();
|
|
expect(body?.error || '').toMatch(/invalid username or password/i);
|
|
// no sensitive fields in body
|
|
expect(body).not.toHaveProperty('id');
|
|
expect(body).not.toHaveProperty('user');
|
|
expect(body).not.toHaveProperty('token');
|
|
|
|
// standard rate-limit headers (express-rate-limit standardHeaders:true)
|
|
const h = resp.headers();
|
|
expect(
|
|
'ratelimit-remaining' in h ||
|
|
'ratelimit-limit' in h ||
|
|
'ratelimit-reset' in h
|
|
).toBeTruthy();
|
|
|
|
// B) Via UI (no cookie; generic message)
|
|
await page.context().clearCookies();
|
|
await page.goto('/signin', { waitUntil: 'networkidle' });
|
|
await page.getByPlaceholder('Username', { exact: true }).fill(badUser);
|
|
await page.getByPlaceholder('Password', { exact: true }).fill(badPass);
|
|
await page.getByRole('button', { name: /^Sign In$/ }).click();
|
|
|
|
// Generic UI error visible
|
|
await expect(page.getByText(/invalid username or password/i)).toBeVisible();
|
|
|
|
// No auth cookie was set
|
|
const cookies = await page.context().cookies();
|
|
const anyAuth = cookies.some(c => c.httpOnly && /jwt|session|auth/i.test(c.name));
|
|
expect(anyAuth).toBeFalsy();
|
|
});
|
|
|
|
test('successful signin: body has no PII/token; secure, HttpOnly cookie; minimal JIT-PII profile', async ({ page }) => {
|
|
const user = loadTestUser();
|
|
|
|
await page.context().clearCookies();
|
|
await page.goto('/signin', { waitUntil: 'networkidle' });
|
|
|
|
// Capture the /api/signin response to inspect body
|
|
const signinResponsePromise = page.waitForResponse(r =>
|
|
r.request().method() === 'POST' && r.url().includes('/api/signin')
|
|
);
|
|
|
|
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
|
|
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
|
|
await page.getByRole('button', { name: /^Sign In$/ }).click();
|
|
|
|
const signinResp = await signinResponsePromise;
|
|
expect(signinResp.status()).toBe(200);
|
|
const signinBody = await signinResp.json();
|
|
// Body should be minimal (no id/email/token/user object)
|
|
expect(signinBody).not.toHaveProperty('id');
|
|
expect(signinBody).not.toHaveProperty('user');
|
|
expect(signinBody).not.toHaveProperty('token');
|
|
expect(signinBody?.message || '').toMatch(/login successful/i);
|
|
|
|
// Land on SignInLanding
|
|
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
|
|
|
|
// Cookie flags: HttpOnly + Secure; SameSite Lax/Strict (or None+Secure)
|
|
const cookies = await page.context().cookies();
|
|
const authCookie = cookies.find(c =>
|
|
c.httpOnly && c.secure && /jwt|session|auth|aptiva/i.test(c.name)
|
|
);
|
|
expect(authCookie).toBeTruthy();
|
|
if (authCookie) {
|
|
// sameSite may be 'Lax' | 'Strict' | 'None'
|
|
expect(['Lax', 'Strict', 'None']).toContain(authCookie.sameSite);
|
|
if (authCookie.sameSite === 'None') {
|
|
expect(authCookie.secure).toBeTruthy();
|
|
}
|
|
}
|
|
|
|
// JIT-PII: minimal profile request should NOT leak id/email
|
|
const prof = await page.request.get('/api/user-profile?fields=firstname');
|
|
expect(prof.status()).toBe(200);
|
|
const profJson = await prof.json();
|
|
// allowlist keys that may legitimately be present
|
|
const allowed = new Set(['firstname', 'is_premium', 'is_pro_premium']);
|
|
for (const k of Object.keys(profJson)) {
|
|
expect(allowed.has(k)).toBeTruthy();
|
|
}
|
|
expect(profJson).not.toHaveProperty('id');
|
|
expect(profJson).not.toHaveProperty('email');
|
|
});
|
|
|
|
test('username availability endpoint returns only {exists} (no leakage)', async ({ page }) => {
|
|
const user = loadTestUser();
|
|
const unknown = `available_${Date.now()}`;
|
|
|
|
const knownResp = await page.request.get(`/api/check-username/${encodeURIComponent(user.username)}`);
|
|
expect(knownResp.status()).toBe(200);
|
|
const knownJson = await knownResp.json();
|
|
expect(knownJson).toHaveProperty('exists');
|
|
|
|
const unknownResp = await page.request.get(`/api/check-username/${encodeURIComponent(unknown)}`);
|
|
expect(unknownResp.status()).toBe(200);
|
|
const unknownJson = await unknownResp.json();
|
|
expect(unknownJson).toHaveProperty('exists');
|
|
|
|
// Both shapes should be minimal (only 'exists')
|
|
expect(Object.keys(knownJson)).toEqual(['exists']);
|
|
expect(Object.keys(unknownJson)).toEqual(['exists']);
|
|
});
|
|
});
|