Drop company_id column from all 22 domain tables via migration. Remove companyId from JWT payload, auth plugins, all service method signatures (~215 occurrences), all route handlers (~105 occurrences), test runner, test suites, and frontend auth store/types. The company table stays as store settings (name, timezone). Tenant isolation in a SaaS deployment would be at the database level (one DB per customer) not the application level. All 107 API tests pass. Zero TSC errors across all packages.
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
import { suite } from '../lib/context.js'
|
|
|
|
suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
|
|
// Helper: register a user with no roles (restricted)
|
|
async function createRestrictedUser() {
|
|
const email = `restricted-${Date.now()}@test.com`
|
|
const password = 'testpassword1234'
|
|
|
|
// Register via raw fetch
|
|
const registerRes = await fetch(`${t.baseUrl}/v1/auth/register`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password, firstName: 'Restricted', lastName: 'User', role: 'staff' }),
|
|
})
|
|
const registerData = await registerRes.json() as { token: string }
|
|
|
|
// Login (no roles assigned, so no permissions)
|
|
const loginRes = await fetch(`${t.baseUrl}/v1/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
})
|
|
const loginData = await loginRes.json() as { token: string }
|
|
return loginData.token
|
|
}
|
|
|
|
// Helper: register a user and assign a specific role
|
|
async function createUserWithRole(roleSlug: string) {
|
|
const email = `${roleSlug}-${Date.now()}@test.com`
|
|
const password = 'testpassword1234'
|
|
|
|
const registerRes = await fetch(`${t.baseUrl}/v1/auth/register`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password, firstName: roleSlug, lastName: 'User', role: 'staff' }),
|
|
})
|
|
const registerData = await registerRes.json() as { user: { id: string } }
|
|
|
|
// Get the role and assign it
|
|
const rolesRes = await t.api.get<{ data: { id: string; slug: string }[] }>('/v1/roles')
|
|
const role = rolesRes.data.data.find((r: { slug: string }) => r.slug === roleSlug)
|
|
if (role) {
|
|
await t.api.post(`/v1/users/${registerData.user.id}/roles`, { roleId: role.id })
|
|
}
|
|
|
|
// Login to get token with permissions
|
|
const loginRes = await fetch(`${t.baseUrl}/v1/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
})
|
|
const loginData = await loginRes.json() as { token: string }
|
|
return loginData.token
|
|
}
|
|
|
|
async function fetchAs(token: string, method: string, path: string, body?: unknown) {
|
|
const headers: Record<string, string> = { Authorization: `Bearer ${token}` }
|
|
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
|
const res = await fetch(`${t.baseUrl}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
})
|
|
return { status: res.status, data: await res.json() }
|
|
}
|
|
|
|
t.test('user with no roles gets 403 on all routes', { tags: ['deny'] }, async () => {
|
|
const token = await createRestrictedUser()
|
|
|
|
const res = await fetchAs(token, 'GET', '/v1/accounts')
|
|
t.assert.equal(res.status, 403)
|
|
})
|
|
|
|
t.test('viewer role can read accounts but not create', { tags: ['viewer'] }, async () => {
|
|
const token = await createUserWithRole('viewer')
|
|
|
|
const readRes = await fetchAs(token, 'GET', '/v1/accounts')
|
|
t.assert.equal(readRes.status, 200)
|
|
|
|
const createRes = await fetchAs(token, 'POST', '/v1/accounts', { name: 'Should Fail' })
|
|
t.assert.equal(createRes.status, 403)
|
|
})
|
|
|
|
t.test('viewer cannot delete accounts', { tags: ['viewer'] }, async () => {
|
|
const token = await createUserWithRole('viewer')
|
|
// Try to delete — should be 403 even before 404
|
|
const res = await fetchAs(token, 'DELETE', '/v1/accounts/a0000000-0000-0000-0000-999999999999')
|
|
t.assert.equal(res.status, 403)
|
|
})
|
|
|
|
t.test('sales associate can create accounts but not delete', { tags: ['sales'] }, async () => {
|
|
const token = await createUserWithRole('sales_associate')
|
|
|
|
const createRes = await fetchAs(token, 'POST', '/v1/accounts', { name: 'Sales Test' })
|
|
t.assert.equal(createRes.status, 201)
|
|
|
|
const deleteRes = await fetchAs(token, 'DELETE', `/v1/accounts/${createRes.data.id}`)
|
|
t.assert.equal(deleteRes.status, 403)
|
|
})
|
|
|
|
t.test('technician can view repairs but not accounts edit', { tags: ['technician'] }, async () => {
|
|
const token = await createUserWithRole('technician')
|
|
|
|
// Can view accounts (via accounts.view in technician role — wait, technician doesn't have accounts.view)
|
|
const acctRes = await fetchAs(token, 'GET', '/v1/accounts')
|
|
t.assert.equal(acctRes.status, 200) // technician has accounts.view
|
|
|
|
// Cannot create accounts
|
|
const createRes = await fetchAs(token, 'POST', '/v1/accounts', { name: 'Should Fail' })
|
|
t.assert.equal(createRes.status, 403)
|
|
})
|
|
|
|
t.test('instructor cannot access inventory', { tags: ['instructor'] }, async () => {
|
|
const token = await createUserWithRole('instructor')
|
|
|
|
const res = await fetchAs(token, 'GET', '/v1/products')
|
|
t.assert.equal(res.status, 403)
|
|
})
|
|
|
|
t.test('admin role has access to everything', { tags: ['admin'] }, async () => {
|
|
// The default test user is admin — just verify
|
|
const res = await t.api.get('/v1/roles')
|
|
t.assert.status(res, 200)
|
|
|
|
const permsRes = await t.api.get('/v1/permissions')
|
|
t.assert.status(permsRes, 200)
|
|
|
|
const usersRes = await t.api.get('/v1/users')
|
|
t.assert.status(usersRes, 200)
|
|
})
|
|
|
|
t.test('permission inheritance: admin implies edit and view', { tags: ['inheritance'] }, async () => {
|
|
// Create a custom role with only accounts.admin
|
|
const roleRes = await t.api.post('/v1/roles', {
|
|
name: 'Admin Only Test',
|
|
slug: `admin_only_${Date.now()}`,
|
|
permissionSlugs: ['accounts.admin'],
|
|
})
|
|
t.assert.status(roleRes, 201)
|
|
|
|
// Create user and assign this role
|
|
const email = `inherit-${Date.now()}@test.com`
|
|
const password = 'testpassword1234'
|
|
const regRes = await fetch(`${t.baseUrl}/v1/auth/register`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password, firstName: 'Inherit', lastName: 'Test', role: 'staff' }),
|
|
})
|
|
const regData = await regRes.json() as { user: { id: string } }
|
|
await t.api.post(`/v1/users/${regData.user.id}/roles`, { roleId: roleRes.data.id })
|
|
|
|
const loginRes = await fetch(`${t.baseUrl}/v1/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
})
|
|
const loginData = await loginRes.json() as { token: string }
|
|
|
|
// Should be able to view (inherited from admin)
|
|
const viewRes = await fetchAs(loginData.token, 'GET', '/v1/accounts')
|
|
t.assert.equal(viewRes.status, 200)
|
|
|
|
// Should be able to create (edit inherited from admin)
|
|
const createRes = await fetchAs(loginData.token, 'POST', '/v1/accounts', { name: 'Inherited Edit' })
|
|
t.assert.equal(createRes.status, 201)
|
|
|
|
// Should be able to delete (admin)
|
|
const deleteRes = await fetchAs(loginData.token, 'DELETE', `/v1/accounts/${createRes.data.id}`)
|
|
t.assert.equal(deleteRes.status, 200)
|
|
})
|
|
|
|
t.test('roles list returns system roles', { tags: ['roles'] }, async () => {
|
|
const res = await t.api.get('/v1/roles')
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.pagination)
|
|
const slugs = res.data.data.map((r: { slug: string }) => r.slug)
|
|
t.assert.includes(slugs, 'admin')
|
|
t.assert.includes(slugs, 'manager')
|
|
t.assert.includes(slugs, 'sales_associate')
|
|
t.assert.includes(slugs, 'technician')
|
|
t.assert.includes(slugs, 'instructor')
|
|
t.assert.includes(slugs, 'viewer')
|
|
})
|
|
|
|
t.test('roles/all returns unpaginated list', { tags: ['roles'] }, async () => {
|
|
const res = await t.api.get('/v1/roles/all')
|
|
t.assert.status(res, 200)
|
|
t.assert.greaterThan(res.data.data.length, 5)
|
|
t.assert.equal(res.data.pagination, undefined)
|
|
})
|
|
|
|
t.test('permissions list returns all system permissions', { tags: ['permissions'] }, async () => {
|
|
const res = await t.api.get('/v1/permissions')
|
|
t.assert.status(res, 200)
|
|
t.assert.greaterThan(res.data.data.length, 30)
|
|
})
|
|
|
|
t.test('cannot delete system role', { tags: ['roles'] }, async () => {
|
|
const rolesRes = await t.api.get('/v1/roles')
|
|
const adminRole = rolesRes.data.data.find((r: { slug: string }) => r.slug === 'admin')
|
|
t.assert.ok(adminRole)
|
|
|
|
const deleteRes = await t.api.del(`/v1/roles/${adminRole.id}`)
|
|
t.assert.equal(deleteRes.status, 403)
|
|
})
|
|
|
|
t.test('roles search by name', { tags: ['roles', 'search'] }, async () => {
|
|
const res = await t.api.get('/v1/roles', { q: 'admin' })
|
|
t.assert.status(res, 200)
|
|
t.assert.greaterThan(res.data.data.length, 0)
|
|
t.assert.ok(res.data.data.every((r: { name: string }) => r.name.toLowerCase().includes('admin')))
|
|
})
|
|
|
|
t.test('roles sort by name descending', { tags: ['roles', 'sort'] }, async () => {
|
|
const res = await t.api.get('/v1/roles', { sort: 'name', order: 'desc' })
|
|
t.assert.status(res, 200)
|
|
const names = res.data.data.map((r: { name: string }) => r.name)
|
|
const sorted = [...names].sort().reverse()
|
|
t.assert.equal(JSON.stringify(names), JSON.stringify(sorted))
|
|
})
|
|
|
|
t.test('users list is paginated with roles', { tags: ['users', 'pagination'] }, async () => {
|
|
const res = await t.api.get('/v1/users')
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.pagination)
|
|
t.assert.greaterThan(res.data.data.length, 0)
|
|
// Each user should have a roles array
|
|
const first = res.data.data[0]
|
|
t.assert.ok(Array.isArray(first.roles))
|
|
})
|
|
|
|
t.test('users search by name', { tags: ['users', 'search'] }, async () => {
|
|
// Create a user with a distinctive name
|
|
await fetch(`${t.baseUrl}/v1/auth/register`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: `searchme-${Date.now()}@test.com`, password: 'testpassword1234', firstName: 'Searchable', lastName: 'Pessoa', role: 'staff' }),
|
|
})
|
|
|
|
const res = await t.api.get('/v1/users', { q: 'Searchable' })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.data.length, 1)
|
|
t.assert.equal(res.data.data[0].firstName, 'Searchable')
|
|
})
|
|
|
|
t.test('users sort by email ascending', { tags: ['users', 'sort'] }, async () => {
|
|
const res = await t.api.get('/v1/users', { sort: 'email', order: 'asc' })
|
|
t.assert.status(res, 200)
|
|
const emails = res.data.data.map((u: { email: string }) => u.email)
|
|
const sorted = [...emails].sort()
|
|
t.assert.equal(JSON.stringify(emails), JSON.stringify(sorted))
|
|
})
|
|
|
|
t.test('can disable and re-enable a user', { tags: ['users', 'status'] }, async () => {
|
|
// Create a user
|
|
const email = `disable-${Date.now()}@test.com`
|
|
const password = 'testpassword1234'
|
|
const regRes = await fetch(`${t.baseUrl}/v1/auth/register`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password, firstName: 'Disable', lastName: 'Me', role: 'staff' }),
|
|
})
|
|
const regData = await regRes.json() as { user: { id: string } }
|
|
const userId = regData.user.id
|
|
|
|
// Disable the user
|
|
const disableRes = await t.api.patch(`/v1/users/${userId}/status`, { isActive: false })
|
|
t.assert.status(disableRes, 200)
|
|
t.assert.equal(disableRes.data.isActive, false)
|
|
|
|
// Disabled user cannot authenticate
|
|
const loginRes = await fetch(`${t.baseUrl}/v1/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
})
|
|
const loginData = await loginRes.json() as { token: string }
|
|
|
|
// Try to use the token — should get 401
|
|
const authRes = await fetch(`${t.baseUrl}/v1/accounts`, {
|
|
headers: { Authorization: `Bearer ${loginData.token}` },
|
|
})
|
|
t.assert.equal(authRes.status, 401)
|
|
|
|
// Re-enable the user
|
|
const enableRes = await t.api.patch(`/v1/users/${userId}/status`, { isActive: true })
|
|
t.assert.status(enableRes, 200)
|
|
t.assert.equal(enableRes.data.isActive, true)
|
|
|
|
// Now they can authenticate again
|
|
const reLoginRes = await fetch(`${t.baseUrl}/v1/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
})
|
|
const reLoginData = await reLoginRes.json() as { token: string }
|
|
const reAuthRes = await fetch(`${t.baseUrl}/v1/accounts`, {
|
|
headers: { Authorization: `Bearer ${reLoginData.token}` },
|
|
})
|
|
// Will be 403 (no permissions) but NOT 401 (not disabled)
|
|
t.assert.notEqual(reAuthRes.status, 401)
|
|
})
|
|
|
|
t.test('cannot disable yourself', { tags: ['users', 'status'] }, async () => {
|
|
// Get current user ID from the users list
|
|
const usersRes = await t.api.get('/v1/users')
|
|
const currentUser = usersRes.data.data.find((u: { email: string }) => u.email === 'test@forte.dev')
|
|
t.assert.ok(currentUser)
|
|
|
|
const res = await t.api.patch(`/v1/users/${currentUser.id}/status`, { isActive: false })
|
|
t.assert.equal(res.status, 400)
|
|
})
|
|
|
|
t.test('users list includes isActive field', { tags: ['users'] }, async () => {
|
|
const res = await t.api.get('/v1/users')
|
|
t.assert.status(res, 200)
|
|
const first = res.data.data[0]
|
|
t.assert.equal(typeof first.isActive, 'boolean')
|
|
})
|
|
|
|
t.test('can create and delete custom role', { tags: ['roles'] }, async () => {
|
|
const createRes = await t.api.post('/v1/roles', {
|
|
name: 'Temp Role',
|
|
slug: `temp_${Date.now()}`,
|
|
permissionSlugs: ['accounts.view'],
|
|
})
|
|
t.assert.status(createRes, 201)
|
|
|
|
const deleteRes = await t.api.del(`/v1/roles/${createRes.data.id}`)
|
|
t.assert.status(deleteRes, 200)
|
|
})
|
|
})
|