mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 06:05:41 +00:00
add cypress api and page sweep tests, fix agent and context link bugs they found
This commit is contained in:
@@ -6,6 +6,24 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_USER: libredesk
|
||||
POSTGRES_PASSWORD: libredesk
|
||||
POSTGRES_DB: libredesk
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U libredesk"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
|
||||
env:
|
||||
LIBREDESK_TEST_DB_DSN: "postgres://libredesk:libredesk@127.0.0.1:5432/libredesk?sslmode=disable&connect_timeout=3"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -53,7 +53,7 @@ func handleCreateContextLink(r *fastglue.Request) error {
|
||||
if err := r.Decode(&contextLink, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError)
|
||||
}
|
||||
if err := validateContextLink(app, contextLink); err != nil {
|
||||
if err := validateContextLink(app, &contextLink); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func handleUpdateContextLink(r *fastglue.Request) error {
|
||||
if err := r.Decode(&contextLink, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError)
|
||||
}
|
||||
if err := validateContextLink(app, contextLink); err != nil {
|
||||
if err := validateContextLink(app, &contextLink); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ func handleGetContextLinkURL(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(url)
|
||||
}
|
||||
|
||||
func validateContextLink(app *App, contextLink models.ContextLink) error {
|
||||
func validateContextLink(app *App, contextLink *models.ContextLink) error {
|
||||
if contextLink.Name == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
|
||||
}
|
||||
|
||||
@@ -630,5 +630,11 @@ func validateAgentRequest(app *App, req *agentReq) error {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil)
|
||||
}
|
||||
|
||||
switch req.AvailabilityStatus {
|
||||
case "", models.Online, models.Offline, models.Away, models.AwayManual, models.AwayAndReassigning:
|
||||
default:
|
||||
return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidAvailabilityStatus"), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Backend contract for /api/v1/agents. No browser: these assert what the API
|
||||
// really accepts, rejects and persists, independent of what the form allows.
|
||||
|
||||
describe('API: agents', () => {
|
||||
const stamp = Date.now()
|
||||
const email = `api.agent.${stamp}@example.com`
|
||||
let agentId
|
||||
|
||||
before(() => cy.login())
|
||||
beforeEach(() => cy.login())
|
||||
|
||||
it('rejects a create with no email', () => {
|
||||
cy.api('POST', '/api/v1/agents', { first_name: 'NoEmail', roles: ['Agent'] }, {
|
||||
failOnStatusCode: false
|
||||
}).then(({ status, body }) => {
|
||||
expect(status).to.eq(400)
|
||||
expect(body.error_type).to.eq('InputException')
|
||||
expect(body.message).to.match(/email/i)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a create with a malformed email', () => {
|
||||
cy.api('POST', '/api/v1/agents', {
|
||||
first_name: 'Bad', email: 'not-an-email', roles: ['Agent']
|
||||
}, { failOnStatusCode: false }).then(({ status, body }) => {
|
||||
expect(status).to.eq(400)
|
||||
expect(body.error_type).to.eq('InputException')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a create with no first name', () => {
|
||||
cy.api('POST', '/api/v1/agents', {
|
||||
first_name: '', email: `blank.${stamp}@example.com`, roles: ['Agent']
|
||||
}, { failOnStatusCode: false }).then(({ status, body }) => {
|
||||
expect(status).to.eq(400)
|
||||
expect(body.error_type).to.eq('InputException')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a create with an unknown role', () => {
|
||||
cy.api('POST', '/api/v1/agents', {
|
||||
first_name: 'Ghost', email: `ghost.${stamp}@example.com`, roles: ['NoSuchRole']
|
||||
}, { failOnStatusCode: false }).then(({ status, body }) => {
|
||||
expect(status).to.eq(400)
|
||||
expect(body.error_type).to.eq('InputException')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an update with an unknown availability status', () => {
|
||||
cy.api('POST', '/api/v1/agents', {
|
||||
first_name: 'Avail', email: `avail.${stamp}@example.com`, roles: ['Agent'], send_welcome_email: false
|
||||
}).then(({ body }) => {
|
||||
cy.api('PUT', `/api/v1/agents/${body.data.id}`, {
|
||||
first_name: 'Avail',
|
||||
email: `avail.${stamp}@example.com`,
|
||||
roles: ['Agent'],
|
||||
enabled: true,
|
||||
availability_status: 'not_a_status'
|
||||
}, { failOnStatusCode: false }).then((res) => {
|
||||
expect(res.status).to.eq(400)
|
||||
expect(res.body.error_type).to.eq('InputException')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves availability status when the update omits it', () => {
|
||||
const availEmail = `keep.${stamp}@example.com`
|
||||
cy.api('POST', '/api/v1/agents', {
|
||||
first_name: 'Keep', email: availEmail, roles: ['Agent'], send_welcome_email: false
|
||||
}).then(({ body }) => {
|
||||
const id = body.data.id
|
||||
const before = body.data.availability_status
|
||||
cy.api('PUT', `/api/v1/agents/${id}`, {
|
||||
first_name: 'Keep', email: availEmail, roles: ['Agent'], enabled: true
|
||||
}).its('status').should('eq', 200)
|
||||
cy.api('GET', `/api/v1/agents/${id}`)
|
||||
.its('body.data.availability_status')
|
||||
.should('eq', before)
|
||||
})
|
||||
})
|
||||
|
||||
it('creates an agent and persists every field', () => {
|
||||
cy.api('POST', '/api/v1/agents', {
|
||||
first_name: 'Api',
|
||||
last_name: 'Agent',
|
||||
email,
|
||||
roles: ['Agent'],
|
||||
enabled: true,
|
||||
send_welcome_email: false
|
||||
}).then(({ status, body }) => {
|
||||
expect(status).to.eq(200)
|
||||
expect(body.status).to.eq('success')
|
||||
agentId = body.data.id
|
||||
expect(agentId).to.be.a('number')
|
||||
expect(body.data.email).to.eq(email)
|
||||
expect(body.data.first_name).to.eq('Api')
|
||||
expect(body.data.last_name).to.eq('Agent')
|
||||
expect(body.data.type).to.eq('agent')
|
||||
expect(body.data.enabled).to.eq(true)
|
||||
expect(body.data.roles).to.deep.eq(['Agent'])
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the agent back by id', () => {
|
||||
cy.api('GET', `/api/v1/agents/${agentId}`).then(({ status, body }) => {
|
||||
expect(status).to.eq(200)
|
||||
expect(body.data.email).to.eq(email)
|
||||
expect(body.data.first_name).to.eq('Api')
|
||||
})
|
||||
})
|
||||
|
||||
it('lists the agent', () => {
|
||||
cy.api('GET', '/api/v1/agents').then(({ status, body }) => {
|
||||
expect(status).to.eq(200)
|
||||
const rows = body.data.results || body.data
|
||||
expect(rows.some((a) => a.email === email), 'created agent in list').to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a duplicate email', () => {
|
||||
cy.api('POST', '/api/v1/agents', {
|
||||
first_name: 'Dupe', email, roles: ['Agent'], send_welcome_email: false
|
||||
}, { failOnStatusCode: false }).its('status').should('be.gte', 400)
|
||||
})
|
||||
|
||||
it('updates the agent', () => {
|
||||
cy.api('PUT', `/api/v1/agents/${agentId}`, {
|
||||
first_name: 'Renamed',
|
||||
last_name: 'Agent',
|
||||
email,
|
||||
roles: ['Agent'],
|
||||
enabled: true
|
||||
}).its('status').should('eq', 200)
|
||||
|
||||
cy.api('GET', `/api/v1/agents/${agentId}`)
|
||||
.its('body.data.first_name')
|
||||
.should('eq', 'Renamed')
|
||||
})
|
||||
|
||||
it('404s on an agent that does not exist', () => {
|
||||
cy.api('GET', '/api/v1/agents/99999999', null, { failOnStatusCode: false })
|
||||
.its('status')
|
||||
.should('be.gte', 400)
|
||||
})
|
||||
|
||||
it('deletes the agent', () => {
|
||||
cy.api('DELETE', `/api/v1/agents/${agentId}`).its('status').should('eq', 200)
|
||||
cy.api('GET', `/api/v1/agents/${agentId}`, null, { failOnStatusCode: false })
|
||||
.its('status')
|
||||
.should('be.gte', 400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
// Opens every page listed in the app's own navigation and reports the ones that
|
||||
// break. A page that blanks out after a shared-component change is caught here
|
||||
// with no per-page knowledge of forms or fields.
|
||||
//
|
||||
// The href list is read from the navigation source at runtime, so adding a nav
|
||||
// entry extends this spec automatically.
|
||||
|
||||
const NAV_SOURCE = 'apps/main/src/constants/navigation.js'
|
||||
|
||||
const normalise = (p) => (p.length > 1 ? p.replace(/\/+$/, '') : p)
|
||||
|
||||
describe('Every navigable page loads', () => {
|
||||
let hrefs = []
|
||||
const failures = []
|
||||
let current = null
|
||||
|
||||
// Record the page that threw and keep going, so one broken page does not hide
|
||||
// the state of every page after it.
|
||||
Cypress.on('uncaught:exception', (err) => {
|
||||
failures.push(`${current}: ${err.message.split('\n')[0]}`)
|
||||
return false
|
||||
})
|
||||
|
||||
before(() => {
|
||||
cy.readFile(NAV_SOURCE).then((src) => {
|
||||
hrefs = [...src.matchAll(/href:\s*'([^']+)'/g)].map((m) => m[1])
|
||||
expect(hrefs.length, 'nav hrefs discovered').to.be.greaterThan(10)
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
cy.viewport(1280, 800)
|
||||
cy.login()
|
||||
})
|
||||
|
||||
it('opens each one without a client-side error', () => {
|
||||
cy.wrap(hrefs).each((href) => {
|
||||
current = href
|
||||
cy.visit(href)
|
||||
// Landing anywhere else means the route died or the session broke.
|
||||
cy.location('pathname', { timeout: 15000 })
|
||||
.then((p) => normalise(p))
|
||||
.should('eq', normalise(href))
|
||||
cy.get('body').should('be.visible')
|
||||
cy.contains(/something went wrong|unexpected error/i).should('not.exist')
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
expect(failures, `pages that threw:\n${failures.join('\n')}`).to.be.empty
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -52,3 +52,18 @@ Cypress.Commands.add('selectOption', (triggerLabel, optionText) => {
|
||||
cy.contains('button[role="combobox"]', triggerLabel).click()
|
||||
cy.get('[role="option"]').contains(optionText).click()
|
||||
})
|
||||
|
||||
// Authenticated API request. Writes need the X-CSRFTOKEN header echoing the
|
||||
// csrf_token cookie the backend set at login, else they are rejected with 403.
|
||||
// Pass failOnStatusCode: false to assert on error responses.
|
||||
Cypress.Commands.add('api', (method, path, body, options = {}) => {
|
||||
return cy.getCookie('csrf_token').then((cookie) => {
|
||||
return cy.request({
|
||||
method,
|
||||
url: path,
|
||||
body,
|
||||
headers: cookie ? { 'X-CSRFTOKEN': cookie.value } : {},
|
||||
...options
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1471,6 +1471,7 @@
|
||||
"user.userAlreadyLoggedIn": "User already logged in",
|
||||
"user.userCannotDeleteSelf": "You cannot delete yourself",
|
||||
"validation.invalid": "Invalid",
|
||||
"validation.invalidAvailabilityStatus": "Invalid availability status",
|
||||
"validation.invalidColor": "Invalid color",
|
||||
"validation.invalidCredential": "Invalid credential",
|
||||
"validation.invalidCsvFile": "Invalid CSV file",
|
||||
@@ -1484,6 +1485,7 @@
|
||||
"validation.invalidPermission": "Invalid permission",
|
||||
"validation.invalidPhone": "Invalid phone number",
|
||||
"validation.invalidPortValue": "Invalid port value",
|
||||
"validation.invalidRole": "Invalid role",
|
||||
"validation.invalidSnoozeDuration": "Invalid snooze duration",
|
||||
"validation.invalidTimeFormat": "Invalid time format (HH:mm)",
|
||||
"validation.invalidUrl": "Invalid URL",
|
||||
|
||||
+14
-1
@@ -2,6 +2,8 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -102,6 +104,10 @@ func (u *Manager) CreateAgent(firstName, lastName, email string, roles []string)
|
||||
if dbutil.IsUniqueViolationError(err) {
|
||||
return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil)
|
||||
}
|
||||
// The insert joins the named roles, so an unknown role yields no row.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return models.User{}, envelope.NewError(envelope.InputError, u.i18n.T("validation.invalidRole"), nil)
|
||||
}
|
||||
u.lo.Error("error creating user", "error", err)
|
||||
return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -129,7 +135,14 @@ func (u *Manager) UpdateAgent(id int, firstName, lastName, email string, roles [
|
||||
}
|
||||
|
||||
// Update user in the database.
|
||||
if _, err := u.q.UpdateAgent.Exec(id, firstName, lastName, email, pq.Array(roles), null.String{}, hashedPassword, enabled, availabilityStatus); err != nil {
|
||||
// COALESCE in the query preserves the stored status only on NULL; an empty
|
||||
// string would reach the enum cast and error.
|
||||
availability := null.String{}
|
||||
if availabilityStatus != "" {
|
||||
availability = null.StringFrom(availabilityStatus)
|
||||
}
|
||||
|
||||
if _, err := u.q.UpdateAgent.Exec(id, firstName, lastName, email, pq.Array(roles), null.String{}, hashedPassword, enabled, availability); err != nil {
|
||||
if dbutil.IsUniqueViolationError(err) {
|
||||
return envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user