459 lines
14 KiB
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)
|
|
}
|
|
}
|