diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index ab32da70..53f28de9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -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 diff --git a/cmd/context_links.go b/cmd/context_links.go index 76bc90f0..ca5cc6ad 100644 --- a/cmd/context_links.go +++ b/cmd/context_links.go @@ -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) } diff --git a/cmd/users.go b/cmd/users.go index 6e78956a..4a5766c2 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -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 } diff --git a/frontend/cypress/e2e/api/agents.cy.js b/frontend/cypress/e2e/api/agents.cy.js new file mode 100644 index 00000000..6bb9495f --- /dev/null +++ b/frontend/cypress/e2e/api/agents.cy.js @@ -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) + }) +}) diff --git a/frontend/cypress/e2e/ui/adminSweep.cy.js b/frontend/cypress/e2e/ui/adminSweep.cy.js new file mode 100644 index 00000000..9d4454aa --- /dev/null +++ b/frontend/cypress/e2e/ui/adminSweep.cy.js @@ -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 + }) + }) +}) diff --git a/frontend/cypress/support/commands.js b/frontend/cypress/support/commands.js index bc949e8f..e3b3dc35 100644 --- a/frontend/cypress/support/commands.js +++ b/frontend/cypress/support/commands.js @@ -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 + }) + }) +}) diff --git a/i18n/en-US.json b/i18n/en-US.json index b3e628e6..2304cd5e 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -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", diff --git a/internal/user/agent.go b/internal/user/agent.go index 958473ca..97558aaf 100644 --- a/internal/user/agent.go +++ b/internal/user/agent.go @@ -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) }