288 lines
13 KiB
Go
288 lines
13 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"
|
|
)
|
|
|
|
// TestHumaWebhook ports the v1 webhook coverage (TestWebhook) to /api/v2 and
|
|
// extends it to the full permission matrix the v1 model enforces but its thin
|
|
// webtest never exercised. Project webhooks are nested under
|
|
// /projects/{project}/webhooks{,/{webhook}} — list, create, update, delete; there
|
|
// is deliberately no ReadOne (webhooks carry secrets).
|
|
//
|
|
// Permission gradient — Webhook.CanRead delegates to Project.CanRead (any share
|
|
// level), while Can{Create,Update,Delete} delegate to Project.CanUpdate →
|
|
// Project.CanWrite. The same user walks every rung by switching the parent path:
|
|
// - project 1 (owned by testuser1): can do everything; holds fixture webhook #1
|
|
// - project 9 (read share): CAN list, CANNOT create/update/delete (webhook #2)
|
|
// - project 10 (write share): CAN list/create/update/delete (webhook #3)
|
|
// - project 11 (admin share): CAN list/create/update/delete (webhook #4)
|
|
// - project 2 (no access, owned by user3): forbidden on everything
|
|
func TestHumaWebhook(t *testing.T) {
|
|
// availableWebhookEvents is populated by RegisterListeners(), which the
|
|
// webtests harness does not call. Register the one event the fixtures and
|
|
// these cases use so Create/Update validation accepts it.
|
|
models.RegisterEventForWebhook(&models.TaskUpdatedEvent{})
|
|
|
|
owned := webHandlerTestV2{
|
|
user: &testuser1,
|
|
basePath: "/api/v2/projects/1/webhooks",
|
|
idParam: "webhook",
|
|
t: t,
|
|
}
|
|
require.NoError(t, owned.ensureEnv())
|
|
// All harnesses share owned's Echo: each setupTestEnv() regenerates the JWT
|
|
// signing secret, so independent instances would invalidate each other's tokens.
|
|
on := func(projectID string) *webHandlerTestV2 {
|
|
return &webHandlerTestV2{
|
|
user: &testuser1,
|
|
basePath: "/api/v2/projects/" + projectID + "/webhooks",
|
|
idParam: "webhook",
|
|
t: t,
|
|
e: owned.e,
|
|
}
|
|
}
|
|
readShared := on("9")
|
|
writeShared := on("10")
|
|
adminShared := on("11")
|
|
forbidden := on("2")
|
|
|
|
t.Run("ReadAll", func(t *testing.T) {
|
|
t.Run("Normal", func(t *testing.T) {
|
|
rec, err := owned.testReadAllWithUser(nil, nil)
|
|
require.NoError(t, err)
|
|
// project 1 has exactly fixture webhook #1.
|
|
ids := webhookIDsFromReadAll(t, rec.Body.Bytes())
|
|
assert.ElementsMatch(t, []int64{1}, 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 := owned.testReadAllWithUser(nil, nil)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, rec.Body.String(), `webhook-user`)
|
|
assert.NotContains(t, rec.Body.String(), `webhook-password`)
|
|
assert.NotContains(t, rec.Body.String(), `webhook-secret-fixture`)
|
|
})
|
|
t.Run("Read-only share can list", func(t *testing.T) {
|
|
rec, err := readShared.testReadAllWithUser(nil, nil)
|
|
require.NoError(t, err)
|
|
ids := webhookIDsFromReadAll(t, rec.Body.Bytes())
|
|
assert.ElementsMatch(t, []int64{2}, ids, "body: %s", rec.Body.String())
|
|
})
|
|
t.Run("Write share can list", func(t *testing.T) {
|
|
rec, err := writeShared.testReadAllWithUser(nil, nil)
|
|
require.NoError(t, err)
|
|
ids := webhookIDsFromReadAll(t, rec.Body.Bytes())
|
|
assert.ElementsMatch(t, []int64{3}, ids, "body: %s", rec.Body.String())
|
|
})
|
|
t.Run("Forbidden", func(t *testing.T) {
|
|
_, err := forbidden.testReadAllWithUser(nil, nil)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
|
|
t.Run("Create", func(t *testing.T) {
|
|
t.Run("Normal", func(t *testing.T) {
|
|
rec, err := owned.testCreateWithUser(nil, nil, `{"target_url":"https://example.com/new","events":["task.updated"]}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusCreated, rec.Code)
|
|
assert.Contains(t, rec.Body.String(), `"target_url":"https://example.com/new"`)
|
|
// parent project comes from the URL.
|
|
assert.Contains(t, rec.Body.String(), `"project_id":1`)
|
|
})
|
|
t.Run("Secret and basic auth are not echoed back", func(t *testing.T) {
|
|
rec, err := owned.testCreateWithUser(nil, nil,
|
|
`{"target_url":"https://example.com/secret","events":["task.updated"],"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("Admin share can create", func(t *testing.T) {
|
|
rec, err := adminShared.testCreateWithUser(nil, nil, `{"target_url":"https://example.com/admin","events":["task.updated"]}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusCreated, rec.Code)
|
|
assert.Contains(t, rec.Body.String(), `"project_id":11`)
|
|
})
|
|
t.Run("Write share can create", func(t *testing.T) {
|
|
rec, err := writeShared.testCreateWithUser(nil, nil, `{"target_url":"https://example.com/write","events":["task.updated"]}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusCreated, rec.Code)
|
|
assert.Contains(t, rec.Body.String(), `"project_id":10`)
|
|
})
|
|
t.Run("Read share cannot create", func(t *testing.T) {
|
|
_, err := readShared.testCreateWithUser(nil, nil, `{"target_url":"https://example.com/nope","events":["task.updated"]}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Forbidden", func(t *testing.T) {
|
|
_, err := forbidden.testCreateWithUser(nil, nil, `{"target_url":"https://example.com/nope","events":["task.updated"]}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Invalid event", func(t *testing.T) {
|
|
// An unregistered event name → InvalidFieldError, which v1 surfaces as
|
|
// 412 Precondition Failed (ValidationHTTPError.HTTPCode); v2 mirrors it.
|
|
_, err := owned.testCreateWithUser(nil, nil, `{"target_url":"https://example.com/x","events":["not.a.real.event"]}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusPreconditionFailed, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Missing target url", func(t *testing.T) {
|
|
// Create rejects a non-http target_url via InvalidFieldError → 412.
|
|
_, err := owned.testCreateWithUser(nil, nil, `{"events":["task.updated"]}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusPreconditionFailed, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
|
|
t.Run("Update", func(t *testing.T) {
|
|
t.Run("Normal - only events change", func(t *testing.T) {
|
|
// Update persists only the events list (model writes Cols("events")).
|
|
// Send a different target_url and confirm the stored value is untouched.
|
|
rec, err := owned.testUpdateWithUser(nil, map[string]string{"webhook": "1"},
|
|
`{"events":["task.updated"],"target_url":"https://example.com/ignored"}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Contains(t, rec.Body.String(), `"id":1`)
|
|
|
|
rec, err = owned.testReadAllWithUser(nil, nil)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, rec.Body.String(), `https://example.com/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("Write share can update", func(t *testing.T) {
|
|
rec, err := writeShared.testUpdateWithUser(nil, map[string]string{"webhook": "3"}, `{"events":["task.updated"]}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
})
|
|
t.Run("Admin share can update", func(t *testing.T) {
|
|
rec, err := adminShared.testUpdateWithUser(nil, map[string]string{"webhook": "4"}, `{"events":["task.updated"]}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
})
|
|
t.Run("Read share cannot update", func(t *testing.T) {
|
|
// webhook #2 lives in project 9 (read share); CanUpdate needs write.
|
|
_, err := readShared.testUpdateWithUser(nil, map[string]string{"webhook": "2"}, `{"events":["task.updated"]}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Forbidden", func(t *testing.T) {
|
|
// webhook #5 lives in project 2, which user1 cannot access at all.
|
|
// canDoWebhook resolves the parent from the webhook row, so the URL
|
|
// project is irrelevant — the real project (2) gates the check.
|
|
_, err := forbidden.testUpdateWithUser(nil, map[string]string{"webhook": "5"}, `{"events":["task.updated"]}`)
|
|
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 := owned.testUpdateWithUser(nil, map[string]string{"webhook": "9999"}, `{"events":["task.updated"]}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
|
|
t.Run("Delete", func(t *testing.T) {
|
|
t.Run("Read share cannot delete", func(t *testing.T) {
|
|
_, err := readShared.testDeleteWithUser(nil, map[string]string{"webhook": "2"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Forbidden", func(t *testing.T) {
|
|
// webhook #5 lives in project 2, which user1 cannot access at all.
|
|
_, err := forbidden.testDeleteWithUser(nil, map[string]string{"webhook": "5"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Write share can delete", func(t *testing.T) {
|
|
rec, err := writeShared.testDeleteWithUser(nil, map[string]string{"webhook": "3"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusNoContent, rec.Code)
|
|
assert.Empty(t, rec.Body.String())
|
|
})
|
|
t.Run("Admin share can delete", func(t *testing.T) {
|
|
rec, err := adminShared.testDeleteWithUser(nil, map[string]string{"webhook": "4"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusNoContent, rec.Code)
|
|
assert.Empty(t, rec.Body.String())
|
|
})
|
|
t.Run("Normal", func(t *testing.T) {
|
|
rec, err := owned.testDeleteWithUser(nil, map[string]string{"webhook": "1"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusNoContent, rec.Code)
|
|
assert.Empty(t, rec.Body.String())
|
|
})
|
|
t.Run("Nonexisting", func(t *testing.T) {
|
|
// canDoWebhook returns false for a missing webhook → 403.
|
|
_, err := owned.testDeleteWithUser(nil, map[string]string{"webhook": "9999"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
}
|
|
|
|
// TestHumaWebhook_DisabledByConfig confirms RegisterWebhookRoutes skips the
|
|
// resource entirely when webhooks.enabled is false, so the v2 routes 404 rather
|
|
// than running with the feature toggled off.
|
|
func TestHumaWebhook_DisabledByConfig(t *testing.T) {
|
|
// setupTestEnv loads fixtures and resets config to defaults (webhooks on).
|
|
_, err := setupTestEnv()
|
|
require.NoError(t, err)
|
|
|
|
config.WebhooksEnabled.Set(false)
|
|
defer config.WebhooksEnabled.Set(true)
|
|
|
|
// Rebuild the router so RegisterWebhookRoutes re-reads the now-disabled flag.
|
|
e := routes.NewEcho()
|
|
routes.RegisterRoutes(e)
|
|
|
|
token := humaTokenFor(t, &testuser1)
|
|
rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/1/webhooks", "", token, "")
|
|
assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when webhooks are disabled; body: %s", rec.Body.String())
|
|
}
|
|
|
|
// webhookIDsFromReadAll pulls the webhook IDs out of a v2 paginated list body so
|
|
// the visible set can be asserted exactly rather than via substring matching.
|
|
func webhookIDsFromReadAll(t *testing.T, body []byte) []int64 {
|
|
t.Helper()
|
|
var resp struct {
|
|
Items []struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"items"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body))
|
|
ids := make([]int64, 0, len(resp.Items))
|
|
for _, it := range resp.Items {
|
|
ids = append(ids, it.ID)
|
|
}
|
|
return ids
|
|
}
|