diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5936ebe2b..65e0bff12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -98,13 +98,18 @@ jobs: - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 with: go-version: stable + - name: Install mage + # The cached mage-static artifact has the parent magefile compiled + # in — we need a generic mage binary to pick up veans/magefile.go. + run: go install github.com/magefile/mage@v1.17.2 - name: Run unit tests - # `-short` skips the e2e package via its TestMain (see - # veans/e2e/main_test.go), mirroring the parent monorepo's - # pkg/webtests convention. The heavier test-veans-e2e job - # runs the full suite against the api-build artifact. + # `mage test` is the Aliases entry for Test.All which passes + # `-short` — the e2e package's TestMain skips under -short, + # mirroring the parent monorepo's pkg/webtests convention. The + # heavier test-veans-e2e job runs the full suite against the + # api-build artifact. working-directory: veans - run: go test -count=1 -short ./... + run: mage test check-translations: runs-on: ubuntu-latest diff --git a/veans/internal/auth/oauth_test.go b/veans/internal/auth/oauth_test.go index 5f6a68ca3..9af726bc2 100644 --- a/veans/internal/auth/oauth_test.go +++ b/veans/internal/auth/oauth_test.go @@ -96,36 +96,6 @@ func TestBuildAuthorizeURL(t *testing.T) { } } -func TestGenerateState_Shape(t *testing.T) { - s, err := generateState() - if err != nil { - t.Fatal(err) - } - // 24 random bytes base64url-no-pad → 32 chars. - if len(s) < 30 { - t.Fatalf("state length %d shorter than expected", len(s)) - } - for _, r := range s { - switch { - case r >= 'A' && r <= 'Z', - r >= 'a' && r <= 'z', - r >= '0' && r <= '9', - r == '-', r == '_': - default: - t.Fatalf("state contains illegal rune %q", r) - } - } - // Decodes cleanly as base64url-no-pad. - if _, err := base64.RawURLEncoding.DecodeString(s); err != nil { - t.Fatalf("state isn't base64url-no-pad: %v", err) - } - // Two consecutive states should differ — sanity for entropy. - s2, _ := generateState() - if s == s2 { - t.Fatal("two consecutive states are identical — entropy is broken") - } -} - // newCallbackHandler returns just the http.Handler portion of // newCallbackServer so tests can drive it directly with httptest.NewRecorder // without binding a real loopback socket. diff --git a/veans/internal/bootstrap/bootstrap_test.go b/veans/internal/bootstrap/bootstrap_test.go new file mode 100644 index 000000000..8c70fb333 --- /dev/null +++ b/veans/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,458 @@ +// 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) + } +}