vikunja/veans/internal/bootstrap/bootstrap_test.go

459 lines
14 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 bootstrap
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"code.vikunja.io/veans/internal/client"
"code.vikunja.io/veans/internal/output"
"code.vikunja.io/veans/internal/status"
)
func TestValidateBotUsername(t *testing.T) {
cases := []struct {
name string
input string
wantValid bool
}{
// Valid names.
{"valid simple", "bot-foo", true},
{"valid multi-hyphen", "bot-foo-bar", true},
{"valid digits", "bot-foo123", true},
{"valid underscore", "bot-foo_bar", true},
{"valid dot", "bot-foo.bar", true},
{"valid single letter", "bot-a", true},
// Invalid: missing/malformed bot- prefix.
{"missing prefix", "foo", false},
{"uppercase prefix", "Bot-foo", false},
{"empty", "", false},
// Invalid: forbidden characters in the body.
{"space after prefix", "bot- foo", false},
{"comma", "bot-foo,bar", false},
{"uppercase body", "bot-FOO", false},
{"bang", "bot-foo!", false},
{"space in body", "bot-foo bar", false},
// Invalid: reserved link-share pattern.
{"link-share-0", "bot-link-share-0", false},
{"link-share-1", "bot-link-share-1", false},
{"link-share-42", "bot-link-share-42", false},
// Edge: regex `^bot-[a-z0-9][a-z0-9._-]*$` requires at least one char
// after the `bot-` prefix, so a bare prefix is rejected.
{"bare prefix", "bot-", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateBotUsername(tc.input)
if tc.wantValid {
if err != nil {
t.Fatalf("validateBotUsername(%q) = %v, want nil", tc.input, err)
}
return
}
if err == nil {
t.Fatalf("validateBotUsername(%q) = nil, want error", tc.input)
}
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("validateBotUsername(%q): expected *output.Error, got %T", tc.input, err)
}
if oe.Code != output.CodeValidation {
t.Errorf("validateBotUsername(%q): code = %q, want %q", tc.input, oe.Code, output.CodeValidation)
}
})
}
}
// queuePrompter is a richer scriptedPrompter that can also return an error
// on a chosen call (to simulate stdin read failures) and tracks how many
// times ReadLine was invoked. Defined locally because the existing
// scriptedPrompter in botuser_test.go can't inject errors.
type queuePrompter struct {
answers []string
err error // returned on every call once exhausted, or immediately if no answers
calls int
}
func (q *queuePrompter) ReadLine(_ string) (string, error) {
q.calls++
if q.err != nil {
return "", q.err
}
if q.calls-1 >= len(q.answers) {
return "", nil
}
return q.answers[q.calls-1], nil
}
func (q *queuePrompter) ReadPassword(_ string) (string, error) { return "", nil }
// errReadFailure is a sentinel used to simulate a stdin read failure
// inside the prompter. Kept at package level to satisfy err113's
// preference for static errors (test files are exempt, but using a
// named value reads more clearly than fmt.Errorf at the call site).
var errReadFailure = errors.New("simulated stdin read failure")
func TestConfirmOverwriteExistingConfig(t *testing.T) {
t.Run("file missing — no prompt", func(t *testing.T) {
dir := t.TempDir()
p := &queuePrompter{}
opts := &Options{ConfigPath: filepath.Join(dir, "does-not-exist.yml")}
if err := confirmOverwriteExistingConfig(opts, p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.calls != 0 {
t.Errorf("prompter called %d times, want 0", p.calls)
}
})
t.Run("OverwriteExistingConfig=true — no prompt", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
t.Fatal(err)
}
p := &queuePrompter{}
opts := &Options{ConfigPath: path, OverwriteExistingConfig: true}
if err := confirmOverwriteExistingConfig(opts, p); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.calls != 0 {
t.Errorf("prompter called %d times, want 0", p.calls)
}
})
t.Run("answers — yes/no/error", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
t.Fatal(err)
}
yesAnswers := []string{"y", "yes", "Y", "Yes", " yes "}
for _, ans := range yesAnswers {
p := &queuePrompter{answers: []string{ans}}
opts := &Options{ConfigPath: path}
if err := confirmOverwriteExistingConfig(opts, p); err != nil {
t.Errorf("answer %q: unexpected error: %v", ans, err)
}
}
// "n", "", and any other input → conflict.
noAnswers := []string{"n", "", "no", "garbage"}
for _, ans := range noAnswers {
p := &queuePrompter{answers: []string{ans}}
opts := &Options{ConfigPath: path}
err := confirmOverwriteExistingConfig(opts, p)
if err == nil {
t.Errorf("answer %q: expected error, got nil", ans)
continue
}
var oe *output.Error
if !errors.As(err, &oe) {
t.Errorf("answer %q: want *output.Error, got %T", ans, err)
continue
}
if oe.Code != output.CodeConflict {
t.Errorf("answer %q: code = %q, want %q", ans, oe.Code, output.CodeConflict)
}
if !strings.Contains(oe.Message, path) {
t.Errorf("answer %q: message %q should contain config path %q", ans, oe.Message, path)
}
}
// Prompter read failure → wrapped as CodeUnknown.
p := &queuePrompter{err: errReadFailure}
opts := &Options{ConfigPath: path}
err := confirmOverwriteExistingConfig(opts, p)
if err == nil {
t.Fatal("expected error from prompter failure, got nil")
}
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("want *output.Error, got %T", err)
}
if oe.Code != output.CodeUnknown {
t.Errorf("code = %q, want %q", oe.Code, output.CodeUnknown)
}
if !errors.Is(err, errReadFailure) {
t.Errorf("wrapped error should unwrap to errReadFailure, got %v", err)
}
})
}
// bucketServer is a minimal httptest server modelling
// GET/PUT /api/v1/projects/{p}/views/{v}/buckets. The caller pre-seeds
// existing buckets; PUT requests append to that list with a synthetic ID.
type bucketServer struct {
mu sync.Mutex
existing []*client.Bucket
creates []*client.Bucket // recorded create payloads (in order)
nextID int64
}
func newBucketServer(seed []*client.Bucket) *bucketServer {
s := &bucketServer{existing: seed, nextID: 1000}
for _, b := range seed {
if b.ID >= s.nextID {
s.nextID = b.ID + 1
}
}
return s
}
func (s *bucketServer) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Path is /api/v1/projects/{p}/views/{v}/buckets.
if !strings.HasSuffix(r.URL.Path, "/buckets") || !strings.Contains(r.URL.Path, "/views/") {
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusInternalServerError)
return
}
s.mu.Lock()
defer s.mu.Unlock()
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(s.existing)
case http.MethodPut:
var b client.Bucket
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
b.ID = s.nextID
s.nextID++
created := &client.Bucket{ID: b.ID, Title: b.Title, ProjectViewID: b.ProjectViewID}
s.existing = append(s.existing, created)
s.creates = append(s.creates, created)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(created)
default:
http.Error(w, "method not allowed: "+r.Method, http.StatusMethodNotAllowed)
}
})
}
// allBucketsSeed returns one bucket per canonical status (using the
// canonical title from BucketTitle).
func allBucketsSeed() []*client.Bucket {
var out []*client.Bucket
id := int64(10)
for _, s := range status.All() {
out = append(out, &client.Bucket{ID: id, Title: s.BucketTitle()})
id++
}
return out
}
func TestBootstrapBuckets_AllPresent_NoPrompt(t *testing.T) {
srv := newBucketServer(allBucketsSeed())
ts := httptest.NewServer(srv.handler())
defer ts.Close()
c := client.New(ts.URL, "token")
p := &queuePrompter{} // any call would still return "" but we'll assert calls==0
var buf bytes.Buffer
opts := &Options{Out: &buf}
buckets, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.calls != 0 {
t.Errorf("prompter called %d times, want 0 (no missing buckets means no prompt)", p.calls)
}
if len(srv.creates) != 0 {
t.Errorf("CreateBucket called %d times, want 0", len(srv.creates))
}
if buckets.Todo == 0 || buckets.InProgress == 0 || buckets.InReview == 0 || buckets.Done == 0 || buckets.Scrapped == 0 {
t.Errorf("expected all bucket IDs populated, got %+v", buckets)
}
}
func TestBootstrapBuckets_AutoApprove_CreatesMissing(t *testing.T) {
// Seed only Todo; the other four are missing.
srv := newBucketServer([]*client.Bucket{
{ID: 10, Title: status.Todo.BucketTitle()},
})
ts := httptest.NewServer(srv.handler())
defer ts.Close()
c := client.New(ts.URL, "token")
p := &queuePrompter{}
var buf bytes.Buffer
opts := &Options{Out: &buf, AutoApproveBuckets: true}
buckets, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.calls != 0 {
t.Errorf("AutoApprove should skip prompt; got %d calls", p.calls)
}
if len(srv.creates) != 4 {
t.Errorf("expected 4 buckets created (the missing ones), got %d", len(srv.creates))
}
if buckets.Todo != 10 {
t.Errorf("existing Todo bucket should be reused, got id=%d", buckets.Todo)
}
if buckets.InProgress == 0 || buckets.InReview == 0 || buckets.Done == 0 || buckets.Scrapped == 0 {
t.Errorf("missing buckets not populated: %+v", buckets)
}
}
func TestBootstrapBuckets_PromptDeclined(t *testing.T) {
srv := newBucketServer([]*client.Bucket{
{ID: 10, Title: status.Todo.BucketTitle()},
})
ts := httptest.NewServer(srv.handler())
defer ts.Close()
c := client.New(ts.URL, "token")
p := &queuePrompter{answers: []string{"n"}}
var buf bytes.Buffer
opts := &Options{Out: &buf}
_, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
if err == nil {
t.Fatal("expected error on declined prompt, got nil")
}
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("want *output.Error, got %T", err)
}
if oe.Code != output.CodeValidation {
t.Errorf("code = %q, want %q", oe.Code, output.CodeValidation)
}
// Message should mention at least one of the missing canonical titles.
mentionsMissing := false
for _, s := range status.All() {
if s == status.Todo {
continue // not missing
}
if strings.Contains(oe.Message, s.BucketTitle()) {
mentionsMissing = true
break
}
}
if !mentionsMissing {
t.Errorf("error message %q should mention missing bucket titles", oe.Message)
}
if len(srv.creates) != 0 {
t.Errorf("no buckets should be created on decline; got %d", len(srv.creates))
}
}
func TestBootstrapBuckets_PromptAborted(t *testing.T) {
srv := newBucketServer([]*client.Bucket{
{ID: 10, Title: status.Todo.BucketTitle()},
})
ts := httptest.NewServer(srv.handler())
defer ts.Close()
c := client.New(ts.URL, "token")
// Five garbage answers (each within the unknown limit), then "a" → abort.
p := &queuePrompter{answers: []string{"huh", "what", "?", "??", "???", "a"}}
var buf bytes.Buffer
opts := &Options{Out: &buf}
_, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
if err == nil {
t.Fatal("expected abort error, got nil")
}
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("want *output.Error, got %T", err)
}
if oe.Code != output.CodeValidation {
t.Errorf("code = %q, want %q", oe.Code, output.CodeValidation)
}
if !strings.Contains(oe.Message, "abort") {
t.Errorf("message %q should mention user abort", oe.Message)
}
if len(srv.creates) != 0 {
t.Errorf("no buckets should be created on abort; got %d", len(srv.creates))
}
}
func TestBootstrapBuckets_PromptUnknownCap(t *testing.T) {
srv := newBucketServer([]*client.Bucket{
{ID: 10, Title: status.Todo.BucketTitle()},
})
ts := httptest.NewServer(srv.handler())
defer ts.Close()
c := client.New(ts.URL, "token")
// Six garbage answers — exceeds maxUnknownAnswers (5).
p := &queuePrompter{answers: []string{"huh", "what", "?", "??", "???", "still no"}}
var buf bytes.Buffer
opts := &Options{Out: &buf}
_, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
if err == nil {
t.Fatal("expected cap error, got nil")
}
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("want *output.Error, got %T", err)
}
if oe.Code != output.CodeValidation {
t.Errorf("code = %q, want %q", oe.Code, output.CodeValidation)
}
if !strings.Contains(oe.Message, fmt.Sprintf("%d attempts", 5)) {
t.Errorf("message %q should mention 5 attempts", oe.Message)
}
}
func TestBootstrapBuckets_PromptAccepted(t *testing.T) {
srv := newBucketServer([]*client.Bucket{
{ID: 10, Title: status.Todo.BucketTitle()},
})
ts := httptest.NewServer(srv.handler())
defer ts.Close()
c := client.New(ts.URL, "token")
p := &queuePrompter{answers: []string{"y"}}
var buf bytes.Buffer
opts := &Options{Out: &buf}
buckets, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(srv.creates) != 4 {
t.Errorf("expected 4 missing buckets created, got %d", len(srv.creates))
}
if buckets.Todo != 10 || buckets.InProgress == 0 || buckets.InReview == 0 || buckets.Done == 0 || buckets.Scrapped == 0 {
t.Errorf("buckets not populated: %+v", buckets)
}
}