diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..166c3ea --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - run: go vet ./... + - run: go test -race -count=1 -coverprofile=coverage.out ./... + - run: go tool cover -func=coverage.out | tail -20 diff --git a/Makefile b/Makefile index 39e0f5c..3829b4d 100644 --- a/Makefile +++ b/Makefile @@ -34,4 +34,4 @@ clean: check: go vet ./... - go test ./... + go test -race ./... diff --git a/api/client_test.go b/api/client_test.go new file mode 100644 index 0000000..a8e7b6f --- /dev/null +++ b/api/client_test.go @@ -0,0 +1,28 @@ +package api + +import "testing" + +func TestSetRegion(t *testing.T) { + orig := region + t.Cleanup(func() { region = orig }) + + cases := []struct { + name string + input string + want string + }{ + {name: "empty stays empty", input: "", want: ""}, + {name: "US is normalized to empty", input: "US", want: ""}, + {name: "EMEA is preserved", input: "EMEA", want: "EMEA"}, + {name: "AUS is preserved", input: "AUS", want: "AUS"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + SetRegion(tc.input) + if region != tc.want { + t.Errorf("SetRegion(%q): region = %q, want %q", tc.input, region, tc.want) + } + }) + } +} diff --git a/api/details_test.go b/api/details_test.go new file mode 100644 index 0000000..9f7c078 --- /dev/null +++ b/api/details_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "testing" + "time" +) + +func TestParseTime(t *testing.T) { + mustParse := func(layout, s string) time.Time { + t.Helper() + v, err := time.Parse(layout, s) + if err != nil { + t.Fatalf("setup: parsing %q with %q: %v", s, layout, err) + } + return v + } + + cases := []struct { + name string + input string + wantZero bool + want time.Time + }{ + { + name: "empty string returns zero time", + input: "", + wantZero: true, + }, + { + name: "RFC3339 UTC", + input: "2024-01-15T10:30:45Z", + want: mustParse(time.RFC3339, "2024-01-15T10:30:45Z"), + }, + { + name: "fractional Z fallback", + input: "2024-01-15T10:30:45.123Z", + want: mustParse("2006-01-02T15:04:05.000Z", "2024-01-15T10:30:45.123Z"), + }, + { + name: "RFC3339 with positive offset", + input: "2024-01-15T10:30:45+02:00", + want: mustParse(time.RFC3339, "2024-01-15T10:30:45+02:00"), + }, + { + name: "garbage returns zero time", + input: "garbage", + wantZero: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseTime(tc.input) + if tc.wantZero { + if !got.IsZero() { + t.Errorf("parseTime(%q) = %v, want zero time", tc.input, got) + } + return + } + if !got.Equal(tc.want) { + t.Errorf("parseTime(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestApiUser_FullName(t *testing.T) { + cases := []struct { + name string + first string + last string + want string + }{ + {name: "both empty", first: "", last: "", want: ""}, + {name: "first only", first: "Ada", last: "", want: "Ada"}, + {name: "last only", first: "", last: "Lovelace", want: "Lovelace"}, + {name: "both present", first: "Ada", last: "Lovelace", want: "Ada Lovelace"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + u := apiUser{First: tc.first, Last: tc.last} + got := u.fullName() + if got != tc.want { + t.Errorf("apiUser{%q,%q}.fullName() = %q, want %q", tc.first, tc.last, got, tc.want) + } + }) + } +} diff --git a/api/download_test.go b/api/download_test.go new file mode 100644 index 0000000..4b20b29 --- /dev/null +++ b/api/download_test.go @@ -0,0 +1,61 @@ +package api + +import "testing" + +func TestSanitizeFilename(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + { + name: "empty", + input: "", + want: "", + }, + { + name: "plain alphanumeric", + input: "MyDesign", + want: "MyDesign", + }, + { + name: "space and dot are allowed", + input: "My Design v2.0", + want: "My Design v2.0", + }, + { + name: "path traversal — slashes replaced, dots kept", + input: "../../etc/passwd", + want: ".._.._etc_passwd", + }, + { + name: "non-ASCII letters replaced", + input: "Caractères Spéciaux", + want: "Caract_res Sp_ciaux", + }, + { + name: "all slashes become underscores (TrimSpace does not strip _)", + input: "////", + want: "____", + }, + { + name: "leading and trailing whitespace trimmed", + input: " spaces ", + want: "spaces", + }, + { + name: "null byte replaced", + input: "with\x00null", + want: "with_null", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := sanitizeFilename(tc.input) + if got != tc.want { + t.Errorf("sanitizeFilename(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} diff --git a/api/queries_test.go b/api/queries_test.go new file mode 100644 index 0000000..7b3e330 --- /dev/null +++ b/api/queries_test.go @@ -0,0 +1,42 @@ +package api + +import "testing" + +func TestNavItemFromTypename(t *testing.T) { + cases := []struct { + name string + typename string + wantKind string + wantIsContainer bool + }{ + {name: "DesignItem", typename: "DesignItem", wantKind: "design", wantIsContainer: false}, + {name: "ConfiguredDesignItem", typename: "ConfiguredDesignItem", wantKind: "configured", wantIsContainer: false}, + {name: "DrawingItem", typename: "DrawingItem", wantKind: "drawing", wantIsContainer: false}, + {name: "Folder", typename: "Folder", wantKind: "folder", wantIsContainer: true}, + {name: "unknown typename", typename: "MysteryType", wantKind: "unknown", wantIsContainer: false}, + {name: "empty typename", typename: "", wantKind: "unknown", wantIsContainer: false}, + } + + const ( + id = "urn:test:item:123" + name = "Test Name" + ) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := navItemFromTypename(id, name, tc.typename) + if got.ID != id { + t.Errorf("ID = %q, want %q", got.ID, id) + } + if got.Name != name { + t.Errorf("Name = %q, want %q", got.Name, name) + } + if got.Kind != tc.wantKind { + t.Errorf("Kind = %q, want %q", got.Kind, tc.wantKind) + } + if got.IsContainer != tc.wantIsContainer { + t.Errorf("IsContainer = %v, want %v", got.IsContainer, tc.wantIsContainer) + } + }) + } +} diff --git a/auth/oauth_test.go b/auth/oauth_test.go new file mode 100644 index 0000000..0409329 --- /dev/null +++ b/auth/oauth_test.go @@ -0,0 +1,73 @@ +package auth + +import ( + "net/url" + "testing" +) + +func TestNewVerifier_Length(t *testing.T) { + v, err := newVerifier() + if err != nil { + t.Fatalf("newVerifier() returned error: %v", err) + } + if got, want := len(v), 86; got != want { + t.Errorf("newVerifier() length = %d, want %d (verifier=%q)", got, want, v) + } +} + +func TestNewVerifier_Uniqueness(t *testing.T) { + const n = 100 + seen := make(map[string]bool, n) + for i := 0; i < n; i++ { + v, err := newVerifier() + if err != nil { + t.Fatalf("newVerifier() iteration %d returned error: %v", i, err) + } + if seen[v] { + t.Fatalf("newVerifier() returned duplicate value at iteration %d: %q", i, v) + } + seen[v] = true + } + if len(seen) != n { + t.Errorf("expected %d unique verifiers, got %d", n, len(seen)) + } +} + +func TestVerifierToChallenge_RFCExample(t *testing.T) { + // RFC 7636 Appendix B test vector. + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + if got := verifierToChallenge(verifier); got != expected { + t.Errorf("verifierToChallenge(%q) = %q, want %q", verifier, got, expected) + } +} + +func TestBuildAuthURL_Shape(t *testing.T) { + raw := buildAuthURL("my-client-id", "my-challenge") + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("url.Parse(%q) returned error: %v", raw, err) + } + + if got, want := u.Host, "developer.api.autodesk.com"; got != want { + t.Errorf("host = %q, want %q", got, want) + } + if got, want := u.Path, "/authentication/v2/authorize"; got != want { + t.Errorf("path = %q, want %q", got, want) + } + + q := u.Query() + wantParams := map[string]string{ + "client_id": "my-client-id", + "response_type": "code", + "redirect_uri": CallbackURL, + "scope": "data:read user-profile:read", + "code_challenge": "my-challenge", + "code_challenge_method": "S256", + } + for k, want := range wantParams { + if got := q.Get(k); got != want { + t.Errorf("query[%q] = %q, want %q", k, got, want) + } + } +} diff --git a/auth/tokens_test.go b/auth/tokens_test.go new file mode 100644 index 0000000..c53674e --- /dev/null +++ b/auth/tokens_test.go @@ -0,0 +1,173 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/schneik80/FusionDataCLI/config" +) + +func TestTokenData_Valid(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + td *TokenData + want bool + }{ + { + name: "nil receiver", + td: nil, + want: false, + }, + { + name: "empty access token", + td: &TokenData{AccessToken: "", ExpiresAt: now.Add(1 * time.Hour)}, + want: false, + }, + { + name: "valid token, expires in 1h", + td: &TokenData{AccessToken: "abc", ExpiresAt: now.Add(1 * time.Hour)}, + want: true, + }, + { + name: "within 30s pre-expiry guard", + td: &TokenData{AccessToken: "abc", ExpiresAt: now.Add(10 * time.Second)}, + want: false, + }, + { + name: "just past 30s buffer", + td: &TokenData{AccessToken: "abc", ExpiresAt: now.Add(31 * time.Second)}, + want: true, + }, + { + name: "already expired", + td: &TokenData{AccessToken: "abc", ExpiresAt: now.Add(-1 * time.Second)}, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.td.Valid(); got != tc.want { + t.Errorf("TokenData.Valid() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestTokens_RoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + want := &TokenData{ + AccessToken: "a", + RefreshToken: "r", + ExpiresAt: time.Date(2030, 1, 1, 12, 0, 0, 0, time.UTC), + } + if err := SaveTokens(want); err != nil { + t.Fatalf("SaveTokens returned error: %v", err) + } + + got, err := LoadTokens() + if err != nil { + t.Fatalf("LoadTokens returned error: %v", err) + } + if got == nil { + t.Fatal("LoadTokens returned nil TokenData") + } + if got.AccessToken != want.AccessToken { + t.Errorf("AccessToken = %q, want %q", got.AccessToken, want.AccessToken) + } + if got.RefreshToken != want.RefreshToken { + t.Errorf("RefreshToken = %q, want %q", got.RefreshToken, want.RefreshToken) + } + if !got.ExpiresAt.Equal(want.ExpiresAt) { + t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, want.ExpiresAt) + } + + dir, err := config.Dir() + if err != nil { + t.Fatalf("config.Dir() returned error: %v", err) + } + path := filepath.Join(dir, "tokens.json") + info, err := os.Stat(path) + if err != nil { + t.Fatalf("os.Stat(%q) returned error: %v", path, err) + } + if got, want := info.Mode().Perm(), os.FileMode(0600); got != want { + t.Errorf("file mode = %o, want %o", got, want) + } +} + +func TestLoadTokens_Missing_NilNil(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + td, err := LoadTokens() + if err != nil { + t.Errorf("LoadTokens returned error: %v, want nil", err) + } + if td != nil { + t.Errorf("LoadTokens returned %+v, want nil", td) + } +} + +func TestLoadTokens_Corrupt_DeletesAndReturnsNil(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dir, err := config.Dir() + if err != nil { + t.Fatalf("config.Dir() returned error: %v", err) + } + path := filepath.Join(dir, "tokens.json") + if err := os.WriteFile(path, []byte("{not-json"), 0600); err != nil { + t.Fatalf("WriteFile(%q) returned error: %v", path, err) + } + + td, err := LoadTokens() + if err != nil { + t.Errorf("LoadTokens returned error: %v, want nil", err) + } + if td != nil { + t.Errorf("LoadTokens returned %+v, want nil", td) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("expected tokens file to be deleted; os.Stat error = %v", err) + } +} + +func TestDeleteTokens_Missing_NoError(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + if err := DeleteTokens(); err != nil { + t.Errorf("DeleteTokens with no file returned error: %v", err) + } +} + +func TestDeleteTokens_Existing_Removes(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + td := &TokenData{ + AccessToken: "a", + RefreshToken: "r", + ExpiresAt: time.Date(2030, 1, 1, 12, 0, 0, 0, time.UTC), + } + if err := SaveTokens(td); err != nil { + t.Fatalf("SaveTokens returned error: %v", err) + } + + if err := DeleteTokens(); err != nil { + t.Fatalf("DeleteTokens returned error: %v", err) + } + + dir, err := config.Dir() + if err != nil { + t.Fatalf("config.Dir() returned error: %v", err) + } + path := filepath.Join(dir, "tokens.json") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("expected tokens file to be deleted; os.Stat error = %v", err) + } +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..d0c2c9f --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,225 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// clearEnv unsets all three APS_* environment variables for the duration of the test. +// Load() treats empty strings as unset (it checks `id != ""`), so this works correctly. +func clearEnv(t *testing.T) { + t.Helper() + t.Setenv("APS_CLIENT_ID", "") + t.Setenv("APS_CLIENT_SECRET", "") + t.Setenv("APS_REGION", "") +} + +// writeConfigFile creates ~/.config/fusiondatacli/config.json under the given home dir. +func writeConfigFile(t *testing.T, home, contents string) { + t.Helper() + dir := filepath.Join(home, ".config", "fusiondatacli") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatalf("write: %v", err) + } +} + +// saveDefaults snapshots the package-level ldflags vars and restores them via t.Cleanup. +func saveDefaults(t *testing.T) { + t.Helper() + prevID := DefaultClientID + prevRegion := DefaultRegion + t.Cleanup(func() { + DefaultClientID = prevID + DefaultRegion = prevRegion + }) +} + +func TestLoad_EnvVarsTakePrecedence(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + saveDefaults(t) + DefaultClientID = "ld-id" + DefaultRegion = "EMEA" + + // File present with a different client_id — env should still win. + writeConfigFile(t, home, `{"client_id":"file-id","region":"AUS"}`) + + t.Setenv("APS_CLIENT_ID", "env-id") + t.Setenv("APS_CLIENT_SECRET", "env-secret") + t.Setenv("APS_REGION", "EMEA") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ClientID != "env-id" { + t.Errorf("ClientID = %q, want %q", cfg.ClientID, "env-id") + } + if cfg.ClientSecret != "env-secret" { + t.Errorf("ClientSecret = %q, want %q", cfg.ClientSecret, "env-secret") + } + if cfg.Region != "EMEA" { + t.Errorf("Region = %q, want %q", cfg.Region, "EMEA") + } +} + +func TestLoad_FileFallback(t *testing.T) { + clearEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + saveDefaults(t) + DefaultClientID = "" + DefaultRegion = "" + + writeConfigFile(t, home, `{"client_id":"file-id","region":"EMEA"}`) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ClientID != "file-id" { + t.Errorf("ClientID = %q, want %q", cfg.ClientID, "file-id") + } + if cfg.Region != "EMEA" { + t.Errorf("Region = %q, want %q", cfg.Region, "EMEA") + } +} + +func TestLoad_FileFallback_RegionEnvOverride(t *testing.T) { + clearEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + saveDefaults(t) + DefaultClientID = "" + DefaultRegion = "" + + writeConfigFile(t, home, `{"client_id":"file-id","region":"EMEA"}`) + t.Setenv("APS_REGION", "AUS") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ClientID != "file-id" { + t.Errorf("ClientID = %q, want %q", cfg.ClientID, "file-id") + } + if cfg.Region != "AUS" { + t.Errorf("Region = %q, want %q (env override)", cfg.Region, "AUS") + } +} + +func TestLoad_LdflagsFallback(t *testing.T) { + clearEnv(t) + home := t.TempDir() // empty — no config file + t.Setenv("HOME", home) + saveDefaults(t) + DefaultClientID = "ld-id" + DefaultRegion = "EMEA" + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ClientID != "ld-id" { + t.Errorf("ClientID = %q, want %q", cfg.ClientID, "ld-id") + } + if cfg.Region != "EMEA" { + t.Errorf("Region = %q, want %q", cfg.Region, "EMEA") + } +} + +func TestLoad_NoneConfigured_Errors(t *testing.T) { + clearEnv(t) + home := t.TempDir() // empty — no config file + t.Setenv("HOME", home) + saveDefaults(t) + DefaultClientID = "" + DefaultRegion = "" + + cfg, err := Load() + if err == nil { + t.Fatalf("Load: got nil error, want error; cfg = %+v", cfg) + } + if !strings.Contains(err.Error(), "no APS client_id") { + t.Errorf("error = %q, want to contain %q", err.Error(), "no APS client_id") + } +} + +func TestLoad_MalformedFile_Errors(t *testing.T) { + clearEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + saveDefaults(t) + DefaultClientID = "" + DefaultRegion = "" + + writeConfigFile(t, home, `{garbage`) + + cfg, err := Load() + if err == nil { + t.Fatalf("Load: got nil error, want error; cfg = %+v", cfg) + } + if !strings.Contains(err.Error(), "parsing") { + t.Errorf("error = %q, want to contain %q", err.Error(), "parsing") + } +} + +func TestLoad_EmptyClientIDInFile_Errors(t *testing.T) { + clearEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + saveDefaults(t) + DefaultClientID = "" + DefaultRegion = "" + + writeConfigFile(t, home, `{"client_id":""}`) + + cfg, err := Load() + if err == nil { + t.Fatalf("Load: got nil error, want error; cfg = %+v", cfg) + } + if !strings.Contains(err.Error(), "client_id is empty") { + t.Errorf("error = %q, want to contain %q", err.Error(), "client_id is empty") + } +} + +func TestDir_CreatesWithMode0700(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + dir, err := Dir() + if err != nil { + t.Fatalf("Dir: %v", err) + } + want := filepath.Join(home, ".config", "fusiondatacli") + if dir != want { + t.Errorf("Dir = %q, want %q", dir, want) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat: %v", err) + } + if !info.IsDir() { + t.Errorf("%s is not a directory", dir) + } + if perm := info.Mode().Perm(); perm != 0700 { + t.Errorf("perm = %o, want %o", perm, 0700) + } +} + +func TestPath_ReturnsExpected(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + got := Path() + want := filepath.Join(home, ".config", "fusiondatacli", "config.json") + if got != want { + t.Errorf("Path = %q, want %q", got, want) + } +} diff --git a/fusion/mcp_test.go b/fusion/mcp_test.go new file mode 100644 index 0000000..2f13997 --- /dev/null +++ b/fusion/mcp_test.go @@ -0,0 +1,269 @@ +package fusion + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +func TestNormalizeProjectID(t *testing.T) { + // A payload that base64-encodes differently in std vs URL-safe encoding. + // "foo:bar#baz" -> bytes that map to a `+` or `/` in std encoding when + // permuted. We pick a payload where std encoding differs from URL-safe. + stdPayload := "\xfb\xff?foo#bar" // bytes guaranteed to produce + and / in std encoding + stdEncoded := base64.RawStdEncoding.EncodeToString([]byte(stdPayload)) + urlEncoded := base64.RawURLEncoding.EncodeToString([]byte(stdPayload)) + if stdEncoded == urlEncoded { + t.Fatalf("test setup: expected std and URL-safe encodings to differ, got %q == %q", stdEncoded, urlEncoded) + } + + // Self-encoded payload for the documented assertion in the spec. + selfPayload := "business:autodesk#12345" + selfEncoded := "a." + base64.RawURLEncoding.EncodeToString([]byte(selfPayload)) + + // "justatag" with no '#' + noHash := "a." + base64.RawURLEncoding.EncodeToString([]byte("justatag")) + + // "foo#" - ends in '#' with no id + endHash := "a." + base64.RawURLEncoding.EncodeToString([]byte("foo#")) + + cases := []struct { + name string + in string + want string + }{ + { + name: "valid url-safe base64 from spec", + in: "a.YnVzaW5lc3M6YXV0b2Rlc2s4MDgzIzIwMjUwMjEzODc2NjAyNTMx", + want: "20250213876602531", + }, + { + name: "self-encoded url-safe", + in: selfEncoded, + want: "12345", + }, + { + name: "missing a. prefix", + in: "b.something", + want: "", + }, + { + name: "empty string", + in: "", + want: "", + }, + { + name: "garbage base64", + in: "a.!!!notbase64!!!", + want: "", + }, + { + name: "decoded payload missing #", + in: noHash, + want: "", + }, + { + name: "decoded payload ends in # with no id", + in: endHash, + want: "", + }, + { + name: "std-base64 fallback decodes when url-safe fails", + in: "a." + stdEncoded, + want: "bar", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := NormalizeProjectID(tc.in) + if got != tc.want { + t.Errorf("NormalizeProjectID(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestValidFileID(t *testing.T) { + cases := []struct { + name string + input string + want bool + }{ + // Accepted + {"lineage urn", "urn:adsk.wipprod:dm.lineage:hC6gVxs6QYC6OWpnQNd7Ow", true}, + {"a. prefixed b64", "a.YnVzaW5lc3M6YXV0b2Rlc2s", true}, + {"plain alnum", "abc123", true}, + {"all allowed chars", "A-Z_0.9:colons", true}, + + // Rejected + {"empty", "", false}, + {"single space", " ", false}, + {"contains space", "foo bar", false}, + {"contains slash", "foo/bar", false}, + {"contains single quote", "foo'bar", false}, + {"contains double quote", "foo\"bar", false}, + {"contains backslash", "foo\\bar", false}, + {"contains newline", "foo\nbar", false}, + {"contains tab", "foo\tbar", false}, + {"unicode chars", "caractères", false}, + {"contains paren", "foo)bar", false}, + {"contains semicolon", "foo;bar", false}, + {"contains dollar", "foo$bar", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := validFileID.MatchString(tc.input) + if got != tc.want { + t.Errorf("validFileID.MatchString(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestBuildInsertScript_JSONEscaping(t *testing.T) { + cases := []struct { + name string + input string + }{ + {"normal lineage urn", "urn:adsk.wipprod:dm.lineage:abc123"}, + {"plain", "plain"}, + {"empty", ""}, + {"with double quote", "with\"quote"}, + {"with backslash", "with\\backslash"}, + {"with newline", "with\nnewline"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + script := buildInsertScript(tc.input) + + marker := "file_id = " + start := strings.Index(script, marker) + if start < 0 { + t.Fatalf("buildInsertScript output missing %q marker:\n%s", marker, script) + } + afterMarker := script[start+len(marker):] + end := strings.Index(afterMarker, "\n") + if end < 0 { + t.Fatalf("buildInsertScript output missing newline after marker:\n%s", script) + } + found := afterMarker[:end] + + var out string + if err := json.Unmarshal([]byte(found), &out); err != nil { + t.Fatalf("found literal %q is not valid JSON string: %v", found, err) + } + if out != tc.input { + t.Errorf("round-trip: decoded %q from script literal %q, want %q", out, found, tc.input) + } + }) + } +} + +func TestParseToolErrorText(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"success false with error", `{"success":false,"error":"oops"}`, "oops"}, + {"success false no error", `{"success":false}`, "tool reported failure"}, + {"success true", `{"success":true}`, ""}, + {"success true with error ignored", `{"success":true,"error":"ignored"}`, ""}, + {"plain text not json", "plain text not json", ""}, + {"json array starts with bracket", "[1,2,3]", ""}, + {"empty", "", ""}, + {"trim spaces around json", ` {"success":false,"error":"trim me"} `, "trim me"}, + {"malformed json", "{garbage", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseToolErrorText(tc.in) + if got != tc.want { + t.Errorf("parseToolErrorText(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestExtractSSEData(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"single data line", "data: {\"foo\":1}\n", `{"foo":1}`}, + {"multiple data lines concat", "data: {\"a\":1}\ndata: {\"b\":2}\n", `{"a":1}{"b":2}`}, + {"event prefix then data", "event: ping\ndata: {\"x\":true}\n\n", `{"x":true}`}, + {"crlf line endings", "data: {\"v\":1}\r\n", `{"v":1}`}, + {"no sse framing returns input", "plain body", "plain body"}, + {"empty returns empty", "", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := string(extractSSEData([]byte(tc.in))) + if got != tc.want { + t.Errorf("extractSSEData(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// invalidFileIDCases lists fileId values that should be rejected by the public +// OpenDocument / InsertDocument validators *before* any network I/O happens. +// The tests below use a sentinel endpoint that would refuse to connect — so a +// connect-error in place of a validation-error is a regression. +var invalidFileIDCases = []struct { + name string + id string +}{ + {"empty", ""}, + {"with space", "with space"}, + {"shell metachars", "foo;rm -rf"}, + {"single quote", "foo'bar"}, + {"newline", "with\nnewline"}, + {"unicode", "caractères"}, +} + +// tripwireClient returns a Client whose endpoint will refuse connections, +// so that any code path which tries to dial out fails the test. +func tripwireClient() *Client { + return &Client{Endpoint: "http://127.0.0.1:1/should-not-be-called"} +} + +func assertValidationRejection(t *testing.T, name string, err error) { + t.Helper() + if err == nil { + t.Fatalf("%s: expected error, got nil (validation did not run)", name) + } + msg := err.Error() + if !(strings.Contains(msg, "fileId") || strings.Contains(msg, "empty") || strings.Contains(msg, "invalid")) { + t.Fatalf("%s: error %q does not look like a validation rejection — validation may not be running before dial", name, msg) + } +} + +func TestOpenDocument_ValidatesInput(t *testing.T) { + client := tripwireClient() + for _, tc := range invalidFileIDCases { + t.Run(tc.name, func(t *testing.T) { + err := client.OpenDocument(context.Background(), tc.id) + assertValidationRejection(t, "OpenDocument("+tc.name+")", err) + }) + } +} + +func TestInsertDocument_ValidatesInput(t *testing.T) { + client := tripwireClient() + for _, tc := range invalidFileIDCases { + t.Run(tc.name, func(t *testing.T) { + err := client.InsertDocument(context.Background(), tc.id) + assertValidationRejection(t, "InsertDocument("+tc.name+")", err) + }) + } +} diff --git a/ui/helpers_test.go b/ui/helpers_test.go new file mode 100644 index 0000000..05ebd12 --- /dev/null +++ b/ui/helpers_test.go @@ -0,0 +1,30 @@ +package ui + +import "testing" + +func TestFormatSize(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"zero", "0", "0 B"}, + {"small bytes", "512", "512 B"}, + {"kb boundary", "1024", "1.0 KB"}, + {"one and a half kb", "1536", "1.5 KB"}, + {"one mb", "1048576", "1.0 MB"}, + {"one gb", "1073741824", "1.0 GB"}, + {"non numeric returns input as-is", "abc", "abc"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := formatSize(tc.in) + if got != tc.want { + t.Errorf("formatSize(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +}