Rebrand from Forte (music-store-specific) to LunarFront (any small business): - Package namespace @forte/* → @lunarfront/* - Database forte/forte_test → lunarfront/lunarfront_test - Docker containers, volumes, connection strings - UI branding, localStorage keys, test emails - All documentation and planning docs Generalize music-specific terminology: - instrumentDescription → itemDescription - instrumentCount → itemCount - instrumentType → itemCategory (on service templates) - New migration 0027_generalize_terminology for column renames - Seed data updated with generic examples - RBAC descriptions updated
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@lunarfront.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)
|
|
})
|
|
})
|