github.com/rclone/rclone@v1.66.1-0.20240517100346-7b89735ae726/fs/rc/rcserver/rcserver_test.go (about)

     1  package rcserver
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/http/httptest"
    11  	"os"
    12  	"path/filepath"
    13  	"regexp"
    14  	"strings"
    15  	"testing"
    16  	"time"
    17  
    18  	_ "github.com/rclone/rclone/backend/local"
    19  	"github.com/rclone/rclone/fs"
    20  	"github.com/rclone/rclone/fs/accounting"
    21  	"github.com/rclone/rclone/fs/config/configfile"
    22  	"github.com/rclone/rclone/fs/rc"
    23  	"github.com/stretchr/testify/assert"
    24  	"github.com/stretchr/testify/require"
    25  )
    26  
    27  const (
    28  	testBindAddress     = "localhost:0"
    29  	defaultTestTemplate = "testdata/golden/testindex.html"
    30  	testFs              = "testdata/files"
    31  	remoteURL           = "[" + testFs + "]/" // initial URL path to fetch from that remote
    32  )
    33  
    34  func TestMain(m *testing.M) {
    35  	// Pretend to be rclone version if we have a version string parameter
    36  	if os.Args[len(os.Args)-1] == "version" {
    37  		fmt.Printf("rclone %s\n", fs.Version)
    38  		os.Exit(0)
    39  	}
    40  	// Pretend to error if we have an unknown command
    41  	if os.Args[len(os.Args)-1] == "unknown_command" {
    42  		fmt.Printf("rclone %s\n", fs.Version)
    43  		fmt.Fprintf(os.Stderr, "Unknown command\n")
    44  		os.Exit(1)
    45  	}
    46  	os.Exit(m.Run())
    47  }
    48  
    49  // Test the RC server runs and we can do HTTP fetches from it.
    50  // We'll do the majority of the testing with the httptest framework
    51  func TestRcServer(t *testing.T) {
    52  	opt := rc.DefaultOpt
    53  	opt.HTTP.ListenAddr = []string{testBindAddress}
    54  	opt.Template.Path = defaultTestTemplate
    55  	opt.Enabled = true
    56  	opt.Serve = true
    57  	opt.Files = testFs
    58  	mux := http.NewServeMux()
    59  	rcServer, err := newServer(context.Background(), &opt, mux)
    60  	require.NoError(t, err)
    61  	assert.NoError(t, rcServer.Serve())
    62  	defer func() {
    63  		assert.NoError(t, rcServer.Shutdown())
    64  		rcServer.Wait()
    65  	}()
    66  	testURL := rcServer.server.URLs()[0]
    67  
    68  	// Do the simplest possible test to check the server is alive
    69  	// Do it a few times to wait for the server to start
    70  	var resp *http.Response
    71  	for i := 0; i < 10; i++ {
    72  		resp, err = http.Get(testURL + "file.txt")
    73  		if err == nil {
    74  			break
    75  		}
    76  		time.Sleep(10 * time.Millisecond)
    77  	}
    78  
    79  	require.NoError(t, err)
    80  	body, err := io.ReadAll(resp.Body)
    81  	_ = resp.Body.Close()
    82  
    83  	require.NoError(t, err)
    84  	require.NoError(t, resp.Body.Close())
    85  
    86  	assert.Equal(t, http.StatusOK, resp.StatusCode)
    87  	assert.Equal(t, "this is file1.txt\n", string(body))
    88  }
    89  
    90  type testRun struct {
    91  	Name        string
    92  	URL         string
    93  	User        string
    94  	Pass        string
    95  	Status      int
    96  	Method      string
    97  	Range       string
    98  	Body        string
    99  	ContentType string
   100  	Expected    string
   101  	Contains    *regexp.Regexp
   102  	Headers     map[string]string
   103  }
   104  
   105  // Run a suite of tests
   106  func testServer(t *testing.T, tests []testRun, opt *rc.Options) {
   107  	t.Helper()
   108  
   109  	ctx := context.Background()
   110  	configfile.Install()
   111  	if opt.Template.Path == "" {
   112  		opt.Template.Path = defaultTestTemplate
   113  	}
   114  	rcServer, err := newServer(ctx, opt, http.DefaultServeMux)
   115  	require.NoError(t, err)
   116  	testURL := rcServer.server.URLs()[0]
   117  	mux := rcServer.server.Router()
   118  	for _, test := range tests {
   119  		t.Run(test.Name, func(t *testing.T) {
   120  			t.Helper()
   121  
   122  			method := test.Method
   123  			if method == "" {
   124  				method = "GET"
   125  			}
   126  			var inBody io.Reader
   127  			if test.Body != "" {
   128  				buf := bytes.NewBufferString(test.Body)
   129  				inBody = buf
   130  			}
   131  			req, err := http.NewRequest(method, "http://1.2.3.4/"+test.URL, inBody)
   132  			require.NoError(t, err)
   133  			if test.Range != "" {
   134  				req.Header.Add("Range", test.Range)
   135  			}
   136  			if test.ContentType != "" {
   137  				req.Header.Add("Content-Type", test.ContentType)
   138  			}
   139  			if test.User != "" && test.Pass != "" {
   140  				req.SetBasicAuth(test.User, test.Pass)
   141  			}
   142  
   143  			w := httptest.NewRecorder()
   144  			mux.ServeHTTP(w, req)
   145  			resp := w.Result()
   146  
   147  			assert.Equal(t, test.Status, resp.StatusCode)
   148  			body, err := io.ReadAll(resp.Body)
   149  			require.NoError(t, err)
   150  
   151  			if test.ContentType == "application/json" && test.Expected != "" {
   152  				expectedNormalized := normalizeJSON(t, test.Expected)
   153  				actualNormalized := normalizeJSON(t, string(body))
   154  				assert.Equal(t, expectedNormalized, actualNormalized, "Normalized JSON does not match")
   155  			} else if test.Contains == nil {
   156  				assert.Equal(t, test.Expected, string(body))
   157  			} else {
   158  				assert.True(t, test.Contains.Match(body), fmt.Sprintf("body didn't match: %v: %v", test.Contains, string(body)))
   159  			}
   160  
   161  			for k, v := range test.Headers {
   162  				if v == "testURL" {
   163  					v = testURL
   164  				}
   165  				assert.Equal(t, v, resp.Header.Get(k), k)
   166  			}
   167  		})
   168  	}
   169  }
   170  
   171  // return an enabled rc
   172  func newTestOpt() rc.Options {
   173  	opt := rc.DefaultOpt
   174  	opt.Enabled = true
   175  	opt.HTTP.ListenAddr = []string{testBindAddress}
   176  	return opt
   177  }
   178  
   179  func TestFileServing(t *testing.T) {
   180  	tests := []testRun{{
   181  		Name:   "index",
   182  		URL:    "",
   183  		Status: http.StatusOK,
   184  		Expected: `<pre>
   185  <a href="dir/">dir/</a>
   186  <a href="file.txt">file.txt</a>
   187  <a href="modtime/">modtime/</a>
   188  </pre>
   189  `,
   190  	}, {
   191  		Name:     "notfound",
   192  		URL:      "notfound",
   193  		Status:   http.StatusNotFound,
   194  		Expected: "404 page not found\n",
   195  	}, {
   196  		Name:     "dirnotfound",
   197  		URL:      "dirnotfound/",
   198  		Status:   http.StatusNotFound,
   199  		Expected: "404 page not found\n",
   200  	}, {
   201  		Name:   "dir",
   202  		URL:    "dir/",
   203  		Status: http.StatusOK,
   204  		Expected: `<pre>
   205  <a href="file2.txt">file2.txt</a>
   206  </pre>
   207  `,
   208  	}, {
   209  		Name:     "file",
   210  		URL:      "file.txt",
   211  		Status:   http.StatusOK,
   212  		Expected: "this is file1.txt\n",
   213  		Headers: map[string]string{
   214  			"Content-Length": "18",
   215  		},
   216  	}, {
   217  		Name:     "file2",
   218  		URL:      "dir/file2.txt",
   219  		Status:   http.StatusOK,
   220  		Expected: "this is dir/file2.txt\n",
   221  	}, {
   222  		Name:     "file-head",
   223  		URL:      "file.txt",
   224  		Method:   "HEAD",
   225  		Status:   http.StatusOK,
   226  		Expected: ``,
   227  		Headers: map[string]string{
   228  			"Content-Length": "18",
   229  		},
   230  	}, {
   231  		Name:     "file-range",
   232  		URL:      "file.txt",
   233  		Status:   http.StatusPartialContent,
   234  		Range:    "bytes=8-12",
   235  		Expected: `file1`,
   236  	}}
   237  	opt := newTestOpt()
   238  	opt.Serve = true
   239  	opt.Files = testFs
   240  	testServer(t, tests, &opt)
   241  }
   242  
   243  func TestRemoteServing(t *testing.T) {
   244  	tests := []testRun{
   245  		// Test serving files from the test remote
   246  		{
   247  			Name:   "index",
   248  			URL:    remoteURL + "",
   249  			Status: http.StatusOK,
   250  			Expected: `<!DOCTYPE html>
   251  <html lang="en">
   252  <head>
   253  <meta charset="utf-8">
   254  <title>Directory listing of /</title>
   255  </head>
   256  <body>
   257  <h1>Directory listing of /</h1>
   258  <a href="dir/">dir/</a><br />
   259  <a href="modtime/">modtime/</a><br />
   260  <a href="file.txt">file.txt</a><br />
   261  </body>
   262  </html>
   263  `,
   264  		}, {
   265  			Name:   "notfound-index",
   266  			URL:    "[notfound]/",
   267  			Status: http.StatusNotFound,
   268  			Expected: `{
   269  	"error": "failed to list directory: directory not found",
   270  	"input": null,
   271  	"path": "",
   272  	"status": 404
   273  }
   274  `,
   275  		}, {
   276  			Name:   "notfound",
   277  			URL:    remoteURL + "notfound",
   278  			Status: http.StatusNotFound,
   279  			Expected: `{
   280  	"error": "failed to find object: object not found",
   281  	"input": null,
   282  	"path": "notfound",
   283  	"status": 404
   284  }
   285  `,
   286  		}, {
   287  			Name:   "dirnotfound",
   288  			URL:    remoteURL + "dirnotfound/",
   289  			Status: http.StatusNotFound,
   290  			Expected: `{
   291  	"error": "failed to list directory: directory not found",
   292  	"input": null,
   293  	"path": "dirnotfound",
   294  	"status": 404
   295  }
   296  `,
   297  		}, {
   298  			Name:   "dir",
   299  			URL:    remoteURL + "dir/",
   300  			Status: http.StatusOK,
   301  			Expected: `<!DOCTYPE html>
   302  <html lang="en">
   303  <head>
   304  <meta charset="utf-8">
   305  <title>Directory listing of /dir</title>
   306  </head>
   307  <body>
   308  <h1>Directory listing of /dir</h1>
   309  <a href="file2.txt">file2.txt</a><br />
   310  </body>
   311  </html>
   312  `,
   313  		}, {
   314  			Name:     "file",
   315  			URL:      remoteURL + "file.txt",
   316  			Status:   http.StatusOK,
   317  			Expected: "this is file1.txt\n",
   318  			Headers: map[string]string{
   319  				"Content-Length": "18",
   320  			},
   321  		}, {
   322  			Name:     "file with no slash after ]",
   323  			URL:      strings.TrimRight(remoteURL, "/") + "file.txt",
   324  			Status:   http.StatusOK,
   325  			Expected: "this is file1.txt\n",
   326  			Headers: map[string]string{
   327  				"Content-Length": "18",
   328  			},
   329  		}, {
   330  			Name:     "file2",
   331  			URL:      remoteURL + "dir/file2.txt",
   332  			Status:   http.StatusOK,
   333  			Expected: "this is dir/file2.txt\n",
   334  		}, {
   335  			Name:     "file-head",
   336  			URL:      remoteURL + "file.txt",
   337  			Method:   "HEAD",
   338  			Status:   http.StatusOK,
   339  			Expected: ``,
   340  			Headers: map[string]string{
   341  				"Content-Length": "18",
   342  			},
   343  		}, {
   344  			Name:     "file-range",
   345  			URL:      remoteURL + "file.txt",
   346  			Status:   http.StatusPartialContent,
   347  			Range:    "bytes=8-12",
   348  			Expected: `file1`,
   349  		}, {
   350  			Name:   "bad-remote",
   351  			URL:    "[notfoundremote:]/",
   352  			Status: http.StatusInternalServerError,
   353  			Expected: `{
   354  	"error": "failed to make Fs: didn't find section in config file",
   355  	"input": null,
   356  	"path": "/",
   357  	"status": 500
   358  }
   359  `,
   360  		}}
   361  	opt := newTestOpt()
   362  	opt.Serve = true
   363  	opt.Files = testFs
   364  	testServer(t, tests, &opt)
   365  }
   366  
   367  func TestRC(t *testing.T) {
   368  	tests := []testRun{{
   369  		Name:   "rc-root",
   370  		URL:    "",
   371  		Method: "POST",
   372  		Status: http.StatusNotFound,
   373  		Expected: `{
   374  	"error": "couldn't find method \"\"",
   375  	"input": {},
   376  	"path": "",
   377  	"status": 404
   378  }
   379  `,
   380  	}, {
   381  		Name:     "rc-noop",
   382  		URL:      "rc/noop",
   383  		Method:   "POST",
   384  		Status:   http.StatusOK,
   385  		Expected: "{}\n",
   386  	}, {
   387  		Name:   "rc-error",
   388  		URL:    "rc/error",
   389  		Method: "POST",
   390  		Status: http.StatusInternalServerError,
   391  		Expected: `{
   392  	"error": "arbitrary error on input map[]",
   393  	"input": {},
   394  	"path": "rc/error",
   395  	"status": 500
   396  }
   397  `,
   398  	}, {
   399  		Name:     "core-gc",
   400  		URL:      "core/gc", // returns nil, nil so check it is made into {}
   401  		Method:   "POST",
   402  		Status:   http.StatusOK,
   403  		Expected: "{}\n",
   404  	}, {
   405  		Name:   "url-params",
   406  		URL:    "rc/noop?param1=potato&param2=sausage",
   407  		Method: "POST",
   408  		Status: http.StatusOK,
   409  		Expected: `{
   410  	"param1": "potato",
   411  	"param2": "sausage"
   412  }
   413  `,
   414  	}, {
   415  		Name:        "json",
   416  		URL:         "rc/noop",
   417  		Method:      "POST",
   418  		Body:        `{ "param1":"string", "param2":true }`,
   419  		ContentType: "application/json",
   420  		Status:      http.StatusOK,
   421  		Expected: `{
   422  	"param1": "string",
   423  	"param2": true
   424  }
   425  `,
   426  	}, {
   427  		Name:        "json-and-url-params",
   428  		URL:         "rc/noop?param1=potato&param2=sausage",
   429  		Method:      "POST",
   430  		Body:        `{ "param1":"string", "param3":true }`,
   431  		ContentType: "application/json",
   432  		Status:      http.StatusOK,
   433  		Expected: `{
   434  	"param1": "string",
   435  	"param2": "sausage",
   436  	"param3": true
   437  }
   438  `,
   439  	}, {
   440  		Name:        "json-bad",
   441  		URL:         "rc/noop?param1=potato&param2=sausage",
   442  		Method:      "POST",
   443  		Body:        `{ param1":"string", "param3":true }`,
   444  		ContentType: "application/json",
   445  		Status:      http.StatusBadRequest,
   446  		Expected: `{
   447  	"error": "failed to read input JSON: invalid character 'p' looking for beginning of object key string",
   448  	"input": {
   449  		"param1": "potato",
   450  		"param2": "sausage"
   451  	},
   452  	"path": "rc/noop",
   453  	"status": 400
   454  }
   455  `,
   456  	}, {
   457  		Name:        "form",
   458  		URL:         "rc/noop",
   459  		Method:      "POST",
   460  		Body:        `param1=string&param2=true`,
   461  		ContentType: "application/x-www-form-urlencoded",
   462  		Status:      http.StatusOK,
   463  		Expected: `{
   464  	"param1": "string",
   465  	"param2": "true"
   466  }
   467  `,
   468  	}, {
   469  		Name:        "form-and-url-params",
   470  		URL:         "rc/noop?param1=potato&param2=sausage",
   471  		Method:      "POST",
   472  		Body:        `param1=string&param3=true`,
   473  		ContentType: "application/x-www-form-urlencoded",
   474  		Status:      http.StatusOK,
   475  		Expected: `{
   476  	"param1": "potato",
   477  	"param2": "sausage",
   478  	"param3": "true"
   479  }
   480  `,
   481  	}, {
   482  		Name:        "form-bad",
   483  		URL:         "rc/noop?param1=potato&param2=sausage",
   484  		Method:      "POST",
   485  		Body:        `%zz`,
   486  		ContentType: "application/x-www-form-urlencoded",
   487  		Status:      http.StatusBadRequest,
   488  		Expected: `{
   489  	"error": "failed to parse form/URL parameters: invalid URL escape \"%zz\"",
   490  	"input": null,
   491  	"path": "rc/noop",
   492  	"status": 400
   493  }
   494  `,
   495  	}}
   496  	opt := newTestOpt()
   497  	opt.Serve = true
   498  	opt.Files = testFs
   499  	testServer(t, tests, &opt)
   500  }
   501  
   502  func TestRCWithAuth(t *testing.T) {
   503  	tests := []testRun{{
   504  		Name:        "core-command",
   505  		URL:         "core/command",
   506  		Method:      "POST",
   507  		Body:        `command=version`,
   508  		ContentType: "application/x-www-form-urlencoded",
   509  		Status:      http.StatusOK,
   510  		Expected: fmt.Sprintf(`{
   511  	"error": false,
   512  	"result": "rclone %s\n"
   513  }
   514  `, fs.Version),
   515  	}, {
   516  		Name:        "core-command-bad-returnType",
   517  		URL:         "core/command",
   518  		Method:      "POST",
   519  		Body:        `command=version&returnType=POTATO`,
   520  		ContentType: "application/x-www-form-urlencoded",
   521  		Status:      http.StatusInternalServerError,
   522  		Expected: `{
   523  	"error": "unknown returnType \"POTATO\"",
   524  	"input": {
   525  		"command": "version",
   526  		"returnType": "POTATO"
   527  	},
   528  	"path": "core/command",
   529  	"status": 500
   530  }
   531  `,
   532  	}, {
   533  		Name:        "core-command-stream",
   534  		URL:         "core/command",
   535  		Method:      "POST",
   536  		Body:        `command=version&returnType=STREAM`,
   537  		ContentType: "application/x-www-form-urlencoded",
   538  		Status:      http.StatusOK,
   539  		Expected: fmt.Sprintf(`rclone %s
   540  {}
   541  `, fs.Version),
   542  	}, {
   543  		Name:        "core-command-stream-error",
   544  		URL:         "core/command",
   545  		Method:      "POST",
   546  		Body:        `command=unknown_command&returnType=STREAM`,
   547  		ContentType: "application/x-www-form-urlencoded",
   548  		Status:      http.StatusOK,
   549  		Expected: fmt.Sprintf(`rclone %s
   550  Unknown command
   551  {
   552  	"error": "exit status 1",
   553  	"input": {
   554  		"command": "unknown_command",
   555  		"returnType": "STREAM"
   556  	},
   557  	"path": "core/command",
   558  	"status": 500
   559  }
   560  `, fs.Version),
   561  	}}
   562  	opt := newTestOpt()
   563  	opt.Serve = true
   564  	opt.Files = testFs
   565  	opt.NoAuth = true
   566  	testServer(t, tests, &opt)
   567  }
   568  
   569  func TestMetrics(t *testing.T) {
   570  	stats := accounting.GlobalStats()
   571  	tests := makeMetricsTestCases(stats)
   572  	opt := newTestOpt()
   573  	opt.EnableMetrics = true
   574  	testServer(t, tests, &opt)
   575  
   576  	// Test changing a couple options
   577  	stats.Bytes(500)
   578  	for i := 0; i < 30; i++ {
   579  		require.NoError(t, stats.DeleteFile(context.Background(), 0))
   580  	}
   581  	stats.Errors(2)
   582  	stats.Bytes(324)
   583  
   584  	tests = makeMetricsTestCases(stats)
   585  	testServer(t, tests, &opt)
   586  }
   587  
   588  func makeMetricsTestCases(stats *accounting.StatsInfo) (tests []testRun) {
   589  	tests = []testRun{{
   590  		Name:     "Bytes Transferred Metric",
   591  		URL:      "/metrics",
   592  		Method:   "GET",
   593  		Status:   http.StatusOK,
   594  		Contains: regexp.MustCompile(fmt.Sprintf("rclone_bytes_transferred_total %d", stats.GetBytes())),
   595  	}, {
   596  		Name:     "Checked Files Metric",
   597  		URL:      "/metrics",
   598  		Method:   "GET",
   599  		Status:   http.StatusOK,
   600  		Contains: regexp.MustCompile(fmt.Sprintf("rclone_checked_files_total %d", stats.GetChecks())),
   601  	}, {
   602  		Name:     "Errors Metric",
   603  		URL:      "/metrics",
   604  		Method:   "GET",
   605  		Status:   http.StatusOK,
   606  		Contains: regexp.MustCompile(fmt.Sprintf("rclone_errors_total %d", stats.GetErrors())),
   607  	}, {
   608  		Name:     "Deleted Files Metric",
   609  		URL:      "/metrics",
   610  		Method:   "GET",
   611  		Status:   http.StatusOK,
   612  		Contains: regexp.MustCompile(fmt.Sprintf("rclone_files_deleted_total %d", stats.GetDeletes())),
   613  	}, {
   614  		Name:     "Files Transferred Metric",
   615  		URL:      "/metrics",
   616  		Method:   "GET",
   617  		Status:   http.StatusOK,
   618  		Contains: regexp.MustCompile(fmt.Sprintf("rclone_files_transferred_total %d", stats.GetTransfers())),
   619  	},
   620  	}
   621  	return
   622  }
   623  
   624  var matchRemoteDirListing = regexp.MustCompile(`<title>Directory listing of /</title>`)
   625  
   626  func TestServingRoot(t *testing.T) {
   627  	tests := []testRun{{
   628  		Name:     "rootlist",
   629  		URL:      "*",
   630  		Status:   http.StatusOK,
   631  		Contains: matchRemoteDirListing,
   632  	}}
   633  	opt := newTestOpt()
   634  	opt.Serve = true
   635  	opt.Files = testFs
   636  	testServer(t, tests, &opt)
   637  }
   638  
   639  func TestServingRootNoFiles(t *testing.T) {
   640  	tests := []testRun{{
   641  		Name:     "rootlist",
   642  		URL:      "",
   643  		Status:   http.StatusOK,
   644  		Contains: matchRemoteDirListing,
   645  	}}
   646  	opt := newTestOpt()
   647  	opt.Serve = true
   648  	opt.Files = ""
   649  	testServer(t, tests, &opt)
   650  }
   651  
   652  func TestNoFiles(t *testing.T) {
   653  	tests := []testRun{{
   654  		Name:     "file",
   655  		URL:      "file.txt",
   656  		Status:   http.StatusNotFound,
   657  		Expected: "Not Found\n",
   658  	}, {
   659  		Name:     "dir",
   660  		URL:      "dir/",
   661  		Status:   http.StatusNotFound,
   662  		Expected: "Not Found\n",
   663  	}}
   664  	opt := newTestOpt()
   665  	opt.Serve = true
   666  	opt.Files = ""
   667  	testServer(t, tests, &opt)
   668  }
   669  
   670  func TestNoServe(t *testing.T) {
   671  	tests := []testRun{{
   672  		Name:     "file",
   673  		URL:      remoteURL + "file.txt",
   674  		Status:   http.StatusNotFound,
   675  		Expected: "404 page not found\n",
   676  	}, {
   677  		Name:     "dir",
   678  		URL:      remoteURL + "dir/",
   679  		Status:   http.StatusNotFound,
   680  		Expected: "404 page not found\n",
   681  	}}
   682  	opt := newTestOpt()
   683  	opt.Serve = false
   684  	opt.Files = testFs
   685  	testServer(t, tests, &opt)
   686  }
   687  
   688  func TestAuthRequired(t *testing.T) {
   689  	tests := []testRun{{
   690  		Name:        "auth",
   691  		URL:         "rc/noopauth",
   692  		Method:      "POST",
   693  		Body:        `{}`,
   694  		ContentType: "application/javascript",
   695  		Status:      http.StatusForbidden,
   696  		Expected: `{
   697  	"error": "authentication must be set up on the rc server to use \"rc/noopauth\" or the --rc-no-auth flag must be in use",
   698  	"input": {},
   699  	"path": "rc/noopauth",
   700  	"status": 403
   701  }
   702  `,
   703  	}}
   704  	opt := newTestOpt()
   705  	opt.Serve = false
   706  	opt.Files = ""
   707  	opt.NoAuth = false
   708  	testServer(t, tests, &opt)
   709  }
   710  
   711  func TestNoAuth(t *testing.T) {
   712  	tests := []testRun{{
   713  		Name:        "auth",
   714  		URL:         "rc/noopauth",
   715  		Method:      "POST",
   716  		Body:        `{}`,
   717  		ContentType: "application/javascript",
   718  		Status:      http.StatusOK,
   719  		Expected:    "{}\n",
   720  	}}
   721  	opt := newTestOpt()
   722  	opt.Serve = false
   723  	opt.Files = ""
   724  	opt.NoAuth = true
   725  	testServer(t, tests, &opt)
   726  }
   727  
   728  func TestWithUserPass(t *testing.T) {
   729  	tests := []testRun{{
   730  		Name:        "authMissing",
   731  		URL:         "rc/noopauth",
   732  		Method:      "POST",
   733  		Body:        `{}`,
   734  		ContentType: "application/javascript",
   735  		Status:      http.StatusUnauthorized,
   736  		Expected:    "401 Unauthorized\n",
   737  	}, {
   738  		Name:        "authWrong",
   739  		URL:         "rc/noopauth",
   740  		Method:      "POST",
   741  		Body:        `{}`,
   742  		ContentType: "application/javascript",
   743  		Status:      http.StatusUnauthorized,
   744  		Expected:    "401 Unauthorized\n",
   745  		User:        "user1",
   746  		Pass:        "pass2",
   747  	}, {
   748  		Name:        "authOK",
   749  		URL:         "rc/noopauth",
   750  		Method:      "POST",
   751  		Body:        `{}`,
   752  		ContentType: "application/javascript",
   753  		Status:      http.StatusOK,
   754  		Expected:    "{}\n",
   755  		User:        "user",
   756  		Pass:        "pass",
   757  	}}
   758  	opt := newTestOpt()
   759  	opt.Serve = false
   760  	opt.Files = ""
   761  	opt.NoAuth = false
   762  	opt.Auth.BasicUser = "user"
   763  	opt.Auth.BasicPass = "pass"
   764  	testServer(t, tests, &opt)
   765  }
   766  
   767  func TestRCAsync(t *testing.T) {
   768  	tests := []testRun{{
   769  		Name:        "ok",
   770  		URL:         "rc/noop",
   771  		Method:      "POST",
   772  		ContentType: "application/json",
   773  		Body:        `{ "_async":true }`,
   774  		Status:      http.StatusOK,
   775  		Contains:    regexp.MustCompile(`(?s)\{.*\"jobid\":.*\}`),
   776  	}, {
   777  		Name:        "bad",
   778  		URL:         "rc/noop",
   779  		Method:      "POST",
   780  		ContentType: "application/json",
   781  		Body:        `{ "_async":"truthy" }`,
   782  		Status:      http.StatusBadRequest,
   783  		Expected: `{
   784  	"error": "couldn't parse key \"_async\" (truthy) as bool: strconv.ParseBool: parsing \"truthy\": invalid syntax",
   785  	"input": {
   786  		"_async": "truthy"
   787  	},
   788  	"path": "rc/noop",
   789  	"status": 400
   790  }
   791  `,
   792  	}}
   793  	opt := newTestOpt()
   794  	opt.Serve = true
   795  	opt.Files = ""
   796  	testServer(t, tests, &opt)
   797  }
   798  
   799  // Check the debug handlers are attached
   800  func TestRCDebug(t *testing.T) {
   801  	tests := []testRun{{
   802  		Name:        "index",
   803  		URL:         "debug/pprof/",
   804  		Method:      "GET",
   805  		ContentType: "text/html",
   806  		Status:      http.StatusOK,
   807  		Contains:    regexp.MustCompile(`Types of profiles available`),
   808  	}, {
   809  		Name:        "goroutines",
   810  		URL:         "debug/pprof/goroutine?debug=1",
   811  		Method:      "GET",
   812  		ContentType: "text/html",
   813  		Status:      http.StatusOK,
   814  		Contains:    regexp.MustCompile(`goroutine profile`),
   815  	}}
   816  	opt := newTestOpt()
   817  	opt.Serve = true
   818  	opt.Files = ""
   819  	testServer(t, tests, &opt)
   820  }
   821  
   822  func TestServeModTime(t *testing.T) {
   823  	for file, mtime := range map[string]time.Time{
   824  		"dir":         time.Date(2023, 4, 12, 21, 15, 17, 0, time.UTC),
   825  		"modtime.txt": time.Date(2021, 1, 18, 5, 2, 28, 0, time.UTC),
   826  	} {
   827  		path := filepath.Join(testFs, "modtime", file)
   828  		err := os.Chtimes(path, mtime, mtime)
   829  		require.NoError(t, err)
   830  	}
   831  
   832  	opt := newTestOpt()
   833  	opt.Serve = true
   834  	opt.Template.Path = "testdata/golden/testmodtime.html"
   835  
   836  	tests := []testRun{{
   837  		Name:     "modtime",
   838  		Method:   "GET",
   839  		URL:      remoteURL + "modtime/",
   840  		Status:   http.StatusOK,
   841  		Expected: "* dir/ - 2023-04-12T21:15:17Z\n* modtime.txt - 2021-01-18T05:02:28Z\n",
   842  	}}
   843  	testServer(t, tests, &opt)
   844  
   845  	opt.ServeNoModTime = true
   846  	tests = []testRun{{
   847  		Name:     "no modtime",
   848  		Method:   "GET",
   849  		URL:      remoteURL + "modtime/",
   850  		Status:   http.StatusOK,
   851  		Expected: "* dir/ - 0001-01-01T00:00:00Z\n* modtime.txt - 0001-01-01T00:00:00Z\n",
   852  	}}
   853  	testServer(t, tests, &opt)
   854  }
   855  
   856  func TestContentTypeJSON(t *testing.T) {
   857  	tests := []testRun{
   858  		{
   859  			Name:        "Check Content-Type for JSON response",
   860  			URL:         "rc/noop",
   861  			Method:      "POST",
   862  			Body:        `{}`,
   863  			ContentType: "application/json",
   864  			Status:      http.StatusOK,
   865  			Expected:    "{}\n",
   866  			Headers: map[string]string{
   867  				"Content-Type": "application/json",
   868  			},
   869  		},
   870  		{
   871  			Name:        "Check Content-Type for JSON error response",
   872  			URL:         "rc/error",
   873  			Method:      "POST",
   874  			Body:        `{}`,
   875  			ContentType: "application/json",
   876  			Status:      http.StatusInternalServerError,
   877  			Expected: `{
   878  				"error": "arbitrary error on input map[]",
   879  				"input": {},
   880  				"path": "rc/error",
   881  				"status": 500
   882  			}
   883  			`,
   884  			Headers: map[string]string{
   885  				"Content-Type": "application/json",
   886  			},
   887  		},
   888  	}
   889  	opt := newTestOpt()
   890  	testServer(t, tests, &opt)
   891  }
   892  
   893  func normalizeJSON(t *testing.T, jsonStr string) string {
   894  	var jsonObj map[string]interface{}
   895  	err := json.Unmarshal([]byte(jsonStr), &jsonObj)
   896  	require.NoError(t, err, "JSON unmarshalling failed")
   897  	normalizedJSON, err := json.Marshal(jsonObj)
   898  	require.NoError(t, err, "JSON marshalling failed")
   899  	return string(normalizedJSON)
   900  }