190 lines
8.3 KiB
Go
190 lines
8.3 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package webtests
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"code.vikunja.io/api/pkg/config"
|
|
"code.vikunja.io/api/pkg/models"
|
|
"code.vikunja.io/api/pkg/routes"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestHumaUserWebhook ports the v1 user-webhook coverage (the per-user sibling of
|
|
// the project webhooks tested in TestHumaWebhook) to /api/v2. User webhooks live
|
|
// at /user/settings/webhooks{,/{webhook}} — list, events, create, update, delete;
|
|
// there is deliberately no ReadOne (webhooks carry credentials).
|
|
//
|
|
// Ownership gradient — a user webhook is owned by its UserID, and every Can* boils
|
|
// down to "are you that user". Fixtures: webhooks #6/#7 belong to user6, #8 to
|
|
// user1. The actor is user6 (not user1): the user-webhook e2e tests dispatch
|
|
// user-directed events only for users 1 and 2, so user6-owned fixtures never fire
|
|
// there. The point of these cases is that user6 sees and mutates only their own
|
|
// webhooks and is forbidden on user1's.
|
|
func TestHumaUserWebhook(t *testing.T) {
|
|
// availableWebhookEvents / userDirectedWebhookEvents are populated by
|
|
// RegisterListeners(), which the webtests harness does not call. Register the
|
|
// one user-directed event the fixtures and these cases use so Create/Update
|
|
// validation accepts it.
|
|
models.RegisterUserDirectedEventForWebhook(&models.TaskReminderFiredEvent{})
|
|
|
|
owner := webHandlerTestV2{
|
|
user: &testuser6,
|
|
basePath: "/api/v2/user/settings/webhooks",
|
|
idParam: "webhook",
|
|
t: t,
|
|
}
|
|
require.NoError(t, owner.ensureEnv())
|
|
|
|
t.Run("ReadAll", func(t *testing.T) {
|
|
t.Run("Normal - sees only own webhooks", func(t *testing.T) {
|
|
rec, err := owner.testReadAllWithUser(nil, nil)
|
|
require.NoError(t, err)
|
|
ids := webhookIDsFromReadAll(t, rec.Body.Bytes())
|
|
// user6 owns #6 and #7; #8 belongs to user1 and must not appear.
|
|
assert.ElementsMatch(t, []int64{6, 7}, ids, "body: %s", rec.Body.String())
|
|
assert.Contains(t, rec.Body.String(), `"target_url"`)
|
|
})
|
|
t.Run("Secret and basic auth credentials are never exposed", func(t *testing.T) {
|
|
rec, err := owner.testReadAllWithUser(nil, nil)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, rec.Body.String(), `uwh-secret-fixture`)
|
|
assert.NotContains(t, rec.Body.String(), `uwh-basicauth-user`)
|
|
assert.NotContains(t, rec.Body.String(), `uwh-basicauth-pass`)
|
|
})
|
|
})
|
|
|
|
t.Run("Events", func(t *testing.T) {
|
|
// The events route reports only user-directed events. task.reminder.fired
|
|
// is registered above; task.updated (project-only) must not be listed.
|
|
token := humaTokenFor(t, &testuser6)
|
|
rec := humaRequest(t, owner.e, http.MethodGet, "/api/v2/user/settings/webhooks/events", "", token, "")
|
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
|
var events []string
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &events), "body: %s", rec.Body.String())
|
|
assert.Contains(t, events, "task.reminder.fired")
|
|
assert.NotContains(t, events, "task.updated")
|
|
})
|
|
|
|
t.Run("Create", func(t *testing.T) {
|
|
t.Run("Normal", func(t *testing.T) {
|
|
rec, err := owner.testCreateWithUser(nil, nil,
|
|
`{"target_url":"https://example.com/new","events":["task.reminder.fired"]}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusCreated, rec.Code)
|
|
assert.Contains(t, rec.Body.String(), `"target_url":"https://example.com/new"`)
|
|
// Ownership comes from the token, not the body.
|
|
assert.Contains(t, rec.Body.String(), `"user_id":6`)
|
|
})
|
|
t.Run("Secret and basic auth are not echoed back", func(t *testing.T) {
|
|
rec, err := owner.testCreateWithUser(nil, nil,
|
|
`{"target_url":"https://example.com/secret","events":["task.reminder.fired"],"secret":"top-secret","basic_auth_user":"u","basic_auth_password":"p"}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusCreated, rec.Code)
|
|
assert.NotContains(t, rec.Body.String(), `top-secret`)
|
|
assert.NotContains(t, rec.Body.String(), `"basic_auth_user":"u"`)
|
|
assert.NotContains(t, rec.Body.String(), `"basic_auth_password":"p"`)
|
|
})
|
|
t.Run("Non user-directed event rejected", func(t *testing.T) {
|
|
// task.updated is a project event, not user-directed; Create rejects it
|
|
// → InvalidFieldError, surfaced as 422 on v2.
|
|
_, err := owner.testCreateWithUser(nil, nil,
|
|
`{"target_url":"https://example.com/x","events":["task.updated"]}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Missing target url", func(t *testing.T) {
|
|
_, err := owner.testCreateWithUser(nil, nil, `{"events":["task.reminder.fired"]}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
|
|
t.Run("Update", func(t *testing.T) {
|
|
t.Run("Normal - only events change", func(t *testing.T) {
|
|
rec, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "6"},
|
|
`{"events":["task.reminder.fired"],"target_url":"https://example.com/ignored"}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Contains(t, rec.Body.String(), `"id":6`)
|
|
|
|
rec, err = owner.testReadAllWithUser(nil, nil)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, rec.Body.String(), `https://example.com/user-webhook-fixture`,
|
|
"target_url must stay the fixture value; only events are mutable")
|
|
assert.NotContains(t, rec.Body.String(), `https://example.com/ignored`)
|
|
})
|
|
t.Run("Cannot update another user's webhook", func(t *testing.T) {
|
|
// webhook #8 belongs to user1; canDoWebhook resolves ownership from the
|
|
// stored row, so user6 is forbidden regardless of the URL.
|
|
_, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "8"},
|
|
`{"target_url":"https://example.com/wh","events":["task.reminder.fired"]}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Nonexisting", func(t *testing.T) {
|
|
// canDoWebhook returns false for a missing webhook → 403, not 404.
|
|
_, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "9999"},
|
|
`{"target_url":"https://example.com/wh","events":["task.reminder.fired"]}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
|
|
t.Run("Delete", func(t *testing.T) {
|
|
t.Run("Cannot delete another user's webhook", func(t *testing.T) {
|
|
_, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "8"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Nonexisting", func(t *testing.T) {
|
|
_, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "9999"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Normal", func(t *testing.T) {
|
|
rec, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "7"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusNoContent, rec.Code)
|
|
assert.Empty(t, rec.Body.String())
|
|
})
|
|
})
|
|
}
|
|
|
|
// TestHumaUserWebhook_DisabledByConfig confirms RegisterUserWebhookRoutes skips
|
|
// the resource when webhooks.enabled is false, so the v2 user-webhook routes 404
|
|
// rather than running with the feature toggled off.
|
|
func TestHumaUserWebhook_DisabledByConfig(t *testing.T) {
|
|
_, err := setupTestEnv()
|
|
require.NoError(t, err)
|
|
|
|
config.WebhooksEnabled.Set(false)
|
|
defer config.WebhooksEnabled.Set(true)
|
|
|
|
e := routes.NewEcho()
|
|
routes.RegisterRoutes(e)
|
|
|
|
token := humaTokenFor(t, &testuser1)
|
|
rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/webhooks", "", token, "")
|
|
assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when webhooks are disabled; body: %s", rec.Body.String())
|
|
}
|