mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-07-16 06:42:23 -05:00
Port the BotUser resource from /api/v1's /user/bots routes to the
Huma-backed /api/v2, preserving every v1 behavior:
- Full CRUD at /user/bots and /user/bots/{bot} with v2 verbs (POST
creates, PUT updates; PATCH is synthesised by AutoPatch).
- ReadAll returns only the caller's own bots; read/update/delete of an
unowned or missing bot is refused with 403, since ownership is resolved
by loading the user (no existence disclosure, no 404 branch).
- Create requires a real user account and rejects link shares, the
bot- username prefix is enforced, and bots are created without an
email or password — all delegated to the unchanged model layer.
- ReadOne surfaces max_permission via the shared value-embed pattern and
carries an ETag for conditional requests.
doc/readOnly tags are added to the exposed user.User fields the bot
response surfaces, and to BotUser.Status, so the v2 OpenAPI schema is
documented. The model and v1 routes are untouched.
The webtest ports the v1 model-level permission matrix to the v2 HTTP
surface and adds the v2-only ETag/304 and merge-patch coverage.
294 lines
12 KiB
Go
294 lines
12 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"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"code.vikunja.io/api/pkg/user"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Bot ownership fixtures (pkg/db/fixtures/users.yml):
|
|
// - user 21 (user_bot_owner_a) owns bot 23 (bot-owner-a-assistant).
|
|
// - user 22 (user_bot_owner_b) owns bot 24 (bot-owner-b-assistant).
|
|
//
|
|
// These two owner/bot pairs give a clean matrix: every read/update/delete of a
|
|
// bot the caller does not own must be refused, and a caller's own bot must be
|
|
// reachable. Constructed locally (not in integrations.go) so this test is
|
|
// self-contained.
|
|
var (
|
|
botOwnerA = user.User{ID: 21, Username: "user_bot_owner_a"}
|
|
botOwnerB = user.User{ID: 22, Username: "user_bot_owner_b"}
|
|
)
|
|
|
|
// TestHumaBotUser ports the v1 bot-user permission matrix to the v2 HTTP
|
|
// surface 1:1 (the v1 coverage lives in pkg/models/bot_users_test.go; there is
|
|
// no v1 webtest). Unlike labels, ownership is verified by loading the user, so
|
|
// every unowned/nonexistent read/update/delete is refused with 403 — there is
|
|
// no 404 branch.
|
|
//
|
|
// One shared env (one fixture load, one signing secret) backs every request;
|
|
// the caller is swapped via h.user. A second env would regenerate the random
|
|
// service secret and invalidate the first env's JWTs.
|
|
func TestHumaBotUser(t *testing.T) {
|
|
h := webHandlerTestV2{
|
|
user: &botOwnerA,
|
|
basePath: "/api/v2/user/bots",
|
|
idParam: "bot",
|
|
t: t,
|
|
}
|
|
// asOwnerB runs fn with the caller temporarily switched to user 22.
|
|
asOwnerB := func(fn func()) {
|
|
h.user = &botOwnerB
|
|
defer func() { h.user = &botOwnerA }()
|
|
fn()
|
|
}
|
|
|
|
t.Run("ReadAll", func(t *testing.T) {
|
|
t.Run("Normal - only own bots", func(t *testing.T) {
|
|
rec, err := h.testReadAllWithUser(nil, nil)
|
|
require.NoError(t, err)
|
|
ids := botIDsFromReadAll(t, rec.Body.Bytes())
|
|
// user 21 owns exactly bot 23; user 22's bot 24 must never leak.
|
|
assert.ElementsMatch(t, []int64{23}, ids,
|
|
"ReadAll must return exactly {23}; body: %s", rec.Body.String())
|
|
assert.NotContains(t, ids, int64(24), "bot #24 (other owner) must be hidden")
|
|
})
|
|
t.Run("Search filters by username", func(t *testing.T) {
|
|
rec, err := h.testReadAllWithUser(map[string][]string{"q": {"nomatch-xyz"}}, nil)
|
|
require.NoError(t, err)
|
|
ids := botIDsFromReadAll(t, rec.Body.Bytes())
|
|
assert.Empty(t, ids, "a non-matching search must return no bots; body: %s", rec.Body.String())
|
|
})
|
|
})
|
|
|
|
t.Run("ReadOne", func(t *testing.T) {
|
|
t.Run("Normal - owner", func(t *testing.T) {
|
|
rec, err := h.testReadOneWithUser(nil, map[string]string{"bot": "23"})
|
|
require.NoError(t, err)
|
|
assert.Contains(t, rec.Body.String(), `"username":"bot-owner-a-assistant"`)
|
|
assert.Contains(t, rec.Body.String(), `"bot_owner_id":21`)
|
|
assert.Contains(t, rec.Body.String(), `"max_permission":`)
|
|
assert.NotEmpty(t, rec.Result().Header.Get("ETag"))
|
|
})
|
|
t.Run("Forbidden - other owner (#24)", func(t *testing.T) {
|
|
_, err := h.testReadOneWithUser(nil, map[string]string{"bot": "24"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Nonexisting refuses with 403", func(t *testing.T) {
|
|
// Ownership is resolved by loading the user; a missing bot is
|
|
// indistinguishable from one owned by someone else, so it is 403,
|
|
// not 404 — existence is never disclosed.
|
|
_, err := h.testReadOneWithUser(nil, map[string]string{"bot": "999999"})
|
|
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 := h.testCreateWithUser(nil, nil, `{"username":"bot-create-success","name":"Created Bot"}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusCreated, rec.Code)
|
|
assert.Contains(t, rec.Body.String(), `"username":"bot-create-success"`)
|
|
assert.Contains(t, rec.Body.String(), `"name":"Created Bot"`)
|
|
// The creating user becomes the owner.
|
|
assert.Contains(t, rec.Body.String(), `"bot_owner_id":21`)
|
|
// Bots are created active and carry no email.
|
|
assert.Contains(t, rec.Body.String(), `"status":0`)
|
|
assert.NotContains(t, rec.Body.String(), `"email":`)
|
|
})
|
|
t.Run("Missing bot- prefix", func(t *testing.T) {
|
|
_, err := h.testCreateWithUser(nil, nil, `{"username":"no-prefix-bot"}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusBadRequest, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Empty username", func(t *testing.T) {
|
|
// minLength:"1" makes Huma reject the body before the model runs (422).
|
|
_, err := h.testCreateWithUser(nil, nil, `{"username":""}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Username with spaces", func(t *testing.T) {
|
|
// ErrUsernameMustNotContainSpaces maps to 412, matching v1.
|
|
_, err := h.testCreateWithUser(nil, nil, `{"username":"bot- with space"}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusPreconditionFailed, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Duplicate username", func(t *testing.T) {
|
|
// bot-owner-a-assistant already exists (bot #23).
|
|
_, err := h.testCreateWithUser(nil, nil, `{"username":"bot-owner-a-assistant"}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusBadRequest, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
|
|
t.Run("Update", func(t *testing.T) {
|
|
t.Run("Normal - rename owned bot", func(t *testing.T) {
|
|
// Renames bot 23 but keeps it active so the Delete cases below can
|
|
// still reach it (disabling poisons GetUserByID with a 412).
|
|
rec, err := h.testUpdateWithUser(nil, map[string]string{"bot": "23"},
|
|
`{"name":"Renamed Bot"}`)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, rec.Body.String(), `"name":"Renamed Bot"`)
|
|
assert.Contains(t, rec.Body.String(), `"status":0`)
|
|
})
|
|
t.Run("Rename owned bot's username", func(t *testing.T) {
|
|
// A new username must keep the bot- prefix.
|
|
rec, err := h.testUpdateWithUser(nil, map[string]string{"bot": "23"},
|
|
`{"username":"bot-owner-a-renamed"}`)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, rec.Body.String(), `"username":"bot-owner-a-renamed"`)
|
|
})
|
|
t.Run("Disable sets status; bot then resolves as disabled (412)", func(t *testing.T) {
|
|
// Disabling is allowed, but once disabled GetUserByID surfaces
|
|
// ErrAccountDisabled, so a follow-up read fails the precondition (412)
|
|
// — same as v1. Use a throwaway bot so bot 23 stays usable.
|
|
rec, err := h.testCreateWithUser(nil, nil, `{"username":"bot-to-disable"}`)
|
|
require.NoError(t, err)
|
|
id := botID(t, rec.Body.Bytes())
|
|
|
|
rec, err = h.testUpdateWithUser(nil, map[string]string{"bot": id}, `{"status":2}`)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, rec.Body.String(), `"status":2`)
|
|
|
|
_, err = h.testReadOneWithUser(nil, map[string]string{"bot": id})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusPreconditionFailed, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Forbidden - other owner (#24)", func(t *testing.T) {
|
|
_, err := h.testUpdateWithUser(nil, map[string]string{"bot": "24"}, `{"name":"Nope"}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Nonexisting refuses with 403", func(t *testing.T) {
|
|
_, err := h.testUpdateWithUser(nil, map[string]string{"bot": "999999"}, `{"name":"Nope"}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
|
|
t.Run("Delete", func(t *testing.T) {
|
|
t.Run("Forbidden - other owner (#23)", func(t *testing.T) {
|
|
// user 22 does not own bot 23.
|
|
asOwnerB(func() {
|
|
_, err := h.testDeleteWithUser(nil, map[string]string{"bot": "23"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
t.Run("Nonexisting refuses with 403", func(t *testing.T) {
|
|
_, err := h.testDeleteWithUser(nil, map[string]string{"bot": "999999"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Normal", func(t *testing.T) {
|
|
// Runs last so the deleted bot doesn't disturb the assertions above.
|
|
rec, err := h.testDeleteWithUser(nil, map[string]string{"bot": "23"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusNoContent, rec.Code)
|
|
assert.Empty(t, rec.Body.String())
|
|
})
|
|
})
|
|
}
|
|
|
|
// The two tests below cover v2-only behaviour with no v1 counterpart:
|
|
// ETag + conditional requests, and AutoPatch (merge-patch+json).
|
|
|
|
func TestHumaBotUser_ETagReturns304(t *testing.T) {
|
|
e, err := setupTestEnv()
|
|
require.NoError(t, err)
|
|
token := humaTokenFor(t, &botOwnerA)
|
|
|
|
rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/bots/23", "", token, "")
|
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
|
etag := rec.Header().Get("ETag")
|
|
require.NotEmpty(t, etag, "GET must return an ETag header")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v2/user/bots/23", strings.NewReader(""))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("If-None-Match", etag)
|
|
rec = httptest.NewRecorder()
|
|
e.ServeHTTP(rec, req)
|
|
require.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String())
|
|
}
|
|
|
|
func TestHumaBotUser_PATCHMergePatch(t *testing.T) {
|
|
e, err := setupTestEnv()
|
|
require.NoError(t, err)
|
|
token := humaTokenFor(t, &botOwnerA)
|
|
|
|
// Create a fresh bot so we don't stomp fixtures.
|
|
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/bots",
|
|
`{"username":"bot-patch-target","name":"keep me"}`, token, "")
|
|
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
|
id := botID(t, rec.Body.Bytes())
|
|
|
|
// PATCH only the username; AutoPatch must leave name untouched.
|
|
rec = humaRequest(t, e, http.MethodPatch, "/api/v2/user/bots/"+id,
|
|
`{"username":"bot-patched"}`, token, "application/merge-patch+json")
|
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
|
|
|
rec = humaRequest(t, e, http.MethodGet, "/api/v2/user/bots/"+id, "", token, "")
|
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
|
var after struct {
|
|
Username string `json:"username"`
|
|
Name string `json:"name"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
|
assert.Equal(t, "bot-patched", after.Username)
|
|
assert.Equal(t, "keep me", after.Name, "name must survive the PATCH")
|
|
}
|
|
|
|
// botID extracts the id from a single-bot response body as a path string.
|
|
func botID(t *testing.T, body []byte) string {
|
|
t.Helper()
|
|
var resp struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(body, &resp), "body must carry an id: %s", string(body))
|
|
require.NotZero(t, resp.ID, "created bot must have an id: %s", string(body))
|
|
return strconv.FormatInt(resp.ID, 10)
|
|
}
|
|
|
|
// botIDsFromReadAll extracts the bot user IDs from a v2 paginated list body so
|
|
// the owned set can be asserted exactly rather than via substring matching.
|
|
func botIDsFromReadAll(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
|
|
}
|