// 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 . 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) } }