diff --git a/cmd/backup.go b/cmd/backup.go index 8078da1..3aa75cc 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -144,6 +144,8 @@ Example: mediaData, err := c.Get("/media?limit=10000") if err != nil { fmt.Printf("failed: %v\n", err) + // A backup without its media is not a backup; say so instead of exiting 0 + return fmt.Errorf("could not list media (%v); the token may lack media:read. Use --skip-media to back up documents only", err) } else { var mediaItems []struct { ID string `json:"id"` diff --git a/cmd/clean.go b/cmd/clean.go index efdaba2..85e60a2 100644 --- a/cmd/clean.go +++ b/cmd/clean.go @@ -126,6 +126,7 @@ Or use --dry-run to preview what would be deleted first. } // Clean media (paginated, with rate-limit handling) + mediaStuck := 0 if !documentsOnly { fmt.Print("Cleaning media files...\n") @@ -158,6 +159,7 @@ Or use --dry-run to preview what would be deleted first. } totalMediaCount += len(mediaItems) + deletedBefore := totalMediaDeleted for _, item := range mediaItems { if dryRun { continue @@ -180,6 +182,14 @@ Or use --dry-run to preview what would be deleted first. } fmt.Printf(" Deleted %d media file(s) so far...\n", totalMediaDeleted) + + // The listing always returns the first page, so a page in which nothing could be + // deleted would come back unchanged forever + if totalMediaDeleted == deletedBefore { + mediaStuck = len(mediaItems) + fmt.Fprintf(os.Stderr, " Stopping: none of these %d media file(s) could be deleted. The token may lack media:delete; run 'trokky login' again with a current CLI.\n", len(mediaItems)) + break + } } if totalMediaDeleted > 0 || totalMediaCount == 0 { @@ -206,6 +216,9 @@ Or use --dry-run to preview what would be deleted first. fmt.Printf("Mode: %s\n", mode) fmt.Println("──────────────────────────────────────────────────") + if mediaStuck > 0 { + return fmt.Errorf("%d media file(s) could not be deleted", mediaStuck) + } return nil }, } diff --git a/cmd/clean_media_test.go b/cmd/clean_media_test.go new file mode 100644 index 0000000..9404abf --- /dev/null +++ b/cmd/clean_media_test.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +func runClean(t *testing.T, args ...string) (string, string, error) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("TROKKY_URL", "") + t.Setenv("TROKKY_TOKEN", "") + t.Setenv("TROKKY_INSTANCE", "") + for name, def := range map[string]string{"collections": "", "media-only": "false", "documents-only": "false", "dry-run": "false", "confirm": "false"} { + if err := cleanCmd.Flags().Set(name, def); err != nil { + t.Fatal(err) + } + } + var stdout, stderr bytes.Buffer + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&stderr) + defer func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + }() + rootCmd.SetArgs(append([]string{"clean"}, args...)) + var err error + rawOut, rawErr := captureProcessOutput(t, func() { + err = rootCmd.Execute() + }) + return stdout.String() + rawOut, stderr.String() + rawErr, err +} + +// A token that can list media but not delete it gets the same first page back forever; +// clean must stop with an error instead of looping. +func TestCleanStopsWhenMediaCannotBeDeleted(t *testing.T) { + rec := &recorder{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.record(r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && apiPath(r) == "/media": + io.WriteString(w, `{"success":true,"data":[{"id":"m1"},{"id":"m2"}]}`) + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, `{"success":false,"error":{"message":"media:delete required"}}`) + default: + w.WriteHeader(http.StatusNotFound) + io.WriteString(w, `{"success":false}`) + } + })) + defer server.Close() + + done := make(chan error, 1) + go func() { + _, _, err := runClean(t, "--media-only", "--confirm", "--url", server.URL+"/api", "--token", "t") + done <- err + }() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "could not be deleted") { + t.Fatalf("err = %v, want a media deletion failure", err) + } + case <-time.After(60 * time.Second): + t.Fatal("clean did not stop; it loops on media it cannot delete") + } + if n := rec.countPath("/media"); n != 1 { + t.Errorf("listed media %d times, want 1", n) + } +} + +// A backup that cannot list media must fail rather than write an archive without media. +func TestBackupFailsWhenMediaCannotBeListed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && apiPath(r) == "/collections": + io.WriteString(w, testCollectionsResponse) + case r.Method == http.MethodGet && strings.HasPrefix(apiPath(r), "/collections/"): + io.WriteString(w, `{"success":true,"data":{"documents":[]}}`) + case apiPath(r) == "/media": + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, `{"success":false,"error":{"message":"media:read required"}}`) + default: + w.WriteHeader(http.StatusNotFound) + io.WriteString(w, `{"success":false}`) + } + })) + defer server.Close() + + out := filepath.Join(t.TempDir(), "b.zip") + _, _, err := runBackup(t, "--output", out, "--url", server.URL+"/api", "--token", "t") + if err == nil || !strings.Contains(err.Error(), "could not list media") { + t.Fatalf("err = %v, want the media listing failure", err) + } +} diff --git a/cmd/login.go b/cmd/login.go index d206359..7030d5e 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -32,6 +32,13 @@ Example: } setDefault, _ := cmd.Flags().GetBool("set-default") + // Logging in again to the same instance replaces its session; a different site under + // the same name would silently take its place + if existing, err := config.GetInstance(name); err == nil && existing != nil && + config.NormalizeBaseURL(existing.URL) != baseURL { + return fmt.Errorf("an instance named %q already exists (%s); choose another with --name", name, existing.URL) + } + fmt.Println() fmt.Println("Trokky CLI Login") fmt.Println("────────────────────────────────────────") diff --git a/cmd/logout.go b/cmd/logout.go index d0b0a87..fe3b46b 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -1,9 +1,11 @@ package cmd import ( + "errors" "fmt" "github.com/spf13/cobra" + "github.com/trokky/cli/internal/auth" "github.com/trokky/cli/internal/config" ) @@ -46,6 +48,14 @@ Example: } } + // Revoke the sign-in on the instance first, so it also disappears from its Studio's + // connected applications; best effort, an unreachable instance must not block logout + inst, _ := config.GetInstance(name) + var revokeErr error + if inst != nil && inst.AuthType == config.AuthTypeOAuth2 && (inst.RefreshToken != "" || inst.Token != "") { + revokeErr = auth.RevokeToken(*inst) + } + removed, err := config.RemoveInstance(name) if err != nil { return fmt.Errorf("failed to save config: %w", err) @@ -55,6 +65,12 @@ Example: } fmt.Printf("✓ Logged out from %q\n", name) + var refused *auth.RevokeRefusedError + if errors.As(revokeErr, &refused) { + fmt.Printf(" The instance did not accept the revocation (HTTP %d); revoke the access in its Studio under Account > Connected applications.\n", refused.Status) + } else if revokeErr != nil { + fmt.Println(" The instance could not be reached to revoke the sign-in; revoke it in its Studio under Account > Connected applications.") + } return nil }, } diff --git a/cmd/logout_test.go b/cmd/logout_test.go new file mode 100644 index 0000000..a2105d8 --- /dev/null +++ b/cmd/logout_test.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/trokky/cli/internal/config" +) + +func runLogout(t *testing.T, name string) (string, error) { + t.Helper() + var err error + out, _ := captureProcessOutput(t, func() { + rootCmd.SetArgs([]string{"logout", name, "--force"}) + err = rootCmd.Execute() + }) + return out, err +} + +func addTestInstance(t *testing.T, name string, inst config.InstanceConfig) { + t.Helper() + if err := config.AddInstance(name, inst, false); err != nil { + t.Fatal(err) + } +} + +func TestLogout_RevokesABrowserSignInThenForgetsIt(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var body map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/auth/revoke" { + t.Errorf("unexpected request to %s", r.URL.Path) + } + json.NewDecoder(r.Body).Decode(&body) + })) + defer server.Close() + addTestInstance(t, "site", config.InstanceConfig{URL: server.URL + "/api", Token: "a", RefreshToken: "r", AuthType: config.AuthTypeOAuth2, ClientID: "trokky-cli"}) + + out, err := runLogout(t, "site") + if err != nil { + t.Fatal(err) + } + if body["token"] != "r" || body["client_id"] != "trokky-cli" { + t.Fatalf("revocation sent %v", body) + } + if strings.Contains(out, "revoke") { + t.Fatalf("a successful revocation needs no note: %q", out) + } + if inst, _ := config.GetInstance("site"); inst != nil { + t.Fatal("instance still configured") + } +} + +func TestLogout_ForgetsAnUnreachableOrRefusingInstanceAndSaysWhy(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + addTestInstance(t, "gone", config.InstanceConfig{URL: "http://127.0.0.1:1/api", RefreshToken: "r", AuthType: config.AuthTypeOAuth2}) + out, err := runLogout(t, "gone") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "could not be reached") { + t.Fatalf("output %q", out) + } + if inst, _ := config.GetInstance("gone"); inst != nil { + t.Fatal("an unreachable instance must still be forgotten") + } + + old := httptest.NewServer(http.NotFoundHandler()) + defer old.Close() + addTestInstance(t, "old", config.InstanceConfig{URL: old.URL + "/api", RefreshToken: "r", AuthType: config.AuthTypeOAuth2}) + out, err = runLogout(t, "old") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "did not accept the revocation (HTTP 404)") { + t.Fatalf("output %q", out) + } + if inst, _ := config.GetInstance("old"); inst != nil { + t.Fatal("a refusing instance must still be forgotten") + } +} + +func TestLogout_DoesNotRevokeAnAPIToken(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) + defer server.Close() + addTestInstance(t, "tok", config.InstanceConfig{URL: server.URL + "/api", Token: "api-token", AuthType: config.AuthTypeAPIToken}) + + out, err := runLogout(t, "tok") + if err != nil { + t.Fatal(err) + } + if calls.Load() != 0 || strings.Contains(out, "revoke") { + t.Fatalf("an API token is not an OAuth grant: %d calls, output %q", calls.Load(), out) + } +} + +// A browser sign-in that stopped working says how to sign in again +func TestCommand_Unauthorized_OAuthInstance_HintsAtLogin(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("TROKKY_URL", "") + t.Setenv("TROKKY_TOKEN", "") + t.Setenv("TROKKY_INSTANCE", "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"success":false,"error":{"message":"Invalid or expired authentication token"}}`)) + })) + defer server.Close() + addTestInstance(t, "site", config.InstanceConfig{URL: server.URL + "/api", Token: "a", AuthType: config.AuthTypeOAuth2}) + + var err error + captureProcessOutput(t, func() { + // rootCmd keeps flags from earlier tests: clear the ones that would override the instance + rootCmd.SetArgs([]string{"documents", "list", "posts", "--instance", "site", "--url", "", "--token", "", "-q"}) + err = rootCmd.Execute() + }) + if err == nil || !strings.Contains(err.Error(), "trokky login "+server.URL+"/api --name site") { + t.Fatalf("err = %v", err) + } +} + +// An API token does not come from a sign-in, so its 401 is not answered with "trokky login" +func TestCommand_Unauthorized_APIToken_NoLoginHint(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("TROKKY_URL", "") + t.Setenv("TROKKY_TOKEN", "") + t.Setenv("TROKKY_INSTANCE", "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"success":false,"error":{"message":"Invalid API token"}}`)) + })) + defer server.Close() + addTestInstance(t, "tok", config.InstanceConfig{URL: server.URL + "/api", Token: "t", AuthType: config.AuthTypeAPIToken}) + + var err error + captureProcessOutput(t, func() { + rootCmd.SetArgs([]string{"documents", "list", "posts", "--instance", "tok", "--url", "", "--token", "", "-q"}) + err = rootCmd.Execute() + }) + if err == nil || !strings.Contains(err.Error(), "Invalid API token") || strings.Contains(err.Error(), "trokky login") { + t.Fatalf("err = %v", err) + } +} diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..4f9c737 --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "os" + "testing" +) + +// Every test in this package runs against a throwaway HOME. RefreshAccessToken reads and +// writes ~/.trokky/config.yaml under a lock, and a test that forgot overrideHome once +// rewrote a developer's real config; this makes forgetting harmless. +func TestMain(m *testing.M) { + home, err := os.MkdirTemp("", "trokky-cmd-test-home-") + if err != nil { + panic(err) + } + os.Setenv("HOME", home) + os.Setenv("USERPROFILE", home) + code := m.Run() + os.RemoveAll(home) + os.Exit(code) +} diff --git a/cmd/restore.go b/cmd/restore.go index c5c32c7..e2c0ecd 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -229,6 +229,7 @@ Example: if json.Unmarshal(mediaData, &mediaItems) != nil || len(mediaItems) == 0 { break } + deletedBefore := deletedMedia for _, item := range mediaItems { for attempt := 1; attempt <= 3; attempt++ { _, err := c.Delete("/media/" + item.ID) @@ -243,6 +244,13 @@ Example: time.Sleep(100 * time.Millisecond) } fmt.Fprintf(out, "\r Cleaning existing media... %d deleted", deletedMedia) + + // The listing always returns the first page: if nothing on it could be deleted, + // it would come back unchanged forever + if deletedMedia == deletedBefore { + fmt.Fprintln(out) + return fmt.Errorf("could not delete existing media (%d file(s) refused); the token may lack media:delete: run 'trokky login' again with a current CLI, or restore without --clean", len(mediaItems)) + } } fmt.Fprintf(out, "\r Cleaning existing media... %d file(s) deleted\n", deletedMedia) } diff --git a/cmd/restore_test.go b/cmd/restore_test.go index ea04168..870dbc2 100644 --- a/cmd/restore_test.go +++ b/cmd/restore_test.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "testing" + "time" ) // recorder captures every request a fake Trokky instance receives so a test can @@ -606,3 +607,46 @@ func TestRestoreReadsLegacyFlatLayoutAndWarnsOnCollisions(t *testing.T) { t.Errorf("warning should tell the user how to get an uncorrupted archive:\n%s", stderr) } } + +// With --clean and a token that may list media but not delete it, the media listing keeps +// returning the same first page. The restore must stop with an error instead of looping. +func TestRestoreCleanStopsWhenMediaCannotBeDeleted(t *testing.T) { + rec := &recorder{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.record(r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && apiPath(r) == "/collections": + io.WriteString(w, testCollectionsResponse) + case r.Method == http.MethodGet && strings.HasPrefix(apiPath(r), "/collections/"): + io.WriteString(w, `{"success":true,"data":{"documents":[]}}`) + case r.Method == http.MethodGet && apiPath(r) == "/media": + io.WriteString(w, `{"success":true,"data":[{"id":"m1"},{"id":"m2"}]}`) + case r.Method == http.MethodDelete && strings.HasPrefix(apiPath(r), "/media/"): + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, `{"success":false,"error":{"message":"Insufficient permissions: media:delete required"}}`) + default: + w.WriteHeader(http.StatusNotFound) + io.WriteString(w, `{"success":false}`) + } + })) + defer server.Close() + + done := make(chan error, 1) + go func() { + _, _, err := runRestore(t, "--input", writeBackupZip(t), "--url", server.URL+"/api", "--token", "t", "--clean", "-q") + done <- err + }() + + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "could not delete existing media") { + t.Fatalf("err = %v, want the media cleanup failure", err) + } + case <-time.After(60 * time.Second): + t.Fatal("restore --clean did not stop; it loops on media it cannot delete") + } + if n := rec.countPath("/media"); n != 1 { + t.Errorf("listed media %d times, want 1", n) + } +} diff --git a/cmd/root.go b/cmd/root.go index 0cabe0a..4f0c7e1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/trokky/cli/internal/useragent" ) var ( @@ -24,6 +25,7 @@ var rootCmd = &cobra.Command{ } func Execute() { + useragent.Version = version if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index fe28725..7c2a52c 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -13,17 +13,24 @@ import ( "time" "github.com/trokky/cli/internal/config" + "github.com/trokky/cli/internal/useragent" ) const ( - ClientID = "trokky-cli" - DefaultScopes = "openid profile content:read content:write content:delete media:read media:write offline_access" + ClientID = "trokky-cli" + // content:publish and media:delete are needed by restore (published documents) and by + // clean; servers before 3.5.2 ignore scopes they do not know + DefaultScopes = "openid profile content:read content:write content:delete content:publish media:read media:write media:delete offline_access" deviceAuthPath = "/auth/device" tokenPath = "/auth/token" ) -var httpClient = &http.Client{Timeout: 30 * time.Second} +var httpClient = &http.Client{Timeout: 30 * time.Second, Transport: useragent.Transport(nil)} + +// signInClient names the machine: the instance records it with the sign-in, for its Studio's +// connected applications +var signInClient = &http.Client{Timeout: 30 * time.Second, Transport: useragent.SignInTransport(nil)} // DeviceAuthResponse is returned by the device authorization endpoint. type DeviceAuthResponse struct { @@ -75,7 +82,7 @@ func StartDeviceAuth(baseURL string) (*DeviceAuthResponse, error) { "scope": DefaultScopes, }) - resp, err := httpClient.Post(endpoint, "application/json", bytes.NewReader(body)) + resp, err := signInClient.Post(endpoint, "application/json", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("failed to connect: %w", err) } @@ -117,7 +124,7 @@ func PollForToken(baseURL, deviceCode string, interval, expiresIn int) (*TokenRe "client_id": ClientID, }) - resp, err := httpClient.Post(endpoint, "application/json", bytes.NewReader(body)) + resp, err := signInClient.Post(endpoint, "application/json", bytes.NewReader(body)) if err != nil { continue } @@ -158,6 +165,10 @@ func PollForToken(baseURL, deviceCode string, interval, expiresIn int) (*TokenRe // OpenBrowser attempts to open a URL in the user's default browser. func OpenBrowser(rawURL string) error { + // The site chose this URL: open only a web address, never a file or another handler + if parsed, err := url.Parse(rawURL); err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return fmt.Errorf("not a web address: %q", rawURL) + } var cmd *exec.Cmd switch runtime.GOOS { case "darwin": @@ -197,10 +208,54 @@ type TokenRefreshResult struct { ExpiresAt string Error string RequiresReauth bool + // The rotated refresh token, if the server sent one; stored by RefreshAccessToken + newRefreshToken string } // RefreshAccessToken refreshes an OAuth2 token using a refresh token. +// +// The refresh happens under the config lock, against the instance as it is stored now: the +// Trokky MCP server shares this file and may have refreshed the same instance a moment ago. +// If the stored token is already fresh, it is used as is and no request is made. func RefreshAccessToken(instanceName string, instance config.InstanceConfig) TokenRefreshResult { + var result TokenRefreshResult + err := config.Update(func(cfg *config.Config) error { + current, ok := cfg.Instances[instanceName] + if !ok { + current = instance + } + // Changed since this process read it and usable as it stands: renewed by the MCP server, + // or replaced by a new login or an API token + changed := current.Token != instance.Token + usable := current.AuthType != config.AuthTypeOAuth2 || current.TokenExpiresAt == "" || !IsTokenExpired(current.TokenExpiresAt, 0) + if changed && usable { + result = TokenRefreshResult{Success: true, Token: current.Token, ExpiresAt: current.TokenExpiresAt} + return nil + } + + result = requestRefresh(current) + if !result.Success || !ok { + return nil + } + current.Token = result.Token + current.TokenExpiresAt = result.ExpiresAt + if result.newRefreshToken != "" { + current.RefreshToken = result.newRefreshToken + } + current.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + cfg.Instances[instanceName] = current + return nil + }) + if err != nil && result.Success { + result.Error = fmt.Sprintf("token refreshed but failed to save config: %v", err) + } else if err != nil { + result = TokenRefreshResult{Error: err.Error()} + } + return result +} + +// requestRefresh exchanges the instance's refresh token for a new access token. +func requestRefresh(instance config.InstanceConfig) TokenRefreshResult { if instance.RefreshToken == "" { return TokenRefreshResult{Error: "no refresh token available", RequiresReauth: true} } @@ -210,10 +265,14 @@ func RefreshAccessToken(instanceName string, instance config.InstanceConfig) Tok endpoint := strings.TrimRight(instance.URL, "/") + tokenPath + clientID := instance.ClientID + if clientID == "" { + clientID = ClientID + } body, _ := json.Marshal(map[string]string{ "grant_type": "refresh_token", "refresh_token": instance.RefreshToken, - "client_id": ClientID, + "client_id": clientID, }) resp, err := httpClient.Post(endpoint, "application/json", bytes.NewReader(body)) @@ -237,31 +296,52 @@ func RefreshAccessToken(instanceName string, instance config.InstanceConfig) Tok return TokenRefreshResult{Error: fmt.Sprintf("invalid refresh response: %v", err), RequiresReauth: false} } - expiresAt := ExpiresAtFromNow(tokenResp.ExpiresIn) - - // Update stored config - cfg, err := config.Load() - if err == nil { - if inst, ok := cfg.Instances[instanceName]; ok { - inst.Token = tokenResp.AccessToken - inst.TokenExpiresAt = expiresAt - if tokenResp.RefreshToken != "" { - inst.RefreshToken = tokenResp.RefreshToken - } - inst.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - cfg.Instances[instanceName] = inst - if saveErr := config.Save(cfg); saveErr != nil { - return TokenRefreshResult{ - Success: true, - Token: tokenResp.AccessToken, - ExpiresAt: expiresAt, - Error: fmt.Sprintf("token refreshed but failed to save config: %v", saveErr), - } - } - } + return TokenRefreshResult{ + Success: true, + Token: tokenResp.AccessToken, + ExpiresAt: ExpiresAtFromNow(tokenResp.ExpiresIn), + newRefreshToken: tokenResp.RefreshToken, } +} + +// RevokeRefusedError is a revocation the instance answered but did not accept, such as the +// 404 of a server from before 3.5.2, which has no revocation endpoint. +type RevokeRefusedError struct { + Status int +} + +func (e *RevokeRefusedError) Error() string { + return fmt.Sprintf("the instance did not accept the revocation (HTTP %d)", e.Status) +} - return TokenRefreshResult{Success: true, Token: tokenResp.AccessToken, ExpiresAt: expiresAt} +// revokeClient is short on patience: revoking is a courtesy on the way out, and a hanging +// instance must not hold up a logout for long. +var revokeClient = &http.Client{Timeout: 10 * time.Second, Transport: useragent.Transport(nil)} + +// RevokeToken ends the instance's sign-in on the server (RFC 7009), so the approval also +// disappears from the instance's connected applications. The refresh token is revoked when +// there is one (it outlives the access token); a sign-in without offline access has only the +// access token, which names the same approval. A refusal is a *RevokeRefusedError; any other +// error means the instance could not be reached. +func RevokeToken(instance config.InstanceConfig) error { + clientID := instance.ClientID + if clientID == "" { + clientID = ClientID + } + token := instance.RefreshToken + if token == "" { + token = instance.Token + } + body, _ := json.Marshal(map[string]string{"token": token, "client_id": clientID}) + resp, err := revokeClient.Post(config.NormalizeBaseURL(instance.URL)+"/auth/revoke", "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return &RevokeRefusedError{Status: resp.StatusCode} + } + return nil } // GetValidToken returns a valid token, refreshing if necessary. @@ -284,18 +364,30 @@ func GetValidToken(instanceName string, instance config.InstanceConfig) (token s instanceName, instance.URL) } - return instance.Token, false, nil + // The token is expired: sending it anyway would only come back as a confusing 401 + return "", false, fmt.Errorf("could not renew the session for instance %q: %s", instanceName, result.Error) } // DeriveInstanceName derives a short name from a URL. +// +// The first host label, skipping "www", with the port when there is one, so two local sites +// do not collide (localhost:3253 → localhost-3253). The Trokky MCP server derives names the +// same way, so a site gets one name whichever tool added it. func DeriveInstanceName(rawURL string) string { parsed, err := url.Parse(rawURL) - if err != nil { + if err != nil || parsed.Hostname() == "" { return "default" } parts := strings.Split(parsed.Hostname(), ".") - if len(parts) > 0 && parts[0] != "" { - return parts[0] + name := parts[0] + if name == "www" && len(parts) > 1 { + name = parts[1] + } + if name == "" { + return "default" + } + if port := parsed.Port(); port != "" { + name += "-" + port } - return "default" + return name } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index e459114..8fc6a7c 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -2,6 +2,7 @@ package auth import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -460,7 +461,8 @@ func TestDeriveInstanceName(t *testing.T) { }{ {"https://cms.example.com/api", "cms"}, {"https://my-trokky.example.com", "my-trokky"}, - {"http://localhost:3000/api", "localhost"}, + {"http://localhost:3000/api", "localhost-3000"}, + {"https://www.cms.example.com", "cms"}, {"https://example.com", "example"}, {"not-a-url", "default"}, } @@ -472,3 +474,182 @@ func TestDeriveInstanceName(t *testing.T) { } } } + +func TestRefreshAccessToken_UsesATokenAnotherProcessAlreadyRefreshed(t *testing.T) { + overrideHome(t) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + json.NewEncoder(w).Encode(TokenResponse{AccessToken: "from-network", ExpiresIn: 3600}) + })) + defer server.Close() + + stale := cfg.InstanceConfig{URL: server.URL, Token: "old", RefreshToken: "r", AuthType: cfg.AuthTypeOAuth2, + TokenExpiresAt: time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)} + // What the MCP server wrote while this process held the stale copy + fresh := stale + fresh.Token = "refreshed-elsewhere" + fresh.TokenExpiresAt = time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + cfg.AddInstance("test", fresh, true) + + result := RefreshAccessToken("test", stale) + if !result.Success || result.Token != "refreshed-elsewhere" { + t.Fatalf("got %+v, want the token already in the config", result) + } + if requests != 0 { + t.Fatalf("made %d refresh requests, want none", requests) + } +} + +func TestRefreshAccessToken_UsesTheStoredRefreshTokenAndClient(t *testing.T) { + overrideHome(t) + var got map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&got) + json.NewEncoder(w).Encode(TokenResponse{AccessToken: "new", RefreshToken: "rotated", ExpiresIn: 3600}) + })) + defer server.Close() + + expired := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + // The MCP server rotated the refresh token after this process read the instance + cfg.AddInstance("s", cfg.InstanceConfig{URL: server.URL, Token: "old", RefreshToken: "current-refresh", + AuthType: cfg.AuthTypeOAuth2, TokenExpiresAt: expired, ClientID: "trokky-mcp"}, true) + staleCopy := cfg.InstanceConfig{URL: server.URL, Token: "old", RefreshToken: "outdated-refresh", + AuthType: cfg.AuthTypeOAuth2, TokenExpiresAt: expired, ClientID: "trokky-mcp"} + + result := RefreshAccessToken("s", staleCopy) + if !result.Success || result.Token != "new" { + t.Fatalf("got %+v", result) + } + if got["refresh_token"] != "current-refresh" || got["client_id"] != "trokky-mcp" { + t.Fatalf("refresh sent %v, want the stored refresh token and the site's own client", got) + } + inst, _ := cfg.GetInstance("s") + if inst.Token != "new" || inst.RefreshToken != "rotated" { + t.Fatalf("stored %+v, want the new token and the rotated refresh token", inst) + } +} + +func TestRefreshAccessToken_DoesNotReuseAnExpiredToken(t *testing.T) { + overrideHome(t) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + json.NewEncoder(w).Encode(TokenResponse{AccessToken: "new", ExpiresIn: 3600}) + })) + defer server.Close() + expired := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + cfg.AddInstance("s", cfg.InstanceConfig{URL: server.URL, Token: "other-but-expired", RefreshToken: "r", + AuthType: cfg.AuthTypeOAuth2, TokenExpiresAt: expired}, true) + + result := RefreshAccessToken("s", cfg.InstanceConfig{URL: server.URL, Token: "old", RefreshToken: "r", + AuthType: cfg.AuthTypeOAuth2, TokenExpiresAt: expired}) + if result.Token != "new" || requests != 1 { + t.Fatalf("got %+v after %d requests, want a real refresh", result, requests) + } +} + +func TestDefaultScopesCoverRestoreAndClean(t *testing.T) { + for _, scope := range []string{"content:publish", "media:delete", "offline_access"} { + if !strings.Contains(" "+DefaultScopes+" ", " "+scope+" ") { + t.Errorf("DefaultScopes lacks %s", scope) + } + } +} + +func revokeServer(t *testing.T, status int, got *map[string]string, path *string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *path = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(got); err != nil { + t.Errorf("revocation body is not JSON: %v", err) + } + w.WriteHeader(status) + })) + t.Cleanup(server.Close) + return server +} + +func TestRevokeToken_SendsTheRefreshTokenWithItsClient(t *testing.T) { + var got map[string]string + var path string + server := revokeServer(t, http.StatusOK, &got, &path) + + err := RevokeToken(cfg.InstanceConfig{URL: server.URL, Token: "a", RefreshToken: "r", AuthType: cfg.AuthTypeOAuth2, ClientID: "trokky-mcp"}) + if err != nil { + t.Fatal(err) + } + if path != "/api/auth/revoke" || got["token"] != "r" || got["client_id"] != "trokky-mcp" { + t.Fatalf("sent %v to %s", got, path) + } +} + +func TestRevokeToken_FallsBackToTheAccessTokenAndTheCLIClient(t *testing.T) { + var got map[string]string + var path string + server := revokeServer(t, http.StatusOK, &got, &path) + + // A sign-in without offline access has no refresh token, and a login from before + // per-instance client ids has none recorded + if err := RevokeToken(cfg.InstanceConfig{URL: server.URL, Token: "a", AuthType: cfg.AuthTypeOAuth2}); err != nil { + t.Fatal(err) + } + if got["token"] != "a" || got["client_id"] != ClientID { + t.Fatalf("sent %v", got) + } +} + +func TestRevokeToken_ReportsARefusalWithItsStatus(t *testing.T) { + var got map[string]string + var path string + server := revokeServer(t, http.StatusNotFound, &got, &path) + + err := RevokeToken(cfg.InstanceConfig{URL: server.URL, RefreshToken: "r", AuthType: cfg.AuthTypeOAuth2}) + var refused *RevokeRefusedError + if !errors.As(err, &refused) || refused.Status != http.StatusNotFound { + t.Fatalf("err = %v, want a refusal with 404", err) + } +} + +func TestRevokeToken_ReportsAnUnreachableInstanceAsSuch(t *testing.T) { + err := RevokeToken(cfg.InstanceConfig{URL: "http://127.0.0.1:1", RefreshToken: "r", AuthType: cfg.AuthTypeOAuth2}) + var refused *RevokeRefusedError + if err == nil || errors.As(err, &refused) { + t.Fatalf("err = %v, want a connection error", err) + } +} + +// The instance records the sign-in's User-Agent with the grant: it names the machine. Other +// requests name only the CLI. +func TestSignInNamesTheMachine(t *testing.T) { + agents := map[string]string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + agents[r.URL.Path] = r.UserAgent() + switch r.URL.Path { + case "/api/auth/device": + w.Write([]byte(`{"device_code":"d","user_code":"U","verification_uri":"v","expires_in":60,"interval":1}`)) + case "/api/auth/token": + w.Write([]byte(`{"access_token":"a","token_type":"Bearer","expires_in":3600}`)) + default: + w.WriteHeader(http.StatusOK) + } + })) + defer server.Close() + + if _, err := StartDeviceAuth(server.URL + "/api"); err != nil { + t.Fatal(err) + } + if _, err := PollForToken(server.URL+"/api", "d", 0, 60); err != nil { + t.Fatal(err) + } + _ = RevokeToken(cfg.InstanceConfig{URL: server.URL + "/api", Token: "t"}) + + for _, path := range []string{"/api/auth/device", "/api/auth/token"} { + if got := agents[path]; !strings.HasPrefix(got, "trokky-cli/") || !strings.Contains(got, " (") { + t.Fatalf("%s User-Agent = %q, want the machine named", path, got) + } + } + if got := agents["/api/auth/revoke"]; !strings.HasPrefix(got, "trokky-cli/") || strings.Contains(got, "(") { + t.Fatalf("revoke User-Agent = %q, want the CLI only", got) + } +} diff --git a/internal/auth/main_test.go b/internal/auth/main_test.go new file mode 100644 index 0000000..29d23f9 --- /dev/null +++ b/internal/auth/main_test.go @@ -0,0 +1,21 @@ +package auth + +import ( + "os" + "testing" +) + +// Every test in this package runs against a throwaway HOME. RefreshAccessToken reads and +// writes ~/.trokky/config.yaml under a lock, and a test that forgot overrideHome once +// rewrote a developer's real config; this makes forgetting harmless. +func TestMain(m *testing.M) { + home, err := os.MkdirTemp("", "trokky-auth-test-home-") + if err != nil { + panic(err) + } + os.Setenv("HOME", home) + os.Setenv("USERPROFILE", home) + code := m.Run() + os.RemoveAll(home) + os.Exit(code) +} diff --git a/internal/client/client.go b/internal/client/client.go index b9f61af..15279eb 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -17,12 +17,16 @@ import ( "github.com/spf13/cobra" "github.com/trokky/cli/internal/auth" "github.com/trokky/cli/internal/config" + "github.com/trokky/cli/internal/useragent" ) type Client struct { BaseURL string Token string HTTPClient *http.Client + // SignInHint is added to a 401 when the token came from a browser sign-in, which can end + // without the CLI knowing: revoked in Studio, or issued before the server's upgrade + SignInHint string } type HealthResponse struct { @@ -79,14 +83,18 @@ func FromContext(cmd *cobra.Command) (*Client, error) { } } - return New(creds.URL, resolvedToken), nil + c := New(creds.URL, resolvedToken) + if creds.Source == "config" && creds.Instance != nil && creds.Instance.AuthType == config.AuthTypeOAuth2 { + c.SignInHint = fmt.Sprintf("the sign-in may have been revoked or have expired; sign in again with: trokky login %s --name %s", creds.URL, creds.InstanceName) + } + return c, nil } func New(baseURL, token string) *Client { return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), Token: token, - HTTPClient: &http.Client{Timeout: 30 * time.Second}, + HTTPClient: &http.Client{Timeout: 30 * time.Second, Transport: useragent.Transport(nil)}, } } @@ -121,10 +129,16 @@ func (c *Client) request(method, path string, body io.Reader) ([]byte, error) { if resp.StatusCode >= 400 { var apiErr apiResponse + var err error if json.Unmarshal(data, &apiErr) == nil && apiErr.Error != nil { - return nil, fmt.Errorf("%s (HTTP %d)", apiErr.Error.Message, resp.StatusCode) + err = fmt.Errorf("%s (HTTP %d)", apiErr.Error.Message, resp.StatusCode) + } else { + err = fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + } + if resp.StatusCode == http.StatusUnauthorized && c.SignInHint != "" { + err = fmt.Errorf("%w: %s", err, c.SignInHint) } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + return nil, err } // Auto-extract .data from {success: true, data: ...} envelope diff --git a/internal/client/client_test.go b/internal/client/client_test.go index eb23b3b..91fead6 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -719,3 +719,45 @@ func TestUploadFile_ServerError(t *testing.T) { } // GenerateTypes() is covered in generate_types_test.go + +// A browser sign-in can end without the CLI knowing (revoked in Studio, or from before the +// server's upgrade): its 401 says how to sign in again, and nothing else gains the hint. +func TestRequest_Unauthorized_AddsTheSignInHint(t *testing.T) { + status := http.StatusUnauthorized + server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + w.Write([]byte(`{"success":false,"error":{"message":"Invalid or expired authentication token"}}`)) + }) + + c := New(server.URL, "token") + c.SignInHint = "sign in again with: trokky login" + _, err := c.Get("/collections/posts") + if err == nil || !strings.Contains(err.Error(), "Invalid or expired authentication token (HTTP 401): sign in again with: trokky login") { + t.Fatalf("err = %v", err) + } + + status = http.StatusForbidden + if _, err := c.Get("/collections/posts"); err == nil || strings.Contains(err.Error(), "trokky login") { + t.Fatalf("a 403 is not a sign-in problem: %v", err) + } + + status = http.StatusUnauthorized + c.SignInHint = "" + if _, err := c.Get("/collections/posts"); err == nil || strings.Contains(err.Error(), "trokky login") { + t.Fatalf("an API token gets no sign-in hint: %v", err) + } +} + +func TestRequestsNameTheCLI(t *testing.T) { + var got string + server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + got = r.UserAgent() + w.Write([]byte(`{"success":true,"data":{}}`)) + }) + if _, err := New(server.URL, "t").Get("/health"); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(got, "trokky-cli/") { + t.Fatalf("User-Agent = %q", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6b31e16..92558dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,19 +32,26 @@ type InstanceConfig struct { Description string `yaml:"description,omitempty"` AddedAt string `yaml:"addedAt,omitempty"` UpdatedAt string `yaml:"updatedAt,omitempty"` + // The OAuth2 client the token was issued to; a refresh must name the same one. Set by the + // Trokky MCP server (trokky-mcp); empty means the CLI's own client. + ClientID string `yaml:"clientId,omitempty"` + // Keys this version does not know, kept so a save never drops what another tool wrote + Extra map[string]interface{} `yaml:",inline"` } type Config struct { Version string `yaml:"version"` Default string `yaml:"default,omitempty"` Instances map[string]InstanceConfig `yaml:"instances"` + // Keys this version does not know, kept so a save never drops what another tool wrote + Extra map[string]interface{} `yaml:",inline"` } // ResolvedCredentials holds credentials resolved from flags, env, or config. type ResolvedCredentials struct { URL string Token string - Source string // "cli", "env", or "config" + Source string // "cli", "env", or "config" InstanceName string // set when Source is "config" Instance *InstanceConfig // set when Source is "config" } @@ -96,7 +103,8 @@ func Load() (*Config, error) { var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { - return newConfig(), nil + // Never an empty config: the next save would overwrite every saved instance + return nil, fmt.Errorf("%s is not valid YAML (%v); fix it or move it aside", path, err) } if cfg.Version == "" { @@ -109,7 +117,9 @@ func Load() (*Config, error) { return &cfg, nil } -// Save writes the config to ~/.trokky/config.yaml. +// Save writes the config to ~/.trokky/config.yaml, atomically: it is written aside and +// renamed into place, so a reader never sees a half-written file. Use Update for a +// read-modify-write, which also takes the lock shared with the Trokky MCP server. func Save(cfg *Config) error { path := ConfigPath() if path == "" { @@ -125,59 +135,84 @@ func Save(cfg *Config) error { return err } - return os.WriteFile(path, data, 0600) -} - -// AddInstance adds or updates a named instance in the config. -// If setAsDefault is true, or this is the first instance, it becomes the default. -func AddInstance(name string, inst InstanceConfig, setAsDefault bool) error { - cfg, err := Load() + tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.yaml") if err != nil { return err } - - now := time.Now().UTC().Format(time.RFC3339) - - existing, exists := cfg.Instances[name] - if exists { - inst.AddedAt = existing.AddedAt - } else { - inst.AddedAt = now + defer os.Remove(tmp.Name()) + if err := tmp.Chmod(0600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err } - inst.UpdatedAt = now + // On disk before it replaces the old file, so a crash cannot leave an empty config + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + // Windows refuses to replace a file another process has open; a reader holds it briefly + for attempt := 0; ; attempt++ { + err := os.Rename(tmp.Name(), path) + if err == nil || attempt == 5 { + return err + } + time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond) + } +} - cfg.Instances[name] = inst +// AddInstance adds or updates a named instance in the config. +// If setAsDefault is true, or this is the first instance, it becomes the default. +func AddInstance(name string, inst InstanceConfig, setAsDefault bool) error { + return Update(func(cfg *Config) error { + now := time.Now().UTC().Format(time.RFC3339) + + existing, exists := cfg.Instances[name] + if exists { + inst.AddedAt = existing.AddedAt + } else { + inst.AddedAt = now + } + inst.UpdatedAt = now - if setAsDefault || len(cfg.Instances) == 1 { - cfg.Default = name - } + cfg.Instances[name] = inst - return Save(cfg) + if setAsDefault || len(cfg.Instances) == 1 { + cfg.Default = name + } + return nil + }) } // RemoveInstance removes a named instance. Returns true if it existed. // If the removed instance was the default, the default is reassigned. func RemoveInstance(name string) (bool, error) { - cfg, err := Load() - if err != nil { - return false, err - } - - if _, ok := cfg.Instances[name]; !ok { - return false, nil - } - - delete(cfg.Instances, name) - - if cfg.Default == name { - cfg.Default = "" - for k := range cfg.Instances { - cfg.Default = k - break + existed := false + err := Update(func(cfg *Config) error { + if _, ok := cfg.Instances[name]; !ok { + return nil } - } - - return true, Save(cfg) + existed = true + delete(cfg.Instances, name) + + // The only instance left is the obvious default. With several, none: a command without + // --instance must not quietly run against one picked at random + if cfg.Default == name { + cfg.Default = "" + if len(cfg.Instances) == 1 { + for k := range cfg.Instances { + cfg.Default = k + } + } + } + return nil + }) + return existed, err } // ListInstances returns all configured instances and the default name. @@ -225,17 +260,16 @@ func GetDefaultInstance() (string, *InstanceConfig, error) { // SetDefaultInstance sets the default instance by name. // Returns false if the instance does not exist. func SetDefaultInstance(name string) (bool, error) { - cfg, err := Load() - if err != nil { - return false, err - } - - if _, ok := cfg.Instances[name]; !ok { - return false, nil - } - - cfg.Default = name - return true, Save(cfg) + found := false + err := Update(func(cfg *Config) error { + if _, ok := cfg.Instances[name]; !ok { + return nil + } + found = true + cfg.Default = name + return nil + }) + return found, err } // NormalizeBaseURL trims trailing slashes and ensures the URL points at the diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6f5be57..a47e434 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,10 +1,13 @@ package config import ( + "fmt" "os" "path/filepath" "strings" + "sync" "testing" + "time" "gopkg.in/yaml.v3" ) @@ -161,12 +164,15 @@ func TestLoad_InvalidYAML(t *testing.T) { os.MkdirAll(filepath.Dir(path), 0700) os.WriteFile(path, []byte("not: [valid: yaml: {{"), 0600) - cfg, err := Load() - if err != nil { - t.Fatalf("Load() should not error on invalid YAML, got: %v", err) + // An empty config here would be saved over the broken file, losing every instance + if _, err := Load(); err == nil { + t.Fatal("Load() must error on invalid YAML, not return an empty config") } - if cfg.Version != "1.0" { - t.Fatal("expected empty config fallback") + if err := AddInstance("x", InstanceConfig{URL: "http://x"}, true); err == nil { + t.Fatal("AddInstance() must refuse to overwrite an unreadable config") + } + if data, _ := os.ReadFile(path); string(data) != "not: [valid: yaml: {{" { + t.Fatalf("the broken config was overwritten: %q", data) } } @@ -317,11 +323,22 @@ func TestRemoveInstance_ReassignsDefault(t *testing.T) { RemoveInstance("a") cfg, _ := Load() - if cfg.Default == "a" { - t.Fatal("default should not be the removed instance") - } if cfg.Default != "b" { - t.Fatalf("default should be reassigned to remaining instance, got %q", cfg.Default) + t.Fatalf("the only remaining instance should become the default, got %q", cfg.Default) + } +} + +func TestRemoveInstance_PicksNoDefaultAmongSeveral(t *testing.T) { + overrideHome(t) + AddInstance("a", InstanceConfig{URL: "http://a", Token: "t"}, true) + AddInstance("b", InstanceConfig{URL: "http://b", Token: "t"}, false) + AddInstance("c", InstanceConfig{URL: "http://c", Token: "t"}, false) + + RemoveInstance("a") + + cfg, _ := Load() + if cfg.Default != "" { + t.Fatalf("with several instances left none should be picked, got %q", cfg.Default) } } @@ -850,3 +867,116 @@ func TestInstanceConfig_YAMLRoundtrip(t *testing.T) { t.Fatal("UpdatedAt should be set") } } + +func TestUpdate_ConcurrentWritersLoseNothing(t *testing.T) { + overrideHome(t) + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if err := AddInstance(fmt.Sprintf("site-%d", i), InstanceConfig{URL: "http://x"}, false); err != nil { + t.Errorf("AddInstance: %v", err) + } + }(i) + } + wg.Wait() + instances, _, err := ListInstances() + if err != nil { + t.Fatal(err) + } + if len(instances) != 20 { + t.Fatalf("got %d instances, want 20: concurrent saves overwrote each other", len(instances)) + } + if _, err := os.Stat(lockPath()); !os.IsNotExist(err) { + t.Fatal("lock file left behind") + } + entries, _ := os.ReadDir(filepath.Dir(ConfigPath())) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".config-") { + t.Fatalf("temp file left behind: %s", e.Name()) + } + } +} + +func TestUpdate_RecoversAStaleLock(t *testing.T) { + overrideHome(t) + os.MkdirAll(filepath.Dir(ConfigPath()), 0700) + os.WriteFile(lockPath(), []byte("12345"), 0600) + old := time.Now().Add(-2 * lockStaleAfter) + os.Chtimes(lockPath(), old, old) + + if err := AddInstance("x", InstanceConfig{URL: "http://x"}, true); err != nil { + t.Fatalf("a lock abandoned long ago must not block: %v", err) + } +} + +func TestSave_IsPrivate(t *testing.T) { + overrideHome(t) + if err := AddInstance("x", InstanceConfig{URL: "http://x", Token: "secret"}, true); err != nil { + t.Fatal(err) + } + info, err := os.Stat(ConfigPath()) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("config mode = %v, want 0600", info.Mode().Perm()) + } +} + +func TestLock_ReleaseLeavesAnotherOwnersLock(t *testing.T) { + overrideHome(t) + release, err := acquireLock() + if err != nil { + t.Fatal(err) + } + // A waiter took the lock over while this holder was believed dead + os.WriteFile(lockPath(), []byte("someone-else"), 0600) + release() + if data, err := os.ReadFile(lockPath()); err != nil || string(data) != "someone-else" { + t.Fatalf("release removed or changed another owner's lock: %q, %v", data, err) + } +} + +func TestLock_TakeoverPutsBackALiveLock(t *testing.T) { + overrideHome(t) + os.MkdirAll(filepath.Dir(lockPath()), 0700) + os.WriteFile(lockPath(), []byte("live-owner"), 0600) + // Seen stale a moment ago, but replaced by a live lock before the rename + takeOverStaleLock(lockPath()) + if data, err := os.ReadFile(lockPath()); err != nil || string(data) != "live-owner" { + t.Fatalf("a live lock was taken over: %q, %v", data, err) + } + entries, _ := os.ReadDir(filepath.Dir(lockPath())) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".stale") { + t.Fatalf("left %s behind", e.Name()) + } + } +} + +func TestSave_KeepsKeysThisVersionDoesNotKnow(t *testing.T) { + overrideHome(t) + os.MkdirAll(filepath.Dir(ConfigPath()), 0700) + os.WriteFile(ConfigPath(), []byte(`version: "1.0" +default: a +mcp: + uploadDirs: /tmp +instances: + a: + url: http://a + token: t + clientId: trokky-mcp + grantedScopes: content:read +`), 0600) + if err := AddInstance("b", InstanceConfig{URL: "http://b"}, false); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(ConfigPath()) + for _, want := range []string{"mcp:", "uploadDirs", "grantedScopes: content:read", "clientId: trokky-mcp"} { + if !strings.Contains(string(data), want) { + t.Errorf("saved config lost %q:\n%s", want, data) + } + } +} diff --git a/internal/config/lock.go b/internal/config/lock.go new file mode 100644 index 0000000..8958cff --- /dev/null +++ b/internal/config/lock.go @@ -0,0 +1,120 @@ +package config + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// The config file is shared: the CLI and the Trokky MCP server (@trokky/mcp) both read and +// write ~/.trokky/config.yaml, and a token refresh in one must not be lost to a concurrent +// save in the other. Both follow the same lock-file protocol before any read-modify-write, +// kept simple enough that the Node side implements it identically: +// +// - take: create ".lock" exclusively (O_EXCL) and write a random owner id into it +// - wait: retry every 25ms, give up after lockGiveUpAfter +// - release: remove the lock only if it still holds this owner's id +// - stale: a lock older than lockStaleAfter was abandoned by a crashed process; take it +// over by renaming it aside (only one waiter can), and put it back if it proves live +// +// A holder must finish well inside lockStaleAfter: the longest hold is a token refresh, +// bounded by a 30-second request timeout. +const ( + lockRetryEvery = 25 * time.Millisecond + // Longer than the stale threshold, so a waiter outlasts a crashed holder and a slow refresh + lockGiveUpAfter = 75 * time.Second + lockStaleAfter = 60 * time.Second +) + +func lockPath() string { + return ConfigPath() + ".lock" +} + +func newOwnerID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// acquireLock takes the config lock, returning a function that releases it. +func acquireLock() (func(), error) { + path := lockPath() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return nil, err + } + owner := newOwnerID() + deadline := time.Now().Add(lockGiveUpAfter) + + for { + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err == nil { + _, werr := f.WriteString(owner) + cerr := f.Close() + if werr != nil || cerr != nil { + _ = os.Remove(path) + return nil, fmt.Errorf("cannot write %s", path) + } + return func() { releaseLock(path, owner) }, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, fmt.Errorf("cannot lock %s: %w", path, err) + } + + if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter { + takeOverStaleLock(path) + continue + } + + if time.Now().After(deadline) { + return nil, fmt.Errorf("the Trokky config is locked by another process (%s); if nothing else is running, remove that file", path) + } + time.Sleep(lockRetryEvery) + } +} + +// releaseLock removes the lock only if it is still this owner's: after a stale takeover it +// belongs to someone else, and removing it would let a third process in alongside them. +func releaseLock(path, owner string) { + data, err := os.ReadFile(path) + if err == nil && string(data) == owner { + _ = os.Remove(path) + } +} + +// takeOverStaleLock moves an abandoned lock aside. Renaming is atomic, so of several waiters +// that saw it stale only one moves it. If what was moved turns out to be fresh (another +// waiter replaced the stale lock between our stat and our rename), it is put back — with a +// link, which fails rather than overwrite a lock created meanwhile. +func takeOverStaleLock(path string) { + aside := fmt.Sprintf("%s.%s.stale", path, newOwnerID()) + if os.Rename(path, aside) != nil { + return + } + if info, err := os.Stat(aside); err == nil && time.Since(info.ModTime()) <= lockStaleAfter { + _ = os.Link(aside, path) + } + _ = os.Remove(aside) +} + +// Update runs a read-modify-write of the config under the lock. fn edits cfg in place; its +// changes are saved unless it returns an error. +func Update(fn func(cfg *Config) error) error { + release, err := acquireLock() + if err != nil { + return err + } + defer release() + + cfg, err := Load() + if err != nil { + return err + } + if err := fn(cfg); err != nil { + return err + } + return Save(cfg) +} diff --git a/internal/config/main_test.go b/internal/config/main_test.go new file mode 100644 index 0000000..71003c1 --- /dev/null +++ b/internal/config/main_test.go @@ -0,0 +1,21 @@ +package config + +import ( + "os" + "testing" +) + +// Every test in this package runs against a throwaway HOME. RefreshAccessToken reads and +// writes ~/.trokky/config.yaml under a lock, and a test that forgot overrideHome once +// rewrote a developer's real config; this makes forgetting harmless. +func TestMain(m *testing.M) { + home, err := os.MkdirTemp("", "trokky-config-test-home-") + if err != nil { + panic(err) + } + os.Setenv("HOME", home) + os.Setenv("USERPROFILE", home) + code := m.Run() + os.RemoveAll(home) + os.Exit(code) +} diff --git a/internal/useragent/useragent.go b/internal/useragent/useragent.go new file mode 100644 index 0000000..7ef640e --- /dev/null +++ b/internal/useragent/useragent.go @@ -0,0 +1,97 @@ +// Package useragent names the CLI to the instances it talks to: "trokky-cli/0.3.0" on every +// request, and on a sign-in, which the instance records so its Studio's connected +// applications can tell one machine from another, "trokky-cli/0.3.0 (macOS; amens-mbp)". The +// machine name goes only there, not into every request a proxy logs. +package useragent + +import ( + "fmt" + "net/http" + "os" + "runtime" + "strings" +) + +// Version is set from cmd's build-time version. +var Version = "dev" + +// String is the User-Agent of an ordinary request. +func String() string { + return "trokky-cli/" + Version +} + +// SignIn is the User-Agent of a sign-in, naming the machine. +func SignIn() string { + return fmt.Sprintf("trokky-cli/%s (%s; %s)", Version, osName(), host()) +} + +func osName() string { + switch runtime.GOOS { + case "darwin": + return "macOS" + case "windows": + return "Windows" + case "linux": + return "Linux" + default: + return runtime.GOOS + } +} + +func host() string { + name, err := os.Hostname() + if err != nil || name == "" { + return "unknown host" + } + return clean(strings.TrimSuffix(name, ".local")) +} + +// clean keeps a value printable ASCII, without the comment syntax, as a header value should be +func clean(value string) string { + var b strings.Builder + for _, r := range value { + if r < 0x20 || r > 0x7e || r == '(' || r == ')' || r == ';' { + b.WriteByte('?') + } else { + b.WriteRune(r) + } + } + out := strings.TrimSpace(b.String()) + if len(out) > 60 { + out = out[:60] + } + if out == "" { + return "unknown host" + } + return out +} + +type transport struct { + base http.RoundTripper + agent func() string +} + +func (t transport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Header.Get("User-Agent") == "" { + req = req.Clone(req.Context()) + req.Header.Set("User-Agent", t.agent()) + } + return t.base.RoundTrip(req) +} + +// Transport wraps base (http.DefaultTransport when nil) to send the CLI's User-Agent. +func Transport(base http.RoundTripper) http.RoundTripper { + return wrap(base, String) +} + +// SignInTransport is Transport for the requests of a sign-in, naming the machine. +func SignInTransport(base http.RoundTripper) http.RoundTripper { + return wrap(base, SignIn) +} + +func wrap(base http.RoundTripper, agent func() string) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return transport{base: base, agent: agent} +} diff --git a/internal/useragent/useragent_test.go b/internal/useragent/useragent_test.go new file mode 100644 index 0000000..88c0d26 --- /dev/null +++ b/internal/useragent/useragent_test.go @@ -0,0 +1,49 @@ +package useragent + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTransportNamesTheCLI(t *testing.T) { + Version = "9.9.9" + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.UserAgent() })) + defer srv.Close() + + client := &http.Client{Transport: Transport(nil)} + if _, err := client.Get(srv.URL); err != nil { + t.Fatal(err) + } + if got != "trokky-cli/9.9.9" { + t.Fatalf("User-Agent = %q", got) + } + + // A caller's own User-Agent is kept + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("User-Agent", "custom") + if _, err := client.Do(req); err != nil { + t.Fatal(err) + } + if got != "custom" { + t.Fatalf("User-Agent = %q, want custom", got) + } +} + +func TestSignInNamesTheMachineInPlainASCII(t *testing.T) { + Version = "9.9.9" + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.UserAgent() })) + defer srv.Close() + if _, err := (&http.Client{Transport: SignInTransport(nil)}).Get(srv.URL); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(got, "trokky-cli/9.9.9 (") || !strings.HasSuffix(got, ")") { + t.Fatalf("User-Agent = %q", got) + } + if c := clean("Łukasz (PC); 🤖\r\n"); c != "?ukasz ?PC?? ???" { + t.Fatalf("clean = %q", c) + } +}