diff options
| -rw-r--r-- | .gitmodules | 4 | ||||
| -rw-r--r-- | docker-compose.yml | 38 | ||||
| -rw-r--r-- | docker/php/zz-test.ini | 4 | ||||
| -rw-r--r-- | magefile.go | 333 | ||||
| m--------- | studip | 0 |
5 files changed, 372 insertions, 7 deletions
diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..95bd0ba --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "studip"] + path = studip + url = https://github.com/Mewsen/studip.git + branch = testing diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..27bb8bb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + db: + image: mariadb:10.5 + command: + - --innodb-file-per-table=ON + - --innodb-large-prefix=ON + - --innodb-file-format=Barracuda + - --sql-mode=NO_ENGINE_SUBSTITUTION + volumes: + - db_data:/var/lib/mysql + restart: unless-stopped + environment: + MYSQL_RANDOM_ROOT_PASSWORD: 1 + MYSQL_DATABASE: studip_db + MYSQL_USER: studip_user + MYSQL_PASSWORD: studip_password + web: + build: + context: ./studip + dockerfile: ./docker/studip/Dockerfile + image: rclone-studip-demo-web:local-studip + depends_on: + - db + ports: + - "8034:80" + volumes: + - ./docker/php/zz-test.ini:/usr/local/etc/php/conf.d/zz-test.ini:ro + restart: unless-stopped + environment: + MYSQL_DATABASE: studip_db + MYSQL_USER: studip_user + MYSQL_PASSWORD: studip_password + MYSQL_HOST: db + ENV: production + DEMO_DATA: 1 + +volumes: + db_data: {} diff --git a/docker/php/zz-test.ini b/docker/php/zz-test.ini new file mode 100644 index 0000000..10770c3 --- /dev/null +++ b/docker/php/zz-test.ini @@ -0,0 +1,4 @@ +display_errors=Off +display_startup_errors=Off +html_errors=Off +log_errors=On diff --git a/magefile.go b/magefile.go index 0adfa82..be97194 100644 --- a/magefile.go +++ b/magefile.go @@ -7,11 +7,17 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net/http" + "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" + "time" + + "github.com/rclone/rclone/fs/config/obscure" ) const ( @@ -28,10 +34,92 @@ const ( pluginBuildTreeDir = "rclone" pluginEntrypointDir = "cmd/studipplugin" pluginEntrypointSrc = "package main\n\nimport _ \"github.com/mewsen/rclone-studip-backend-oot/backend/studip\"\n" + + studIPDemoBaseURL = "http://localhost:8034/jsonapi.php/v1/" + studIPDemoUsername = "test_dozent" + studIPDemoPassword = "testing" + studIPDemoLicense = "SELFMADE_NONPUB" + studIPConfigName = "test-rclone.conf" ) var Default = BuildPluginAndStandaloneBinary +type studIPTestConfig struct { + BaseURL string + Username string + Password string + CourseID string + License string + ConfigPath string +} + +type studIPCoursesResponse struct { + Data []studIPCourse `json:"data"` +} + +type studIPCourse struct { + ID string `json:"id"` + Attributes struct { + Title string `json:"title"` + CourseType int `json:"course-type"` + } `json:"attributes"` + Relationships struct { + Folders struct { + Links struct { + Related string `json:"related"` + } `json:"links"` + } `json:"folders"` + } `json:"relationships"` +} + +type studIPFoldersResponse struct { + Data []struct { + Attributes struct { + FolderType string `json:"folder-type"` + IsReadable bool `json:"is-readable"` + IsWritable bool `json:"is-writable"` + } `json:"attributes"` + } `json:"data"` +} + +func TestAgainstContainer() error { + err := runCommandWithEnv(nil, "git", "submodule", "update", "--init", "--recursive") + if err != nil { + return err + } + + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create output dir %q: %w", outputDir, err) + } + + config, course, err := prepareStudIPTestEnvironment() + if err != nil { + return err + } + + fmt.Printf(">> using Stud.IP demo account %q\n", config.Username) + fmt.Printf(">> using demo course %q (%s)\n", course.Attributes.Title, course.ID) + fmt.Printf(">> wrote rclone config to %s\n", config.ConfigPath) + fmt.Printf(">> running backend tests: RCLONE_CONFIG=%s go test -parallel=1 -v -count=1 ./backend/studip/studip_test.go\n", config.ConfigPath) + + defer func() { + fmt.Println(">> stopping Stud.IP demo stack") + if downErr := runCommandWithEnv(nil, "docker", "compose", "down", "--volumes", "--remove-orphans"); downErr != nil { + fmt.Fprintf(os.Stderr, "failed to stop Stud.IP demo stack: %v\n", downErr) + } + }() + + err = runCommandWithEnv( + []string{"RCLONE_CONFIG=" + config.ConfigPath}, + "go", "test", "-parallel=1", "-v", "-count=1", "./backend/studip/studip_test.go", + ) + if err != nil { + return err + } + + return nil +} + func BuildPluginAndStandaloneBinary() error { if err := Plugin(); err != nil { return err @@ -44,8 +132,8 @@ func Plugin() error { return fmt.Errorf("go plugin buildmode is not supported on windows") } - if err := ensureOutputDir(); err != nil { - return err + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create output dir %q: %w", outputDir, err) } repoRoot, err := os.Getwd() @@ -136,8 +224,8 @@ func UninstallPlugin() error { } func Standalone() error { - if err := ensureOutputDir(); err != nil { - return err + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create output dir %q: %w", outputDir, err) } return runCommandWithEnv( @@ -177,14 +265,245 @@ func Clean() error { return nil } -func ensureOutputDir() error { - if err := os.MkdirAll(outputDir, 0o755); err != nil { - return fmt.Errorf("create output dir %q: %w", outputDir, err) +func prepareStudIPTestEnvironment() (studIPTestConfig, studIPCourse, error) { + config := studIPTestConfig{ + BaseURL: envOrDefault("STUDIP_TEST_BASE_URL", studIPDemoBaseURL), + Username: envOrDefault("STUDIP_TEST_USERNAME", studIPDemoUsername), + Password: envOrDefault("STUDIP_TEST_PASSWORD", studIPDemoPassword), + License: envOrDefault("STUDIP_TEST_LICENSE", studIPDemoLicense), + ConfigPath: envOrDefault("RCLONE_CONFIG", filepath.Join(outputDir, studIPConfigName)), + } + + configPath, err := filepath.Abs(config.ConfigPath) + if err != nil { + return config, studIPCourse{}, fmt.Errorf("resolve rclone config path: %w", err) + } + config.ConfigPath = configPath + + if err := recreateStudIPDemoStack(); err != nil { + return config, studIPCourse{}, err + } + + var course studIPCourse + course, err = waitForStudIPDemoCourse(config.BaseURL, config.Username, config.Password, 2*time.Minute) + if err != nil { + return config, studIPCourse{}, err + } + config.CourseID = course.ID + + if err := writeStudIPTestConfig(config); err != nil { + return config, studIPCourse{}, err + } + + return config, course, nil +} + +func recreateStudIPDemoStack() error { + fmt.Println(">> recreating fresh Stud.IP demo stack") + + if err := runCommandWithEnv(nil, "docker", "compose", "down", "--volumes", "--remove-orphans"); err != nil { + return err + } + + return runCommandWithEnv(nil, "docker", "compose", "up", "-d", "--build") +} + +func waitForStudIPDemoCourse(baseURL, username, password string, timeout time.Duration) (studIPCourse, error) { + var ( + course studIPCourse + lastErr error + ) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + course, lastErr = discoverStudIPDemoCourse(baseURL, username, password) + if lastErr == nil { + return course, nil + } + time.Sleep(2 * time.Second) + } + + if lastErr == nil { + lastErr = errors.New("no writable demo course became available") + } + + return studIPCourse{}, fmt.Errorf("wait for Stud.IP demo course: %w", lastErr) +} + +func waitForStudIPCourse(baseURL, username, password, courseID string, timeout time.Duration) (studIPCourse, error) { + var ( + course studIPCourse + lastErr error + ) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + course, lastErr = fetchStudIPCourse(baseURL, username, password, courseID) + if lastErr == nil { + lastErr = verifyStudIPCourseFolders(baseURL, username, password, courseID) + } + if lastErr == nil { + return course, nil + } + time.Sleep(2 * time.Second) + } + + if lastErr == nil { + lastErr = fmt.Errorf("course %q never became readable", courseID) + } + + return studIPCourse{}, fmt.Errorf("wait for Stud.IP course %q: %w", courseID, lastErr) +} + +func discoverStudIPDemoCourse(baseURL, username, password string) (studIPCourse, error) { + courses, err := fetchStudIPCourses(baseURL, username, password) + if err != nil { + return studIPCourse{}, err + } + + var lastErr error + for _, course := range courses.Data { + if strings.TrimSpace(course.Relationships.Folders.Links.Related) == "" { + continue + } + if err := verifyStudIPCourseFolders(baseURL, username, password, course.ID); err != nil { + lastErr = err + continue + } + return course, nil + } + + if lastErr != nil { + return studIPCourse{}, lastErr + } + + return studIPCourse{}, errors.New("courses endpoint returned no course with folder access") +} + +func fetchStudIPCourses(baseURL, username, password string) (*studIPCoursesResponse, error) { + response := new(studIPCoursesResponse) + if err := studIPGetJSON(baseURL, username, password, "courses", response); err != nil { + return nil, err + } + return response, nil +} + +func fetchStudIPCourse(baseURL, username, password, courseID string) (studIPCourse, error) { + var response struct { + Data studIPCourse `json:"data"` + } + + if err := studIPGetJSON(baseURL, username, password, fmt.Sprintf("courses/", courseID), &response); err != nil { + return studIPCourse{}, err + } + + return response.Data, nil +} + +func verifyStudIPCourseFolders(baseURL, username, password, courseID string) error { + response := new(studIPFoldersResponse) + if err := studIPGetJSON(baseURL, username, password, fmt.Sprintf("courses/%s/folders", courseID), response); err != nil { + return err + } + + for _, folder := range response.Data { + if folder.Attributes.FolderType != "RootFolder" { + continue + } + if !folder.Attributes.IsReadable { + return fmt.Errorf("course %q root folder is not readable", courseID) + } + if !folder.Attributes.IsWritable { + return fmt.Errorf("course %q root folder is not writable", courseID) + } + return nil + } + + return fmt.Errorf("course %q has no root folder in the folders response", courseID) +} + +func studIPGetJSON(baseURL, username, password, relativePath string, out any) error { + requestURL, err := studIPAPIURL(baseURL, relativePath) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + return fmt.Errorf("create request %q: %w", requestURL, err) + } + req.SetBasicAuth(username, password) + req.Header.Set("Accept", "application/vnd.api+json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("GET %s failed: %w", requestURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GET %s returned %s: %s", requestURL, resp.Status, strings.TrimSpace(string(body))) + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode %s response: %w", requestURL, err) + } + + return nil +} + +func studIPAPIURL(baseURL, relativePath string) (string, error) { + base, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return "", fmt.Errorf("parse Stud.IP base URL %q: %w", baseURL, err) + } + + relativePath = strings.TrimLeft(strings.TrimSpace(relativePath), "/") + ref, err := url.Parse(relativePath) + if err != nil { + return "", fmt.Errorf("parse Stud.IP relative path %q: %w", relativePath, err) + } + + return base.ResolveReference(ref).String(), nil +} + +func writeStudIPTestConfig(config studIPTestConfig) error { + if err := os.MkdirAll(filepath.Dir(config.ConfigPath), 0o755); err != nil { + return fmt.Errorf("create rclone config dir: %w", err) + } + + obscuredPassword, err := obscure.Obscure(config.Password) + if err != nil { + return fmt.Errorf("obscure Stud.IP password: %w", err) + } + + data := strings.Join([]string{ + "[TestStudIP]", + "type = studip", + "base_url = " + config.BaseURL, + "username = " + config.Username, + "password = " + obscuredPassword, + "course_id = " + config.CourseID, + "license = " + config.License, + "", + }, "\n") + + if err := os.WriteFile(config.ConfigPath, []byte(data), 0o600); err != nil { + return fmt.Errorf("write rclone config %q: %w", config.ConfigPath, err) } return nil } +func envOrDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + func installedPluginPath() (string, error) { if p := os.Getenv("RCLONE_PLUGIN_PATH"); p != "" { return filepath.Join(p, pluginName), nil diff --git a/studip b/studip new file mode 160000 +Subproject 7c0ce99a4e31495a203f1afdad03d8c8afe9c7a |
